diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs new file mode 100644 index 000000000000..dc4b55bc6517 --- /dev/null +++ b/.github/scripts/check-nightly-release.cjs @@ -0,0 +1,44 @@ +const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; + +// Runs after the workflow acquires the nightly concurrency lock. +async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { + const releases = await github.paginate(github.rest.repos.listReleases, { + ...context.repo, + per_page: 100, + }); + const lastNightly = releases + .filter( + (release) => + !release.draft && + release.published_at && + (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")), + ) + .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; + + if (!lastNightly) { + core.info("No published nightly found. Proceeding with release."); + return true; + } + + if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) { + core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`); + return false; + } + + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${lastNightly.tag_name}...${context.sha}`, + per_page: 1, + }); + if (comparison.status !== "ahead") { + core.info( + `Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`, + ); + return false; + } + + core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`); + return true; +} + +module.exports = { shouldReleaseNightly }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs new file mode 100644 index 000000000000..476773bc4e5a --- /dev/null +++ b/.github/scripts/check-nightly-release.test.cjs @@ -0,0 +1,101 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); + +const now = Date.parse("2026-09-05T12:00:00Z"); +const hour = 60 * 60 * 1000; +const nightly = (hoursAgo, overrides = {}) => ({ + tag_name: "v1.0.1-nightly.20260905.123", + draft: false, + published_at: new Date(now - hoursAgo * hour).toISOString(), + ...overrides, +}); + +function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { + const calls = []; + return { + calls, + options: { + now, + context: { repo: { owner: "example", repo: "app" }, sha: "new" }, + core: { info() {} }, + github: { + rest: { + repos: { + listReleases() {}, + async compareCommitsWithBasehead(params) { + calls.push(params); + return { data: { status: comparisonStatus } }; + }, + }, + }, + async paginate() { + return releases; + }, + }, + }, + }; +} + +test("releases the first nightly when no nightly is published", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("waits six hours after publication, including manual nightlies", async () => { + for (const age of [0, 3, 6 - 1 / 3600]) { + const { options, calls } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), false); + assert.equal(calls.length, 0); + } +}); + +test("releases new commits at six hours and after an idle period", async () => { + for (const age of [6, 7, 24]) { + const { options } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), true); + } +}); + +test("skips unchanged commits after the gap", async () => { + const { options } = fixture({ comparisonStatus: "identical" }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("uses publication time, not release order or the tagged commit date", async () => { + const { options } = fixture({ + releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], + }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("ignores stable releases and drafts when checking the gap", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("compares against the published tag, including legacy nightly tags", async () => { + const tag = "nightly-v0.9.0"; + const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] }); + assert.equal(await shouldReleaseNightly(options), true); + assert.equal(calls[0].basehead, `${tag}...new`); +}); + +test("fails instead of releasing when GitHub cannot supply release state", async () => { + const { options } = fixture(); + options.github.paginate = async () => { + throw new Error("GitHub unavailable"); + }; + await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/); +}); + +for (const status of ["behind", "diverged"]) { + test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => { + const { options } = fixture({ comparisonStatus: status }); + assert.equal(await shouldReleaseNightly(options), false); + }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da9f5cfbd58e..7a6744944124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + # Files/dependencies are repo-wide; export checks cover clean workspaces only. + - name: Check unused code + run: vp run knip:check + - name: Check run: vp check @@ -111,6 +115,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test nightly release checks + run: node --test .github/scripts/check-nightly-release.test.cjs + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2785ecb8fa78..48cd451e3fea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ on: - "v*.*.*" - "!v*-nightly.*" schedule: - # Off minute zero: GitHub delays scheduled runs most at the top of the hour. - - cron: "38 */3 * * *" + # Avoid minute zero, when GitHub scheduled jobs are busiest. + - cron: "8,38 * * * *" workflow_dispatch: inputs: channel: @@ -28,7 +28,7 @@ on: # own group so a nightly never blocks them. Running publishers are never # canceled, and queue: max keeps every pending run instead of the default # newest-wins single slot, so a queued stable tag can never be silently -# dropped. Queued nightlies with no new commits skip via check_changes. +# dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} cancel-in-progress: false @@ -40,41 +40,25 @@ permissions: jobs: check_changes: - name: Check for changes since last nightly + name: Check automatic nightly release if: github.event_name == 'schedule' runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 5 outputs: - has_changes: ${{ steps.check.outputs.has_changes }} + has_changes: ${{ steps.check.outputs.result }} steps: - name: Checkout uses: actions/checkout@v6 with: - fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + sparse-checkout: .github/scripts - id: check - name: Compare HEAD to last nightly tag - run: | - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) - if [[ -z "$last_nightly_tag" ]]; then - echo "No previous nightly tag found. Proceeding with release." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") - head_sha=$(git rev-parse HEAD) - - if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi + name: Check release gap and new commits + uses: actions/github-script@v8 + with: + script: | + const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); + return await shouldReleaseNightly({ github, context, core }); preflight: name: Preflight @@ -228,7 +212,7 @@ jobs: name: Resolve T3 Connect public config # Consumes only the commit SHA, not preflight's resolved version, so it runs # alongside preflight instead of after it. The condition mirrors preflight's: - # check_changes is skipped on non-schedule events (skipped is neither failure + # check_changes is skipped on manual and tag releases (skipped is neither failure # nor success, so success() would be wrong here). needs: [check_changes] if: | diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d5a8ac3b7836..b717e905f92b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,9 +1,12 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import type { DesktopBackendMode as DesktopBackendModeValue } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -13,6 +16,7 @@ import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -22,6 +26,7 @@ import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; +import * as DesktopRunningLocalServers from "./DesktopRunningLocalServers.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -62,6 +67,17 @@ export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErr } } +export class DesktopRendererAssetsUnavailableError extends Schema.TaggedErrorClass()( + "DesktopRendererAssetsUnavailableError", + { + candidates: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `The packaged desktop renderer was not found. Checked: ${this.candidates.join(", ")}.`; + } +} + const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = DesktopObservability.makeComponentLogger("desktop-bootstrap"); @@ -141,22 +157,95 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); +export const latchDesktopBackendModeForStartup = Effect.fn( + "desktop.startup.latchDesktopBackendMode", +)(function* (configuredMode: DesktopBackendModeValue) { + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; + return yield* backendMode + .latch(configuredMode) + .pipe(Effect.catchCause((cause) => fatalStartupCause("backendMode", cause))); +}); + +export const handleClientOnlyRendererReady = ( + rendererReady: Effect.Effect, +): Effect.Effect => + rendererReady.pipe( + Effect.tapError((error) => + logBootstrapWarning("failed to open main window after renderer readiness", { + error: error.message, + }), + ), + ); + +const resolvePackagedClientRoot = Effect.fn("desktop.bootstrap.resolvePackagedClientRoot")( + function* (candidates: readonly string[]) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const candidate of candidates) { + if ( + yield* fileSystem + .exists(path.join(candidate, "index.html")) + .pipe(Effect.orElseSucceed(() => false)) + ) { + return candidate; + } + } + return yield* new DesktopRendererAssetsUnavailableError({ candidates: [...candidates] }); + }, +); + const bootstrap = Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const backendMode = (yield* launchMode.get).effectiveMode; yield* logBootstrapInfo("bootstrap start"); + if (backendMode === "client-only") { + if (environment.isDevelopment) { + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(true), + source: "proxy", + targetOrigin: Option.getOrThrow(environment.devServerUrl), + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + } else { + const staticRoot = yield* resolvePackagedClientRoot(environment.packagedClientRootCandidates); + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(false), + source: "static", + staticRoot, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + yield* logBootstrapInfo("bootstrap resolved packaged renderer", { staticRoot }); + } + + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + if (!(yield* Ref.get(state.quitting))) { + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => + logStartupError("desktop app control socket unavailable", { error }), + ), + ); + yield* handleClientOnlyRendererReady(desktopWindow.handleRendererReady); + } + return; + } + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( @@ -177,14 +266,13 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; const rendererTarget = environment.isDevelopment ? Option.getOrThrow(environment.devServerUrl) : backendConfig.httpBaseUrl; yield* electronProtocol.registerDesktopProtocol({ scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + source: "proxy", targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { @@ -194,7 +282,10 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap enabled network access", { endpointUrl: serverExposureState.endpointUrl, }); - } else if (settings.serverExposureMode === "network-accessible") { + } else if ( + settings.serverExposureMode === "network-accessible" && + serverExposureState.mode === "local-only" + ) { yield* logBootstrapWarning( "bootstrap fell back to local-only because no advertised network host was available", ); @@ -263,7 +354,33 @@ const startup = Effect.gen(function* () { const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); - yield* desktopSettings.load; + const settings = yield* desktopSettings.load; + const latchedLaunchMode = yield* latchDesktopBackendModeForStartup(settings.backendMode); + const existingServer = + latchedLaunchMode.effectiveMode === "managed" && !environment.isDevelopment + ? (yield* (yield* DesktopRunningLocalServers.DesktopRunningLocalServers).discover).find( + (server) => server.variant === "userdata", + ) + : undefined; + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; + const launchMode = yield* backendMode.resolveExistingServer({ + isDevelopment: environment.isDevelopment, + hasRunningUserdataServer: existingServer !== undefined, + }); + yield* logStartupInfo("desktop backend mode selected", { + effectiveMode: launchMode.effectiveMode, + configuredMode: launchMode.configuredMode, + source: launchMode.source, + ...(launchMode.cliOverride === null ? {} : { cliOverride: launchMode.cliOverride }), + }); + if (launchMode.source === "existing-server" && existingServer !== undefined) { + yield* logStartupInfo("using existing local server instead of starting managed backend", { + environmentId: existingServer.environmentId, + origin: existingServer.httpBaseUrl, + pid: existingServer.pid, + statePath: existingServer.statePath, + }); + } if (linuxElectronOptions !== null) { yield* logStartupInfo("linux password store configured", { @@ -308,6 +425,10 @@ const scopedProgram = Effect.scoped( yield* Effect.addFinalizer(() => Effect.gen(function* () { + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; + if ((yield* backendMode.get).effectiveMode === "client-only") { + return; + } const pool = yield* DesktopBackendPool.DesktopBackendPool; // Stop every backend in the pool, not just the primary. The // electronApp.quit() path can race ahead of the layer-scope diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index 666c36d391de..14c2f0435e5a 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -1,9 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import { DesktopBackendPortUnavailableError, DesktopDevelopmentBackendPortRequiredError, + handleClientOnlyRendererReady, + latchDesktopBackendModeForStartup, } from "./DesktopApp.ts"; +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; describe("DesktopApp errors", () => { it("preserves unavailable backend port context", () => { @@ -27,4 +40,59 @@ describe("DesktopApp errors", () => { assert.equal(error.message, "T3CODE_PORT is required in desktop development."); }); + + it.effect("preserves client-only window creation failures after logging", () => + Effect.gen(function* () { + const error = new Error("window creation failed"); + + const exit = yield* Effect.exit(handleClientOnlyRendererReady(Effect.fail(error))); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert.strictEqual(failure.value, error); + }), + ); + + it.effect("reports invalid backend-mode launch arguments as fatal startup errors", () => + Effect.gen(function* () { + const quitCount = yield* Ref.make(0); + const shownErrors = yield* Ref.make([]); + + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(["electron", "--backend-mode=invalid"]), + DesktopShutdown.layer, + DesktopState.layer, + Layer.mock(ElectronApp.ElectronApp)({ + quit: Ref.update(quitCount, (count) => count + 1), + }), + Layer.mock(ElectronDialog.ElectronDialog)({ + showErrorBox: (title, content) => + Ref.update(shownErrors, (errors) => [...errors, { title, content }]), + }), + ); + + yield* Effect.gen(function* () { + const exit = yield* Effect.exit(latchDesktopBackendModeForStartup("managed")); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert( + DesktopBackendMode.isDesktopBackendModeArgumentError(failure.value), + "expected the original backend mode argument error", + ); + + const errors = yield* Ref.get(shownErrors); + assert.equal(errors.length, 1); + assert.equal(errors[0]?.title, "T3 Code failed to start"); + assert.include(errors[0]?.content ?? "", "Stage: backendMode"); + assert.include(errors[0]?.content ?? "", 'Invalid --backend-mode value "invalid"'); + assert.equal(yield* Ref.get(quitCount), 1); + + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + const shutdown = yield* DesktopShutdown.DesktopShutdown; + yield* shutdown.awaitRequest; + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopBackendMode.test.ts b/apps/desktop/src/app/DesktopBackendMode.test.ts new file mode 100644 index 000000000000..476adb17776a --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.test.ts @@ -0,0 +1,102 @@ +import { assert, describe, expect, it } from "@effect/vitest"; + +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; + +describe("DesktopBackendMode", () => { + const captureThrown = (run: () => unknown): unknown => { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); + }; + + it("uses the persisted mode when no CLI override is present", () => { + assert.deepEqual(DesktopBackendMode.resolveDesktopBackendModeState([], "client-only"), { + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + source: "settings", + }); + }); + + it("gives the CLI override precedence without changing the configured mode", () => { + assert.deepEqual( + DesktopBackendMode.resolveDesktopBackendModeState( + ["electron", "main.cjs", "--backend-mode=client-only"], + "managed", + ), + { + effectiveMode: "client-only", + configuredMode: "managed", + cliOverride: "client-only", + source: "cli", + }, + ); + }); + + it("accepts a separate flag value", () => { + assert.equal( + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", "--backend-mode", "managed"]), + "managed", + ); + }); + + it("uses client-only for a packaged launch when the userdata server is already running", () => { + expect( + DesktopBackendMode.resolveDesktopBackendModeForExistingServer( + DesktopBackendMode.resolveDesktopBackendModeState([], "managed"), + { isDevelopment: false, hasRunningUserdataServer: true }, + ), + ).toEqual({ + effectiveMode: "client-only", + configuredMode: "managed", + cliOverride: null, + source: "existing-server", + }); + }); + + it("keeps managed mode in development and when no userdata server is running", () => { + const state = DesktopBackendMode.resolveDesktopBackendModeState([], "managed"); + expect( + DesktopBackendMode.resolveDesktopBackendModeForExistingServer(state, { + isDevelopment: true, + hasRunningUserdataServer: true, + }), + ).toBe(state); + expect( + DesktopBackendMode.resolveDesktopBackendModeForExistingServer(state, { + isDevelopment: false, + hasRunningUserdataServer: false, + }), + ).toBe(state); + }); + + it.each([ + ["--backend-mode=other", "invalid-value"], + ["--backend-mode=", "missing-value"], + ["--backend-mode", "missing-value"], + ])("rejects invalid launch argument %s", (argument, reason) => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", argument]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, reason); + } + }); + + it("rejects repeated overrides", () => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride([ + "--backend-mode=managed", + "--backend-mode=client-only", + ]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, "repeated"); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendMode.ts b/apps/desktop/src/app/DesktopBackendMode.ts new file mode 100644 index 000000000000..f7aed0296d41 --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.ts @@ -0,0 +1,155 @@ +import { + type DesktopBackendMode as DesktopBackendModeValue, + type DesktopBackendModeState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +const BACKEND_MODE_FLAG = "--backend-mode"; + +export class DesktopBackendModeArgumentError extends Schema.TaggedErrorClass()( + "DesktopBackendModeArgumentError", + { + value: Schema.NullOr(Schema.String), + reason: Schema.Literals(["missing-value", "invalid-value", "repeated"]), + }, +) { + override get message(): string { + if (this.reason === "missing-value") { + return `${BACKEND_MODE_FLAG} requires either "managed" or "client-only".`; + } + if (this.reason === "repeated") { + return `${BACKEND_MODE_FLAG} may only be specified once.`; + } + return `Invalid ${BACKEND_MODE_FLAG} value ${JSON.stringify(this.value)}. Expected "managed" or "client-only".`; + } +} + +export const isDesktopBackendModeArgumentError = Schema.is(DesktopBackendModeArgumentError); + +function parseBackendMode(value: string | undefined): DesktopBackendModeValue { + if (value === undefined || value.length === 0) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "missing-value", + }); + } + if (value === "managed" || value === "client-only") { + return value; + } + throw new DesktopBackendModeArgumentError({ + value, + reason: "invalid-value", + }); +} + +export function parseDesktopBackendModeOverride( + argv: readonly string[], +): DesktopBackendModeValue | null { + let override: DesktopBackendModeValue | null = null; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === undefined) continue; + + let value: string | undefined; + if (argument === BACKEND_MODE_FLAG) { + value = argv[index + 1]; + index += 1; + } else if (argument.startsWith(`${BACKEND_MODE_FLAG}=`)) { + value = argument.slice(BACKEND_MODE_FLAG.length + 1); + } else { + continue; + } + + if (override !== null) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "repeated", + }); + } + override = parseBackendMode(value); + } + + return override; +} + +export function resolveDesktopBackendModeState( + argv: readonly string[], + configuredMode: DesktopBackendModeValue, +): DesktopBackendModeState { + const cliOverride = parseDesktopBackendModeOverride(argv); + return { + effectiveMode: cliOverride ?? configuredMode, + configuredMode, + cliOverride, + source: cliOverride === null ? "settings" : "cli", + }; +} + +export function resolveDesktopBackendModeForExistingServer( + state: DesktopBackendModeState, + input: { + readonly isDevelopment: boolean; + readonly hasRunningUserdataServer: boolean; + }, +): DesktopBackendModeState { + if (input.isDevelopment || !input.hasRunningUserdataServer || state.effectiveMode !== "managed") { + return state; + } + return { + ...state, + effectiveMode: "client-only", + source: "existing-server", + }; +} + +export class DesktopBackendMode extends Context.Service< + DesktopBackendMode, + { + readonly latch: ( + configuredMode: DesktopBackendModeValue, + ) => Effect.Effect; + readonly get: Effect.Effect; + readonly resolveExistingServer: (input: { + readonly isDevelopment: boolean; + readonly hasRunningUserdataServer: boolean; + }) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopBackendMode") {} + +export const make = Effect.fn("desktop.backendMode.make")(function* (argv: readonly string[]) { + const stateRef = yield* Ref.make({ + effectiveMode: "managed", + configuredMode: "managed", + cliOverride: null, + source: "settings", + }); + + return DesktopBackendMode.of({ + latch: (configuredMode) => + Effect.try({ + try: () => resolveDesktopBackendModeState(argv, configuredMode), + catch: (cause) => + isDesktopBackendModeArgumentError(cause) + ? cause + : new DesktopBackendModeArgumentError({ + value: null, + reason: "invalid-value", + }), + }).pipe(Effect.tap((state) => Ref.set(stateRef, state))), + get: Ref.get(stateRef), + resolveExistingServer: (input) => + Ref.updateAndGet(stateRef, (state) => + resolveDesktopBackendModeForExistingServer(state, input), + ), + }); +}); + +export const layer = Layer.effect(DesktopBackendMode, make(process.argv)); + +export const layerTest = (argv: readonly string[] = []) => + Layer.effect(DesktopBackendMode, make(argv)); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..1641149e9e35 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -63,17 +63,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { - const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; - - assert.equal( - DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), - "clerk.t3.codes", - ); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); - }); - it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); const events: string[] = []; @@ -208,27 +197,4 @@ describe("DesktopClerk", () => { Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), ); }); - - it.each([ - { isDevelopment: true, scheme: "t3code-dev" }, - { isDevelopment: false, scheme: "t3code" }, - ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue(bridge); - - assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); - assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - passkeys: true, - renderer: { scheme, host: "app" }, - }, - ], - ]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..d3c99e5e1d24 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -53,7 +53,7 @@ export class DesktopClerk extends Context.Service< } >()("@t3tools/desktop/app/DesktopClerk") {} -export function resolveDesktopClerkFrontendApiHostname( +function resolveDesktopClerkFrontendApiHostname( publishableKey: string | undefined, ): string | undefined { const normalizedKey = publishableKey?.trim(); @@ -72,7 +72,7 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { +function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), passkeys: true, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 0c0d23242aeb..bcfc9471dd89 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -73,6 +73,10 @@ describe("DesktopEnvironment", () => { assert.equal(environment.serverRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); + assert.deepEqual(environment.packagedClientRootCandidates, [ + "/Applications/T3 Code.app/Contents/Resources/app.asar/apps/server/dist/client", + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/apps/server/dist/client", + ]); assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); assert.equal(environment.linuxWmClass, "t3code-dev"); assert.deepEqual( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 4583e5124091..f029d22fcc08 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -61,6 +61,7 @@ export class DesktopEnvironment extends Context.Service< readonly serverRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; + readonly packagedClientRootCandidates: readonly string[]; readonly preloadPath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; @@ -211,6 +212,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( serverRoot, backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, + packagedClientRootCandidates: [ + path.join(input.appPath, "apps/server/dist/client"), + path.join(resourcesPath, "app.asar.unpacked/apps/server/dist/client"), + ], preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged ? path.join(resourcesPath, "app-update.yml") diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 0086edf20bf9..cccd9a23284a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -90,6 +90,7 @@ function makeDesktopWindowLayer( createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, + handleRendererReady: Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, dispatchMenuAction: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 0a0cc6dca933..74563afd4dc0 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -48,7 +48,7 @@ export class DesktopLifecycle extends Context.Service< { readonly relaunch: ( reason: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly register: Effect.Effect< void, never, @@ -165,6 +165,18 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const state = yield* DesktopState.DesktopState; yield* logLifecycleInfo("desktop relaunch requested", { reason }); + if (!environment.isDevelopment) { + yield* electronApp + .relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }) + .pipe( + Effect.catchCause((cause) => + Effect.fail(new DesktopLifecycleRelaunchError({ reason, cause })), + ), + ); + } yield* Effect.gen(function* () { yield* Effect.yieldNow; yield* Ref.set(state.quitting, true); @@ -173,18 +185,13 @@ export const make = DesktopLifecycle.of({ yield* electronApp.exit(75); return; } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); yield* electronApp.exit(0); }).pipe( Effect.catchCause((cause) => { const error = new DesktopLifecycleRelaunchError({ reason, cause }); - return logLifecycleError(error.message, { error }); + return Effect.fail(error); }), - Effect.forkDetach, - Effect.asVoid, + Effect.tapError((error) => logLifecycleError(error.message, { error })), ); }), register: Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a29e0fd3baf6..a180f45937d8 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -37,45 +37,22 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); }); - it("reads an explicit Electron command-line switch value", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: (switchName) => switchName === "password-store", - getSwitchValue: (switchName) => { - assert.equal(switchName, "password-store"); - return "basic"; - }, - }, - "password-store", + it.effect("preserves an explicit Linux password-store switch", () => { + hasSwitchMock.mockImplementation((switchName) => switchName === "password-store"); + getSwitchValueMock.mockReturnValue(" basic "); + + return Effect.gen(function* () { + const options = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + + assert.equal(options.linuxPasswordStoreCommandLine, "basic"); + assert.isFalse(appendSwitchMock.mock.calls.some(([name]) => name === "password-store")); + }).pipe( + Effect.provide( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ), ); - - assert.equal(value, "basic"); - }); - - it("treats valueless Electron command-line switches as absent", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => true, - getSwitchValue: () => "", - }, - "password-store", - ); - - assert.isNull(value); - }); - - it("returns null for missing Electron command-line switches", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => false, - getSwitchValue: () => { - throw new Error("Unexpected switch value read."); - }, - }, - "password-store", - ); - - assert.isNull(value); }); it.effect( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 7d145632d0bb..718f54115065 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -17,7 +17,7 @@ export interface DesktopPreReadyCommandLineReader { readonly getSwitchValue: (switchName: string) => string; } -export function readCommandLineSwitchValue( +function readCommandLineSwitchValue( commandLine: DesktopPreReadyCommandLineReader, switchName: string, ): string | null { diff --git a/apps/desktop/src/app/DesktopRunningLocalServers.test.ts b/apps/desktop/src/app/DesktopRunningLocalServers.test.ts new file mode 100644 index 000000000000..8f79cc5eb106 --- /dev/null +++ b/apps/desktop/src/app/DesktopRunningLocalServers.test.ts @@ -0,0 +1,261 @@ +import * as NodePath from "@effect/platform-node/NodePath"; +import { EnvironmentId } from "@t3tools/contracts"; +import { deriveServerRuntimeStatePath } from "@t3tools/shared/serverRuntimeState"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { make } from "./DesktopRunningLocalServers.ts"; + +const textEncoder = new TextEncoder(); +const baseDir = "/test/.t3"; +const environmentId = EnvironmentId.make("environment-local"); +const descriptor = { + environmentId, + label: "Local development server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.28", + capabilities: { repositoryIdentity: true }, +} as const; + +const runtimeStatePath = (variant: "userdata" | "dev") => + deriveServerRuntimeStatePath({ + baseDir, + variant, + joinPath: (...segments) => segments.join("/"), + }); + +const runtimeState = (input: { readonly pid: number; readonly origin: string }) => + JSON.stringify({ + version: 1, + pid: input.pid, + port: Number(new URL(input.origin).port), + origin: input.origin, + startedAt: "2026-01-01T00:00:00.000Z", + }); + +const fakeFileSystemLayer = (files: ReadonlyMap) => + FileSystem.layerNoop({ + readFileString: (path) => { + const value = files.get(path); + return value === undefined + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(value); + }, + }); + +const makeProcess = (input: { + readonly stdout: string; + readonly stderr?: string; + readonly exitCode?: number; +}) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(textEncoder.encode(input.stdout)), + stderr: input.stderr ? Stream.make(textEncoder.encode(input.stderr)) : Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + +const makeTestService = (input: { + readonly files: ReadonlyMap; + readonly probe?: typeof descriptor | null; + readonly processIsAlive?: (pid: number) => boolean; + readonly spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; +}) => + make({ + baseDir, + backendEntryPath: "/bundle/apps/server/dist/bin.mjs", + backendCwd: "/home/user", + executablePath: "/bundle/electron", + probeEnvironment: () => Effect.succeed(input.probe === undefined ? descriptor : input.probe), + processIsAlive: input.processIsAlive ?? (() => true), + }).pipe( + Effect.provide( + Layer.mergeAll( + fakeFileSystemLayer(input.files), + NodePath.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + input.spawner ?? ChildProcessSpawner.make(() => Effect.die("unexpected pairing command")), + ), + ), + ), + ); + +describe("DesktopRunningLocalServers", () => { + it.effect("discovers runtime state and confirms its persisted environment identity", () => { + const statePath = runtimeStatePath("userdata"); + return Effect.gen(function* () { + const service = yield* makeTestService({ + files: new Map([ + [statePath, runtimeState({ pid: 42, origin: "http://127.0.0.1:3773" })], + ["/test/.t3/userdata/environment-id", environmentId], + ]), + }); + + expect(yield* service.discover).toEqual([ + { + statePath, + baseDir, + variant: "userdata", + pid: 42, + httpBaseUrl: "http://127.0.0.1:3773", + startedAt: "2026-01-01T00:00:00.000Z", + environmentId, + label: descriptor.label, + }, + ]); + }); + }); + + it.effect( + "skips dead processes before probing and descriptors for another state directory", + () => { + const userdataPath = runtimeStatePath("userdata"); + const devPath = runtimeStatePath("dev"); + let probeCount = 0; + return Effect.gen(function* () { + const service = yield* make({ + baseDir, + backendEntryPath: "/bundle/apps/server/dist/bin.mjs", + backendCwd: "/home/user", + executablePath: "/bundle/electron", + probeEnvironment: () => { + probeCount += 1; + return Effect.succeed({ + ...descriptor, + environmentId: EnvironmentId.make("another-environment"), + }); + }, + processIsAlive: (pid) => pid !== 41, + }).pipe( + Effect.provide( + Layer.mergeAll( + fakeFileSystemLayer( + new Map([ + [userdataPath, runtimeState({ pid: 41, origin: "http://127.0.0.1:3773" })], + ["/test/.t3/userdata/environment-id", environmentId], + [devPath, runtimeState({ pid: 42, origin: "http://127.0.0.1:3774" })], + ["/test/.t3/dev/environment-id", environmentId], + ]), + ), + NodePath.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected pairing command")), + ), + ), + ), + ); + + expect(yield* service.discover).toEqual([]); + expect(probeCount).toBe(1); + }); + }, + ); + + it.effect("pairs through the bundled CLI and validates its JSON result", () => { + const statePath = runtimeStatePath("userdata"); + let command: ChildProcess.StandardCommand | null = null; + const spawner = ChildProcessSpawner.make((candidate) => { + assert.equal(candidate._tag, "StandardCommand"); + if (candidate._tag === "StandardCommand") command = candidate; + return Effect.succeed( + makeProcess({ + stdout: JSON.stringify({ + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + token: "PAIRCODE", + expiresAt: "2099-01-01T00:00:00.000Z", + origin: "http://127.0.0.1:3773", + environmentId, + label: descriptor.label, + }), + }), + ); + }); + + return Effect.gen(function* () { + const service = yield* makeTestService({ + files: new Map([ + [statePath, runtimeState({ pid: 42, origin: "http://127.0.0.1:3773" })], + ["/test/.t3/userdata/environment-id", environmentId], + ]), + spawner, + }); + + expect(yield* service.pairLocalServer(environmentId)).toEqual({ + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + }); + expect(command?.command).toBe("/bundle/electron"); + expect(command?.args).toEqual([ + "/bundle/apps/server/dist/bin.mjs", + "pair", + "--json", + "--label", + "T3 Code Desktop", + "--base-dir", + baseDir, + ]); + expect(command?.options.env).toEqual({ ELECTRON_RUN_AS_NODE: "1" }); + }); + }); + + it.effect("rejects malformed JSON and pairing URLs that retarget the credential", () => { + const statePath = runtimeStatePath("userdata"); + const files = new Map([ + [statePath, runtimeState({ pid: 42, origin: "http://127.0.0.1:3773" })], + ["/test/.t3/userdata/environment-id", environmentId], + ]); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: JSON.stringify({ + pairingUrl: + "http://127.0.0.1:3773/pair?host=https%3A%2F%2Fattacker.example#token=PAIRCODE", + token: "PAIRCODE", + expiresAt: "2099-01-01T00:00:00.000Z", + origin: "http://127.0.0.1:3773", + environmentId, + label: descriptor.label, + }), + }), + ), + ); + return Effect.gen(function* () { + const service = yield* makeTestService({ files, spawner }); + const invalidUrl = yield* service.pairLocalServer(environmentId).pipe(Effect.flip); + expect(invalidUrl.reason).toBe("request_failed"); + expect(invalidUrl.detail).toContain("invalid pairing link"); + + const malformedService = yield* makeTestService({ + files, + spawner: ChildProcessSpawner.make(() => + Effect.succeed(makeProcess({ stdout: "not-json" })), + ), + }); + const malformed = yield* malformedService.pairLocalServer(environmentId).pipe(Effect.flip); + expect(malformed.reason).toBe("request_failed"); + expect(malformed.detail).toContain("invalid JSON"); + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopRunningLocalServers.ts b/apps/desktop/src/app/DesktopRunningLocalServers.ts new file mode 100644 index 000000000000..fce55c22ffda --- /dev/null +++ b/apps/desktop/src/app/DesktopRunningLocalServers.ts @@ -0,0 +1,301 @@ +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { + type EnvironmentId, + type ExecutionEnvironmentDescriptor, + LocalServerPairCommandOutput, + type LocalServerPairingResult, + type RunningLocalServer, +} from "@t3tools/contracts"; +import { + deriveServerRuntimeStatePath, + isProcessAlive, + readPersistedServerRuntimeState, +} from "@t3tools/shared/serverRuntimeState"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +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 Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const LOCAL_SERVER_PAIRING_TIMEOUT = Duration.seconds(10); +const SERVER_RUNTIME_STATE_VARIANTS = ["userdata", "dev"] as const; +const decodePairCommandOutput = Schema.decodeUnknownEffect( + Schema.fromJsonString(LocalServerPairCommandOutput), +); + +export class LocalServerPairingError extends Schema.TaggedErrorClass()( + "LocalServerPairingError", + { + reason: Schema.Literals(["not_found", "request_failed"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail; + } +} + +const isLocalServerPairingError = Schema.is(LocalServerPairingError); + +type ProbeEnvironment = ( + httpBaseUrl: string, +) => Effect.Effect; + +export interface DesktopRunningLocalServersOptions { + readonly baseDir: string; + readonly backendEntryPath: string; + readonly backendCwd: string; + readonly executablePath: string; + readonly probeEnvironment: ProbeEnvironment; + readonly processIsAlive?: (pid: number) => boolean; +} + +export class DesktopRunningLocalServers extends Context.Service< + DesktopRunningLocalServers, + { + readonly discover: Effect.Effect>; + readonly pairLocalServer: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopRunningLocalServers") {} + +export function isValidLocalServerPairingUrl(input: { + readonly pairingUrl: string; + readonly httpBaseUrl: string; + readonly token: string; +}): boolean { + try { + const pairingUrl = new URL(input.pairingUrl); + const httpBaseUrl = new URL(input.httpBaseUrl); + const token = new URLSearchParams(pairingUrl.hash.slice(1)).get("token")?.trim(); + return ( + pairingUrl.origin === httpBaseUrl.origin && + pairingUrl.username === "" && + pairingUrl.password === "" && + pairingUrl.pathname === "/pair" && + pairingUrl.search === "" && + token !== undefined && + token.length > 0 && + token === input.token + ); + } catch { + return false; + } +} + +function parseUrlOrigin(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +const makePairCommand = (options: DesktopRunningLocalServersOptions, server: RunningLocalServer) => + ChildProcess.make( + options.executablePath, + [ + options.backendEntryPath, + "pair", + "--json", + "--label", + "T3 Code Desktop", + "--base-dir", + server.baseDir, + ], + { + cwd: options.backendCwd, + env: { ELECTRON_RUN_AS_NODE: "1" }, + extendEnv: true, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: Duration.seconds(2), + }, + ); + +export const make = Effect.fn("desktop.runningLocalServers.make")(function* ( + options: DesktopRunningLocalServersOptions, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const processIsAlive = options.processIsAlive ?? isProcessAlive; + + const discover = Effect.gen(function* () { + const discovered = yield* Effect.forEach( + SERVER_RUNTIME_STATE_VARIANTS, + (variant) => + Effect.gen(function* () { + const statePath = deriveServerRuntimeStatePath({ + baseDir: options.baseDir, + variant, + joinPath: path.join, + }); + const state = yield* readPersistedServerRuntimeState(statePath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + if (Option.isNone(state) || state.value.pid <= 0 || !processIsAlive(state.value.pid)) { + return null; + } + + const persistedEnvironmentId = yield* fileSystem + .readFileString(path.join(path.dirname(statePath), "environment-id")) + .pipe( + Effect.map((value) => value.trim()), + Effect.option, + ); + if (Option.isNone(persistedEnvironmentId) || persistedEnvironmentId.value.length === 0) { + return null; + } + + const descriptor = yield* options.probeEnvironment(state.value.origin); + if (descriptor === null || descriptor.environmentId !== persistedEnvironmentId.value) { + return null; + } + + return { + statePath, + baseDir: options.baseDir, + variant, + pid: state.value.pid, + httpBaseUrl: state.value.origin, + startedAt: state.value.startedAt, + environmentId: descriptor.environmentId, + label: descriptor.label, + } satisfies RunningLocalServer; + }), + { concurrency: "unbounded" }, + ); + + const byEnvironmentId = new Map(); + for (const server of discovered) { + if (server !== null && !byEnvironmentId.has(server.environmentId)) { + byEnvironmentId.set(server.environmentId, server); + } + } + return [...byEnvironmentId.values()].toSorted( + (left, right) => + left.label.localeCompare(right.label) || left.statePath.localeCompare(right.statePath), + ); + }); + + const runPairCommand = (server: RunningLocalServer) => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(makePairCommand(options, server)); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + handle.stderr.pipe(Stream.decodeText(), Stream.mkString), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== ChildProcessSpawner.ExitCode(0)) { + return yield* new LocalServerPairingError({ + reason: "request_failed", + detail: + stderr.trim() || `The local pairing command exited with code ${String(exitCode)}.`, + }); + } + return stdout.trim(); + }), + ).pipe( + Effect.timeout(LOCAL_SERVER_PAIRING_TIMEOUT), + Effect.mapError((cause) => + isLocalServerPairingError(cause) + ? cause + : new LocalServerPairingError({ + reason: "request_failed", + detail: "Could not run the bundled T3 Code pairing command.", + cause, + }), + ), + ); + + const pairLocalServer = Effect.fn("desktop.runningLocalServers.pair")(function* ( + environmentId: EnvironmentId, + ) { + const servers = yield* discover; + const server = servers.find((candidate) => candidate.environmentId === environmentId); + if (server === undefined) { + return yield* new LocalServerPairingError({ + reason: "not_found", + detail: "This local T3 Code server is no longer running.", + }); + } + + const rawOutput = yield* runPairCommand(server); + const output = yield* decodePairCommandOutput(rawOutput).pipe( + Effect.mapError( + (cause) => + new LocalServerPairingError({ + reason: "request_failed", + detail: "The local T3 Code pairing command returned invalid JSON.", + cause, + }), + ), + ); + + const outputOrigin = parseUrlOrigin(output.origin); + const discoveredOrigin = parseUrlOrigin(server.httpBaseUrl); + if (outputOrigin === null || discoveredOrigin === null) { + return yield* new LocalServerPairingError({ + reason: "request_failed", + detail: "The local T3 Code pairing command returned an invalid server origin.", + }); + } + if ( + output.environmentId !== server.environmentId || + outputOrigin !== discoveredOrigin || + !isValidLocalServerPairingUrl({ + pairingUrl: output.pairingUrl, + httpBaseUrl: server.httpBaseUrl, + token: output.token, + }) + ) { + return yield* new LocalServerPairingError({ + reason: "request_failed", + detail: "The local T3 Code pairing command returned an invalid pairing link.", + }); + } + + return { + pairingUrl: output.pairingUrl, + pairingExpiresAt: output.expiresAt, + } satisfies LocalServerPairingResult; + }); + + return DesktopRunningLocalServers.of({ discover, pairLocalServer }); +}); + +export const layer = Layer.effect( + DesktopRunningLocalServers, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const httpClient = yield* HttpClient.HttpClient; + return yield* make({ + baseDir: environment.baseDir, + backendEntryPath: environment.backendEntryPath, + backendCwd: environment.backendCwd, + executablePath: process.execPath, + probeEnvironment: (httpBaseUrl) => + fetchRemoteEnvironmentDescriptor({ httpBaseUrl, timeoutMs: 2_000 }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.orElseSucceed(() => null), + ), + }); + }), +); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index accfdf70b3a3..747663b80ac0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -218,14 +218,6 @@ const withPackagedWslHarness = ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("DesktopBackendConfiguration", () => { - it("accepts only normalized SHA-256 archive identities", () => { - assert.equal( - DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`), - "a".repeat(64), - ); - assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123")); - }); - it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..7a8dc8334cf1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -243,7 +243,7 @@ const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; -export const parseWslRuntimeArchiveHash = (value: string): string | null => { +const parseWslRuntimeArchiveHash = (value: string): string | null => { const trimmed = value.trim(); return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; }; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 436c0c08e4ed..05fcb4895861 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -654,6 +654,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); + const startRequestedRef = yield* Ref.make(false); const mutex = yield* Semaphore.make(1); const { logWarning: logInstanceWarning, logError: logInstanceError } = @@ -691,6 +692,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { + yield* Ref.set(startRequestedRef, true); const current = yield* Ref.get(state); if (Option.isSome(current.active)) { if (!current.desiredRunning) { @@ -1154,7 +1156,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.map(Option.getOrElse(() => false)), ); - yield* Effect.addFinalizer(() => stop()); + yield* Effect.addFinalizer(() => + Ref.get(startRequestedRef).pipe( + Effect.flatMap((startRequested) => (startRequested ? stop() : Effect.void)), + ), + ); return { id: spec.id, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5fe02c8d1728..b61d2286b017 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -93,6 +93,7 @@ function makePoolLayer( activate: Effect.die("unexpected window activate"), createMainIfBackendReady: Effect.die("unexpected window create"), showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..5a4dafadf898 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -251,6 +251,7 @@ describe("DesktopServerExposure", () => { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), @@ -272,8 +273,6 @@ describe("DesktopServerExposure", () => { modeError, DesktopServerExposure.DesktopServerExposureModePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); assert.equal(modeError.mode, "network-accessible"); assert.strictEqual(modeError.cause, settingsFailure); assert.strictEqual(modeError.cause.cause, diskFailure); @@ -290,7 +289,6 @@ describe("DesktopServerExposure", () => { tailscaleError, DesktopServerExposure.DesktopTailscaleServePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); assert.equal(tailscaleError.enabled, true); assert.equal(tailscaleError.port, 8443); assert.strictEqual(tailscaleError.cause, settingsFailure); @@ -307,9 +305,9 @@ describe("DesktopServerExposure", () => { ); }); - it.effect("resolves advertised endpoints from the scoped runtime state", () => + it.effect("keeps LAN and Tailscale endpoints distinct when Tailscale is enumerated first", () => withHarness( - { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces }, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); @@ -324,6 +322,32 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("keeps Tailscale-only hosts network-accessible", () => + withHarness( + tailnetNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "network-accessible"); + assert.equal(state.advertisedHost, null); + assert.equal(state.endpointUrl, null); + assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => [endpoint.reachability, endpoint.httpBaseUrl]), + [ + ["loopback", "http://127.0.0.1:4173/"], + ["private-network", "http://100.90.1.2:4173/"], + ], + ); + }), + ), + ); + it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => withHarness( lanNetworkInterfaces, @@ -345,7 +369,7 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("uses ConfigProvider desktop exposure overrides", () => + it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { @@ -353,17 +377,17 @@ describe("DesktopServerExposure", () => { yield* serverExposure.configureFromSettings({ port: 4173 }); const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "10.0.0.7"); - assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + assert.equal(change.state.advertisedHost, "100.90.1.2"); + assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ["http://127.0.0.1:4173/", "http://100.90.1.2:4173/", "https://public.example.test/"], ); }), { - T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_LAN_HOST: "100.90.1.2", T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", }, ), diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..24c24c15a00f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => }; const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); + !address.startsWith("127.") && + !address.startsWith("169.254.") && + !isTailscaleIpv4Address(address); const isHttpsEndpointUrl = (value: string): boolean => { try { @@ -244,7 +246,6 @@ export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureModePersistenceError, ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; -export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, @@ -252,7 +253,6 @@ export const DesktopServerExposureError = Schema.Union([ DesktopTailscaleServePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; -export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; @@ -378,7 +378,14 @@ function resolveRuntimeState(input: { ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); const unavailable = - input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + input.requestedMode === "network-accessible" && + requestedExposure.endpointUrl === null && + !Object.values(input.networkInterfaces).some((addresses) => + addresses?.some( + (address) => + !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address), + ), + ); const exposure = unavailable ? resolveDesktopServerExposure({ mode: "local-only", diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 3acaf7154508..2ed5a1f2f913 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -43,7 +43,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 4300d9ab0d39..30ca73a5e143 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -73,7 +73,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index a5c03e0b9336..e58a41d8f489 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -36,8 +36,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -100,8 +100,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); @@ -128,8 +128,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:5733/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); @@ -152,8 +152,8 @@ describe("ElectronProtocol", () => { const error = yield* Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -177,8 +177,8 @@ describe("ElectronProtocol", () => { Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -198,8 +198,8 @@ describe("ElectronProtocol", () => { it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( @@ -228,4 +228,117 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); + + it.effect("serves packaged assets, SPA routes, HEAD requests, and CSP", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async (url: string) => { + if (url.endsWith("/index.html")) { + return new Response("
T3 Code
", { status: 200 }); + } + if (url.endsWith("/assets/app-123.js")) { + return new Response("export {};", { status: 200 }); + } + return new Response(null, { status: 404 }); + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const root = yield* Effect.promise(() => + handler!( + new Request("t3code://app/", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(root.status, 200); + assert.equal(root.headers.get("content-type"), "text/html; charset=utf-8"); + assert.include(root.headers.get("content-security-policy") ?? "", "default-src 'self'"); + assert.equal(yield* Effect.promise(() => root.text()), "
T3 Code
"); + + const asset = yield* Effect.promise(() => + handler!(new Request("t3code://app/assets/app-123.js")), + ); + assert.equal(asset.headers.get("content-type"), "text/javascript; charset=utf-8"); + assert.equal(yield* Effect.promise(() => asset.text()), "export {};"); + + const route = yield* Effect.promise(() => + handler!( + new Request("t3code://app/settings/connections", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(route.status, 200); + assert.equal(yield* Effect.promise(() => route.text()), "
T3 Code
"); + + const head = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/app-123.js", { + method: "HEAD", + }), + ), + ); + assert.equal(head.status, 200); + assert.equal(yield* Effect.promise(() => head.text()), ""); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("does not fall back missing assets and rejects unsafe static requests", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockResolvedValue(new Response(null, { status: 404 })); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const missingAsset = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/missing.js", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(missingAsset.status, 404); + assert.equal(netFetchMock.mock.calls.length, 1); + + const traversal = yield* Effect.promise(() => + handler!(new Request("t3code://app/%2e%2e%2fsecret.txt")), + ); + assert.equal(traversal.status, 403); + + const unsupported = yield* Effect.promise(() => + handler!(new Request("t3code://app/", { method: "POST" })), + ); + assert.equal(unsupported.status, 405); + assert.equal(unsupported.headers.get("allow"), "GET, HEAD"); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index fabd598d7ffa..8b0b2702b9c7 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron static protocol handlers require synchronous platform path validation. import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -5,6 +6,8 @@ import * as NodeTimersPromises from "node:timers/promises"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as Electron from "electron"; @@ -48,13 +51,23 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedErrorClass } } -export interface DesktopProtocolRegistrationInput { +interface DesktopProtocolRegistrationBase { readonly scheme: string; - readonly targetOrigin: URL; - readonly backendOrigin: URL; readonly clerkFrontendApiHostname: string | undefined; } +export type DesktopProtocolRegistrationInput = DesktopProtocolRegistrationBase & + ( + | { + readonly source: "proxy"; + readonly targetOrigin: URL; + } + | { + readonly source: "static"; + readonly staticRoot: string; + } + ); + export class ElectronProtocol extends Context.Service< ElectronProtocol, { @@ -140,6 +153,143 @@ const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivile export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); +const STATIC_CONTENT_TYPES: Readonly> = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +type StaticPathResolution = + | { readonly _tag: "Invalid"; readonly status: 400 | 403 } + | { readonly _tag: "Resolved"; readonly path: string; readonly relativePath: string }; + +export function resolveDesktopStaticPath( + staticRoot: string, + encodedPathname: string, +): StaticPathResolution { + let decodedPathname: string; + try { + decodedPathname = decodeURIComponent(encodedPathname); + } catch { + return { _tag: "Invalid", status: 400 }; + } + + if ( + decodedPathname.includes("\0") || + decodedPathname.includes("\\") || + /^[a-zA-Z]:/u.test(decodedPathname.replace(/^\/+/u, "")) + ) { + return { _tag: "Invalid", status: 403 }; + } + + const segments = decodedPathname.split("/").filter((segment) => segment.length > 0); + if (segments.some((segment) => segment === "." || segment === "..")) { + return { _tag: "Invalid", status: 403 }; + } + + const relativePath = segments.length === 0 ? "index.html" : segments.join("/"); + const normalizedRoot = NodePath.resolve(staticRoot); + const resolvedPath = NodePath.resolve(normalizedRoot, relativePath); + const relativeToRoot = NodePath.relative(normalizedRoot, resolvedPath); + if ( + relativeToRoot === ".." || + relativeToRoot.startsWith(`..${NodePath.sep}`) || + NodePath.isAbsolute(relativeToRoot) + ) { + return { _tag: "Invalid", status: 403 }; + } + + return { + _tag: "Resolved", + path: resolvedPath, + relativePath, + }; +} + +function shouldUseSpaFallback(request: Request, relativePath: string): boolean { + if (NodePath.extname(relativePath) !== "") { + return false; + } + const accept = request.headers.get("accept") ?? ""; + const mode = request.headers.get("sec-fetch-mode") ?? ""; + return mode === "navigate" || accept.includes("text/html"); +} + +async function fetchStaticFile(path: string): Promise { + try { + return await Electron.net.fetch(NodeURL.pathToFileURL(path).href, { method: "GET" }); + } catch { + return new Response(null, { status: 404 }); + } +} + +function withStaticResponseHeaders(response: Response, path: string, headOnly: boolean): Response { + const headers = new Headers(response.headers); + const contentType = STATIC_CONTENT_TYPES[NodePath.extname(path).toLowerCase()]; + if (contentType !== undefined) { + headers.set("Content-Type", contentType); + } + return new Response(headOnly ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export async function serveDesktopStaticRequest( + request: Request, + staticRoot: string, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return withContentSecurityPolicy(new Response(null, { status: 404 }), contentSecurityPolicy); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return withContentSecurityPolicy( + new Response(null, { + status: 405, + headers: { Allow: "GET, HEAD" }, + }), + contentSecurityPolicy, + ); + } + + const resolution = resolveDesktopStaticPath(staticRoot, requestUrl.pathname); + if (resolution._tag === "Invalid") { + return withContentSecurityPolicy( + new Response(null, { status: resolution.status }), + contentSecurityPolicy, + ); + } + + let response = await fetchStaticFile(resolution.path); + let responsePath = resolution.path; + if (response.status === 404 && shouldUseSpaFallback(request, resolution.relativePath)) { + // Resolve the shell through the same normalization as asset paths so a + // relative or non-normalized staticRoot still falls back correctly. + responsePath = NodePath.resolve(staticRoot, "index.html"); + response = await fetchStaticFile(responsePath); + } + + return withContentSecurityPolicy( + withStaticResponseHeaders(response, responsePath, request.method === "HEAD"), + contentSecurityPolicy, + ); +} + async function proxyRequest( request: Request, targetOrigin: URL, @@ -217,9 +367,12 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), - ); + Electron.protocol.handle(input.scheme, (request) => { + if (input.source === "static") { + return serveDesktopStaticRequest(request, input.staticRoot, contentSecurityPolicy); + } + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); + }); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff2b..b4028930af66 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d0954f..24b2d856b9d2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df8..8e044de65ad6 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index bebb0e5c4178..c802e595633a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "T3 Code", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d34280b..9234399191cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowOperationError", { diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index fc311877f829..5533831f9b55 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -41,7 +41,6 @@ describe("DesktopIpc", () => { const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "invoke"); assert.strictEqual(error.channel, invokeMethod.channel); assert.strictEqual(error.cause, cause); @@ -69,7 +68,6 @@ describe("DesktopIpc", () => { if (exit._tag === "Success") return; const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "sync"); assert.strictEqual(error.channel, syncMethod.channel); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index e948571cc628..643543d4ec33 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -55,7 +55,6 @@ export const DesktopIpcError = Schema.Union([ DesktopIpcUnregistrationError, ]); export type DesktopIpcError = typeof DesktopIpcError.Type; -export const isDesktopIpcError = Schema.is(DesktopIpcError); export interface DesktopIpcMethod { readonly channel: string; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 3e30083064af..4b2cc0af3475 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,8 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { getBackendModeState, setBackendMode } from "./methods/backendMode.ts"; +import { discoverLocalServers, pairLocalServer } from "./methods/localServerDiscovery.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -59,9 +61,13 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); + yield* ipc.handleSync(getBackendModeState); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(discoverLocalServers); + yield* ipc.handle(pairLocalServer); + yield* ipc.handle(setBackendMode); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5b2c815eaa42..1fa40d08a1a7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -21,9 +21,13 @@ export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; +export const GET_BACKEND_MODE_STATE_CHANNEL = "desktop:get-backend-mode-state"; +export const SET_BACKEND_MODE_CHANNEL = "desktop:set-backend-mode"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; +export const DISCOVER_LOCAL_SERVERS_CHANNEL = "desktop:discover-local-servers"; +export const PAIR_LOCAL_SERVER_CHANNEL = "desktop:pair-local-server"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; diff --git a/apps/desktop/src/ipc/methods/backendMode.test.ts b/apps/desktop/src/ipc/methods/backendMode.test.ts new file mode 100644 index 000000000000..afe93d224c4e --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.test.ts @@ -0,0 +1,84 @@ +import { DesktopBackendModeStateSchema } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import { setBackendMode } from "./backendMode.ts"; + +const decodeBackendModeState = Schema.decodeUnknownEffect(DesktopBackendModeStateSchema); + +const unusedLifecycleRuntimeLayer = + Layer.empty as Layer.Layer; + +describe("backend mode IPC", () => { + it.effect("reports the saved mode while a successful relaunch is pending", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(), + DesktopAppSettings.layerTest(), + Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: (reason) => Effect.sync(() => relaunchReasons.push(reason)), + register: Effect.void, + }), + ), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* launchMode.latch("managed"); + + const state = yield* setBackendMode + .handler("client-only") + .pipe(Effect.flatMap(decodeBackendModeState)); + + assert.deepEqual(state, { + effectiveMode: "managed", + configuredMode: "client-only", + cliOverride: null, + source: "settings", + }); + assert.deepEqual(relaunchReasons, ["backendMode=client-only"]); + assert.equal((yield* settings.get).backendMode, "client-only"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("restores the configured mode when a packaged relaunch cannot be scheduled", () => { + const relaunchError = new DesktopLifecycle.DesktopLifecycleRelaunchError({ + reason: "backendMode=client-only", + cause: Cause.die(new Error("relaunch failed")), + }); + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(), + DesktopAppSettings.layerTest(), + Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: () => Effect.fail(relaunchError), + register: Effect.void, + }), + ), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* launchMode.latch("managed"); + const error = yield* setBackendMode + .handler("client-only") + .pipe(Effect.flatMap(decodeBackendModeState), Effect.flip); + + assert.strictEqual(error, relaunchError); + assert.equal((yield* settings.get).backendMode, "managed"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/backendMode.ts b/apps/desktop/src/ipc/methods/backendMode.ts new file mode 100644 index 000000000000..6f4b0a9301de --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.ts @@ -0,0 +1,66 @@ +import { + DesktopBackendModeSchema, + DesktopBackendModeStateSchema, + type DesktopBackendModeState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; + +const readBackendModeState: Effect.Effect< + DesktopBackendModeState, + never, + DesktopBackendMode.DesktopBackendMode | DesktopAppSettings.DesktopAppSettings +> = Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const launchState = yield* launchMode.get; + const configuredMode = (yield* settings.get).backendMode; + return { + ...launchState, + configuredMode, + }; +}); + +export const getBackendModeState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_BACKEND_MODE_STATE_CHANNEL, + result: DesktopBackendModeStateSchema, + handler: Effect.fn("desktop.ipc.backendMode.get")(function* () { + return yield* readBackendModeState; + }), +}); + +export const setBackendMode = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_BACKEND_MODE_CHANNEL, + payload: DesktopBackendModeSchema, + result: DesktopBackendModeStateSchema, + handler: Effect.fn("desktop.ipc.backendMode.set")(function* (mode) { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const previousMode = (yield* settings.get).backendMode; + const change = yield* settings.setBackendMode(mode); + const launchState = yield* launchMode.get; + const relaunchRequired = + change.changed && + launchState.cliOverride === null && + launchState.effectiveMode !== change.settings.backendMode; + if (relaunchRequired) { + yield* lifecycle + .relaunch(`backendMode=${mode}`) + .pipe(Effect.tapError(() => settings.setBackendMode(previousMode))); + return { + ...launchState, + configuredMode: change.settings.backendMode, + }; + } + return { + ...launchState, + configuredMode: change.settings.backendMode, + }; + }), +}); diff --git a/apps/desktop/src/ipc/methods/localServerDiscovery.ts b/apps/desktop/src/ipc/methods/localServerDiscovery.ts new file mode 100644 index 000000000000..9369fd22e13b --- /dev/null +++ b/apps/desktop/src/ipc/methods/localServerDiscovery.ts @@ -0,0 +1,27 @@ +import { EnvironmentId, LocalServerPairingResult, RunningLocalServer } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopRunningLocalServers from "../../app/DesktopRunningLocalServers.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const discoverLocalServers = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DISCOVER_LOCAL_SERVERS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(RunningLocalServer), + handler: Effect.fn("desktop.ipc.localServerDiscovery.discover")(function* () { + const discovery = yield* DesktopRunningLocalServers.DesktopRunningLocalServers; + return yield* discovery.discover; + }), +}); + +export const pairLocalServer = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PAIR_LOCAL_SERVER_CHANNEL, + payload: EnvironmentId, + result: LocalServerPairingResult, + handler: Effect.fn("desktop.ipc.localServerDiscovery.pair")(function* (environmentId) { + const discovery = yield* DesktopRunningLocalServers.DesktopRunningLocalServers; + return yield* discovery.pairLocalServer(environmentId); + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..13c6c44a9344 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -8,6 +8,7 @@ import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { @@ -70,7 +71,14 @@ describe("getLocalEnvironmentBootstraps", () => { bootstrapToken: "bootstrap-token", }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), ); it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { @@ -105,7 +113,14 @@ describe("getLocalEnvironmentBootstraps", () => { wsBaseUrl: null, }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([retryingInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); it.effect("omits a bounded transient bootstrap after retries stop", () => { @@ -133,8 +148,30 @@ describe("getLocalEnvironmentBootstraps", () => { return Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); assert.deepEqual(result, []); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([stoppedInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); + + it.effect("returns no local bootstraps in client-only mode", () => + Effect.gen(function* () { + const mode = yield* DesktopBackendMode.DesktopBackendMode; + yield* mode.latch("client-only"); + assert.deepEqual(yield* getLocalEnvironmentBootstraps.handler(), []); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), + ); }); describe("getWindowFullscreenState", () => { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 61de1361a311..2e36258c3d6e 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -23,6 +23,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; @@ -90,6 +91,10 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + if ((yield* launchMode.get).effectiveMode === "client-only") { + return []; + } const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; const bootstraps: DesktopEnvironmentBootstrap[] = []; diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a91790200771..5827e38e406f 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index fe3e21eadb92..3aa7a440d1e8 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; -} - -function getKWalletRemediationMessage(): string { - return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa962..dd0269ee80b2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -33,8 +33,10 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; +import * as DesktopBackendMode from "./app/DesktopBackendMode.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; +import * as DesktopRunningLocalServers from "./app/DesktopRunningLocalServers.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; @@ -134,9 +136,11 @@ const electronLayer = Layer.mergeAll( const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, DesktopShutdown.layer, + DesktopBackendMode.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), + DesktopRunningLocalServers.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 74001dd785d3..b1d6e390921b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -43,6 +43,22 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, + getBackendModeState: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_BACKEND_MODE_STATE_CHANNEL); + if (typeof result !== "object" || result === null) { + // An unavailable mode handler means the renderer cannot safely assume + // that this process owns a backend. Fail closed into the connection-only + // routing path instead of trying to resolve t3code:// as an HTTP backend. + return { + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + source: "settings", + }; + } + return result as ReturnType; + }, + setBackendMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_BACKEND_MODE_CHANNEL, mode), getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { @@ -52,6 +68,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), + discoverLocalServers: () => ipcRenderer.invoke(IpcChannels.DISCOVER_LOCAL_SERVERS_CHANNEL), + pairLocalServer: (environmentId) => + ipcRenderer.invoke(IpcChannels.PAIR_LOCAL_SERVER_CHANNEL, environmentId), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index ff22f3dd2272..aaf34c3578f9 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -172,8 +172,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-a"); assert.strictEqual(error.cause, platformCause); assert.strictEqual(error.cause.reason.cause, nativeCause); @@ -196,8 +194,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-b"); assert.equal(error.partition, partition); assert.strictEqual(error.cause, cause); @@ -270,7 +266,6 @@ describe("BrowserSession", () => { const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); assert.equal(storageError.partition, secondPartition); assert.strictEqual(storageError.cause, storageCause); assert.equal( @@ -287,7 +282,6 @@ describe("BrowserSession", () => { const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); assert.equal(cacheError.partition, firstPartition); assert.strictEqual(cacheError.cause, cacheCause); assert.equal( diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7f3c9ec5d7ac..7ff879852283 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -93,7 +93,6 @@ export const BrowserSessionGetSessionError = Schema.Union([ BrowserSessionCreationError, ]); export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; -export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, @@ -102,7 +101,6 @@ export const BrowserSessionError = Schema.Union([ BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; -export const isBrowserSessionError = Schema.is(BrowserSessionError); export class BrowserSession extends Context.Service< BrowserSession, diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..e21ef5124182 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + backendMode: Schema.optionalKey(Schema.Literals(["managed", "client-only"])), linuxPasswordStore: Schema.optionalKey( Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), ), @@ -105,6 +106,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + backendMode: "managed", linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -125,6 +127,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + backendMode: "client-only", linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, @@ -134,6 +137,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "client-only", linuxPasswordStore: "gnome-libsecret", mainWindowBounds: null, mainWindowMaximized: false, @@ -162,6 +166,10 @@ describe("DesktopSettings", () => { assert.isTrue(updateChannel.changed); assert.equal(updateChannel.settings.updateChannel, "nightly"); assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + + const backendMode = yield* settings.setBackendMode("managed"); + assert.isTrue(backendMode.changed); + assert.equal(backendMode.settings.backendMode, "managed"); }), ), ); @@ -241,6 +249,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + backendMode: "managed", linuxPasswordStore: "auto", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, @@ -297,6 +306,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + backendMode: "managed", linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -322,11 +332,13 @@ describe("DesktopSettings", () => { yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); + yield* settings.setBackendMode("client-only"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + backendMode: "client-only", mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", @@ -345,6 +357,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -373,6 +386,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, @@ -400,6 +414,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", linuxPasswordStore: "auto", mainWindowBounds: null, mainWindowMaximized: false, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index aefc67525531..bbfe371fc3cd 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,6 +1,8 @@ import { DesktopServerExposureModeSchema, + DesktopBackendModeSchema, DesktopUpdateChannelSchema, + type DesktopBackendMode, type DesktopServerExposureMode, type DesktopUpdateChannel, } from "@t3tools/contracts"; @@ -25,6 +27,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly backendMode: DesktopBackendMode; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -73,6 +76,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + backendMode: "managed", linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -94,6 +98,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + backendMode: Schema.optionalKey(DesktopBackendModeSchema), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -156,6 +161,9 @@ export class DesktopAppSettings extends Context.Service< bounds: DesktopWindowBounds, isMaximized: boolean, ) => Effect.Effect; + readonly setBackendMode: ( + mode: DesktopBackendMode, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -224,6 +232,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + backendMode: parsed.backendMode === "client-only" ? "client-only" : "managed", linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -247,6 +256,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.backendMode !== defaults.backendMode) { + document.backendMode = settings.backendMode; + } if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -296,6 +308,18 @@ function setServerExposureMode( }; } +function setBackendMode( + settings: DesktopSettings, + requestedMode: DesktopBackendMode, +): DesktopSettings { + return settings.backendMode === requestedMode + ? settings + : { + ...settings, + backendMode: requestedMode, + }; +} + function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, @@ -518,6 +542,10 @@ export const make = Effect.gen(function* () { }, }), ), + setBackendMode: (mode) => + persist((settings) => setBackendMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setBackendMode", { attributes: { mode } }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -577,6 +605,7 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET load: SynchronizedRef.get(settingsRef), setMainWindowBounds: (bounds, isMaximized) => update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setBackendMode: (mode) => update((settings) => setBackendMode(settings, mode)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44cf70..d2fd166e878c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer) => { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, records, }; @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { Effect.gen(function* () { const result = yield* readWithLogs(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.deepEqual(result.records, []); }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("reports non-missing filesystem failures and logs the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.strictEqual(result.result.failure.cause, permissionError); assert.equal(result.records.length, 1); assert.deepEqual(result.records[0]?.message, [ "Could not read desktop client settings.", @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("reports malformed settings documents and logs the settings path", () => Effect.gen(function* () { const result = yield* readWithLogs( FileSystem.layerNoop({ @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "decode-document"); assert.equal(result.records.length, 1); const message = result.records[0]?.message; if (!Array.isArray(message)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index e896e85620e9..cbe6e35599c0 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -44,6 +44,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + onboardingCompletedAt: null, panelAnimationDurationMs: 0, planModeEnabled: false, proactivePanelsEnabled: true, @@ -58,6 +59,8 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + loadBalancingEnabled: false, + loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, timestampFormat: "24-hour", wordWrap: true, }; @@ -137,6 +140,59 @@ describe("DesktopClientSettings", () => { ), ); + for (const failure of [ + { label: "permission", reason: "PermissionDenied" }, + { label: "I/O", reason: "Unknown" }, + ] as const) { + it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); + + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); + + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); + } + it.effect("reports the failed client settings write operation and path", () => withClientSettings( Effect.gen(function* () { @@ -223,17 +279,31 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + for (const document of [ + { label: "malformed JSON", contents: "{not-json" }, + { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, + { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, + ]) { + it.effect(`reports ${document.label} without treating the settings file as absent`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); + } }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27a27..5eadd27d5454 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); -const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); -const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( - LegacyClientSettingsDocumentJson, +const decodeClientSettingsDocument = Schema.decodeEffect( + fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), ); -const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); -const decodeClientSettingsJson = (raw: string): Effect.Effect => - decodeLegacyClientSettingsDocumentJson(raw).pipe( - Effect.map((document) => document.settings), - Effect.catchTags({ - SchemaError: () => decodeClientSettingsJsonValue(raw), - }), +const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); +const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { + const document = yield* decodeClientSettingsDocument(raw); + // Select the shape before validation so invalid legacy settings cannot become defaults. + return yield* decodeClientSettingsValue( + Object.hasOwn(document, "settings") ? document.settings : document, ); +}); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); +export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass()( + "DesktopClientSettingsReadError", + { + operation: Schema.Literals(["read-file", "decode-document"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} + const DesktopClientSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>; + readonly get: Effect.Effect, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect; @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect> => +): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ @@ -74,7 +82,15 @@ const readClientSettings = ( ? Effect.succeed(Option.none()) : Effect.logWarning("Could not read desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-file", + path: settingsPath, + cause, + }), + ), + ), ), }), Effect.flatMap( @@ -87,7 +103,15 @@ const readClientSettings = ( SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "decode-document", + path: settingsPath, + cause, + }), + ), + ), ), }), ), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 1978337df3e7..509778521511 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -794,7 +794,6 @@ describe("DesktopUpdates", () => { const error = yield* updates.setChannel("nightly").pipe(Effect.flip); assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, settingsFailure); assert.strictEqual(error.cause.cause, diskFailure); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 20f005f2ab2d..344d135a1024 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -155,7 +155,6 @@ export const DesktopUpdateSetChannelError = Schema.Union([ DesktopUpdateChannelPersistenceError, ]); export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; -export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 53a6dc97f5a4..dba1e2b84c37 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -175,6 +175,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { get: Effect.sync(() => testSettings), load: Effect.sync(() => testSettings), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: (channel) => diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 6f10d69d7a31..6d6d0febda97 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -81,6 +81,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => activate: Effect.void, createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index bdd03865c7bf..1549399c52e2 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -234,6 +234,7 @@ function makeTestLayer(input: { } return { settings: desktopSettings, changed }; }), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), @@ -571,6 +572,25 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens once the renderer is ready without a backend callback", () => + 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, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleRendererReady; + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + }).pipe(Effect.provide(layer)); + }), + ); it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index d87c74428a99..a1b3b8c48ca9 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -86,6 +86,9 @@ export class DesktopWindow extends Context.Service< // mode), before the WSL backend that serves the renderer is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; + // Marks the packaged/Vite renderer as loadable independently of whether + // this desktop process owns a backend. + readonly handleRendererReady: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the // macOS "activate without windows" path may open the real main window. The // renderer now always loads the local client URL (getDesktopUrl) and connects @@ -850,6 +853,11 @@ export const make = Effect.gen(function* () { Effect.withSpan("desktop.window.showConnectingSplash"), ); + const handleRendererReady = Ref.set(backendReadyRef, true).pipe( + Effect.andThen(createMainIfBackendReady), + Effect.withSpan("desktop.window.handleRendererReady"), + ); + return DesktopWindow.of({ createMain, ensureMain, @@ -877,10 +885,10 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, showConnectingSplash, + handleRendererReady, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { - yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - yield* createMainIfBackendReady; + yield* handleRendererReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 9dbe43b9650d..e1188e1a3387 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -12,21 +12,17 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - buildWslNodeEnvPreamble, buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, DesktopWslDistroListError, formatMissingToolsReason, - formatNodePtyProbeFailureReason, - formatWslShellTransportFailureReason, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, parseWslRuntimeRoot, probeWslDistros, - sanitizeWslRuntimeId, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -144,46 +140,19 @@ describe("probeWslDistros", () => { }); }); -describe("formatNodePtyProbeFailureReason", () => { - it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { - const reason = formatNodePtyProbeFailureReason(4); - - expect(reason).toContain("packaged Linux node-pty binary was not included"); - expect(reason).toContain("--wsl-prebuild"); - }); - - it("leaves other node-pty load failures to the compatibility diagnostic", () => { - expect(formatNodePtyProbeFailureReason(1)).toBeNull(); - }); -}); - -describe("formatWslShellTransportFailureReason", () => { - it("distinguishes timeouts and spawn failures from normal shell exit codes", () => { - expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out"); - expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe"); - expect(formatWslShellTransportFailureReason("process")).toContain("lost communication"); - expect(formatWslShellTransportFailureReason(null)).toBeNull(); - }); -}); - -describe("buildWslNodeEnvPreamble", () => { - it("passes the required Node engine range into the shared resolver", () => { - const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); - - expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); - expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( - preamble.lastIndexOf("ensure_remote_node_path || true"), - ); - }); - - it("keeps the shared resolver permissive when no Node engine range is provided", () => { - expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); - }); -}); - describe("WSL runtime cache", () => { - it("sanitizes cache ids before interpolating them into Linux paths", () => { - expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope"); + it.each([ + [ + "install", + (id: string) => buildWslRuntimeInstallScript("/runtime.tar.gz", id, "b".repeat(64)), + ], + ["prune", buildWslRuntimePruneScript], + ["invalidate", buildWslRuntimeInvalidateScript], + ] as const)("sanitizes cache ids in the %s script", (_, buildScript) => { + const runtimeId = "1.2.3/x64; touch /tmp/nope"; + const script = buildScript(runtimeId); + expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); + expect(script).not.toContain(runtimeId); }); it("installs through a temporary directory and only reuses valid completed caches", () => { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index b0c9f5ffe44b..d49d95676e64 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -147,7 +147,7 @@ const TIMEOUT_RESULT: ShellResult = { transportFailure: "timeout", }; -export const formatWslShellTransportFailureReason = ( +const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], ): string | null => { switch (failure) { @@ -165,7 +165,7 @@ export const formatWslShellTransportFailureReason = ( // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node // the same way. Passing the engine range lets the resolver fall through to // version managers like nvm when a system node exists but is too old. -export const buildWslNodeEnvPreamble = ( +const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} ensure_remote_node_path || true @@ -263,8 +263,7 @@ const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; -export const sanitizeWslRuntimeId = (value: string): string => - value.replace(/[^A-Za-z0-9._-]/g, "_"); +const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_"); // `archiveSha256` is the digest the build recorded alongside the archive. The // install verifies the bytes before extracting, so an archive can never be @@ -491,7 +490,7 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; -export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => +const formatNodePtyProbeFailureReason = (exitCode: number): string | null => exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild ` or install a build that includes WSL support." : null; diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index 41e358227e1e..dd750164b381 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect } from "vite-plus/test"; import { - DISTRO_NAME_PATTERN, extractDistroFromUncPath, isValidDistroName, parseWslDistroList, - resolveWslHomeUncPath, resolveWslPickFolderDefaultPath, wslUncPathToLinuxPath, } from "./wslPathParsing.ts"; @@ -116,29 +114,6 @@ describe("wslUncPathToLinuxPath", () => { }); }); -describe("resolveWslHomeUncPath", () => { - const distros = [ - { name: "Debian", isDefault: true, version: 2 as const }, - { name: "Ubuntu", isDefault: false, version: 2 as const }, - ]; - - it("uses the configured distro when one is selected", () => { - expect(resolveWslHomeUncPath({ distro: "Ubuntu" }, distros)).toBe( - "\\\\wsl.localhost\\Ubuntu\\home", - ); - }); - - it("uses the actual default distro when config uses the WSL default", () => { - expect(resolveWslHomeUncPath({ distro: null }, distros)).toBe( - "\\\\wsl.localhost\\Debian\\home", - ); - }); - - it("omits the default path when no default distro is known", () => { - expect(resolveWslHomeUncPath({ distro: null }, [])).toBeNull(); - }); -}); - describe("resolveWslPickFolderDefaultPath", () => { const config = { distro: null }; const distros = [{ name: "Debian", isDefault: true, version: 2 as const }]; @@ -184,23 +159,22 @@ describe("resolveWslPickFolderDefaultPath", () => { }); }); -describe("DISTRO_NAME_PATTERN / isValidDistroName", () => { +describe("isValidDistroName", () => { it("accepts common distro names", () => { for (const name of ["Ubuntu", "Ubuntu-22.04", "kali-linux", "Debian", "Ubuntu 22.04"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(true); expect(isValidDistroName(name)).toBe(true); } }); it("rejects names with trailing whitespace, hyphen, or dot", () => { for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); it("rejects names containing control or shell-meta characters", () => { for (const name of ["bad\nname", "bad\tname", "bad/name", "bad!name", "bad;name"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); }); diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index edbab81f6dc2..baae217c823d 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -10,7 +10,7 @@ export interface WslConfig { // Literal space — \s would also match \n/\t/\r and corrupt UNC paths like \\wsl.localhost\\... // Trailing char must also be \w so hand-edited config like "Ubuntu " / "Ubuntu-" / "Ubuntu." rejects. -export const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; +const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; @@ -61,10 +61,7 @@ export function wslUncPathToLinuxPath(windowsPath: string): string | null { return `/${rest.split("\\").filter(Boolean).join("/")}`; } -export function resolveWslHomeUncPath( - config: WslConfig, - distros: readonly WslDistro[], -): string | null { +function resolveWslHomeUncPath(config: WslConfig, distros: readonly WslDistro[]): string | null { const distroName = config.distro ?? distros.find((distro) => distro.isDefault)?.name ?? null; return distroName ? `\\\\wsl.localhost\\${distroName}\\home` : null; } diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 6f37ae922dad..5ba3da4fba10 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,6 +1,7 @@ import { defineConfig } from "astro/config"; export default defineConfig({ + site: "https://t3.codes", server: { port: Number(process.env.PORT ?? 4173), }, diff --git a/apps/marketing/public/95/providers/antigravity-320.webp b/apps/marketing/public/95/providers/antigravity-320.webp new file mode 100644 index 000000000000..979fa8b67ab9 Binary files /dev/null and b/apps/marketing/public/95/providers/antigravity-320.webp differ diff --git a/apps/marketing/public/95/providers/antigravity-640.webp b/apps/marketing/public/95/providers/antigravity-640.webp new file mode 100644 index 000000000000..dfc03912b3f6 Binary files /dev/null and b/apps/marketing/public/95/providers/antigravity-640.webp differ diff --git a/apps/marketing/public/95/providers/antigravity-960.webp b/apps/marketing/public/95/providers/antigravity-960.webp new file mode 100644 index 000000000000..19f27adf2eaa Binary files /dev/null and b/apps/marketing/public/95/providers/antigravity-960.webp differ diff --git a/apps/marketing/public/95/providers/claude-code-320.webp b/apps/marketing/public/95/providers/claude-code-320.webp new file mode 100644 index 000000000000..28a15e299b2b Binary files /dev/null and b/apps/marketing/public/95/providers/claude-code-320.webp differ diff --git a/apps/marketing/public/95/providers/claude-code-640.webp b/apps/marketing/public/95/providers/claude-code-640.webp new file mode 100644 index 000000000000..2df6de1ac6ae Binary files /dev/null and b/apps/marketing/public/95/providers/claude-code-640.webp differ diff --git a/apps/marketing/public/95/providers/claude-code-960.webp b/apps/marketing/public/95/providers/claude-code-960.webp new file mode 100644 index 000000000000..0b6ed03fefce Binary files /dev/null and b/apps/marketing/public/95/providers/claude-code-960.webp differ diff --git a/apps/marketing/public/95/providers/codex-320.webp b/apps/marketing/public/95/providers/codex-320.webp new file mode 100644 index 000000000000..ec67c7949954 Binary files /dev/null and b/apps/marketing/public/95/providers/codex-320.webp differ diff --git a/apps/marketing/public/95/providers/codex-640.webp b/apps/marketing/public/95/providers/codex-640.webp new file mode 100644 index 000000000000..9d1d041f0c1e Binary files /dev/null and b/apps/marketing/public/95/providers/codex-640.webp differ diff --git a/apps/marketing/public/95/providers/codex-960.webp b/apps/marketing/public/95/providers/codex-960.webp new file mode 100644 index 000000000000..a19fe1010ffa Binary files /dev/null and b/apps/marketing/public/95/providers/codex-960.webp differ diff --git a/apps/marketing/public/95/providers/cursor-320.webp b/apps/marketing/public/95/providers/cursor-320.webp new file mode 100644 index 000000000000..32e5e9d27e99 Binary files /dev/null and b/apps/marketing/public/95/providers/cursor-320.webp differ diff --git a/apps/marketing/public/95/providers/cursor-640.webp b/apps/marketing/public/95/providers/cursor-640.webp new file mode 100644 index 000000000000..f5bbf729993e Binary files /dev/null and b/apps/marketing/public/95/providers/cursor-640.webp differ diff --git a/apps/marketing/public/95/providers/cursor-960.webp b/apps/marketing/public/95/providers/cursor-960.webp new file mode 100644 index 000000000000..4480a3ebda96 Binary files /dev/null and b/apps/marketing/public/95/providers/cursor-960.webp differ diff --git a/apps/marketing/public/95/providers/grok-320.webp b/apps/marketing/public/95/providers/grok-320.webp new file mode 100644 index 000000000000..0e8a945c0392 Binary files /dev/null and b/apps/marketing/public/95/providers/grok-320.webp differ diff --git a/apps/marketing/public/95/providers/grok-640.webp b/apps/marketing/public/95/providers/grok-640.webp new file mode 100644 index 000000000000..374fcc4bc1ff Binary files /dev/null and b/apps/marketing/public/95/providers/grok-640.webp differ diff --git a/apps/marketing/public/95/providers/grok-960.webp b/apps/marketing/public/95/providers/grok-960.webp new file mode 100644 index 000000000000..bcb0cc8902af Binary files /dev/null and b/apps/marketing/public/95/providers/grok-960.webp differ diff --git a/apps/marketing/public/95/providers/opencode-320.webp b/apps/marketing/public/95/providers/opencode-320.webp new file mode 100644 index 000000000000..86e97872beda Binary files /dev/null and b/apps/marketing/public/95/providers/opencode-320.webp differ diff --git a/apps/marketing/public/95/providers/opencode-640.webp b/apps/marketing/public/95/providers/opencode-640.webp new file mode 100644 index 000000000000..44720f0e7681 Binary files /dev/null and b/apps/marketing/public/95/providers/opencode-640.webp differ diff --git a/apps/marketing/public/95/providers/opencode-960.webp b/apps/marketing/public/95/providers/opencode-960.webp new file mode 100644 index 000000000000..312f1144fc53 Binary files /dev/null and b/apps/marketing/public/95/providers/opencode-960.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp new file mode 100644 index 000000000000..a05a5738a6cd Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp new file mode 100644 index 000000000000..1a16b037cf0b Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-lime-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-320.webp new file mode 100644 index 000000000000..b380194d23e1 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-lime-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-640.webp new file mode 100644 index 000000000000..046fdff0c0dc Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-lime.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-lime.webp new file mode 100644 index 000000000000..ebce442dba7f Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-lime.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-320.webp new file mode 100644 index 000000000000..3ab511b49eef Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-640.webp new file mode 100644 index 000000000000..6fa9e045056b Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds.webp new file mode 100644 index 000000000000..f9f76140e243 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp new file mode 100644 index 000000000000..dd72a9110265 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp new file mode 100644 index 000000000000..3aa1c274507b Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp new file mode 100644 index 000000000000..e2510c829bb1 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp new file mode 100644 index 000000000000..f3ba3edb5190 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp new file mode 100644 index 000000000000..8971323e7af5 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp new file mode 100644 index 000000000000..aa535e600a57 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp differ diff --git a/apps/marketing/src/components/RetroBox.astro b/apps/marketing/src/components/RetroBox.astro new file mode 100644 index 000000000000..81fcc1eb159f --- /dev/null +++ b/apps/marketing/src/components/RetroBox.astro @@ -0,0 +1,23 @@ +--- +interface Props { + image: string; + alt: string; + sizes: string; + eager?: boolean; +} + +const { image, alt, sizes, eager = false } = Astro.props; +const base = `/95/t3-code-concepts/${image}`; +--- + + diff --git a/apps/marketing/src/components/RetroIcon.astro b/apps/marketing/src/components/RetroIcon.astro new file mode 100644 index 000000000000..21ff375bc5a3 --- /dev/null +++ b/apps/marketing/src/components/RetroIcon.astro @@ -0,0 +1,57 @@ +--- +interface Props { + name: "computer" | "disk" | "globe" | "bin" | "folder" | "cursor"; + size?: number; +} + +const { name, size = 32 } = Astro.props; +--- + + diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index ca5ea15f61de..4c95bc86560e 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,6 +1,7 @@ --- -import { Image } from "astro:assets"; +import { getImage, Image } from "astro:assets"; import appIcon from "../assets/icon.webp"; +import desktopScreenshot from "../assets/app-desktop.webp"; import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; import "../styles/fonts.css"; import { @@ -21,6 +22,21 @@ const { description = "T3 Code. The open-source control plane for coding agents.", pageClass, } = Astro.props; + +// Social preview card. Link unfurlers (Instagram, iMessage, X, Slack) want a +// 1200x630 jpg or png at an absolute URL. Built from the hero screenshot so it +// stays in sync with the homepage. +const socialImage = await getImage({ + src: desktopScreenshot, + width: 1200, + height: 630, + fit: "cover", + position: "top", + format: "jpg", + quality: 90, +}); +const socialImageUrl = new URL(socialImage.src, Astro.site); +const canonicalUrl = new URL(Astro.url.pathname, Astro.site); --- @@ -28,6 +44,21 @@ const { + + + + + + + + + + + + + + + + + + + + + + + T3 Code '95 | The future is now. Like, right now. + + + + + + +
+
+
+ T3 Code '95 - Internet Explorer +
+ + + +
+
+ + + +
+ Address +
https://t3.codes/95
+ Go +
+ +
+
+
+

All your agents.
One happy
little window.

+

Your coding agents in one free, open-source app.
You bring the subscriptions. We bring the buttons.

+
AVAILABLE FORWindows·macOS·Linux
+
+ + +
+ +
+

Bring your favorite agents.

+
+ {agents.map((agent, index) => ( +
+ `/95/providers/${agent.slug}-${width}.webp ${width}w`).join(", ")} + sizes="auto, (max-width: 620px) 43vw, (max-width: 1100px) 27vw, 280px" + alt={agent.artwork} + width="640" + height="800" + loading={index < 3 ? "eager" : "lazy"} + decoding="async" + /> +
+

{agent.name}

+

{agent.caption}

+
+
+ ))} +
+

Use the providers and subscriptions you already have. Provider charges still apply.

+

Parody packaging. No actual boxes for sale.

+
+ +
+

Inside every copy.

+
+
+
01_multitasking.exe
+

More agents.
Less window aerobics.

Run agents side by side. Keep your threads, terminals, and diffs together. Reclaim your Alt-Tab finger.

+
+
+
02_remote_access.exe
+

Your computer.
Now over there.

Connect from the web, desktop, or mobile. Your agents keep working on your machine. You may approach the couch.

+
+
+
03_source_code.txt
+

We left the
source code in.

Read it. Change it. Fork the whole thing. It's MIT licensed, because your tools should actually be yours.

View source
+
+
+
04_terminal.exe
+

A terminal.
Right where you work.

Run commands alongside your agents. Keep the output with the project.

+
+
+
05_changes.diff
+

See what
actually changed.

Inspect file diffs and review the work before you ship it.

+
+
+
06_checkpoint.bak
+

A way
back.

Use turn checkpoints to inspect changes and restore earlier work.

+
+
+
+ +
+

For just 0 easy payments
of absolutely nothing.

Get the app. Keep your subscriptions.

GET T3 CODE. IT'S FREE.

T3 Code is free. Your AI provider may charge for use.

+
Run

Too cool for an installer?

Node.js required. Modem optional.

System requirements
  • A modern computer
  • A supported coding agent
  • A dream, ideally a small one

Does not actually run on Windows 95.

+
+ +
+

Questions

+
Wait. Is this a real product?

Yes. T3 Code is a real, free, open-source app used by {MARKETING_STATS.users} developers. The packaging is a joke. The app is not. Visit the regular website.

+
Does this replace my Claude or Codex subscription?

No. T3 Code connects to the coding agents you already use. Keep your provider accounts and subscriptions. T3 Code gives you one app to work with them.

+
Will it run on Windows 95?

Absolutely not. We brought back the look, not the driver problems. Get a build for a current version of Windows, macOS, or Linux.

+
Where do I mail my check?

Please do not mail us a check for zero dollars. Just download the app. The entire accounts department is a download button.

+
+ + +
+ +
Done. Internet
+
+ + +
+ +
+
Start
+ + +
4:04 PM
+
+ +
T3 Code '95

Get T3 Code
+ + + + diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 3ee459c97dfc..7cde6e35fdc4 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -205,7 +205,11 @@ const screenshot = await getImage({
-
+ @@ -826,8 +830,11 @@ const screenshot = await getImage({ gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; - padding: 4px max(32px, calc((100vw - 1240px) / 2 + 32px)); - scroll-padding-inline: max(32px, calc((100vw - 1240px) / 2 + 32px)); + /* Percentages resolve against this element's own box rather than the + viewport, so the first card lines up with the heading's .container edge + even when a classic scrollbar makes 100vw wider than the layout. */ + padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px)); + scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px)); } .endorsement-card { @@ -899,6 +906,11 @@ const screenshot = await getImage({ align-items: stretch; gap: 20px; } + .git-visual .btn { + line-height: normal; + pointer-events: none; + } + .pr-card { padding: 20px; } .pr-head { display: flex; align-items: center; gap: 10px; @@ -952,6 +964,7 @@ const screenshot = await getImage({ padding: 12px 20px; background: var(--fg); color: #09090b; font-weight: 600; font-size: 13px; + line-height: normal; border-radius: 10px; box-shadow: 0 8px 24px -8px rgba(255, 255, 255, 0.2); } diff --git a/apps/marketing/src/styles/retro.css b/apps/marketing/src/styles/retro.css new file mode 100644 index 000000000000..df166eb71ded --- /dev/null +++ b/apps/marketing/src/styles/retro.css @@ -0,0 +1,1244 @@ +/* This page has its own document so the retro styles do not affect other pages. */ +:root { + color-scheme: dark; + font-family: Tahoma, Verdana, Arial, sans-serif; + color: #fff; + background: #000; + --silver: #c0c0c0; + --yellow: #eaff00; + --pink: #ff79bd; + --navy: #000080; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + height: 100dvh; + overflow: hidden; +} +button, +input { + font: inherit; +} +button, +a, +summary { + -webkit-tap-highlight-color: transparent; +} +button, +summary { + cursor: pointer; +} +button { + color: inherit; +} +a { + color: inherit; +} +button { + border-radius: 0; +} +svg { + flex-shrink: 0; +} +[hidden] { + display: none !important; +} +:focus-visible { + outline: 2px dashed var(--pink); + outline-offset: 4px; +} +section { + scroll-margin-top: 24px; +} +.skip-link { + position: fixed; + top: -80px; + left: 12px; + z-index: 100; +} +.skip-link:focus { + top: 12px; +} +.raised { + border: 2px solid; + border-color: #fff #333 #333 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; +} +.sunken { + border: 2px solid; + border-color: #808080 #fff #fff #808080; + box-shadow: inset 1px 1px #000; +} +.retro-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border: 2px solid; + border-color: #fff #000 #000 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; + padding: 8px 16px; + background: var(--silver); + color: #000; + text-decoration: none; + font-size: 12px; + font-weight: 700; +} +.retro-button:active { + border-color: #000 #fff #fff #000; + box-shadow: inset 1px 1px #808080; +} +.retro-button:hover { + background: #d7d7d7; +} +.desktop { + position: fixed; + inset: 0 0 42px; + z-index: 1; + max-width: 1280px; + margin: 0 auto; + padding: 28px 30px 40px 112px; + pointer-events: none; +} +.desktop-icons { + position: absolute; + top: 37px; + left: max(10px, calc((100vw - 1280px) / 2 + 10px)); + width: 83px; + display: grid; + gap: 29px; +} +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + border: 0; + background: none; + color: #fff; + text-align: center; + text-decoration: none; + font-size: 11px; + line-height: 1.4; + padding: 3px 0; +} +.desktop-icon:hover span, +.desktop-icon:focus-visible span { + background: var(--navy); +} +.browser-window { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 3px; + background: var(--silver); + pointer-events: auto; + transform: translate(var(--window-x, 0px), var(--window-y, 0px)); +} +.browser-window > :not(main) { + flex-shrink: 0; +} +#window-title { + cursor: grab; + touch-action: none; + user-select: none; +} +#window-title.dragging { + cursor: grabbing; +} +#window-title:focus-visible { + outline-offset: -2px; +} +.window-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 27px; + padding: 3px 4px 3px 6px; + background: linear-gradient(90deg, #000080, #2253a4); + color: #fff; + font-size: 12px; + font-weight: 700; +} +.window-name { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} +.window-name > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.window-controls { + display: flex; + gap: 3px; +} +.window-control { + width: 20px; + height: 20px; + min-width: 20px; + padding: 0; + font: + 700 18px Arial, + sans-serif; +} +.window-control:first-child { + font-size: 17px; +} +.maximize-icon { + width: 10px; + height: 10px; + border: 1px solid #000; + border-top-width: 3px; +} +.browser-menu { + display: flex; + align-items: center; + gap: 2px; + padding: 3px 4px; + color: #000; +} +.browser-menu > a, +.browser-menu > button { + padding: 5px 8px; + border: 0; + background: none; + text-decoration: none; + font-size: 11px; +} +.browser-menu > a:hover, +.browser-menu > button:hover { + color: #fff; + background: var(--navy); +} +.address-bar { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 7px 9px; + color: #000; + font-size: 11px; +} +.address-field { + display: flex; + align-items: center; + gap: 7px; + flex: 1; + padding: 4px 6px; + background: #fff; + min-width: 0; +} +.address-field > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.address-go { + align-self: stretch; + padding: 2px 9px; + font-weight: 400; +} +main { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + border: 2px solid; + border-color: #555 #fff #fff #555; + background: #000; +} +main::-webkit-scrollbar { + width: 16px; + height: 16px; +} +main::-webkit-scrollbar-track, +main::-webkit-scrollbar-corner { + background: #dfdfdf; +} +main::-webkit-scrollbar-thumb { + border: 2px solid; + border-color: #fff #333 #333 #fff; + background: var(--silver); + box-shadow: inset -1px -1px #808080; +} +.hero { + display: grid; + grid-template-columns: 1.1fr 1fr; + align-items: center; + padding: 16px 35px 20px; + gap: 8px; +} +.hero h1 { + font-size: clamp(36px, 3.4vw, 48px); + letter-spacing: -2px; +} +.hero .hero-explanation { + margin: 14px 0 0; +} +.edition-packages { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: end; +} +.hero-package { + display: block; + width: min(100%, 170px); + margin: 0 auto; + text-decoration: none; +} +.hero-package > img { + display: block; + width: 100%; + height: auto; +} +.hero-package > .edition-download { + display: flex; + justify-content: center; + padding: 6px 8px; + margin-top: 4px; + font-size: 11px; + background: var(--yellow); +} +.nightly-package > .edition-download { + background: #c9bdff; +} +h1 { + margin: 0; + font: + 900 clamp(40px, 4.7vw, 62px)/0.99 Arial, + Helvetica, + sans-serif; + letter-spacing: -3.3px; +} +h1 > span { + color: var(--yellow); +} +.hero-explanation { + max-width: 360px; + margin: 0 0 23px; + font-size: 12px; + line-height: 1.7; + color: #c0c0c0; +} +.primary-cta { + padding: 13px 17px; + gap: 12px; + background: var(--yellow); + border-color: #ffffd1 #737c00 #737c00 #ffffd1; + box-shadow: + inset -1px -1px #a3b000, + inset 1px 1px #ffffbd; + font: + 900 13px Arial, + sans-serif; + letter-spacing: 0.3px; +} +.primary-cta > span:last-child { + font-size: 22px; + margin-left: 8px; +} +.primary-cta:hover { + background: #f2ff73; +} +.platform-line { + display: flex; + margin-top: 16px; + flex-wrap: wrap; + align-items: center; + gap: 11px; + font-size: 10px; +} +.platform-line > span { + font: + 8px "Courier New", + monospace; + color: #ababab; + letter-spacing: 0.5px; +} +.platform-line > strong { + font-weight: 400; +} +.platform-line > b { + color: #656565; +} +.box-95 { + position: absolute; + right: 9px; + bottom: -6px; + font: + italic 900 75px Arial, + sans-serif; + color: var(--yellow); + letter-spacing: -6px; +} +.agents-section { + padding: 14px 35px 24px; + border-top: 1px solid #363636; + border-bottom: 1px solid #363636; +} +.agent-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 26px 20px; + margin: 10px 0 24px; +} +.agent { + min-width: 0; + margin: 0; + text-align: center; +} +.agent-box { + display: block; + width: 100%; + max-width: 200px; + height: auto; + margin: 0 auto; + object-fit: contain; +} +.agents-section > .section-heading { + margin-bottom: 0; +} +.agent > figcaption { + padding: 12px 4px 0; + border-top: 3px ridge #777; +} +.agent h3 { + margin: 0 0 6px; + font-size: 14px; +} +.agent p { + max-width: 27ch; + margin: 0 auto; + color: var(--yellow); + font: + 11px/1.5 "Courier New", + monospace; +} +.agents-note { + margin: 0; + color: #aaa; + font: + 9px/1.5 "Courier New", + monospace; +} +.features-section { + padding: 33px 35px 38px; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 20px; + margin-bottom: 21px; +} +.section-heading h2 { + font: + 900 20px/1.2 Arial, + sans-serif; + letter-spacing: -0.6px; + margin: 0; +} +.section-heading h2 > span { + color: var(--yellow); +} +.section-heading > span { + font: + 8px/1.5 "Courier New", + monospace; + color: #aaa; + text-align: right; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; +} +.feature-window { + min-width: 0; + padding: 3px; + background: var(--silver); +} +.feature-titlebar { + display: flex; + justify-content: space-between; + gap: 5px; + padding: 5px 6px; + background: #393939; + font: + 9px "Courier New", + monospace; +} +.feature-body { + background: #0b0b0b; + padding: 18px 15px 17px; + display: flex; + flex-direction: column; + align-items: flex-start; + height: calc(100% - 21px); +} +.feature-body h3 { + font: + 700 18px/1.15 Arial, + sans-serif; + margin: 16px 0 12px; + letter-spacing: -0.3px; +} +.feature-body p { + margin: 0 0 22px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; +} +.feature-tag { + display: block; + margin-top: auto; + color: var(--yellow); + font: + 700 8px/1.6 "Courier New", + monospace; + letter-spacing: 0.3px; + text-decoration: none; +} +a.feature-tag { + text-decoration: underline; + text-underline-offset: 3px; +} +.order-section { + position: relative; + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 45px; + padding: 32px 35px; + border-top: 1px solid #546124; + border-bottom: 1px solid #546124; + background: #121707; + align-items: center; +} +.order-pitch h2 { + font: + 900 30px/1.1 Arial, + sans-serif; + letter-spacing: -1px; + margin: 15px 0 12px; +} +.order-pitch h2 > span { + color: var(--yellow); +} +.order-pitch > p { + font-size: 11px; + line-height: 1.6; + margin: 0 0 23px; +} +.order-pitch .offer-note { + color: #b9bea9; + font: + 9px/1.7 "Courier New", + monospace; + margin: 13px 0 0; +} +.run-window { + padding: 3px; + background: var(--silver); + color: #000; +} +.run-window .window-title { + min-height: 23px; + font-size: 11px; +} +.run-body { + padding: 14px 13px; + font-size: 11px; +} +.run-body > p:first-child { + margin: 0 0 14px; + font-weight: 700; +} +.run-body label { + font-size: 10px; +} +.command-row { + display: flex; + gap: 7px; + margin-top: 7px; +} +.command-row input { + width: 0; + min-width: 0; + flex: 1; + border-radius: 0; + padding: 7px 8px; + background: #fff; + color: #000; + font: + 700 15px "Courier New", + monospace; +} +.command-row button { + padding: 5px 12px; +} +.command-feedback { + min-height: 27px; + margin: 8px 0 10px; + font: + 9px/1.5 "Courier New", + monospace; +} +.run-divider { + border-top: 1px solid #808080; + border-bottom: 1px solid #fff; + margin: 0 0 13px; +} +.run-body > b { + font-size: 10px; +} +.run-body ul { + padding-left: 17px; + margin: 7px 0 10px; + font-size: 10px; + line-height: 1.8; +} +.requirements-note { + font: + 8px "Courier New", + monospace; + margin-bottom: 0; +} +.faq-section { + padding: 34px 35px; +} +.faq-section > h2 { + margin: 0 0 20px; + color: var(--pink); + font: + 700 11px "Courier New", + monospace; +} +.faq-section > details { + border-top: 1px dotted #626262; +} +.faq-section > details:last-child { + border-bottom: 1px dotted #626262; +} +.faq-section summary { + padding: 13px 2px; + font-size: 12px; +} +.faq-section summary::marker { + color: var(--yellow); +} +.faq-section details p { + margin: 0; + padding: 0 20px 16px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; + max-width: 740px; +} +.faq-section a { + color: var(--yellow); + text-underline-offset: 3px; +} +.site-footer { + margin: 0 35px; + padding: 25px 0 27px; + text-align: center; + border-top: 1px solid #363636; +} +.visitor-counter { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px; + font: + 9px "Courier New", + monospace; + color: #c0c0c0; +} +.counter-digits { + display: inline-flex; + gap: 2px; + padding: 3px; + border: 2px inset #666; + background: #141414; +} +.counter-digits > span { + display: block; + padding: 2px 4px; + background: #252b1a; + color: var(--yellow); + font: + 700 15px "Courier New", + monospace; +} +.site-footer > p { + color: #b0b0b0; + font: + 9px/1.6 "Courier New", + monospace; + margin: 0 0 10px; +} +.site-footer nav { + display: flex; + flex-wrap: wrap; + gap: 18px; + justify-content: center; + font: + 9px "Courier New", + monospace; +} +.site-footer nav a { + color: var(--pink); + text-underline-offset: 3px; +} +.browser-status { + display: flex; + align-items: stretch; + gap: 4px; + height: 24px; + padding-top: 4px; + color: #000; + font-size: 10px; +} +.browser-status > span { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; +} +.browser-status > span:first-child { + flex: 1; +} +.browser-status > span:nth-child(2) { + min-width: 120px; +} +.resize-grip { + width: 13px; + background: repeating-linear-gradient(135deg, transparent 0 2px, #808080 2px 3px, #fff 3px 4px); + clip-path: polygon(100% 0, 100% 100%, 0 100%); +} +.taskbar { + position: fixed; + z-index: 10; + left: 0; + right: 0; + bottom: 0; + min-height: 40px; + padding: 3px 5px; + background: var(--silver); + color: #000; + display: flex; + align-items: center; + gap: 8px; +} +.start-menu { + position: relative; +} +.start-button { + gap: 7px; + padding: 4px 8px; + min-height: 30px; + font-size: 14px; + list-style: none; +} +.start-button::-webkit-details-marker { + display: none; +} +.start-mark { + display: grid; + grid-template-columns: 8px 8px; + gap: 2px; + transform: skewY(-8deg); +} +.start-mark i { + width: 8px; + height: 8px; + background: #f3433c; +} +.start-mark i:nth-child(2) { + background: #75b53c; +} +.start-mark i:nth-child(3) { + background: #347be3; +} +.start-mark i:nth-child(4) { + background: #ffe348; +} +.start-panel { + position: absolute; + bottom: calc(100% + 5px); + left: -1px; + display: flex; + width: 253px; + padding: 3px; + background: var(--silver); +} +.start-brand { + writing-mode: vertical-rl; + transform: rotate(180deg); + background: #808080; + color: #dedede; + padding: 12px 8px; + font: + 900 18px Arial, + sans-serif; + white-space: nowrap; +} +.start-brand b { + color: #fff; +} +.start-panel > div:last-child { + flex: 1; +} +.start-panel a, +.start-panel button { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 10px; + width: 100%; + background: none; + border: 0; + color: #000; + text-decoration: none; + font-size: 11px; + text-align: left; +} +.start-panel a:hover, +.start-panel button:hover { + background: var(--navy); + color: #fff; +} +.taskbar-divider { + align-self: stretch; + border-left: 1px solid #808080; + border-right: 1px solid #fff; +} +.task-button { + display: flex; + align-items: center; + gap: 8px; + background: #d7d7d7; + color: #000; + padding: 3px 8px; + min-height: 29px; + min-width: 170px; + font-size: 11px; + font-weight: 700; + text-align: left; +} +.taskbar-clock { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 4px 10px; + min-height: 29px; + font-size: 11px; + white-space: nowrap; +} +.taskbar-clock > span:first-child { + font-size: 16px; +} +.maximized .desktop { + max-width: none; + padding: 0; +} +.maximized .browser-window { + transform: none; +} +.maximized #window-title { + cursor: default; +} +.minimized-message { + height: 100%; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + text-align: center; +} +.minimized-message h1 { + font: + 700 25px Arial, + sans-serif; + letter-spacing: -0.5px; +} +.minimized-message p { + font: + 12px "Courier New", + monospace; + margin: 0 0 10px; +} +.retro-dialog { + width: min(440px, calc(100vw - 32px)); + padding: 3px; + background: var(--silver); + color: #000; +} +.retro-dialog::backdrop { + background: #000b; +} +.dialog-content { + display: flex; + align-items: center; + gap: 20px; + padding: 22px 20px 13px; +} +.dialog-content > p { + font-size: 12px; + line-height: 1.7; + margin: 0; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 16px 17px; +} + +@media (min-width: 1450px) { + .maximized .hero { + grid-template-columns: 1fr 1fr; + padding-left: 65px; + padding-right: 65px; + } + .maximized h1 { + font-size: 76px; + } +} + +@media (max-width: 1100px) { + .desktop { + padding-left: 98px; + padding-right: 15px; + } + .hero { + padding-left: 25px; + padding-right: 25px; + } + h1 { + font-size: 47px; + letter-spacing: -2.6px; + } + .order-section { + gap: 23px; + } + .order-pitch h2 { + font-size: 27px; + } + .feature-grid { + gap: 10px; + } + .feature-body { + padding: 15px 11px; + } +} + +@media (max-width: 820px) { + .desktop { + padding: 14px 12px; + } + .desktop-icons { + display: none; + } + .hero { + grid-template-columns: 1.1fr 0.9fr; + gap: 0; + padding-top: 29px; + } + h1 { + font-size: 44px; + } + .hero-explanation { + max-width: 295px; + } + .primary-cta { + font-size: 11px; + gap: 7px; + padding: 12px; + } + .primary-cta > span:last-child { + margin-left: 3px; + } + .section-heading { + display: block; + } + .section-heading > span { + display: block; + text-align: left; + margin-top: 8px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 25px; + padding-right: 25px; + } + .site-footer { + margin-left: 25px; + margin-right: 25px; + } + .feature-body h3 { + font-size: 16px; + } + .taskbar-clock { + margin-left: auto; + } +} + +@media (max-width: 620px) { + .browser-menu { + flex-wrap: wrap; + } + .hero-package { + width: min(100%, 160px); + } + .edition-packages { + width: 100%; + margin-top: 12px; + } + .desktop { + padding: 9px 7px; + } + .window-name { + font-size: 10px; + } + .window-controls { + gap: 2px; + } + .browser-menu { + gap: 0; + } + .browser-menu > a, + .browser-menu > button { + padding: 6px 7px; + font-size: 10px; + } + .address-bar { + padding: 3px 4px 7px; + gap: 6px; + font-size: 10px; + } + .address-go { + font-size: 10px; + } + .hero { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 29px 20px 15px; + } + h1 { + font-size: clamp(41px, 10.5vw, 63px); + letter-spacing: -2.5px; + } + .hero-explanation { + max-width: 420px; + font-size: 11px; + } + .primary-cta { + font-size: 12px; + padding: 12px 15px; + gap: 10px; + } + .platform-line { + font-size: 9px; + gap: 9px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding: 25px 20px; + } + .agent-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px 12px; + margin-top: 20px; + } + .agent h3 { + font-size: 13px; + } + .agent p { + font-size: 10px; + } + .agents-note { + font-size: 8px; + } + .section-heading h2 { + font-size: 20px; + } + .section-heading h2 > span { + display: block; + } + .feature-grid { + grid-template-columns: 1fr; + gap: 17px; + } + .feature-body { + padding: 17px; + height: auto; + } + .feature-body h3 { + font-size: 21px; + margin-top: 13px; + } + .feature-body p { + font-size: 12px; + margin-bottom: 18px; + } + .feature-tag { + font-size: 9px; + } + .feature-titlebar { + font-size: 10px; + } + .order-section { + grid-template-columns: 1fr; + gap: 25px; + } + .order-pitch h2 { + font-size: 29px; + } + .order-pitch > p { + font-size: 11px; + } + .run-body { + padding: 16px; + } + .command-feedback { + min-height: 15px; + } + .faq-section h2 { + font-size: 10px; + line-height: 1.5; + } + .faq-section summary { + font-size: 11px; + line-height: 1.5; + } + .site-footer { + margin-left: 20px; + margin-right: 20px; + } + .visitor-counter { + font-size: 8px; + } + .site-footer > p { + font-size: 8px; + } + .site-footer nav { + font-size: 8px; + gap: 15px; + } + .browser-status { + font-size: 8px; + height: 26px; + } + .browser-status > span:nth-child(2) { + min-width: 67px; + } + .browser-status > span:first-child { + white-space: nowrap; + overflow: hidden; + } + .resize-grip { + display: none !important; + } + .taskbar { + gap: 6px; + } + .task-button { + min-width: 0; + flex: 1; + max-width: 170px; + } + .taskbar-clock { + padding: 4px 7px; + gap: 5px; + font-size: 10px; + } + .dialog-content { + padding: 19px 13px 10px; + gap: 12px; + } +} + +@media (max-width: 620px) { + .hero { + padding-top: 14px; + padding-bottom: 14px; + } + .hero h1 { + font-size: 30px; + } + .hero-package { + max-width: 120px; + } + .hero .platform-line { + display: none; + } + .agents-section { + padding-top: 14px; + } + .agents-section .agent-list { + margin-top: 8px; + } +} + +@media (min-width: 821px) and (max-height: 820px) { + .hero { + padding-top: 10px; + padding-bottom: 12px; + } + .hero h1 { + font-size: 40px; + } + .hero-package { + max-width: 140px; + } + .agent-box { + max-width: 180px; + } + .agents-section { + padding-top: 8px; + } +} + +@media (max-width: 360px) { + .hero-package { + max-width: 110px; + } + .hero, + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 14px; + padding-right: 14px; + } + .primary-cta { + font-size: 10px; + } + .taskbar-clock > span:first-child { + display: none; + } +} diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 427d370acb57..1953880fa946 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -28,6 +28,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "#fffbeb", + "--color-warning-border": "#fde68a", + "--color-warning-foreground": "#b45309", "--color-danger": "#fef2f2", "--color-danger-border": "rgba(239, 68, 68, 0.12)", "--color-danger-foreground": "#dc2626", @@ -95,6 +98,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "rgba(69, 26, 3, 0.4)", + "--color-warning-border": "rgba(120, 53, 15, 0.6)", + "--color-warning-foreground": "#fcd34d", "--color-danger": "rgba(239, 68, 68, 0.14)", "--color-danger-border": "rgba(248, 113, 113, 0.18)", "--color-danger-foreground": "#fca5a5", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 580b3a7ad6b7..d7d89c394f11 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -164,6 +164,9 @@ --color-switch-active-thumb: #ffffff; --color-switch-inactive-track: #f1c4e6; --color-switch-inactive-thumb: #8d1255; + --color-warning: #fcf0ea; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #b05109; --color-danger: #fde4f1; --color-danger-border: rgba(247, 8, 108, 0.32); --color-danger-foreground: #9d174d; @@ -296,6 +299,9 @@ --color-switch-active-thumb: #fbd0e8; --color-switch-inactive-track: #362d3d; --color-switch-inactive-thumb: #e7d0dd; + --color-warning: #412f20; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #fbbf24; --color-danger: #331a2b; --color-danger-border: rgba(157, 23, 77, 0.32); --color-danger-foreground: #fbd0e8; @@ -428,6 +434,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e2ede7; --color-switch-inactive-thumb: #6e696f; + --color-warning: #f4f0e1; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b64a00; --color-danger: #f4e7e5; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -560,6 +569,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #2a4b39; --color-switch-inactive-thumb: #9da5a2; + --color-warning: #3f3a1c; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3f2c28; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6668; @@ -692,6 +704,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e4ecf2; --color-switch-inactive-thumb: #6f6873; + --color-warning: #f6efe4; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b74b00; --color-danger: #f5e6e9; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -824,6 +839,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #293f52; --color-switch-inactive-thumb: #969ca6; + --color-warning: #3c3424; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3c2630; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -956,6 +974,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #f3eae5; --color-switch-inactive-thumb: #74686f; + --color-warning: #f9efe2; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f9e7e6; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1088,6 +1109,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #513728; --color-switch-inactive-thumb: #a59996; + --color-warning: #4b3215; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #4a2321; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -1220,6 +1244,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #edeaf4; --color-switch-inactive-thumb: #726874; + --color-warning: #f8efe5; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f8e6ea; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1352,6 +1379,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #362d51; --color-switch-inactive-thumb: #9690a1; + --color-warning: #412e23; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #40202e; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; diff --git a/apps/mobile/global.css b/apps/mobile/global.css index e6961eac4eea..e153107b1811 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -52,6 +52,11 @@ --color-switch-inactive-track: rgba(0, 0, 0, 0.08); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: #fffbeb; + --color-warning-border: #fde68a; + --color-warning-foreground: #b45309; + /* Danger */ --color-danger: #fef2f2; --color-danger-border: rgba(239, 68, 68, 0.12); @@ -151,6 +156,11 @@ --color-switch-inactive-track: rgba(255, 255, 255, 0.06); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: rgba(69, 26, 3, 0.4); + --color-warning-border: rgba(120, 53, 15, 0.6); + --color-warning-foreground: #fcd34d; + /* Danger */ --color-danger: rgba(239, 68, 68, 0.14); --color-danger-border: rgba(248, 113, 113, 0.18); diff --git a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png new file mode 100644 index 000000000000..87eab9f5218f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png differ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 25f1e94c110f..d42be2e174db 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -60,14 +60,16 @@ static void T3MarkdownTextApplyAttachments( NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; UIImage *image = images[imageUri]; - if ([imageUri hasPrefix:@"sf:"]) { - NSString *symbolName = [imageUri substringFromIndex:3]; - UIColor *foregroundColor = - [attributedString attribute:NSForegroundColorAttributeName - atIndex:attachmentRange.location - effectiveRange:nil] ?: UIColor.labelColor; - image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor - renderingMode:UIImageRenderingModeAlwaysOriginal]; + const BOOL isSymbol = [imageUri hasPrefix:@"sf:"]; + if (isSymbol) { + image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]]; + } + UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil]; + if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) { + image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; } attachment.image = image ?: [[UIImage alloc] init]; const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); @@ -79,8 +81,15 @@ static void T3MarkdownTextApplyAttachments( const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - NSAttributedString *attachmentString = - [NSAttributedString attributedStringWithAttachment:attachment]; + NSMutableAttributedString *attachmentString = + [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; + // Keep the run color on the attachment so a later re-apply (after the image + // loads asynchronously) still tints with the link color, not labelColor. + if (foregroundColor != nil) { + [attachmentString addAttribute:NSForegroundColorAttributeName + value:foregroundColor + range:NSMakeRange(0, attachmentString.length)]; + } [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; } } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63b..e6ce2b3226f0 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -26,6 +26,8 @@ struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + /// Recolor the loaded image with the run's foreground color, like `sf:` symbols. + bool tintWithForeground; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb94..60bbcf2e4f84 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -11,6 +11,7 @@ static constexpr Float ParagraphStyleEncodingOffset = 1000; static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; +static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; static void applyParagraphStyles( NSMutableAttributedString *attributedString, @@ -192,6 +193,7 @@ static void applyAttachments( utf16Offset, 1, props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), + false, }); } else if ( props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { @@ -200,6 +202,15 @@ static void applyAttachments( 1, props.nativeId.substr( std::char_traits::length(SkillAttachmentNativeIdPrefix)), + false, + }); + } else if ( + props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset, + 1, + props.nativeId.substr(std::char_traits::length(LinkAttachmentNativeIdPrefix)), + true, }); } utf16Offset += fragmentLength; diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 1e52d7695ec6..8922c8868c44 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -21,6 +21,7 @@ "exports": { ".": "./index.ts", "./file-icons": "./src/markdownFileIcons.ts", + "./link-icons": "./src/markdownLinkIcons.ts", "./links": "./src/markdownLinks.ts", "./markdown": "./src/nativeMarkdownText.ts", "./primitive": "./src/MarkdownTextPrimitive.tsx", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index 590a2fb1bd1b..a5c6cf540f1c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -12,6 +12,8 @@ import { import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; +import { markdownLinkIconSource } from "./markdownLinkIcons"; +import { resolveMarkdownLinkIcon } from "./markdownLinks"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; import type { MarkdownFileContextMenu, @@ -177,10 +179,14 @@ export function NativeMarkdownSelectableText(props: { }) { const colorScheme = useColorScheme(); const menu = useContext(MarkdownFileContextMenuContext); - const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null); + const containsInlineIcon = props.runs.some( + (run) => + run.fileIcon != null || + (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), + ); const attachAndroidText = useCallback( (textView: RNText | null) => { - if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) { + if (Platform.OS !== "android" || !containsInlineIcon || textView === null) { return; } const reactTag = findNodeHandle(textView); @@ -188,7 +194,7 @@ export function NativeMarkdownSelectableText(props: { installMarkdownCopySanitizer(reactTag); } }, - [containsInlineFileIcon], + [containsInlineIcon], ); const occurrences = new Map(); const prefixedExternalLinks = new Set(); @@ -198,6 +204,7 @@ export function NativeMarkdownSelectableText(props: { occurrences.set(signature, occurrence + 1); let text = run.text; + let linkIcon = null; if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { @@ -207,10 +214,15 @@ export function NativeMarkdownSelectableText(props: { : `$${run.skillName}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); - text = `${EXTERNAL_LINK_PREFIX}${text}`; + linkIcon = resolveMarkdownLinkIcon(run.externalHost); + if (linkIcon === null) { + text = `${EXTERNAL_LINK_PREFIX}${text}`; + } else if (Platform.OS === "ios") { + text = `${INLINE_ATTACHMENT_PREFIX}${text}`; + } } - return { key: `${signature}:${occurrence}`, run, text }; + return { key: `${signature}:${occurrence}`, run, text, linkIcon }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A // color-only child update can otherwise leave the previous appearance cached. @@ -248,7 +260,7 @@ export function NativeMarkdownSelectableText(props: { lineHeight: props.textStyle.lineHeight, }} > - {keyedRuns.map(({ key, run, text }) => { + {keyedRuns.map(({ key, run, text, linkIcon }) => { const href = run.href; const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( @@ -260,7 +272,9 @@ export function NativeMarkdownSelectableText(props: { ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` : run.skillName ? "t3-skill:sf:cube" - : undefined + : linkIcon + ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}` + : undefined : undefined } contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} @@ -284,6 +298,12 @@ export function NativeMarkdownSelectableText(props: { > {Platform.OS === "android" && run.fileIcon ? ( + ) : Platform.OS === "android" && linkIcon ? ( + ) : null} {text} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts new file mode 100644 index 000000000000..568a51005798 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts @@ -0,0 +1,12 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownLinkIcon } from "./markdownLinks"; + +// Black-on-transparent marks; callers tint them with the link color. +const MARKDOWN_LINK_ICON_SOURCES = { + github: require("../assets/link-icons/github.png"), +} as const satisfies Readonly>; + +export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { + return MARKDOWN_LINK_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 176585344167..19f71f631663 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -33,6 +33,18 @@ export type MarkdownLinkPresentation = export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; +export type MarkdownLinkIcon = "github"; + +/** + * Sites whose brand mark replaces the generic external-link glyph. The marks + * are monochrome and tinted with the link color, so they follow the theme. + */ +export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { + const hostname = host.toLowerCase(); + if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; + return null; +} + const FILE_ICON_BY_NAME: Readonly> = { ".babelrc": "babel", ".babelrc.json": "babel", diff --git a/apps/mobile/scripts/wire-widget-asset-catalog.cjs b/apps/mobile/scripts/wire-widget-asset-catalog.cjs deleted file mode 100644 index b7c70f0cdcd6..000000000000 --- a/apps/mobile/scripts/wire-widget-asset-catalog.cjs +++ /dev/null @@ -1,30 +0,0 @@ -"use strict"; - -// One-off: apply the widget asset-catalog wiring to the already-generated -// ios/ project so the current build compiles ExpoWidgetsTarget/Assets.xcassets -// without a full `expo prebuild`. The durable equivalent lives in -// plugins/withWidgetLogoAsset.cjs and runs on prebuild. - -const path = require("path"); -const fs = require("fs"); - -const xcodePath = require.resolve("xcode", { - paths: [ - require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }), - ], -}); -const xcode = require(xcodePath); -const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs"); - -const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj"); -const proj = xcode.project(pbxprojPath); -proj.parseSync(); - -const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" }); - -if (added) { - fs.writeFileSync(pbxprojPath, proj.writeSync()); - console.log("Added widget asset-compile phase to ExpoWidgetsTarget."); -} else { - console.log("No change: phase already present."); -} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 57303a1bb001..dd0b48700ef7 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; @@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsUsageAccount: createNativeStackScreen({ + screen: UsageLimitAccountScreen, + options: { title: "Account" }, + }), SettingsUsage: createNativeStackScreen({ screen: UsageRouteScreen, linking: "usage", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 26fdfd24a4fb..0912693861de 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -20,6 +20,7 @@ import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; import IconBolt from "@tabler/icons-react-native/IconBolt"; import IconBox from "@tabler/icons-react-native/IconBox"; +import IconBrain from "@tabler/icons-react-native/IconBrain"; import IconCamera from "@tabler/icons-react-native/IconCamera"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; @@ -30,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; +import IconTicket from "@tabler/icons-react-native/IconTicket"; import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; @@ -109,11 +111,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "bell.badge": IconBellRinging, "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, + brain: IconBrain, camera: IconCamera, "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, + ticket: IconTicket, cloud: IconCloud, cube: IconBox, "chevron.down": IconChevronDown, @@ -136,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "info.circle": IconInfoCircle, laptopcomputer: IconDeviceLaptop, link: IconLink, + "line.3.horizontal.decrease": IconFilter, "line.3.horizontal.decrease.circle": IconFilter, "line.3.horizontal.decrease.circle.fill": IconFilterFilled, // Tabler has no Apple desktops; the closest silhouettes stand in on Android. diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 39517f0e62ee..6501d2044083 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -35,6 +35,8 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { className, )} placeholderTextColorClassName="accent-placeholder" + selectionColorClassName="accent-foreground-secondary" + cursorColorClassName="accent-foreground-secondary" {...props} /> ); diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 40465012e5da..8c86fcc38e69 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -195,7 +195,7 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { {!props.compact ? ( diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index b7412bd13a83..e0936c57180a 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -9,7 +9,14 @@ import { useMemo, useRef, } from "react"; -import { Platform, Pressable, View, type ColorValue, type PressableProps } from "react-native"; +import { + Platform, + Pressable, + View, + type ColorValue, + type PressableProps, + type AccessibilityProps, +} from "react-native"; import { withUniwind } from "uniwind"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -144,10 +151,11 @@ export function ControlPill(props: { // AppCompat popup can't be themed past its stock animation, metrics, and // submenu chrome. export function ControlPillMenu( - props: Omit, "children" | "themeVariant"> & { - readonly children: ReactNode; - readonly className?: string; - }, + props: Omit, "children" | "themeVariant"> & + Pick & { + readonly children: ReactNode; + readonly className?: string; + }, ) { const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 6c12c9bdd823..38f85de195b5 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -3,8 +3,8 @@ import { View } from "react-native"; import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - - {props.message} + + {props.message} ); } diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c60709baf4c9..932fc6779f20 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -5,9 +5,13 @@ import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useAssetUrl } from "../state/assets"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { projectFaviconUrlAtom } from "../state/assets"; + import { beginProjectFaviconRequest, createProjectFaviconRequest, @@ -16,6 +20,8 @@ import { markProjectFaviconLoaded, } from "./projectFaviconCache"; +const EMPTY_FAVICON_URL = Atom.make(null); + /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; @@ -26,20 +32,23 @@ export function ProjectFavicon(props: { readonly faviconPath?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( - props.environmentId, - props.workspaceRoot === null || props.workspaceRoot === undefined - ? null - : { - _tag: "project-favicon", + const faviconUrl = useAtomValue( + props.workspaceRoot == null + ? EMPTY_FAVICON_URL + : projectFaviconUrlAtom({ + environmentId: props.environmentId, cwd: props.workspaceRoot, - ...(props.faviconPath ? { path: props.faviconPath } : {}), - }, + faviconPath: props.faviconPath, + }), ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + // Inline images are self-contained; remote URLs key on their revision so signed-token + // rotation reuses the disk cache while a changed icon starts from the loading state. const cacheKey = renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; return ( @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) + ? "loaded" + : "loading", ); const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { {requestIsActive ? ( Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + listCache: (kind) => + Effect.sync(() => + [...values.entries()] + .filter(([key]) => key.split(":")[1] === kind) + .map(([, payload]) => payload), + ), saveCache: (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.sync(() => { const id = cacheId(environmentId, kind, cacheKey); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 1942cb7cc35d..3cf5146e9190 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; +import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; @@ -100,6 +101,7 @@ function loadDecodedCache(input: { export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { const database = yield* MobileDatabase.MobileDatabase; + attachProjectFaviconDatabase(database); return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ @@ -111,7 +113,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -222,9 +224,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - database - .clearEnvironmentCache(environmentId) - .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.andThen(database.clearEnvironmentCache(environmentId)), + Effect.mapError(mapDatabaseError("clear-environment")), + ), ), }); }); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index 9b0ef7a19003..2df3f728035e 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -38,7 +38,9 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })), + Layer.provideMerge( + Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 582c58fb27e6..152948274ca3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -33,7 +33,6 @@ import { mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, - normalizeAgentAwarenessRelayBaseUrl, registerAgentAwarenessConnection, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, @@ -363,13 +362,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); - it("normalizes relay base URLs for APNs registration requests", () => { - expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); - }); - it("overrides persisted preferences for an in-flight registration", () => { expect( mergeAgentAwarenessRegistrationPreferences( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2d4261de603..9f4539c44d64 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -139,16 +139,6 @@ export function mergeAgentAwarenessRegistrationPreferences( return { ...stored, ...override }; } -export function normalizeAgentAwarenessRelayBaseUrl( - value: string | null | undefined, -): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayConfig(): { readonly url: string } | null { const relayUrl = resolveCloudPublicConfig().relay.url; if (!relayUrl) { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 42aa8ffebb61..feadf6c81893 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -11,13 +11,11 @@ import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; import { - cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, linkEnvironmentToCloudWithPreference, connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, - normalizeRelayBaseUrl, refreshCloudEnvironmentConnection, } from "./linkEnvironment"; @@ -195,23 +193,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("normalizes configured relay base URLs before building DPoP-bound requests", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - }); - - it("makes linked environments visible while their status is still loading", () => { - expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ - { - environment: { environmentId: "env-1", label: "Desktop" }, - status: null, - statusError: "Checking status...", - }, - ]); - }); - it.effect("decodes relay environment list responses before returning records", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index c2033117f69d..b8dc8f878e0c 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -43,14 +43,6 @@ const RELAY_STATUS_AND_CONNECT_SCOPES = [ RelayEnvironmentConnectScope, ] satisfies ReadonlyArray; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } @@ -405,16 +397,6 @@ export function getCloudEnvironmentStatus(input: { }); } -export function cloudEnvironmentsPendingStatus( - environments: ReadonlyArray, -): ReadonlyArray { - return environments.map((environment) => ({ - environment, - status: null, - statusError: "Checking status...", - })); -} - export function loadCloudEnvironmentStatuses(input: { readonly clerkToken: string; readonly environments: ReadonlyArray; diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 448889549016..806499c2273b 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -299,7 +299,7 @@ function CloudEnvironmentRowShell(props: { traceId: props.connectionErrorTraceId, }); const statusClassName = props.connectionError - ? "text-adaptive-rose-500-400" + ? "text-danger-foreground" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ readonly text: string; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 75d3e8ce7a34..5555548ff799 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -96,7 +96,7 @@ export function ConnectionEnvironmentRow(props: { ({ calls: [] as string[], @@ -257,13 +253,13 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); }); - it("keeps long lines and unknown following syntax plain until the next hunk", async () => { + it("keeps only the long line plain and resumes highlighting after it", async () => { const longLine = `${"x".repeat(1_001)} /*`; const rows = [ line(1, "export const before = 1;"), line(2, longLine), { kind: "comment", id: "note", commentText: "Check this", fileId: TYPESCRIPT_FILE.id }, - line(3, "inside the comment */"), + line(3, "export const inside = 'x';"), makeHunk("next-hunk"), line(100, "export const after = 2;"), ] satisfies ReadonlyArray; @@ -274,14 +270,36 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-2"]).toEqual([ { content: longLine, color: null, fontStyle: null }, ]); - expect(result.tokensByRowId["line-3"]).toEqual([ - { content: "inside the comment */", color: null, fontStyle: null }, - ]); - expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); - expect(result.tokensByRowId["line-100"]?.some((token) => token.color !== null)).toBe(true); + for (const id of ["line-1", "line-3", "line-100"]) { + expect(result.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + } expect(tokenization.calls.some((code) => code.includes(longLine))).toBe(false); }); + it("highlights the rows after a long line the same regardless of the first window", async () => { + const rows = [ + line(1, "export const before = 1;"), + line(2, `const data = "${"x".repeat(1_050)}";`), + line(3, "export const inside = 'x';"), + line(4, "export const after = 2;"), + ]; + const spanning = await highlightRows(rows); + const afterLongLine = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine, + firstRowIndex: 2, + lastRowIndex: 3, + overscanRows: 0, + }); + + for (const id of ["line-3", "line-4"]) { + expect(spanning.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + expect(afterLongLine.tokensByRowId[id]).toEqual(spanning.tokensByRowId[id]); + } + }); + it("preserves multiline grammar and row mapping across character-limited batches", async () => { const opening = line(1, "const message = `open"); const body = Array.from({ length: 40 }, (_, index) => line(index + 2, "inside ".repeat(45))); @@ -318,21 +336,4 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.rowCount).toBe(0); expect(result.tokensByRowId).toEqual({}); }); - - it("applies the same long-line guard to streamed token chunks", async () => { - const content = "x".repeat(10_000); - const chunks: NativeReviewDiffTokenChunk[] = []; - - await streamNativeReviewDiffTokens({ - rows: [line(1, content)], - files: [TYPESCRIPT_FILE], - scheme: "dark", - engine, - onChunk: (chunk) => chunks.push(chunk), - }); - - expect(chunks).toHaveLength(1); - expect(chunks[0]?.tokensByRowId["line-1"]).toEqual([{ content, color: null, fontStyle: null }]); - expect(tokenization.calls).toHaveLength(0); - }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 383e1e73a85f..e924ff1aa645 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -65,26 +65,6 @@ interface IndexedNativeReviewDiffLineRow { readonly rowIndex: number; } -export interface NativeReviewDiffTokenChunk { - readonly chunkIndex: number; - readonly fileId: string; - readonly filePath: string; - readonly language: NativeReviewDiffLanguage; - readonly lineCount: number; - readonly durationMs: number; - readonly tokensByRowId: Record>; -} - -export interface StreamNativeReviewDiffTokenInput { - readonly rows: ReadonlyArray; - readonly files: ReadonlyArray; - readonly scheme: NativeReviewDiffHighlightScheme; - readonly engine?: NativeReviewDiffHighlightEngine; - readonly chunkSize?: number; - readonly signal?: AbortSignal; - readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void; -} - export interface HighlightNativeReviewDiffVisibleRowsInput { readonly rows: ReadonlyArray; readonly files: ReadonlyArray; @@ -98,7 +78,6 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly signal?: AbortSignal; } -const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000; @@ -247,15 +226,16 @@ function createHighlighterHandle( while (start < lines.length) { if (signal?.aborted) return []; - // Skipping this line leaves its ending grammar state unknown. Keep the - // rest of this contiguous segment plain instead of guessing its syntax. + // Skipping this line leaves its ending grammar state unknown. Resume + // from a fresh state rather than leaving the rest of the segment plain: + // highlighted rows are cached for the sheet's lifetime, so a plain tail + // would stick, and which rows it covered would depend on where the + // first visible window happened to start. if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) { - highlighted.push( - ...lines - .slice(start) - .map((content) => [{ content: content || " ", color: null, fontStyle: null }]), - ); - break; + highlighted.push([{ content: lines[start] || " ", color: null, fontStyle: null }]); + grammarState = undefined; + start += 1; + continue; } let end = start; @@ -422,20 +402,6 @@ function canShareGrammarContext( ); } -function groupLineRowsByFileId(rows: ReadonlyArray) { - const rowsByFileId = new Map(); - for (const row of rows) { - if (!isHighlightableLineRow(row)) { - continue; - } - - const fileRows = rowsByFileId.get(row.fileId) ?? []; - fileRows.push(row); - rowsByFileId.set(row.fileId, fileRows); - } - return rowsByFileId; -} - function createFileMap(files: ReadonlyArray) { return new Map(files.map((file) => [file.id, file])); } @@ -560,52 +526,3 @@ export async function highlightNativeReviewDiffVisibleRows( durationMs: Math.round(performance.now() - startedAt), }; } - -export async function streamNativeReviewDiffTokens( - input: StreamNativeReviewDiffTokenInput, -): Promise { - const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); - const rowsByFileId = groupLineRowsByFileId(input.rows); - const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; - const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE; - let chunkIndex = 0; - - for (const file of input.files) { - const fileRows = rowsByFileId.get(file.id) ?? []; - for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) { - if (input.signal?.aborted) { - return highlighter.engine; - } - - const startedAt = performance.now(); - const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); - const code = chunkRows.map((row) => row.content).join("\n"); - const tokenLines = await highlighter.tokenize(code, { - lang: file.language, - theme, - signal: input.signal, - }); - if (input.signal?.aborted) return highlighter.engine; - const tokensByRowId: Record> = {}; - - chunkRows.forEach((row, rowIndex) => { - tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); - }); - - input.onChunk({ - chunkIndex, - fileId: file.id, - filePath: file.path, - language: file.language, - lineCount: chunkRows.length, - durationMs: Math.round(performance.now() - startedAt), - tokensByRowId, - }); - - chunkIndex += 1; - await waitForNextFrame(); - } - } - - return highlighter.engine; -} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ca8fd9c61415..29bbdb49c890 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -190,11 +190,11 @@ function FileContent(props: { return ( {props.truncated ? ( - - + + Partial file - + Preview limited to the first 1 MB of a truncated file. diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index 7345a7f366c5..edab2ba687b8 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ProjectEntry } from "@t3tools/contracts"; -import { - buildFileTree, - countFileNodes, - defaultExpandedTreePaths, - firstFilePath, - flattenFileTree, -} from "./fileTree"; +import { buildFileTree, defaultExpandedTreePaths, flattenFileTree } from "./fileTree"; const entries = [ { kind: "file", path: "README.md" }, @@ -30,8 +24,6 @@ describe("mobile file tree helpers", () => { "directory:src/components", "file:src/index.ts", ]); - expect(countFileNodes(tree)).toBe(4); - expect(firstFilePath(tree)).toBe("src/components/App.tsx"); }); it("flattens expanded directories and hides collapsed descendants", () => { diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 28b5822aaa0f..2e0b8140329c 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -117,18 +117,6 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr return [...root.children.values()].sort(compareNodes).map(freezeNode); } -export function countFileNodes(nodes: ReadonlyArray): number { - let count = 0; - for (const node of nodes) { - if (node.kind === "file") { - count += 1; - } else { - count += countFileNodes(node.children); - } - } - return count; -} - export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { const expanded = new Set(); for (const node of nodes) { @@ -205,16 +193,3 @@ export function flattenFileTree(input: { } return output; } - -export function firstFilePath(nodes: ReadonlyArray): string | null { - for (const node of nodes) { - if (node.kind === "file") { - return node.path; - } - const child = firstFilePath(node.children); - if (child !== null) { - return child; - } - } - return null; -} diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts index 0e7d478c6bdb..937d3a1d3c8f 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -3,30 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import { buildNativeSourceRows, buildNativeSourceTokens, - NATIVE_SOURCE_ROW_HEIGHT, - NATIVE_SOURCE_STYLE, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { - NATIVE_REVIEW_DIFF_ROW_HEIGHT, - NATIVE_REVIEW_DIFF_STYLE, -} from "../review/nativeReviewDiffAdapter"; describe("nativeSourceFileAdapter", () => { - it("uses the same compact code typography as the diff viewer", () => { - expect(NATIVE_SOURCE_ROW_HEIGHT).toBe(NATIVE_REVIEW_DIFF_ROW_HEIGHT); - expect(NATIVE_SOURCE_STYLE).toMatchObject({ - rowHeight: NATIVE_REVIEW_DIFF_STYLE.rowHeight, - gutterWidth: NATIVE_REVIEW_DIFF_STYLE.gutterWidth, - codePadding: NATIVE_REVIEW_DIFF_STYLE.codePadding, - textVerticalInset: NATIVE_REVIEW_DIFF_STYLE.textVerticalInset, - codeFontSize: NATIVE_REVIEW_DIFF_STYLE.codeFontSize, - codeFontWeight: NATIVE_REVIEW_DIFF_STYLE.codeFontWeight, - lineNumberFontSize: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontSize, - lineNumberFontWeight: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontWeight, - }); - }); - it("maps plain source lines onto context rows with stable line numbers", () => { expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([ { diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts index 0c83134ea703..f1dbefdc383b 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -4,17 +4,11 @@ import type { NativeReviewDiffToken, } from "../diffs/nativeReviewDiffSurface"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; import type { SourceHighlightTokens } from "./sourceHighlightingState"; -export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; -export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - export function createNativeSourceStyle( codeSurface: ResolvedMobileCodeSurface, ): NativeReviewDiffStyle { diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 052ac969c10f..c5f6768c55d0 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -62,7 +62,7 @@ interface ThreadSwipeAction { } interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; } function swipeActionsWidth(hasSecondaryAction: boolean) { @@ -80,7 +80,7 @@ function resolveSecondaryAction(input: { if (input.secondaryAction === undefined) { return { accessibilityLabel: `Delete ${input.threadTitle}`, - backgroundColor: "#ff2d55", + tone: "danger", icon: "trash", label: "Delete", onPress: () => { @@ -92,7 +92,7 @@ function resolveSecondaryAction(input: { const action = input.secondaryAction; return { ...action, - backgroundColor: "#5856d6", + tone: "secondary", menu: action.menu === undefined ? undefined @@ -359,7 +359,7 @@ export function ThreadSwipeable(props: { function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly actionsWidth: number; - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; readonly compact: boolean; readonly entryRange: readonly [number, number]; readonly fullSwipeThreshold: number; @@ -462,9 +462,15 @@ function SwipeActionButton(props: { > - + = {}): WorkspaceState { return { @@ -27,14 +23,16 @@ function workspaceState(overrides: Partial = {}): WorkspaceState describe("workspace connection status", () => { it("stays hidden while a ready environment is connected", () => { - expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false); + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); }); it("surfaces offline snapshots", () => { const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("You are offline"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "You are offline", + showsProgress: false, + }); }); it("names the environment while reconnecting", () => { @@ -54,8 +52,10 @@ describe("workspace connection status", () => { ], }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); }); it("surfaces connection errors before the generic disconnected fallback", () => { @@ -65,15 +65,19 @@ describe("workspace connection status", () => { hasReadyEnvironment: false, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Could not reach Julius’s Mac mini", + showsProgress: false, + }); }); 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..."); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Syncing threads...", + showsProgress: true, + }); }); it("distinguishes initial shell loading from cached catch-up", () => { @@ -82,39 +86,9 @@ describe("workspace connection status", () => { hasPendingShellSnapshot: true, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); - }); - - it("presents nothing while connected", () => { - expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); - }); - - it("presents progress while reconnecting but not while offline", () => { - const reconnecting = workspaceState({ - hasConnectingEnvironment: true, - hasReadyEnvironment: false, - connectingEnvironments: [ - { - environmentId: "environment-1" as never, - environmentLabel: "Julius’s Mac mini", - displayUrl: "", - isRelayManaged: false, - connectionState: "reconnecting", - connectionError: null, - connectionErrorTraceId: null, - }, - ], - }); - expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ - label: "Reconnecting to Julius’s Mac mini", + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Loading threads...", showsProgress: true, }); - - const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(workspaceConnectionStatusPresentation(offline)).toEqual({ - label: "You are offline", - showsProgress: false, - }); }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 6f9898b1bb01..d45a46adf933 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -6,7 +6,7 @@ export interface WorkspaceConnectionStatusPresentation { readonly showsProgress: boolean; } -export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { +function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || state.connectionError !== null || @@ -16,7 +16,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool ); } -export function workspaceConnectionStatusLabel(state: WorkspaceState): string { +function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; if (state.connectingEnvironments.length === 1) { return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 80ebe1157d92..9e768112fb4a 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -80,11 +80,9 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - - - Partial diff - - {props.notice} + + Partial diff + {props.notice} ); }); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 3c2eb9016feb..39b9c0cef26e 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -6,8 +6,6 @@ import type { import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; -import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme"; import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; @@ -25,13 +23,8 @@ const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; const NATIVE_RGBA_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; -export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; -export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - function opaqueNativeHexColor(color: string, background: string): string { const hex = NATIVE_HEX_COLOR.exec(color); if (hex) return color; diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts index 9b39a51dc90f..406665def071 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; describe("native review diff bridge", () => { - it("builds stable reset keys from the rendered diff identity", () => { + it("changes reset keys when the rendered diff identity changes", () => { const input = { threadKey: "env:thread", sectionId: "turn:2", @@ -13,15 +13,12 @@ describe("native review diff bridge", () => { rowCount: 4, }; - expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); - expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( - buildNativeReviewTokensResetKey(input), - ); - expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); - }); + const resetKey = buildNativeReviewTokensResetKey(input); - it("includes diff length in the hash key to reduce accidental collisions", () => { - expect(hashReviewDiffKey("abc")).toMatch(/^3:/); - expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); + expect( + buildNativeReviewTokensResetKey({ ...input, diff: "diff --git a/b.ts b/b.ts" }), + ).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).not.toBe(resetKey); }); }); diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts index d04534003b37..6c7c1e545785 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts @@ -3,7 +3,7 @@ import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffH // Pure key-derivation helpers for the native review diff bridge. Kept free of // react-native / hook imports so they stay unit-testable in node. -export function hashReviewDiffKey(diff: string | null | undefined): string { +function hashReviewDiffKey(diff: string | null | undefined): string { if (!diff) { return "empty"; } diff --git a/apps/mobile/src/features/review/reviewFileVisibility.test.ts b/apps/mobile/src/features/review/reviewFileVisibility.test.ts index 4a7a2f98af62..8fec1cbf8bd5 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.test.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - getDefaultReviewExpandedFileIds, getValidExplicitReviewFileIds, getValidReviewFileIds, removeReviewFileId, @@ -29,7 +28,6 @@ describe("review file visibility", () => { const files = [makeFile("a.ts"), makeFile("b.ts")]; it("defaults expanded files to every renderable file", () => { - expect(getDefaultReviewExpandedFileIds(files)).toEqual(["a.ts", "b.ts"]); expect(getValidReviewFileIds(files, undefined)).toEqual(["a.ts", "b.ts"]); }); diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index 53f2d7f5f956..fbdfcf230225 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo } from "react"; import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState"; import type { ReviewRenderableFile } from "./reviewModel"; -export function getDefaultReviewExpandedFileIds( +function getDefaultReviewExpandedFileIds( files: ReadonlyArray, ): ReadonlyArray { return files.map((file) => file.id); diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 770a6561360b..4644258f51f1 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -4,7 +4,6 @@ import { MessageId, RunId, type ReviewDiffPreviewSource } from "@t3tools/contrac import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { - buildReviewListItems, buildReviewParsedDiff, buildReviewSectionItems, getDefaultReviewSectionId, @@ -267,84 +266,4 @@ describe("buildReviewParsedDiff", () => { actionLabel: "Load diff", }); }); - - it("flattens expanded file rows into virtualized review items", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/a.ts", - rows: [ - { - kind: "hunk", - id: "hunk-1", - header: "@@ -1,1 +1,2 @@", - context: null, - }, - { - kind: "line", - id: "line-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, - }, - ], - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "hunk", - fileId: file.id, - file, - row: file.rows[0], - }), - expect.objectContaining({ - kind: "line", - fileId: file.id, - file, - row: file.rows[1], - lineIndex: 0, - }), - ]); - }); - - it("keeps large diffs collapsed into a placeholder item until revealed", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/big.ts", - rows: Array.from({ length: 401 }, (_, index) => ({ - kind: "line" as const, - id: `line-${index}`, - change: "add" as const, - oldLineNumber: null, - newLineNumber: index + 1, - content: `const line${index} = ${index};`, - additionTokenIndex: index, - deletionTokenIndex: null, - comparison: null, - })), - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "file-suppressed", - fileId: file.id, - actionLabel: "Load diff", - }), - ]); - }); }); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 5e1ae6bac5a2..e56fce4f68c5 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -59,45 +59,6 @@ export interface ReviewRenderableFile { readonly rows: ReadonlyArray; } -export interface ReviewFileHeaderListItem { - readonly kind: "file-header"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly expanded: boolean; -} - -export interface ReviewFileSuppressedListItem { - readonly kind: "file-suppressed"; - readonly id: string; - readonly fileId: string; - readonly message: string; - readonly actionLabel: string | null; -} - -export interface ReviewHunkListItem { - readonly kind: "hunk"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableHunkRow; -} - -export interface ReviewLineListItem { - readonly kind: "line"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableLineRow; - readonly lineIndex: number; -} - -export type ReviewListItem = - | ReviewFileHeaderListItem - | ReviewFileSuppressedListItem - | ReviewHunkListItem - | ReviewLineListItem; - export type ReviewFilePreviewState = | { readonly kind: "render"; @@ -317,77 +278,6 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil return { kind: "render" }; } -// The flattened review list item model is inspired by pierre/diffs' iterator-first -// virtualization architecture, adapted here for React Native virtualization. -// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs -// Reference files: -// - src/utils/iterateOverDiff.ts -// - src/components/VirtualizedFileDiff.ts -export function buildReviewListItems(input: { - readonly files: ReadonlyArray; - readonly expandedFileIds: ReadonlyArray; - readonly revealedLargeFileIds: ReadonlyArray; -}): ReadonlyArray { - const expandedFileIds = new Set(input.expandedFileIds); - const revealedLargeFileIds = new Set(input.revealedLargeFileIds); - const items: ReviewListItem[] = []; - - input.files.forEach((file) => { - const expanded = expandedFileIds.has(file.id); - items.push({ - kind: "file-header", - id: `${file.id}:header`, - fileId: file.id, - file, - expanded, - }); - - if (!expanded) { - return; - } - - const previewState = getReviewFilePreviewState(file); - if (previewState.kind === "suppressed") { - if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) { - items.push({ - kind: "file-suppressed", - id: `${file.id}:suppressed`, - fileId: file.id, - message: previewState.message, - actionLabel: previewState.actionLabel, - }); - return; - } - } - - let lineIndex = 0; - file.rows.forEach((row, rowIndex) => { - if (row.kind === "hunk") { - items.push({ - kind: "hunk", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - }); - return; - } - - items.push({ - kind: "line", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - lineIndex, - }); - lineIndex += 1; - }); - }); - - return items; -} - function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index be723040152a..6d36171d2711 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,131 +1,60 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; +import type { ReviewRenderableLineRow } from "./reviewModel"; +import { + highlightCodeSnippet, + highlightReviewSelectedLines, + highlightSourceFile, +} from "./shikiReviewHighlighter"; -function makeRenderableFile( - input: Partial & Pick, -): ReviewRenderableFile { - return { - id: input.path, - cacheKey: input.path, - previousPath: null, - changeType: "new", - additions: 0, - deletions: 0, - languageHint: null, - additionLines: [], - deletionLines: [], - rows: [], - ...input, - }; -} - -describe("highlightReviewFile", () => { - it("preserves one highlighted token row per diff line even without trailing newlines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example.txt", - additionLines: [ - 'const items = ["a"];', - 'expect(items).toEqual(["a"]);', - "const next = items.map((item) => item.toUpperCase());", - 'expect(next).toContain("A");', - ], +describe("highlightSourceFile", () => { + it("preserves one highlighted token row per source line without trailing newlines", async () => { + const lines = [ + 'const items = ["a"];', + 'expect(items).toEqual(["a"]);', + "const next = items.map((item) => item.toUpperCase());", + 'expect(next).toContain("A");', + ]; + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example.ts", + contents: lines.join("\n"), + theme: "light", }); - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(file.additionLines.length); - expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( - file.additionLines[0], - ); - expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( - file.additionLines[1], + expect(highlighted.map((tokens) => tokens.map((token) => token.content).join(""))).toEqual( + lines, ); - expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( - file.additionLines[2], - ); - expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( - file.additionLines[3], - ); - }); - - it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example-inline-diff.txt", - additionLines: ["const after = 2;"], - deletionLines: ["const before = 1;"], - rows: [ - { - kind: "line", - id: "delete-1", - change: "delete", - oldLineNumber: 1, - newLineNumber: null, - content: "const before = 1;", - additionTokenIndex: null, - deletionTokenIndex: 0, - comparison: { change: "add", tokenIndex: 0 }, - }, - { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: { change: "delete", tokenIndex: 0 }, - }, - ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); - expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); }); it("falls back to plain tokens for very long lines", async () => { const longLine = `const value = "${"a".repeat(1_100)}";`; - const file = makeRenderableFile({ - path: "apps/mobile/src/example-long-line.txt", - additionLines: [longLine], - rows: [ + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example-long-line.ts", + contents: longLine, + theme: "light", + }); + + expect(highlighted).toEqual([ + [ { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, content: longLine, - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, + color: null, + fontStyle: null, }, ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(1); - expect(highlighted.additionLines[0]).toEqual([ - { - content: longLine, - color: null, - fontStyle: null, - }, ]); }); -}); -describe("highlightCodeSnippet", () => { - it("resolves language aliases and returns syntax-colored tokens", async () => { + it("initializes source and snippet highlighting without a warmup", async () => { + vi.resetModules(); + const highlighter = await import("./shikiReviewHighlighter"); const source = "const answer: number = 42;"; - const highlighted = await highlightCodeSnippet({ - code: source, - language: "ts", + + const highlighted = await highlighter.highlightSourceFile({ + path: "example.ts", + contents: source, theme: "dark", }); @@ -136,18 +65,56 @@ describe("highlightCodeSnippet", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + expect( + await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), + ).toEqual(highlighted); }); }); -describe("highlightSourceFile", () => { - it("initializes source and snippet highlighting without a warmup", async () => { - vi.resetModules(); - const highlighter = await import("./shikiReviewHighlighter"); - const source = "const answer: number = 42;"; +describe("highlightReviewSelectedLines", () => { + it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { + const lines: ReviewRenderableLineRow[] = [ + { + kind: "line", + id: "delete-1", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + content: "const before = 1;", + additionTokenIndex: null, + deletionTokenIndex: 0, + comparison: { change: "add", tokenIndex: 0 }, + }, + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: "const after = 2;", + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: { change: "delete", tokenIndex: 0 }, + }, + ]; - const highlighted = await highlighter.highlightSourceFile({ - path: "example.ts", - contents: source, + const highlighted = await highlightReviewSelectedLines({ + filePath: "apps/mobile/src/example-inline-diff.txt", + lines, + theme: "light", + }); + + expect(highlighted["delete-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + expect(highlighted["add-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + }); +}); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", theme: "dark", }); @@ -158,8 +125,5 @@ describe("highlightSourceFile", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); - expect( - await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), - ).toEqual(highlighted); }); }); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index c684a6686430..8fa7f69a433c 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -17,7 +17,7 @@ import { resolveReviewHighlighterEnginePreference, type ReviewHighlighterEngine, } from "./reviewHighlighterEngine"; -import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; +import type { ReviewRenderableLineRow } from "./reviewModel"; import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; export type ReviewDiffTheme = "light" | "dark"; @@ -43,17 +43,6 @@ export interface ReviewHighlightedToken { readonly diffHighlight?: boolean; } -export interface ReviewHighlightedFile { - readonly additionLines: ReadonlyArray>; - readonly deletionLines: ReadonlyArray>; -} - -export interface ReviewHighlightFileProgress { - readonly highlightedFile: ReviewHighlightedFile; - readonly complete: boolean; - readonly highlightedLineCount: number; -} - const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", dark: "github-dark-default", @@ -64,16 +53,9 @@ const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); -const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, - false, -); -const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; -const highlightCache = new Map>(); -const resolvedHighlightCache = new Map(); const REVIEW_INITIAL_LANGUAGE_MODULES = [ bashLanguage, javascriptLanguage, @@ -204,22 +186,6 @@ type LoadedLanguageModule = { default: Parameters[0]; }; -function resolveReviewHighlighterBooleanFlag( - value: string | undefined, - defaultValue: boolean, -): boolean { - switch (value) { - case "1": - case "true": - return true; - case "0": - case "false": - return false; - default: - return defaultValue; - } -} - function isReviewHighlighterDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -267,7 +233,6 @@ async function getHighlighter(): Promise { logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, - resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, }); const themes = [githubLightDefault, githubDarkDefault]; @@ -488,13 +453,6 @@ async function resolveLanguageFromPath( return candidate; } -async function resolveLanguage(file: ReviewRenderableFile): Promise { - return ( - resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? - (await resolveLanguageFromPath(file.path, file.languageHint)) - ); -} - function normalizeHighlightedLines( tokenLines: ReadonlyArray>, ): ReadonlyArray> { @@ -507,84 +465,6 @@ function normalizeHighlightedLines( ); } -function makePlainHighlightedLines( - lines: ReadonlyArray, -): ReadonlyArray> { - return lines.map((line) => [ - { - content: stripTrailingNewline(line), - color: null, - fontStyle: null, - }, - ]); -} - -function applyWordAltDiffHighlightsToFile( - file: ReviewRenderableFile, - highlighted: ReviewHighlightedFile, -): ReviewHighlightedFile { - const nextAdditionLines = [...highlighted.additionLines]; - const nextDeletionLines = [...highlighted.deletionLines]; - const processedPairs = new Set(); - let changed = false; - - file.rows.forEach((row) => { - if (row.kind !== "line" || row.change === "context" || !row.comparison) { - return; - } - - const deletionTokenIndex = - row.change === "delete" - ? row.deletionTokenIndex - : row.comparison.change === "delete" - ? row.comparison.tokenIndex - : null; - const additionTokenIndex = - row.change === "add" - ? row.additionTokenIndex - : row.comparison.change === "add" - ? row.comparison.tokenIndex - : null; - - if (deletionTokenIndex === null || additionTokenIndex === null) { - return; - } - - const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; - if (processedPairs.has(pairKey)) { - return; - } - processedPairs.add(pairKey); - - const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); - const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); - const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); - - if (ranges.deletion.length > 0) { - nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( - nextDeletionLines[deletionTokenIndex] ?? [], - ranges.deletion, - ); - changed = true; - } - - if (ranges.addition.length > 0) { - nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( - nextAdditionLines[additionTokenIndex] ?? [], - ranges.addition, - ); - changed = true; - } - }); - - return changed - ? { - additionLines: nextAdditionLines, - deletionLines: nextDeletionLines, - } - : highlighted; -} - function applyWordAltDiffHighlightsToSelectedLines(input: { readonly lines: ReadonlyArray; readonly tokenMap: Record>; @@ -733,292 +613,6 @@ export async function highlightSourceFile(input: { return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } -async function highlightPatchLinesInChunks(input: { - readonly lines: ReadonlyArray; - readonly language: string; - readonly theme: string; - readonly onChunk: ( - startIndex: number, - tokens: ReadonlyArray>, - ) => void; -}): Promise>> { - if (input.lines.length === 0) { - return []; - } - - const highlighter = await getHighlighter(); - const highlightedLines: Array> = []; - - for ( - let startIndex = 0; - startIndex < input.lines.length; - startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE - ) { - const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); - const chunkTokens: Array> = []; - const tokenizableLines: string[] = []; - const tokenizableIndexes: number[] = []; - - lineChunk.forEach((line, index) => { - const strippedLine = stripTrailingNewline(line); - if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { - chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; - return; - } - - tokenizableIndexes.push(index); - tokenizableLines.push(strippedLine); - }); - - if (tokenizableLines.length > 0) { - const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { - lang: input.language, - theme: input.theme, - }); - const normalizedTokenLines = normalizeHighlightedLines(tokenLines); - - tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { - chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; - }); - } - - const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); - highlightedLines.push(...completedChunk); - input.onChunk(startIndex, completedChunk); - - if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { - await waitForNextFrame(); - } - } - - return highlightedLines; -} - -function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { - return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; -} - -function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { - if (resolvedHighlightCache.has(cacheKey)) { - resolvedHighlightCache.delete(cacheKey); - } - - resolvedHighlightCache.set(cacheKey, highlighted); - - while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { - const oldestKey = resolvedHighlightCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - resolvedHighlightCache.delete(oldestKey); - } -} - -export async function highlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return resolved; - } - const cached = highlightCache.get(cacheKey); - if (cached) { - logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return cached; - } - } - - const promise = (async () => { - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines = await highlightLines( - joinPatchLines(file.additionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - const deletionLines = await highlightLines( - joinPatchLines(file.deletionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - })(); - - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.set(cacheKey, promise); - } - return promise.finally(() => { - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.delete(cacheKey); - } - }); -} - -export async function streamHighlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, - onProgress: (progress: ReviewHighlightFileProgress) => void, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - onProgress({ - highlightedFile: resolved, - complete: true, - highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, - }); - return resolved; - } - } - - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file stream highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines: Array> = []; - const deletionLines: Array> = []; - let highlightedLineCount = 0; - - await highlightPatchLinesInChunks({ - lines: file.additionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - additionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - await waitForNextFrame(); - await highlightPatchLinesInChunks({ - lines: file.deletionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - deletionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount, - durationMs: Date.now() - startedAt, - }); - return highlighted; -} - export async function highlightReviewSelectedLines(input: { readonly filePath: string; readonly lines: ReadonlyArray; diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index c6a656e012f7..1728da662686 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -8,7 +8,7 @@ import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighti import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +export { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 41c2076ac7b4..3bd25a20c8da 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -44,6 +44,7 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import { + filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, supportsSharedSettingsSync, @@ -584,11 +585,13 @@ function AutoSettleSettingsRows() { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: reference.environmentId, primarySettings: referenceSettings, + primaryCapabilities: reference.serverConfig?.environment.capabilities, environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, + capabilities: environment.serverConfig?.environment.capabilities, })), }); @@ -652,11 +655,22 @@ function AutoSettleSettingsRows() { { - const patch = pickSharedServerSettings(referenceSettings); + const patch = pickSharedServerSettings( + referenceSettings, + reference.serverConfig?.environment.capabilities, + ); for (const mismatch of mismatches) { + const target = environments.find( + (candidate) => candidate.environmentId === mismatch.environmentId, + ); void updateSettings({ environmentId: mismatch.environmentId, - input: { patch }, + input: { + patch: filterSharedServerPatch( + patch, + target?.serverConfig?.environment.capabilities, + ), + }, }); } }} diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 79d67ebaa7c2..f161e654788b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -37,7 +37,6 @@ import { getMobileUniwindThemeName, type MobileThemeRuntimeState, } from "../../../lib/mobileThemeRuntime"; -import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ @@ -143,8 +142,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN useLayoutEffect(() => { selectedThemeIdsRef.current = themeIds; syncThemeRuntime(runtimeState); - cacheTerminalFontSize(appearance.terminalFontSize); - }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]); + }, [runtimeState, syncThemeRuntime, themeIds]); const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 9b5d8e113f85..bc8ddfc84ba5 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -176,10 +176,9 @@ export function FontSizeSliderRow(props: { /> { - it("returns the Pierre light terminal palette", () => { - expect(getPierreTerminalTheme("light")).toMatchObject({ +describe("getMobileTerminalTheme", () => { + it("preserves the default light terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "light")).toMatchObject({ background: "#f2f2f7", foreground: "#6C6C71", cursorForeground: "#009fff", @@ -19,23 +15,14 @@ describe("getPierreTerminalTheme", () => { }); }); - it("returns the Pierre dark terminal palette", () => { - expect(getPierreTerminalTheme("dark")).toMatchObject({ + it("preserves the default dark terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "dark")).toMatchObject({ background: "#0a0a0a", foreground: "#adadb1", cursorForeground: "#009fff", cursorBackground: "#0a0a0a", }); }); -}); - -describe("getMobileTerminalTheme", () => { - it("preserves the Pierre terminal for the default theme", () => { - for (const scheme of ["light", "dark"] as const) { - expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme)); - } - }); - it("applies the selected palette without replacing ANSI status colors", () => { const standard = getMobileTerminalTheme("t3-code", "dark"); const ocean = getMobileTerminalTheme("ocean", "dark"); @@ -58,7 +45,7 @@ describe("getMobileTerminalTheme", () => { describe("buildGhosttyThemeConfig", () => { it("serializes theme colors into a ghostty config file", () => { - const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); + const config = buildGhosttyThemeConfig(getMobileTerminalTheme("t3-code", "dark")); expect(config).toContain("background = #0a0a0a"); expect(config).toContain("foreground = #adadb1"); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index 9a913022571d..569b10f7bd55 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -74,7 +74,7 @@ const PIERRE_DARK_THEME: TerminalTheme = { ], }; -export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { +function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; } diff --git a/apps/mobile/src/features/terminal/terminalUiState.test.ts b/apps/mobile/src/features/terminal/terminalUiState.test.ts index 0bb3c1395915..6879fdfdbb20 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.test.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { - cacheTerminalFontSize, cacheTerminalGridSize, - getCachedTerminalFontSize, getCachedTerminalGridSize, resetTerminalUiStateCaches, } from "./terminalUiState"; @@ -14,14 +12,6 @@ describe("terminalUiState", () => { resetTerminalUiStateCaches(); }); - it("caches terminal font size using the shared normalization rules", () => { - expect(getCachedTerminalFontSize()).toBeNull(); - expect(cacheTerminalFontSize(8.5)).toBe(8.5); - expect(getCachedTerminalFontSize()).toBe(8.5); - expect(cacheTerminalFontSize(100)).toBe(14); - expect(getCachedTerminalFontSize()).toBe(14); - }); - it("stores terminal grid sizes per terminal target", () => { const primaryTarget = { environmentId: EnvironmentId.make("env-1"), diff --git a/apps/mobile/src/features/terminal/terminalUiState.ts b/apps/mobile/src/features/terminal/terminalUiState.ts index 2cac0bf52b9e..84274430e8a1 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.ts @@ -1,7 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences"; - export interface TerminalGridSize { readonly cols: number; readonly rows: number; @@ -14,22 +12,11 @@ export interface TerminalUiStateTarget { } const terminalGridSizeCache = new Map(); -let cachedTerminalFontSize: number | null = null; function terminalUiStateKey(target: TerminalUiStateTarget): string { return `${target.environmentId}:${target.threadId}:${target.terminalId}`; } -export function getCachedTerminalFontSize(): number | null { - return cachedTerminalFontSize; -} - -export function cacheTerminalFontSize(value: number | null | undefined): number { - const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE); - cachedTerminalFontSize = normalized; - return normalized; -} - export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null { return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null; } @@ -47,6 +34,5 @@ export function cacheTerminalGridSize( } export function resetTerminalUiStateCaches() { - cachedTerminalFontSize = null; terminalGridSizeCache.clear(); } diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..bcd76722c8e5 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -0,0 +1,103 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; + +/** + * The /usage-limits result, docked above the composer. It is the Usage → Limits + * card one size down, so the two read as the same thing. The surface is opaque + * because nothing blurs the feed behind it. + */ +export function ComposerUsageLimits({ + report, + environmentId, + onClose, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; + readonly onClose: () => void; +}) { + const now = Date.parse(report.createdAt); + const { height } = useWindowDimensions(); + const close = ( + + + + ); + return ( + + + {report.accounts.map((account, index) => { + const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); + return ( + + ) : undefined + } + /> + ); + })} + {report.accounts.length === 0 ? ( + // Nothing but notices, so the close control needs a row of its own. + + Usage limits + {close} + + ) : null} + {report.notices.map((notice) => ( + + {notice} + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index fb12a35d6c23..802759b5a2c7 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -143,9 +143,7 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { } const bgClass = - progress.phase === "error" - ? "border-adaptive-red-200-800 bg-adaptive-red-50-950-a80" - : "bg-card border-border"; + progress.phase === "error" ? "border-danger-border bg-danger" : "bg-card border-border"; return ( + diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f87c0aff3b0..3f6389858094 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ShimmeringWorkContent } from "./thread-work-log"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; @@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: { const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. + // T3 owns /usage-limits only where Limits has data for the selected provider. + const offersUsageLimits = + flow.selectedProviderStatus !== null && + hasProviderUsageLimits( + flow.selectedProviderStatus.driver, + selectedEnvironmentServerConfig?.providers ?? [], + selectedEnvironmentServerConfig?.usageLimitSources ?? [], + ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, @@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: { selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, + offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, @@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: { ); return; } + // T3's own limits command is answered by the thread composer; a new task would + // send it to the agent. A provider's same-named command, or a prompt carrying + // attachments, goes through as usual. + if ( + offersUsageLimits && + isUsageLimitsCommand(initialMessageText) && + draft.attachments.length === 0 + ) { + Alert.alert( + "Usage limits", + "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", + ); + return; + } // A failed-send restore can leave the draft over the cap on purpose (it // never drops the user's files); starting anyway would upload everything // and have the server reject the turn. diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index e01ad75fc3b3..4035bf73a7cc 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -32,15 +32,15 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { const canRespond = props.approval.responseCapability === "live"; const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId; return ( - - + + Approval needed - + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( - + {props.approval.detail} ) : null} @@ -51,9 +51,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { ) : null} {warning ? ( - - {warning} - + {warning} ) : null} {options.map((option) => ( @@ -61,10 +59,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { key={option.decision} className={`items-center justify-center rounded-[14px] px-3.5 py-3 ${ option.decision === "accept" - ? "bg-blue-500" + ? "bg-primary" : option.decision === "decline" - ? "bg-adaptive-rose-100-500-a18" - : "bg-adaptive-neutral-200-800" + ? "bg-danger" + : "bg-subtle-strong" }`} disabled={disabled} onPress={() => void props.onRespond(props.approval.requestId, option.decision)} @@ -72,10 +70,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {option.label} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 054bf5801227..f49d45decce2 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -164,7 +164,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { pointerEvents={props.collapsed ? "auto" : "none"} accessibilityElementsHidden={!props.collapsed} importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} - className="flex-row items-center gap-2 rounded-full border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 py-1.5 pl-4 pr-1.5" + className="flex-row items-center gap-2 rounded-full border border-border bg-card-alt py-1.5 pl-4 pr-1.5" > - + User input needed - + {questionCount} question{questionCount === 1 ? "" : "s"} @@ -219,7 +219,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) } layout={CARD_LAYOUT_TRANSITION} - className="overflow-hidden gap-2.5 rounded-[20px] border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 p-4" + className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" style={ EXPANDED_CARD_IS_OVERLAY ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] @@ -233,14 +233,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className="flex-row items-start gap-2" > - + User input needed - - Fill in the pending answers - + Fill in the pending answers - + - + {question.header} - + {question.question} @@ -286,9 +284,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { disabled={!canRespond} className={cn( "min-h-12 w-full rounded-2xl border px-3.5 py-3", - selected - ? "border-adaptive-blue-300-a50-blue-400-a28 bg-adaptive-blue-50-blue-400-a14" - : "border-adaptive-neutral-200-white-a6 bg-adaptive-white-neutral-950-a70", + selected ? "border-primary bg-primary/10" : "border-border bg-input", )} onPress={() => props.onSelectOption( @@ -302,15 +298,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {option.label} {description ? ( - + {description} ) : null} @@ -329,7 +323,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" + className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> ) : null} @@ -339,7 +333,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { void props.onSubmit()} > - Submit answers + + Submit answers + ) : null; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 80cb8b7f0570..9386568666c8 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,7 +7,13 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + UsageLimitsReport, } from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { @@ -20,7 +26,7 @@ import { useState, type RefObject, } from "react"; -import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; +import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -132,6 +138,8 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ + readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -341,6 +349,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + selectedProviderStatus !== null && + hasProviderUsageLimits( + selectedProviderStatus.driver, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + ); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const report = collectProviderUsageLimits( + currentModelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + Date.now(), + ); + onShowUsageLimits(report); + if (!report) { + Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); + } + return report !== null; + }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, @@ -355,6 +387,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, + offersUsageLimits: usageLimitsOffered, + // With attachments aboard the pick just inserts the text, so it sends as a prompt. + onUsageLimits: + usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -433,9 +469,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); - const { onSendMessage } = props; - const handleSend = useCallback(async () => { + // Typed out in full rather than picked from the menu. Attachments mean the + // user is sending a prompt, so those go through as usual. + if ( + usageLimitsOffered && + isUsageLimitsCommand(props.draftMessage) && + props.draftAttachments.length === 0 + ) { + if (openUsageLimits()) onChangeDraftMessage(""); + return; + } if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; @@ -458,6 +502,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer inFlightThreadIdsRef.current.delete(threadKey); } }, [ + props.draftMessage, + props.draftAttachments.length, + onChangeDraftMessage, + openUsageLimits, + usageLimitsOffered, onSendMessage, props.environmentId, props.environmentLabel, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9ee3fa2e8ec9..801c1b99917d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -15,6 +15,7 @@ import type { RuntimeRequestId, ServerConfig as T3ServerConfig, ThreadId, + UsageLimitsReport, } from "@t3tools/contracts"; import { appendCodexArtifactTemplateUsePrompt, @@ -57,6 +58,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; @@ -72,6 +74,7 @@ import type { ThreadFeedLatestRun, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -367,6 +370,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly threadKey: string; + readonly now: number; + } | null>(null); + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = [ + selectedThreadKey, + props.selectedThread.modelSelection.instanceId, + props.selectedThread.latestRun?.runId ?? "", + props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", + ].join(":"); + // Drop the snapshot as soon as the key changes so it cannot resurface stale. + if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { + setUsageLimitsPanel(null); + } + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey + ? collectProviderUsageLimits( + props.selectedThread.modelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + usageLimitsPanel.now, + ) + : null, + [ + props.selectedThread.modelSelection.instanceId, + props.serverConfig, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const showUsageLimits = useCallback( + (report: UsageLimitsReport | null) => + setUsageLimitsPanel( + report === null + ? null + : { + key: usageLimitsKey, + threadKey: selectedThreadKey, + now: Date.parse(report.createdAt), + }, + ), + [selectedThreadKey, usageLimitsKey], + ); + const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); + // A send may resolve after navigating away, so only the originating + // thread's panel is cleared; a panel opened elsewhere in the meantime stays. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ), + [], + ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of @@ -472,7 +537,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (!endFollowEnabledRef.current) { return; } - void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { + void scrollMessageToEnd({ + animated: false, + closeKeyboard: false, + }).catch(() => { freeze.set(false); }); }, delayMs); @@ -559,7 +627,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread setComposerFocused(false); }, [selectedThreadKey, showContent]); - const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + const visitThread = useAtomCommand(threadEnvironment.visit, { + reportFailure: false, + }); const lastDispatchedVisitRef = useRef(null); const selectedThreadId = props.selectedThread.id; const selectedThreadUpdatedAt = props.selectedThread.updatedAt; @@ -664,6 +734,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; } + // A sent message makes the snapshot stale; a refused send leaves it in place. + clearUsageLimitsFor(targetThreadKey); + setSubmittedMessageId(messageId); setAnchorMessageId( resolveThreadFeedSubmissionAnchor({ @@ -678,6 +751,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; }, [ anchorMessageId, + clearUsageLimitsFor, props.onSendMessage, props.selectedThread.latestRun, props.selectedThreadQueueCount, @@ -699,7 +773,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } requestAnimationFrame(() => { composerEditorRef.current?.focus(); - composerEditorRef.current?.setSelection({ start: nextDraft.length, end: nextDraft.length }); + composerEditorRef.current?.setSelection({ + start: nextDraft.length, + end: nextDraft.length, + }); }); }, [props.onChangeDraftMessage], @@ -830,7 +907,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread environmentId={props.environmentId} threadId={props.selectedThread.id} /> - + {usageLimitsReport && activeUserInputRequestId === null ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( @@ -697,7 +710,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly onPress: (href: string) => void; }) { const [failedHost, setFailedHost] = useState(null); - const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); + const linkIcon = resolveMarkdownLinkIcon(props.host); + const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); return ( - {faviconUrl !== null && - failedHost !== props.host && - !failedMarkdownFaviconHosts.has(props.host) ? ( + {linkIcon ? ( + + ) : faviconUrl !== null && + failedHost !== props.host && + !failedMarkdownFaviconHosts.has(props.host) ? ( ; + } + + if (entry.type === "agent-spawn") { + return ( + props.onToggleWorkGroup(entry.id, entry.id)} + onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())} + /> + ); + } + if (entry.type === "work-toggle") { return ( {message.text.trim().length > 0 ? ( - + + + ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( @@ -1649,14 +1696,16 @@ function renderFeedEntry( {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} > {renderedText.trim().length > 0 ? ( - + + + ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( @@ -1720,6 +1769,7 @@ function renderFeedEntry( rowSizing={props.workRowSizing} scrollPositions={props.workGroupScrollPositions} iconSubtleColor={iconSubtleColor} + edgeFadeColor={props.screenColor} themeAppearance={props.themeAppearance} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} @@ -1914,7 +1964,10 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { showsHorizontalScrollIndicator={false} bounces={false} className="border-t" - style={{ backgroundColor: props.colors.codeBackground, borderColor: props.colors.border }} + style={{ + backgroundColor: props.colors.codeBackground, + borderColor: props.colors.border, + }} contentContainerStyle={{ padding: 10 }} > deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }), + () => + deriveThreadWorkLogSizing({ + baseFontSize: appearance.baseFontSize, + fontScale, + }), [appearance.baseFontSize, fontScale], ); const previousTextSize = useRef(workRowSizing.textSizeKey); @@ -2107,6 +2164,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); + const fileShareSourceIdentifier = useId(); + const shareFileChip = useFileChipShare( + props.environmentId, + props.threadId, + fileShareSourceIdentifier, + ); useEffect(() => { setExpandedVideo(null); setExpandedFile(null); @@ -2119,6 +2182,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); const userBubbleMaxWidth = contentWidth * 0.85; + const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT; @@ -2144,6 +2208,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; + const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { @@ -2228,7 +2293,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( - (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, + (current) => + current ?? { + kind: "pdf", + uri: presentation.href!, + name: "Document.pdf", + }, ); return; } @@ -2257,10 +2327,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "open-file": onMarkdownLinkPress(href); return; + case "save": + shareFileChip(target); + return; } }, }), - [onMarkdownLinkPress, props.workspaceRoot], + [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], ); const renderMarkdownImage = useCallback( (image) => { @@ -2738,7 +2811,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "run-fold": return resolveThreadFeedFixedItemSize(entry.type); case "work-toggle": - return resolveThreadFeedFixedItemSize(entry.type); + case "thinking": + return resolveThreadFeedFixedItemSize("work-toggle"); case "activity-group": if (isContextCompactionActivityGroup(entry)) { return undefined; @@ -2786,6 +2860,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { renderMarkdownImage, renderViewedImage, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, @@ -2793,6 +2868,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { themeAppearance, userBubbleMaxWidth, threadTitle: props.threadTitle, + markdownContentWidth, skills: props.skills, workspaceRoot: props.workspaceRoot, })} @@ -2808,12 +2884,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { terminalAssistantMessageIds, unsettledTurnId, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, userBubbleMaxWidth, + markdownContentWidth, onCopyWorkRow, markdownLinkHandlers, onPressPreview, @@ -2845,7 +2923,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } return ( - <> + 0 && viewportWidth > 0 - ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } } + ? { + estimatedListSize: { + height: viewportHeight, + width: viewportWidth, + }, + } : {})} // RN's native scrollTo command clamps targets to a floor of // -contentInset.top using the RAW inset — under automatic insets the @@ -2990,10 +3078,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { /> ) : null} + setExpandedVideo(null)} /> + setExpandedFile(null)} /> - - setExpandedVideo(null)} /> - setExpandedFile(null)} /> - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index c506bf1875eb..a67225f1a336 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -1,5 +1,5 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { useEffect, useId, useState } from "react"; +import { createContext, useContext, useEffect, useId, useState } from "react"; import { ActivityIndicator, Image, @@ -15,32 +15,56 @@ import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { PresentationSource } from "../../components/NativePresentation"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { useAssetUrlState } from "../../state/assets"; -import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { + MARKDOWN_IMAGE_MAX_WIDTH, + type MarkdownImageDisplaySize, + resolveMarkdownImageDisplaySize, +} from "./markdownImageSize"; + +/** + * Width the feed lays markdown out in. The feed already knows this from its + * viewport, so an image can size its frame on the first render instead of + * waiting for its own onLayout, which would change the row's height once + * more after the list has positioned the rows below it. It is an upper + * bound: a list item or blockquote indents its column, and the measured + * width takes over once it is known. + */ +export const MarkdownImageAvailableWidthContext = createContext(0); export function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; + /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ + readonly knownSize?: { readonly width: number; readonly height: number } | undefined; readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); const mediaActions = useMediaActions(props.actionsSource); - const [availableWidth, setAvailableWidth] = useState(0); - const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const contextWidth = useContext(MarkdownImageAvailableWidthContext); + const [measuredWidth, setMeasuredWidth] = useState(0); + const availableWidth = + measuredWidth > 0 && contextWidth > 0 + ? Math.min(contextWidth, measuredWidth) + : contextWidth || measuredWidth; + const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); useEffect(() => { - setSourceSize(null); + setDecodedSize(null); }, [props.sourceKey]); useEffect(() => { setFailedUri(null); }, [props.uri]); - const displaySize = - sourceSize === null + // The decoded size is what the platform actually drew, so it wins over the + // server's header hint once it exists. + const sourceSize = decodedSize ?? props.knownSize ?? null; + const displaySize: MarkdownImageDisplaySize | null = + sourceSize === null || availableWidth <= 0 ? null : resolveMarkdownImageDisplaySize({ sourceWidth: sourceSize.width, @@ -54,7 +78,7 @@ export function ThreadMarkdownImageView(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} + onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)} style={{ alignSelf: "stretch", gap: 6 }} > {props.uri === null || failed ? ( @@ -97,14 +121,12 @@ export function ThreadMarkdownImageView(props: { > setFailedUri(props.uri)} /> @@ -173,6 +195,7 @@ export function ThreadMarkdownImage(props: { : `workspace:${props.resource.path}` } unavailable={assetUrl._tag === "Failure"} + knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} alt={props.alt} actionsSource={props.actionsSource} onPressPreview={props.onPressPreview} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 00f4b8e3ff40..88fb50ca285d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -7,8 +7,17 @@ import { } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ThreadId, + type ProjectScript, +} from "@t3tools/contracts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; @@ -627,7 +636,12 @@ function ThreadRouteContent( gitOperationLabel: gitState.gitOperationLabel, canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), - projectScripts: selectedThreadProject?.scripts ?? [], + projectScripts: selectedThreadProject + ? resolveProjectScripts( + routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedThreadProject, + ) + : [], terminalSessions: terminalMenuSessions, showDirectFileControl: layout.usesSplitView, onOpenTerminal: handleOpenTerminal, @@ -822,6 +836,7 @@ function ThreadRouteContent( <> {activeInspectorRenderer ? : null} { it("resolves a workspace-relative link to both paths", () => { @@ -42,3 +43,45 @@ describe("fileChipMenu", () => { ]); }); }); + +describe("file chip downloads", () => { + const threadId = ThreadId.make("thread-1"); + + it.each([ + [ + "/tmp/maria-counter/maria-counter-final.mp4", + "/tmp/maria-counter/maria-counter-final.mp4", + "video/mp4", + ], + ["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"], + ["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"], + ["screens/image.PNG", "/repo/screens/image.PNG", "image/png"], + ])("offers a host download for %s", (href, path, mimeType) => { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipMenu(target).actions).toContainEqual({ + id: "save", + title: "Save or share", + }); + expect(fileChipShareSource(target, threadId)).toEqual({ + name: path.split("/").at(-1), + mimeType, + resource: { _tag: "media-file", threadId, path }, + }); + }); + + it("retains the thread context for a relative file without a known workspace root", () => { + expect( + fileChipShareSource(resolveFileChipTarget("clips/demo.mp4", null)!, threadId), + ).toMatchObject({ + resource: { _tag: "media-file", threadId, path: "clips/demo.mp4" }, + }); + }); + + it("does not offer downloads the host asset endpoint cannot serve", () => { + for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipShareSource(target, threadId)).toBeNull(); + expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false); + } + }); +}); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 3630a62b3551..9f82e089444d 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,5 +1,8 @@ +import { fileBasename } from "@t3tools/client-runtime/markdown-links"; +import type { ThreadId } from "@t3tools/contracts"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; +import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { isAbsolutePath, @@ -7,7 +10,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; +export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save"; export interface FileChipTarget { /** The host path, when the link is absolute or the workspace root is known. */ @@ -36,7 +39,28 @@ export function resolveFileChipTarget( }; } -/** The same actions the web file chip offers on right-click. Opening is what a tap does. */ +function fileChipMetadata(target: FileChipTarget) { + const path = target.fullPath ?? target.relativePath; + if (!path) return null; + const name = fileBasename(path); + const dot = name.lastIndexOf("."); + const mimeType = dot < 0 ? null : hostPreviewMimeTypeFromExtension(name.slice(dot)); + return mimeType ? { path, name, mimeType } : null; +} + +/** Use literal resolved paths so encoded filename characters are not decoded twice. */ +export function fileChipShareSource(target: FileChipTarget, threadId: ThreadId) { + const metadata = fileChipMetadata(target); + return metadata + ? { + name: metadata.name, + mimeType: metadata.mimeType, + resource: { _tag: "media-file" as const, threadId, path: metadata.path }, + } + : null; +} + +/** Saving is available for the media and documents the host asset endpoint can serve. */ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { return { title: target.fullPath ?? target.relativePath ?? "", @@ -44,6 +68,14 @@ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), { id: "open-file", title: "Open in file viewer" }, + ...(fileChipMetadata(target) + ? [ + { + id: "save", + title: "Save or share", + }, + ] + : []), ], }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index cac2686ab218..a429044fccdd 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -18,8 +18,12 @@ import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -const CONTROL_HEIGHT = 44; -const CONTROL_COMPOSER_GAP = 8; +const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem +// The collapsed composer capsule starts 6 below its overlay's top edge, so +// the pill sits at (gap - 6) above the overlay to leave the same gap to the +// capsule as the feed's end inset leaves between it and the last row. +const CONTROL_GAP = 8; +const COMPOSER_CAPSULE_INSET = 6; const GLASS_MERGE_SPACING = 12; const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); @@ -40,7 +44,8 @@ const UniwindGlassContainer = withUniwind(GlassContainer, { }); const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); -export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; +const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; +export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; /** * What the floating pill says. Syncing and working share one element so the @@ -81,7 +86,7 @@ export function FloatingWorkingControl(props: { diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index f263372bad22..e76672de20f1 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -85,7 +85,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {isDefaultRef ? ( - + Warning: this is the default branch. ) : null} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6d87f284ebda..4020c1de9417 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -92,6 +92,7 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; +import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; type WorkspaceMode = "local" | "worktree"; @@ -426,7 +427,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + selectedProject?.defaultModelSelection ?? + selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? + null, ); const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( @@ -622,51 +625,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback( + // New-task drafts are keyed per (environment, project), so retargeting the + // composer would otherwise show the target's empty draft and strand what the + // user typed under the old key. + const carryDraftContentTo = useCallback( (project: EnvironmentProject) => { - const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - const nextDraftKey = `new-task:${nextProjectKey}`; + const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`; if ( selectedProjectDraftKey?.startsWith("new-task:") && selectedProjectDraftKey !== nextDraftKey ) { void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); } - setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(nextProjectKey); }, [selectedProjectDraftKey], ); + const setProject = useCallback( + (project: EnvironmentProject) => { + carryDraftContentTo(project); + setSelectedEnvironmentId(project.environmentId); + setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); + }, + [carryDraftContentTo], + ); + const selectEnvironment = useCallback( (environmentId: EnvironmentId) => { - const projectsOnTarget = projects.filter( - (project) => project.environmentId === environmentId, + const match = resolveEnvironmentProjectMatch( + projects.filter((project) => project.environmentId === environmentId), + selectedProject, ); - const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; - // Prefer the repository identity; projects without one (e.g. not yet - // indexed) fall back to workspace basename, then title, so switching - // computers still follows the same repo instead of resetting to - // whatever project is first on the target machine. - const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const match = - (repositoryKey !== null - ? projectsOnTarget.find( - (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, - ) - : undefined) ?? - (workspaceBasename !== null - ? projectsOnTarget.find( - (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename, - ) - : undefined) ?? - (selectedProject !== null - ? projectsOnTarget.find((project) => project.title === selectedProject.title) - : undefined); + if (match) { + carryDraftContentTo(match); + } setSelectedEnvironmentId(environmentId); setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); }, - [projects, selectedProject], + [projects, selectedProject, carryDraftContentTo], ); const setWorkspaceMode = useCallback( diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 7068a95d558a..ca59a2b9dddc 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -4,18 +4,35 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { - getOnlySelectableProject, getProjectScopeSelectionTarget, resolveDraftProjectSelection, + resolveEnvironmentProjectMatch, } from "./new-task-project-selection"; -function makeProject(id: string, environmentId = "environment"): EnvironmentProject { +function makeProject( + id: string, + environmentId = "environment", + options: { + readonly title?: string; + readonly workspaceRoot?: string; + readonly repositoryKey?: string; + } = {}, +): EnvironmentProject { return { environmentId: EnvironmentId.make(environmentId), id: ProjectId.make(id), - title: id, - workspaceRoot: `/work/${id}`, - repositoryIdentity: null, + title: options.title ?? id, + workspaceRoot: options.workspaceRoot ?? `/work/${id}`, + repositoryIdentity: options.repositoryKey + ? { + canonicalKey: options.repositoryKey, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://${options.repositoryKey}.git`, + }, + } + : null, defaultModelSelection: null, scripts: [], createdAt: "2026-07-01T00:00:00.000Z", @@ -36,18 +53,6 @@ function makeScope(projects: ReadonlyArray): HomeProjectScop }; } -describe("getOnlySelectableProject", () => { - it("auto-selects when there is exactly one physical project", () => { - const project = makeProject("t3code"); - expect(getOnlySelectableProject([makeScope([project])])).toBe(project); - }); - - it("selects the representative when one logical project has multiple workspaces", () => { - const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; - expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]); - }); -}); - describe("getProjectScopeSelectionTarget", () => { it("keeps the current environment when it hosts the selected logical project", () => { const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; @@ -64,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => { }); }); +describe("resolveEnvironmentProjectMatch", () => { + it("follows the same repository onto the target machine", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [ + makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }), + makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); + }); + + it("falls back to workspace basename, then title, for unindexed projects", () => { + const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" }); + const byBasename = [ + makeProject("other", "server"), + makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); + + const byTitle = [ + makeProject("other", "server"), + makeProject("srv", "server", { title: "t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]); + }); + + it("does not treat a known different repository as a basename or title match", () => { + const selected = makeProject("t3code", "mac", { + repositoryKey: "github.com/t3tools/t3code", + workspaceRoot: "/Users/me/t3code", + }); + const fork = makeProject("fork", "server", { + repositoryKey: "github.com/someone/t3code", + title: "t3code", + workspaceRoot: "/home/me/t3code", + }); + const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); + expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); + // Without any weaker match the fork is still the first-project fallback. + expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + }); + + it("falls back to the first project on the target so the draft has a key to carry over to", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]); + expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); + }); +}); + describe("resolveDraftProjectSelection", () => { it("preserves an explicit project selection", () => { const project = makeProject("t3code"); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 7be899d62a1a..65dd9916f2f6 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -19,13 +19,60 @@ export function getProjectScopeSelectionTarget( ); } -export function getOnlySelectableProject( +function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; return onlyScope?.representative ?? null; } +/** + * Picks the project on a target environment that corresponds to the project + * currently selected in the new-task flow, so switching computers follows the + * same repo. Repository identity is preferred; projects without one (e.g. not + * yet indexed) fall back to workspace basename, then title. When nothing + * matches, the first project on the target stands in — the same fallback the + * render path applies when no key is selected — so the draft always has a + * concrete key to carry over to. + */ +export function resolveEnvironmentProjectMatch( + projectsOnTarget: ReadonlyArray, + selectedProject: EnvironmentProject | null, +): EnvironmentProject | null { + const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + // `|| null` (not `??`): a pending-task placeholder project can have an empty + // workspaceRoot, and an "" basename would match nothing meaningful. + const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; + // The weaker signals only apply where identity is unknown on at least one + // side; two known, different repositories never match on a shared basename + // or title (mirrors the environment list filter in the new-task flow). + const isKnownMismatch = (project: EnvironmentProject) => { + const projectKey = project.repositoryIdentity?.canonicalKey ?? null; + return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; + }; + return ( + (repositoryKey !== null + ? projectsOnTarget.find( + (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, + ) + : undefined) ?? + (workspaceBasename !== null + ? projectsOnTarget.find( + (project) => + !isKnownMismatch(project) && + project.workspaceRoot.split("/").at(-1) === workspaceBasename, + ) + : undefined) ?? + (selectedProject !== null + ? projectsOnTarget.find( + (project) => !isKnownMismatch(project) && project.title === selectedProject.title, + ) + : undefined) ?? + projectsOnTarget[0] ?? + null + ); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index fcba4626be2d..fc3898279e3e 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -297,8 +297,8 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ); const statusPill = ( - - Pending + + Pending ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 5df253292f75..32d3e225dbac 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,7 +23,6 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadListV2SnoozeGateExpiryMs, @@ -54,10 +53,10 @@ const MONO_FONT = Platform.select({ const STATUS_LABEL_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-adaptive-amber-700-300" }, - input: { label: "Input", className: "text-adaptive-indigo-600-300" }, - working: { label: "Working", className: "text-adaptive-sky-600-400" }, - failed: { label: "Failed", className: "text-adaptive-red-700-300" }, + approval: { label: "Approval", className: "text-warning-foreground" }, + input: { label: "Input", className: "text-foreground-secondary" }, + working: { label: "Working", className: "text-foreground-secondary" }, + failed: { label: "Failed", className: "text-danger-foreground" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -108,9 +107,6 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid ); }); -const SNOOZE_ACCENT_LIGHT = "#2563eb"; -const SNOOZE_ACCENT_DARK = "#60a5fa"; - export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; readonly disabled?: boolean; @@ -118,7 +114,6 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }) { - const { themeAppearance: colorScheme } = useAppearancePreferences(); return ( ({ opacity: pressed ? 0.6 : 1 })} > - + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} - + @@ -744,7 +739,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -926,7 +921,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? "text-user-bubble-foreground-muted" : snoozedRow - ? "text-adaptive-blue-600-400" + ? "text-foreground-secondary" : "text-foreground-tertiary", )} style={{ fontFamily: MONO_FONT }} diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 48aaf80249d5..9c478f3c4504 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -65,7 +65,7 @@ export function ThreadSearchMatchExcerpt(props: { props.selected ? "text-user-bubble-foreground" : isUser - ? "text-adaptive-blue-500-400" + ? "text-foreground-secondary" : "text-adaptive-emerald-600-400", )} > diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 8527defeb3c8..606a832cb952 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; -import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import { + type AgentSpawnSummary, + type ThreadFeedActivity, + workEntryRowLabel, +} from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: { } function ShimmerWorkContent(props: { + readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; readonly icon: WorkContentIcon; @@ -147,26 +152,29 @@ function ShimmerWorkContent(props: { }) { return ( - - {props.showIcon && props.toolIcon && props.environmentId ? ( - - ) : props.showIcon ? ( - - ) : null} - + {props.showIcon ? ( + + {props.toolIcon && props.environmentId ? ( + + ) : ( + + )} + + ) : null} { const subscription = AppState.addEventListener("change", (state) => { @@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: { onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} > 0 ? cleaned : null; -} - function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { switch (icon) { case "agent": @@ -400,6 +400,8 @@ interface ThreadWorkLogProps { readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly iconSubtleColor: ColorValue; + /** Feed background, painted as the scroll-edge fade over a long group. */ + readonly edgeFadeColor: string; readonly themeAppearance: "light" | "dark"; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string, anchorKey: string) => void; @@ -445,6 +447,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { {props.activities[0]?.groupedToolDetail ? ( ; + readonly edgeFadeColor: string; readonly expandedRows: Readonly>; readonly groupId: string; readonly rowSizing: ReturnType; @@ -500,21 +504,17 @@ function ThreadWorkGroupList(props: { const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); - const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; - const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height; - // Opaque covers remove each edge fade at the scroll boundary. Scroll offset - // stays on the UI thread; only content-size changes update React state. - const topCoverStyle = useAnimatedStyle(() => ({ - opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), + // Each edge fades only while content continues past it. Scroll offset stays + // on the UI thread; only content-size changes update React state. + const topFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), })); - const bottomCoverStyle = useAnimatedStyle(() => ({ - opacity: - 1 - - Math.min( - 1, - Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, - ), + const bottomFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min( + 1, + Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, + ), })); const rememberPosition = useCallback(() => { if (!loadedRef.current) return; @@ -540,7 +540,7 @@ function ThreadWorkGroupList(props: { } }, []); const onContentSizeChange = useCallback( - (_width: number, nextHeight: number) => { + (nextHeight: number) => { const previous = previousContent.current; const detailsChanged = previous.expandedRows !== props.expandedRows; const followAppend = @@ -580,6 +580,24 @@ function ThreadWorkGroupList(props: { }, [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition], ); + // The native ScrollView reports its content size a frame or more after + // LegendList has laid the rows out, so a detail toggle rendered the group + // at its old height while the rows below already moved. Read the size + // LegendList computes on the JS thread instead; it settles in the same + // commit as the row measurement that changed it. + const onContentSizeChangeRef = useRef(onContentSizeChange); + useLayoutEffect(() => { + onContentSizeChangeRef.current = onContentSizeChange; + }, [onContentSizeChange]); + const subscribeToContentSize = useCallback((list: LegendListRef | null) => { + listRef.current = list; + if (!list) return; + const unsubscribe = list.getState().listen("totalSize", () => { + onContentSizeChangeRef.current(list.getState().contentLength); + }); + onContentSizeChangeRef.current(list.getState().contentLength); + return unsubscribe; + }, []); const getFixedItemSize = useCallback( (row: ThreadFeedActivity, index: number) => props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined @@ -597,34 +615,9 @@ function ThreadWorkGroupList(props: { ); return ( - - - - - - - - - - - - - - - - } - > + { loadedRef.current = true; @@ -669,12 +661,47 @@ function ThreadWorkGroupList(props: { scrollsToTop={false} bounces={false} keyboardShouldPersistTaps="handled" - // MaskedView bridges through a native host whose absolute-fill bounds - // can lag behind a resize. Keep the list's viewport at the group's - // current height when expanding details or appending calls. - style={[StyleSheet.absoluteFill, { height }]} + style={{ height }} /> - + + + + + + + + ); +} + +/** A screen-colored gradient painted over the list edge that still has content past it. */ +function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) { + const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; + return ( + + + + + + + + + ); } @@ -685,7 +712,12 @@ function workLogRowKey(row: ThreadFeedActivity): string { const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, - "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions" + | "activities" + | "copiedRowId" + | "edgeFadeColor" + | "expandedRows" + | "rowSizing" + | "scrollPositions" > & { readonly row: ThreadFeedActivity; readonly copied: boolean; @@ -697,8 +729,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( const fullDetail = expanded ? row.getFullDetail() : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); - const previewText = - toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; + const previewText = workEntryRowLabel(row.workEntry); const displayText = !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; const isSystemNotice = row.projectedItem.item.type === "system_notice"; @@ -923,6 +954,162 @@ export function ThreadWorkGroupToggle(props: { ); } +const AGENT_SPAWN_TONE_DOT_CLASS = { + working: "bg-adaptive-sky-600-400", + completed: "bg-adaptive-emerald-600-400", + failed: "bg-adaptive-rose-600-400", + stopped: "bg-foreground-muted", +} as const satisfies Record; + +/** + * A batch of spawned subagents. The status line updates in place as members + * report progress; expanding lists each member. Text nodes carry keys tied to + * the row identity only, so a progress tick re-renders the labels without + * remounting the card (see the batch key in appendActivityGroupRows). + */ +export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: { + readonly summary: AgentSpawnSummary; + readonly expanded: boolean; + readonly iconSubtleColor: ColorValue; + readonly rowSizing: ReturnType; + readonly onToggle: () => void; + readonly onCopy: () => void; +}) { + const { summary, expanded } = props; + const working = summary.tone === "working"; + const memberCount = summary.members.length; + const canExpand = memberCount > 0; + return ( + + { + if (!canExpand) return; + void Haptics.selectionAsync(); + props.onToggle(); + }} + onLongPress={props.onCopy} + className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle" + > + + + + + + + {summary.title} + + + + {working ? ( + + ) : ( + + {summary.status} + + )} + + + {canExpand ? ( + + ) : null} + + {expanded && canExpand ? ( + + {summary.members.map((member) => ( + + + + + {member.title} + + {member.status} + + {member.detail ? ( + + {member.detail} + + ) : null} + + ))} + + ) : null} + + + ); +}); + +export function ThreadThinkingRow(props: { + readonly rowSizing: ReturnType; + readonly iconSubtleColor: ColorValue; +}) { + return ( + + + + ); +} + function ToolActivityIconView(props: { readonly environmentId: EnvironmentId; readonly icon?: ToolActivityIcon; diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 4e21f4ec99b4..85f5ee6f15c1 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -40,8 +40,8 @@ export function resolveThreadStatus( return { kind: "pending-approval", label: "Needs Approval", - pillClassName: "bg-adaptive-amber-500-a12-a16", - textClassName: "text-adaptive-amber-700-300", + pillClassName: "bg-warning", + textClassName: "text-warning-foreground", iconColor: "#ff9f0a", iconBackground: "rgba(255,159,10,0.22)", pulse: false, @@ -52,8 +52,8 @@ export function resolveThreadStatus( return { kind: "awaiting-input", label: "Awaiting Input", - pillClassName: "bg-adaptive-indigo-500-a12-a16", - textClassName: "text-adaptive-indigo-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#5e5ce6", iconBackground: "rgba(94,92,230,0.22)", pulse: false, @@ -66,8 +66,8 @@ export function resolveThreadStatus( return { kind: "working", label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -78,8 +78,8 @@ export function resolveThreadStatus( return { kind: "connecting", label: "Connecting", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -90,8 +90,8 @@ export function resolveThreadStatus( return { kind: "error", label: "Error", - pillClassName: "bg-adaptive-rose-500-a12-a16", - textClassName: "text-adaptive-rose-700-300", + pillClassName: "bg-danger", + textClassName: "text-danger-foreground", iconColor: "#ff453a", iconBackground: "rgba(255,69,58,0.22)", pulse: false, @@ -106,8 +106,8 @@ export function resolveThreadStatus( return { kind: "plan-ready", label: "Plan Ready", - pillClassName: "bg-adaptive-violet-500-a12-a16", - textClassName: "text-adaptive-violet-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#bf5af2", iconBackground: "rgba(191,90,242,0.22)", pulse: false, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 5ae248b6cde8..4c92325f5fbd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -13,16 +13,9 @@ vi.mock("../../state/use-atom-command", () => ({ import { buildComposerSlashCommandItems, - composerSelectionAtEnd, resolveComposerCommandSelection, } from "./use-composer-command-menu"; -describe("composerSelectionAtEnd", () => { - it("resets a changed draft owner to the new draft end", () => { - expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 }); - }); -}); - describe("mobile slash commands", () => { const antigravity = { driver: ProviderDriverKind.make("antigravity"), diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 5b5b444cca74..23c56d28f4c4 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; +import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { detectComposerTrigger, replaceTextRange, @@ -27,7 +28,7 @@ import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; -export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { +function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; } @@ -36,6 +37,8 @@ export function buildComposerSlashCommandItems(input: { readonly atMessageStart: boolean; readonly hasThread: boolean; readonly hasCompactableConversation?: boolean; + /** Whether T3 itself offers /usage-limits for the selected provider. */ + readonly offersUsageLimits?: boolean; readonly allowInteractionMode: boolean; readonly selectedProviderStatus: Pick< ServerProvider, @@ -78,6 +81,11 @@ export function buildComposerSlashCommandItems(input: { for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; if (command.name === "compact" && !input.hasCompactableConversation) continue; + // T3's own limits command is answered by the thread composer; New Task has + // nowhere to show it. A provider's same-named command is left alone. + if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) { + continue; + } if ( !input.hasThread && input.selectedProviderStatus?.driver === "codex" && @@ -143,9 +151,11 @@ export function useComposerCommandMenu({ selectedProviderStatus, hasThread, hasCompactableConversation, + offersUsageLimits = false, enabled = true, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, }: { readonly draftMessage: string; readonly ownerKey: string | null; @@ -154,9 +164,13 @@ export function useComposerCommandMenu({ readonly selectedProviderStatus: ServerProvider | null; readonly hasThread: boolean; readonly hasCompactableConversation: boolean; + /** Whether T3 itself offers /usage-limits for the selected provider. */ + readonly offersUsageLimits?: boolean; readonly enabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; + /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */ + readonly onUsageLimits?: () => void; }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); const previousOwnerKeyRef = useRef(ownerKey); @@ -267,6 +281,7 @@ export function useComposerCommandMenu({ atMessageStart: trigger.rangeStart === 0, hasThread, hasCompactableConversation, + offersUsageLimits, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus, }); @@ -390,12 +405,25 @@ export function useComposerCommandMenu({ selectedProviderStatus, skills, trigger, + offersUsageLimits, ]); const onSelect = useCallback( (item: ComposerCommandItem) => { if (!trigger) return; + if ( + item.type === "provider-slash-command" && + item.command.name === USAGE_LIMITS_COMMAND.name && + onUsageLimits + ) { + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + onUsageLimits(); + return; + } + const result = resolveComposerCommandSelection({ draftMessage, trigger, @@ -414,6 +442,7 @@ export function useComposerCommandMenu({ draftMessage, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, selectedProviderStatus?.showInteractionModeToggle, trigger, ], diff --git a/apps/mobile/src/features/threads/useFileChipShare.ts b/apps/mobile/src/features/threads/useFileChipShare.ts new file mode 100644 index 000000000000..587aa8ad9572 --- /dev/null +++ b/apps/mobile/src/features/threads/useFileChipShare.ts @@ -0,0 +1,70 @@ +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { Alert } from "react-native"; + +import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; +import { assetEnvironment } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { fileChipShareSource, type FileChipTarget } from "./fileChipMenu"; + +/** Fetches host files through the selected environment before opening the native save/share sheet. */ +export function useFileChipShare( + environmentId: EnvironmentId, + threadId: ThreadId, + sourceIdentifier: string, +) { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + const connectionRef = useRef(httpBaseUrl); + useLayoutEffect(() => { + connectionRef.current = httpBaseUrl; + }, [httpBaseUrl]); + const requestRef = useRef(null); + useEffect(() => () => requestRef.current?.abort(), []); + + const share = useCallback( + (target: FileChipTarget) => { + const source = fileChipShareSource(target, threadId); + if (!source || requestRef.current) return; + const request = new AbortController(); + requestRef.current = request; + const httpBaseUrl = connectionRef.current; + void (async () => { + if (httpBaseUrl === null) throw new Error("Reconnect to the environment and try again."); + const result = await createUrl({ environmentId, input: { resource: source.resource } }); + if (request.signal.aborted) return; + const url = + result._tag === "Success" ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) : null; + if (url === null) throw new Error("The file could not be loaded. Reconnect and try again."); + await downloadAndShareAttachment({ + url, + attachment: source, + signal: request.signal, + sourceIdentifier, + }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (requestRef.current === request) { + requestRef.current = null; + } + }); + }, + [createUrl, environmentId, sourceIdentifier, threadId], + ); + return share; +} diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..61828ad90ee0 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -0,0 +1,374 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { EnvironmentId } from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + remainingPercent, + type LimitAccount, + type LimitPoolWindow, +} from "@t3tools/shared/usageLimits"; +import { useId, useState } from "react"; +import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { environmentPresentations } from "../../state/presentation"; +import { ResetCredits } from "./UsageLimitsSection"; +import { useProviderColors } from "./usageProviders"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; +const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; + +function accountName(account: LimitAccount) { + if (account.displayName) return account.displayName; + if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); + const [local = "", domain = ""] = account.email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; +} + +/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */ +function AccountSegment({ + remaining, + color, + pending, +}: { + readonly remaining: number; + readonly color: string; + readonly pending: boolean; +}) { + const patternId = useId().replace(/:/g, ""); + return ( + + + + + + + {pending ? ( + + ) : null} + + + ); +} + +function PoolWindowCard({ + pool, + color, + now, + environmentIds, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; + readonly environmentIds: readonly string[] | null; +}) { + const navigation = useNavigation(); + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + const openAccount = (account: LimitAccount) => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsUsageAccount", + params: { + accountKey: account.key, + windowId: pool.id, + windowKind: pool.kind, + environmentIds, + now, + }, + }, + }); + return ( + + + + {pool.label} + + + {pool.remainingPercent}% + + left + + + {pool.pace ? ( + {PACE_LABEL[pool.pace]} + ) : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} + + {pool.members.map(({ account, window }, index) => ( + openAccount(account)} + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + > + + + + {index + 1} + + + + ))} + + + {pool.members.map(({ account, window }, index) => { + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + const resetsIn = formatResetsIn(window, now); + return ( + openAccount(account)} + className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" + > + + + {index + 1} + + + + {accountName(account)} + + + {remainingPercent(window)}% + + + {resetsIn ? ( + + {resetsIn.replace("resets in ", "↻ ")} + + ) : null} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + ) : null} + + + ); + })} + + + ); +} + +export function UsageLimitsSection({ + now, + failedLabels, + selectedEnvironmentIds, +}: { + readonly now: number; + readonly failedLabels: readonly string[]; + readonly selectedEnvironmentIds: ReadonlySet | null; +}) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const selected = + selectedEnvironmentIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); + const pools = collectLimitPools(collectLimitAccounts(selected), now); + const notices = collectLimitNotices(selected); + const colors = useProviderColors(); + return ( + + {failedLabels.length ? ( + + {failedLabels.join(", ")} could not refresh limits. Showing the last known values. + + ) : null} + {pools.length === 0 ? ( + + {selected.size === 0 + ? "Select an environment to see limits." + : "No provider on the selected environments reports subscription limits."} + + ) : null} + {pools.map((pool) => ( + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {pool.windows.map((window) => ( + + ))} + + ))} + {notices.map((notice) => ( + + {notice} + + ))} + + ); +} + +type AccountScreenProps = StaticScreenProps<{ + accountKey: string; + windowId: string; + windowKind: LimitPoolWindow["kind"]; + environmentIds: readonly string[] | null; + now: number; +}>; + +/** Resolve the account again so live quota and credit updates reach the open detail screen. */ +export function UsageLimitAccountScreen({ route }: AccountScreenProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const { accountKey, windowId, windowKind, environmentIds, now } = route.params; + const selectedIds = + environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id))); + const selected = + selectedIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedIds.has(id))); + const accounts = collectLimitAccounts(selected); + const account = accounts.find((candidate) => candidate.key === accountKey); + const pool = collectLimitPools(accounts, now) + .find((candidate) => candidate.driver === account?.driver) + ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind); + const window = pool?.members.find((member) => member.account.key === accountKey)?.window; + const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); + const [revealed, setRevealed] = useState(false); + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + {!account || !window ? ( + + This account is no longer reporting limits on the selected environments. + + ) : ( + <> + + + + + {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver} + + + {account.email ? ( + setRevealed((value) => !value)} + className="min-h-[44px] justify-center" + > + + {revealed ? account.email : "••••••@••••••"} + + + ) : null} + {account.plan ? ( + + {account.plan} + + ) : null} + + + {window.label} + + {remainingPercent(window)}% left + + {window.resetsAt ? ( + + Resets{" "} + {new Date(window.resetsAt).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + + Restores {reset.restoresPercent}% of the pool + + ) : null} + + + + {account.environments.length ? "Signed in" : "Source"} + + {account.environments.length ? ( + account.environments.map((environment) => ( + + {environment.label} + + )) + ) : ( + {account.sourceLabel} + )} + + {account.redeem && account.limits.resetCredits ? ( + + Reset credits + + + ) : null} + + )} + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 0668efa30053..a923e903ef18 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -6,18 +6,15 @@ import type { ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, - UsageLimitSourceAccount, UsageProviderKind, } from "@t3tools/contracts"; import { - collectLimitSources, - collectLimitsGroups, elapsedShare, formatDuration, formatResetsIn, limitsNotice, paceOf, - providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useState } from "react"; import { Alert, Pressable, View } from "react-native"; @@ -27,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { SettingsSection } from "../settings/components/SettingsSection"; import { useProviderColors } from "./usageProviders"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; -const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; type Driver = ServerProvider["driver"]; @@ -44,9 +39,10 @@ function useBarColor(driver: Driver): string | null { } /** - * One window as a bar spanning its whole duration: the fill is quota spent, - * the hairline is how far into the window the clock is. Pace sits under the - * left edge, the countdown under the right, so a row reads in one glance. + * One window as a bar spanning its whole duration: the fill is quota left, + * the hairline is how much of the window is left, so even spending keeps the + * fill on the line. Pace sits under the left edge, the countdown under the + * right, so a row reads in one glance. */ function WindowRow(props: { readonly window: ServerProviderUsageWindow; @@ -54,37 +50,40 @@ function WindowRow(props: { readonly now: number; }) { const { window, now } = props; - const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( {window.label} - {used}% + + {remaining}% left + = 90 - ? "h-full rounded-full bg-destructive" - : used >= 70 - ? "h-full rounded-full bg-warning" + remaining <= 10 + ? "h-full rounded-full bg-red-500" + : remaining <= 30 + ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ - { flex: used }, - used < 70 && props.color ? { backgroundColor: props.color } : null, + { flex: remaining }, + remaining > 30 && props.color ? { backgroundColor: props.color } : null, ]} /> - + - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null} @@ -99,7 +98,7 @@ function WindowRow(props: { } /** One account: icon, name and plan on a single line, then its windows. */ -function AccountLimits(props: { +export function AccountLimits(props: { readonly driver: Driver; readonly label: string; readonly instanceLabel: string; @@ -107,14 +106,23 @@ function AccountLimits(props: { readonly limits: ServerProvider["usageLimits"]; readonly now: number; readonly first: boolean; + /** Tighter padding for the composer card. */ + readonly dense?: boolean; + /** Sits at the end of the heading row, such as a close control. */ + readonly trailing?: ReactNode; readonly footer?: ReactNode; }) { - const { limits, now } = props; + const { limits, now, dense = false } = props; const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); + const padding = dense ? "px-4 py-3" : "p-4"; return ( - + @@ -130,6 +138,7 @@ function AccountLimits(props: { ) : null} + {props.trailing} {notice ? ( {notice} @@ -157,19 +166,21 @@ const OUTCOME_TEXT: Record = { * credit the provider granted the user, so it goes through the native * confirm alert rather than firing on a bare tap. */ -function ResetCredits(props: { +export function ResetCredits(props: { readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; readonly credits: ServerProviderResetCredits; readonly now: number; + /** A smaller pill for the composer card. */ + readonly dense?: boolean; }) { - const { environmentId, instanceId, credits, now } = props; + const { environmentId, instanceId, credits, now, dense = false } = props; const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; + if (dense && credits.availableCount === 0 && status === null) return null; const expiresIn = credits.nextExpiresAt ? formatDuration(Date.parse(credits.nextExpiresAt) - now) @@ -217,10 +228,20 @@ function ResetCredits(props: { accessibilityState={{ disabled: busy }} disabled={busy} onPress={confirm} - className="rounded-full bg-subtle-strong px-3 py-1.5" + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1" + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5" + } > - - {busy ? "Using credit…" : "Use a reset credit"} + + {busy ? "Using…" : "Use reset"} ) : null} @@ -229,57 +250,6 @@ function ResetCredits(props: { ); } -function ProviderLimits(props: { - readonly provider: ServerProvider; - readonly environmentId: EnvironmentId; - readonly now: number; - readonly first: boolean; -}) { - const { provider, environmentId, now } = props; - const credits = provider.usageLimits?.resetCredits; - return ( - DRIVER_LABEL[driver])} - detail={provider.auth.label} - limits={provider.usageLimits} - now={now} - first={props.first} - footer={ - credits ? ( - - ) : undefined - } - /> - ); -} - -/** Emails stay off the phone screen; the plan and driver identify the row. */ -function SourceAccountLimits(props: { - readonly account: UsageLimitSourceAccount; - readonly now: number; - readonly first: boolean; -}) { - const { account } = props; - return ( - - ); -} - /** * Re-probes every provider (and usage-limit source) on each connected * environment; the fresh snapshots then arrive over the config stream. @@ -288,107 +258,47 @@ function SourceAccountLimits(props: { * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */ -export function useRefreshLimits() { +export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet | null = null) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); - const [failedLabels, setFailedLabels] = useState([]); + const [failedEnvironments, setFailedEnvironments] = useState< + readonly { environmentId: EnvironmentId; label: string }[] + >([]); // Always toggles `refreshing`, even with nothing to probe: Android's // RefreshControl keeps its spinner up until it sees true then false. const refresh = async () => { const connected = [...presentations].filter( - ([, presentation]) => presentation.connection.phase === "connected", + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); setRefreshing(true); try { const results = await Promise.all( connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), ); - setFailedLabels( + setFailedEnvironments( connected .filter((_, index) => results[index]?._tag === "Failure") - .map(([, presentation]) => presentation.entry.target.label), + .map(([environmentId, presentation]) => ({ + environmentId, + label: presentation.entry.target.label, + })), ); } finally { setNow(Date.now()); setRefreshing(false); } }; + const failedLabels = failedEnvironments + .filter( + ({ environmentId }) => + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), + ) + .map(({ label }) => label); return { now, refreshing, failedLabels, refresh }; } - -/** - * Subscription quota windows from every connected environment's providers, - * read from the config each environment already streams. - */ -export function UsageLimitsSection(props: { - readonly now: number; - readonly failedLabels: readonly string[]; -}) { - const { now } = props; - const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const groups = collectLimitsGroups(presentations); - const sources = collectLimitSources(presentations); - - if (groups.length === 0 && sources.length === 0) { - return ( - - No provider on a connected environment reports subscription limits. - - ); - } - - return ( - <> - {props.failedLabels.length > 0 ? ( - - - {props.failedLabels.join(", ")} could not refresh limits. Showing the last known values. - - - ) : null} - {groups.map((group) => ( - - {group.providers.map((provider, index) => ( - - ))} - - ))} - {sources.map((source) => ( - - {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account, index) => ( - - )) - )} - - ))} - - ); -} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 17841cbd74fa..3e5cd0fc9e3d 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,10 @@ +import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; -import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; +import { + isCompatibleUsageContractVersion, + type DailyTotals, + type MergedUsage, +} from "@t3tools/shared/usageMerge"; import { enumerateDays, enumerateHourStarts, @@ -11,8 +16,9 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; -import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; +import { useRefreshLimits } from "./UsageLimitsSection"; +import { UsageLimitsSection } from "./UsageLimitsPooled"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; @@ -64,8 +74,13 @@ export function UsageRouteScreen() { const [metric, setMetric] = useState("cost"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const { merged, environments, isPending, isPartial, refresh } = useUsage(window); - const limits = useRefreshLimits(); + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = + useState | null>(null); + const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( + window, + selectedEnvironmentIds, + ); + const limits = useRefreshLimits(selectedEnvironmentIds); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -91,10 +106,8 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // The pull spinner tracks re-scans of environments that have answered - // before. The initial scan renders its own placeholder, and an unreachable - // environment stays pending forever — neither may pin the spinner on. - const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); + const [refreshingUsage, setRefreshingUsage] = useState(false); + const refreshingRef = useRef(false); const showingLimits = tab === "limits"; const selectWindow = (days: number) => { setWindowSelection({ @@ -103,31 +116,122 @@ export function UsageRouteScreen() { }); }; const refreshWindow = () => { + if (refreshingRef.current) return; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setRefreshingUsage(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setRefreshingUsage(false); + }); }; + const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; + const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading); + const filterAccessibilityLabel = hasLoadingEnvironments + ? "Filter usage environments, some environments are loading" + : "Filter usage environments"; + const filterIcon = + selectedEnvironmentIds === null + ? "line.3.horizontal.decrease" + : "line.3.horizontal.decrease.circle.fill"; + const environmentActions = useMemo( + () => [ + { + id: "all", + title: "All environments", + subtitle: undefined, + state: selectedEnvironmentIds === null ? ("on" as const) : ("off" as const), + }, + ...environments.map((environment) => ({ + id: environment.environmentId, + title: environment.label, + subtitle: usageEnvironmentStatus(environment), + state: + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) + ? ("on" as const) + : ("off" as const), + })), + ], + [environments, selectedEnvironmentIds], + ); + const selectEnvironment = useCallback( + (value: string) => { + if (value === "all") { + setSelectedEnvironmentIds(null); + return; + } + const id = EnvironmentId.make(value); + setSelectedEnvironmentIds((selected) => toggleUsageEnvironment(selected, environments, id)); + }, + [environments], + ); + const environmentFilter = useMemo( + () => + showEnvironmentFilter ? ( + selectEnvironment(nativeEvent.event)} + > + + + {hasLoadingEnvironments ? ( + + ) : null} + + + ) : null, + [ + showEnvironmentFilter, + environmentActions, + selectEnvironment, + filterAccessibilityLabel, + filterIcon, + hasLoadingEnvironments, + ], + ); + + useLayoutEffect(() => { + if (Platform.OS === "ios") { + navigation.setOptions({ headerRight: () => environmentFilter }); + } + }, [navigation, environmentFilter]); + return ( {Platform.OS === "android" ? ( <> - navigation.goBack()} /> + navigation.goBack()} + trailing={environmentFilter} + /> ) : null} - {showingLimits ? ( - - ) : ( - <> - {/* Period and metric together: neither applies to Limits, and - both change every number below, so they share one bar. */} - - - - - + {showingLimits ? ( + - {isPending ? ( - - Scanning provider transcripts… - - ) : environments.length === 0 ? ( - - Connect an environment to see usage. - - ) : ( - <> - + {/* Period and metric together: neither applies to Limits, and + both change every number below, so they share one bar. */} + + - - - - - )} - - )} + + + {merged.duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {merged.duplicateSources.join(", ")} + + ) : null} + {isPending ? ( + + Scanning provider transcripts… + + ) : selectedEnvironments.length === 0 ? ( + + {environments.length === 0 + ? "Connect an environment to see usage." + : "Select an environment to see usage."} + + ) : ( + <> + + + + + + )} + + )} + ); @@ -218,25 +335,42 @@ function SegmentedControl(props: { const compact = props.size === "compact"; return ( + option.value === props.selected), + ) * + 100) / + props.options.length + }%`, + }} + /> {props.options.map((option) => { const active = option.value === props.selected; return ( props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", compact ? "h-9" : "h-11", - active && "bg-subtle-strong", )} > environment.error !== null); - const stale = props.environments.filter((environment) => - props.merged.staleEnvironments.includes(environment.environmentId), - ); - const duplicateSources = props.merged.duplicateSources; +function isUsageLoading(environment: EnvironmentUsageStatus) { + return environment.isPending || (environment.summary === null && environment.error === null); +} + +function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { if ( - failed.length === 0 && - stale.length === 0 && - duplicateSources.length === 0 && - !props.isPartial + environment.summary && + !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) ) { - return null; + return "Older server · excluded from usage totals"; } - - return ( - - {props.isPartial ? ( - - Some environments are still reporting. Totals are partial. - - ) : null} - {failed.map((environment) => ( - - {environment.label} could not report usage. - - ))} - {stale.map((environment) => ( - - {environment.label} runs an older server version and is excluded from totals. - - ))} - {duplicateSources.length > 0 ? ( - - Counted once across environments sharing a transcript directory:{" "} - {duplicateSources.join(", ")} - - ) : null} - - ); + if (!environment.isConnected) + return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; + if (environment.error) + return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable"; + if (isUsageLoading(environment)) + return environment.summary ? "Updating usage…" : "Loading usage…"; + return "Usage up to date"; } diff --git a/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts new file mode 100644 index 000000000000..4914f9de0f8b --- /dev/null +++ b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts @@ -0,0 +1,40 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; + +const a = EnvironmentId.make("a"); +const b = EnvironmentId.make("b"); +const c = EnvironmentId.make("c"); +const removed = EnvironmentId.make("removed"); +const environments = [a, b, c].map((environmentId) => ({ environmentId })); + +describe("usage environment selection", () => { + it("can exclude an environment from all, then select all again", () => { + const selected = toggleUsageEnvironment(null, environments, b); + expect(selected).toEqual(new Set([a, c])); + expect(toggleUsageEnvironment(selected, environments, b)).toBeNull(); + }); + + it("can deselect the last environment", () => { + expect(toggleUsageEnvironment(new Set([a]), environments, a)).toEqual(new Set()); + }); + + it("does not count removed IDs toward selecting all current environments", () => { + expect(toggleUsageEnvironment(new Set([a, removed]), environments, b)).toEqual(new Set([a, b])); + }); + + it("returns to all mode despite stale IDs when every current environment is selected", () => { + expect(toggleUsageEnvironment(new Set([a, c, removed]), environments, b)).toBeNull(); + }); + + it("ignores a menu action for an environment that was removed", () => { + expect(toggleUsageEnvironment(new Set([a]), environments, removed)).toEqual(new Set([a])); + }); + + it("includes newly connected environments only in all mode", () => { + const expanded = [...environments, { environmentId: removed }]; + expect(toggleUsageEnvironment(null, expanded, a)).toEqual(new Set([b, c, removed])); + expect(toggleUsageEnvironment(new Set([a, b, c]), expanded, a)).toEqual(new Set([b, c])); + }); +}); diff --git a/apps/mobile/src/features/usage/usageEnvironmentSelection.ts b/apps/mobile/src/features/usage/usageEnvironmentSelection.ts new file mode 100644 index 000000000000..3f6e9fb3bae5 --- /dev/null +++ b/apps/mobile/src/features/usage/usageEnvironmentSelection.ts @@ -0,0 +1,16 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** Null follows all environments, including ones connected after the menu opened. */ +export function toggleUsageEnvironment( + selected: ReadonlySet | null, + environments: readonly { readonly environmentId: EnvironmentId }[], + toggledId: EnvironmentId, +): ReadonlySet | null { + const ids = environments.map(({ environmentId }) => environmentId); + const next = new Set(ids.filter((id) => selected === null || selected.has(id))); + if (ids.includes(toggledId)) { + if (next.has(toggledId)) next.delete(toggledId); + else next.add(toggledId); + } + return ids.every((id) => next.has(id)) ? null : next; +} diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 3458f0120f99..417a66138d95 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -2,19 +2,13 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_BASE_FONT_SIZE, - deriveCodeFontSize, - deriveTerminalFontSize, normalizeBaseFontSize, - normalizeCodeFontSize, - normalizeCodeWordBreak, resolveAppearance, resolveAppearancePreferences, resolveMarkdownFontSizes, resolveMobileCodeSurface, resolveNativeMarkdownTypography, resolveTextScaleVariables, - stepBaseFontSize, - stepCodeFontSize, stepTerminalFontSize, } from "./appearancePreferences"; @@ -50,10 +44,8 @@ describe("appearancePreferences", () => { expect(appearance.isCodeFontSizeCustom).toBe(false); const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); - expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); - expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); - expect(scaled.terminalFontSize).toBeGreaterThan(10); - expect(scaled.codeFontSize).toBeGreaterThan(11); + expect(scaled.terminalFontSize).toBe(14); + expect(scaled.codeFontSize).toBe(17); }); it("applies explicit overrides over derived values", () => { @@ -69,15 +61,12 @@ describe("appearancePreferences", () => { it("clamps base and code font sizes", () => { expect(normalizeBaseFontSize(4)).toBe(11); expect(normalizeBaseFontSize(30)).toBe(22); - expect(normalizeCodeFontSize(4)).toBe(8); - expect(normalizeCodeFontSize(30)).toBe(18); + expect(resolveAppearancePreferences({ codeFontSize: 4 }).codeFontSize).toBe(8); + expect(resolveAppearancePreferences({ codeFontSize: 30 }).codeFontSize).toBe(18); }); - it("steps font sizes within bounds", () => { + it("steps terminal font size within bounds", () => { expect(stepTerminalFontSize(6, -1)).toBe(6); - expect(stepBaseFontSize(11, -1)).toBe(11); - expect(stepCodeFontSize(8, -1)).toBe(8); - expect(stepBaseFontSize(15, 1)).toBe(16); }); it("scales markdown typography from the base size", () => { @@ -97,9 +86,8 @@ describe("appearancePreferences", () => { }); }); - it("defaults code word break to false", () => { - expect(normalizeCodeWordBreak(undefined)).toBe(false); - expect(normalizeCodeWordBreak(true)).toBe(true); + it("keeps explicit code word break enabled", () => { + expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); it("returns the authored text scale at the 16pt default", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index d2504a629dda..2ce6a8b5a367 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -75,7 +75,7 @@ export function normalizeBaseFontSize(value: number | null | undefined): number return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeFontSize(value: number | null | undefined): number { +function normalizeCodeFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CODE_FONT_SIZE; } @@ -83,18 +83,18 @@ export function normalizeCodeFontSize(value: number | null | undefined): number return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { +function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { return value === true; } /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ -export function deriveTerminalFontSize(baseFontSize: number): number { +function deriveTerminalFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); } /** Code/diff size derived from base: 12pt at base 16. */ -export function deriveCodeFontSize(baseFontSize: number): number { +function deriveCodeFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); } @@ -235,22 +235,12 @@ export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMar }; } -export function stepBaseFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP; - return normalizeBaseFontSize(next); -} - export function stepTerminalFontSize(current: number, direction: -1 | 1): number { const next = direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; return normalizeTerminalFontSize(next); } -export function stepCodeFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP; - return normalizeCodeFontSize(next); -} - export { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, diff --git a/apps/mobile/src/lib/commandMetadata.test.ts b/apps/mobile/src/lib/commandMetadata.test.ts deleted file mode 100644 index d1ba1d86eba9..000000000000 --- a/apps/mobile/src/lib/commandMetadata.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { makeQueuedMessageMetadata, makeTurnCommandMetadata } from "./commandMetadata"; - -vi.mock("expo-crypto", () => ({ - randomUUID: () => crypto.randomUUID(), -})); - -describe("mobile command metadata", () => { - it("creates ids and timestamps for thread starts", () => { - const metadata = makeTurnCommandMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.threadId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it("creates ids and timestamps for queued messages", () => { - const metadata = makeQueuedMessageMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); -}); diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 6487e572c87d..6cc9ce8607d1 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; -import { - isRelayManagedConnection, - redactPairingCredential, - toStableSavedRemoteConnection, -} from "./connection"; +import { isRelayManagedConnection, toStableSavedRemoteConnection } from "./connection"; import { authClientMetadata } from "./authClientMetadata"; const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); @@ -83,23 +79,6 @@ describe("mobile remote connection records", () => { }); }); - it("removes one-time bootstrap credentials before persisting pairing URLs", () => { - expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - expect(redactPairingCredential("https://desktop.example/?token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - }); - - it("removes hosted pairing credentials while keeping the advertised host", () => { - expect( - redactPairingCredential( - "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&token=bootstrap-token&label=Desktop", - ), - ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); - }); - it("recognizes explicitly managed relay connections", () => { expect(isRelayManagedConnection({ relayManaged: true })).toBe(true); }); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index df26a192cd0f..5919a805ddd2 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,5 +1,4 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; export interface SavedRemoteConnection { @@ -17,15 +16,6 @@ export interface SavedRemoteConnection { export type RemoteClientConnectionState = EnvironmentConnectionPhase; -export function redactPairingCredential(pairingUrl: string): string { - const trimmed = pairingUrl.trim(); - try { - return stripPairingTokenFromUrl(new URL(trimmed)).toString(); - } catch { - return trimmed; - } -} - export function isRelayManagedConnection( connection: Pick, ): boolean { diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 8342fd1aeebc..7d288b1352a5 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { constrainAuxiliaryPaneWidth, - constrainPrimarySidebarWidth, deriveCenteredContentHorizontalPadding, deriveFileInspectorPaneLayout, deriveLayout, - deriveStableFormSheetDetent, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -75,12 +73,6 @@ describe("deriveThreadFeedInitialContentInset", () => { }); describe("resizable pane constraints", () => { - it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { - expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); - expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); - expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); - }); - it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); @@ -392,14 +384,3 @@ describe("deriveWorkspacePaneLayout", () => { }); }); }); - -describe("deriveStableFormSheetDetent", () => { - it.each([ - { height: 1_194, expected: 0.62 }, - { height: 834, expected: 0.863 }, - { height: 600, expected: 0.893 }, - { height: 0, expected: 0.92 }, - ])("derives a stable sheet detent for height $height", ({ height, expected }) => { - expect(deriveStableFormSheetDetent(height)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 4199dc8dc8ed..ee38ac020e74 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -16,7 +16,6 @@ export const SPLIT_LAYOUT_MIN_WIDTH = 720; export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280; -export const SPLIT_SIDEBAR_MAX_WIDTH = 460; const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; @@ -50,10 +49,6 @@ export const AUXILIARY_PANE_MAX_WIDTH = 480; const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; -const STABLE_FORM_SHEET_MAX_HEIGHT = 720; -const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; -const STABLE_FORM_SHEET_MIN_DETENT = 0.62; -const STABLE_FORM_SHEET_MAX_DETENT = 0.92; export type LayoutVariant = "compact" | "split"; @@ -218,22 +213,6 @@ export function deriveFileInspectorPaneLayout(input: { }; } -/** Keep a user-selected sidebar width useful as a window is resized. */ -export function constrainPrimarySidebarWidth( - preferredWidth: number, - viewportWidth = Number.POSITIVE_INFINITY, -): number { - const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; - const viewportMax = Number.isFinite(viewportWidth) - ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) - : SPLIT_SIDEBAR_MAX_WIDTH; - return clamp( - Math.round(safeWidth), - SPLIT_SIDEBAR_MIN_WIDTH, - Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), - ); -} - /** * Keep an auxiliary pane within native-feeling bounds without squeezing its * neighboring content below a usable reading/editor width. @@ -275,20 +254,3 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } - -export function deriveStableFormSheetDetent(containerHeight: number): number { - if (!Number.isFinite(containerHeight) || containerHeight <= 0) { - return STABLE_FORM_SHEET_MAX_DETENT; - } - - const targetHeight = Math.min( - STABLE_FORM_SHEET_MAX_HEIGHT, - Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), - ); - const detent = clamp( - targetHeight / containerHeight, - STABLE_FORM_SHEET_MIN_DETENT, - STABLE_FORM_SHEET_MAX_DETENT, - ); - return Math.round(detent * 1_000) / 1_000; -} diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index bf3d009b74ab..7d02cb71f7b5 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import { + resolveMarkdownLinkIcon, + resolveMarkdownLinkPresentation, +} from "@t3tools/mobile-markdown-text/links"; + +describe("resolveMarkdownLinkIcon", () => { + it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { + expect(resolveMarkdownLinkIcon("github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("GitHub.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("gist.github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("github.community")).toBeNull(); + expect(resolveMarkdownLinkIcon("notgithub.com")).toBeNull(); + expect(resolveMarkdownLinkIcon("example.com")).toBeNull(); + }); +}); describe("resolveMarkdownLinkPresentation", () => { it("treats protocol-relative media as an external URL, not a filesystem path", () => { diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index a3c6712abae8..652de9296f1f 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -153,10 +153,16 @@ describe("mobile themes", () => { it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); - expect(Object.keys(variables)).toHaveLength(65); + expect(Object.keys(variables)).toHaveLength(68); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); + expect(variables["--color-warning"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), + ); + expect(variables["--color-warning-foreground"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), + ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 23034511287e..10ef1b58edec 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -239,6 +239,9 @@ export function createMobileThemeVariables( "--color-switch-active-thumb": c.accentForeground, "--color-switch-inactive-track": c.secondary, "--color-switch-inactive-thumb": c.mutedForeground, + "--color-warning": c.warningSurface, + "--color-warning-border": withAlpha(c.warning, 0.32), + "--color-warning-foreground": c.warningForeground, "--color-danger": c.errorSurface, "--color-danger-border": withAlpha(c.error, 0.32), "--color-danger-foreground": c.errorForeground, diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconCache.test.ts new file mode 100644 index 000000000000..adde56fbf0b1 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; + +const native = vi.hoisted(() => ({ + load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ + width: options.maxWidth, + height: options.maxHeight, + release: vi.fn(), + })), + write: vi.fn(async () => {}), + path: vi.fn(async () => "/cache/thumbnail"), + read: vi.fn(), + remove: vi.fn(), +})); +vi.mock("expo-image", () => ({ + Image: { + loadAsync: native.load, + writeToCacheAsync: native.write, + getCachePathAsync: native.path, + }, +})); +vi.mock("expo-file-system", () => ({ + File: class { + size = 24_000; + base64 = native.read; + delete = native.remove; + }, +})); + +import { downscaleProjectFavicon } from "./projectFaviconCache"; + +const png = "iVBORw0KGgoAAAAA"; +const image = { url: "https://remote/icon.png" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.read.mockReset().mockResolvedValue(png); + native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ + width: maxWidth, + height: maxWidth, + release: vi.fn(), + })); +}); + +describe("mobile project icon thumbnails", () => { + it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { + native.read.mockResolvedValueOnce( + `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); + expect(thumbnail).toBe(`data:image/png;base64,${png}`); + expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); + expect(native.remove).toHaveBeenCalledTimes(2); + for (const call of native.load.mock.results) + expect((await call.value).release).toHaveBeenCalledOnce(); + }); + + it("releases a decoded image when its request was canceled", async () => { + const controller = new AbortController(); + const release = vi.fn(); + native.load.mockImplementationOnce(async () => { + controller.abort(); + return { width: 96, height: 96, release }; + }); + await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); + expect(release).toHaveBeenCalledOnce(); + expect(native.write).not.toHaveBeenCalled(); + }); + + it("rejects an image the native decoder did not downsize", async () => { + const release = vi.fn(); + native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); + await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( + "not resized", + ); + expect(native.write).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts new file mode 100644 index 000000000000..26a6d848d11d --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -0,0 +1,112 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, + type ProjectFaviconEntry, +} from "@t3tools/client-runtime/project-favicon-cache"; +import * as Effect from "effect/Effect"; + +import * as MobileDatabase from "../persistence/mobile-database"; + +const CACHE_KIND = "project-favicon"; +const CACHE_SCHEMA_VERSION = 1; + +let database: MobileDatabase.MobileDatabase["Service"] | undefined; + +/** + * The cache is a module singleton because the favicon atom family holds it outside + * any Effect runtime. Its rows live in `client_cache`, so the environment cache store + * hands over the database it already owns instead of the cache re-entering the runtime. + */ +export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { + database = service; +} + +const runDatabase = ( + use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, +) => + database + ? Effect.runPromise(use(database)) + : Promise.reject(new Error("Project icon storage is not attached.")); + +/** + * Rasterizes a bitmap that is too large to inline. The native decoder writes the + * downsized frame to expo-image's disk cache, which is the only encode path it + * exposes; the temporary entry is removed once its bytes are read. + */ +export async function downscaleProjectFavicon( + image: { readonly url: string }, + signal: AbortSignal, +) { + const [{ Image }, { File }] = await Promise.all([ + import("expo-image"), + import("expo-file-system"), + ]); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + signal.throwIfAborted(); + const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); + const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; + try { + signal.throwIfAborted(); + if (decoded.width > size || decoded.height > size) { + throw new Error("Project icon was not resized."); + } + await Image.writeToCacheAsync(decoded, cacheKey); + const path = await Image.getCachePathAsync(cacheKey); + if (!path) throw new Error("Project icon thumbnail was not written."); + const file = new File(path.startsWith("file:") ? path : `file://${path}`); + try { + if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; + const base64 = await file.base64(); + // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. + const mimeType = base64.startsWith("/9j/") + ? "image/jpeg" + : base64.startsWith("iVBORw0KGgo") + ? "image/png" + : null; + if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); + const dataUrl = `data:${mimeType};base64,${base64}`; + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } finally { + file.delete(); + } + } finally { + decoded.release(); + } + } + throw new Error("Project icon thumbnail exceeds the cache limit."); +} + +/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: () => + runDatabase((database) => + database.listCache(CACHE_KIND).pipe( + Effect.map((payloads) => + payloads.flatMap((payload): Array => { + try { + return [JSON.parse(payload)]; + } catch { + return []; + } + }), + ), + ), + ), + put: (key, entry: ProjectFaviconEntry) => + runDatabase((database) => + database.saveCache( + entry.environmentId, + CACHE_KIND, + key, + CACHE_SCHEMA_VERSION, + JSON.stringify(entry), + ), + ), + remove: (key, entry) => + runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d87df6baaf1d..9b94cecb3db9 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; -import { - applyProviderOptionSelection, - providerOptionValueLabels, - resolveProviderOptionDescriptors, -} from "./providerOptions"; +import { applyProviderOptionSelection, resolveProviderOptionDescriptors } from "./providerOptions"; const CODEX_CAPABILITIES: ModelCapabilities = { optionDescriptors: [ @@ -34,15 +30,6 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("summarizes the option values currently in effect", () => { - const descriptors = resolveProviderOptionDescriptors({ - capabilities: CODEX_CAPABILITIES, - selections: undefined, - }); - - expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); - }); - it("updates generic select options without knowing provider-specific ids", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, @@ -62,7 +49,7 @@ describe("mobile provider options", () => { expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); - it("treats an unspecified boolean capability as off", () => { + it("updates generic boolean options", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: { optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], @@ -70,7 +57,6 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(providerOptionValueLabels(descriptors)).toEqual([]); expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ { id: "fastMode", value: true }, ]); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index 593f5a37442c..dec0d327030d 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, - getProviderOptionCurrentLabel, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -22,23 +21,6 @@ export function resolveProviderOptionDescriptors(input: { }); } -/** - * Labels for the option values currently in effect (select values plus - * enabled booleans), used to summarize the thread configuration in the - * composer trigger pill. - */ -export function providerOptionValueLabels( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.flatMap((descriptor) => { - if (descriptor.type === "boolean") { - return descriptor.currentValue ? [descriptor.label] : []; - } - const label = getProviderOptionCurrentLabel(descriptor); - return label ? [label] : []; - }); -} - /** * Applies one option change (by descriptor id) and returns the full selection * list to store on the model selection, or null when the change doesn't match diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index de557d3f29ee..1a26c8516026 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadFeed, deriveThreadFeedPresentation, + LIVE_ACTIVITY_ROW_ID, threadFeedActivityIsVisible, threadFeedRunIsUnsettled, type ThreadFeedActivity, @@ -27,6 +28,7 @@ import { setPendingUserInputCustomAnswer, isPendingUserInputOptionSelected, buildPendingUserInputAnswers, + workEntryRowLabel, } from "./threadActivity"; const threadId = ThreadId.make("thread-1"); @@ -207,10 +209,18 @@ describe("buildThreadFeed", () => { it("keeps prominent activity visible while it is running", () => { expect( - threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }), + threadFeedActivityIsVisible({ + prominent: true, + status: "neutral", + toolLike: true, + }), ).toBe(true); expect( - threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }), + threadFeedActivityIsVisible({ + prominent: false, + status: "neutral", + toolLike: true, + }), ).toBe(false); }); @@ -652,7 +662,9 @@ describe("buildThreadFeed", () => { "message", "message", ]); - expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } }); + expect(expanded[4]).toMatchObject({ + message: { id: middle.messageId, text: middle.text }, + }); }); it("does not fold a response that only has opening and final messages", () => { @@ -728,7 +740,7 @@ describe("buildThreadFeed", () => { const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); expect(collapsed.map((entry) => entry.type)).toEqual([ "message", - "activity-group", + "agent-spawn", "activity-group", "run-fold", "activity-group", @@ -740,9 +752,11 @@ describe("buildThreadFeed", () => { }); expect( collapsed.flatMap((entry) => - entry.type === "activity-group" - ? entry.activities.map((activity) => activity.projectedItem) - : [], + entry.type === "agent-spawn" + ? [entry.activity.projectedItem] + : entry.type === "activity-group" + ? entry.activities.map((activity) => activity.projectedItem) + : [], ), ).toEqual(projectedResources); }); @@ -805,6 +819,81 @@ describe("buildThreadFeed", () => { expect(presented).toEqual([]); }); + it("shows one stable Thinking row while an active run has no live tool row", () => { + const feed = buildThreadFeed([projected(userMessage(), 0)]); + const latestRun = { + runId, + status: "running" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: null, + }; + const rows = deriveThreadFeedPresentation( + feed, + latestRun, + new Set(), + new Set(), + latestRun.startedAt, + ); + + expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); + expect(rows[1]).toMatchObject({ id: LIVE_ACTIVITY_ROW_ID, runId }); + expect( + deriveThreadFeedPresentation(feed, latestRun, new Set(), new Set(), latestRun.startedAt)[1], + ).toBe(rows[1]); + }); + + it("keeps successful trailing work shimmering and hands failures off to Thinking", () => { + const latestRun = { + runId, + status: "running" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: null, + }; + const present = (item: OrchestrationV2TurnItem) => + deriveThreadFeedPresentation( + buildThreadFeed([projected(item, 0)]), + latestRun, + new Set(), + new Set(), + latestRun.startedAt, + ); + + const successful = present(command()); + expect(successful).toMatchObject([ + { type: "work-toggle", id: LIVE_ACTIVITY_ROW_ID, shimmer: true }, + ]); + + const failed = present({ ...command(), status: "failed" }); + expect(failed.map((entry) => entry.type)).toEqual(["work-toggle", "thinking"]); + expect(failed[0]).toMatchObject({ shimmer: false, hasFailure: true }); + expect(failed[1]).toMatchObject({ id: LIVE_ACTIVITY_ROW_ID }); + }); + + it("only expands V2 work rows when their body adds to the collapsed label", () => { + const rows = buildThreadFeed([ + projected( + { + ...base("single-line-notice", "2026-06-20T00:00:02.000Z", 1), + type: "system_notice", + message: "Provider switched models", + }, + 0, + ), + projected( + { + ...base("multi-line-notice", "2026-06-20T00:00:03.000Z", 2), + type: "system_notice", + message: "Provider warning\nRetrying now", + }, + 1, + ), + projected(command("2026-06-20T00:00:04.000Z"), 2), + ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + + expect(workEntryRowLabel(rows[0]!.workEntry)).toBe("Provider switched models"); + expect(rows.map((row) => row.canExpand)).toEqual([false, true, true]); + }); + it("keeps expanded work in one group with stable row identities", () => { const activity = ( id: string, @@ -914,7 +1003,10 @@ describe("buildThreadFeed", () => { type: "dynamic_tool" as const, toolName, input: { threadId: `thread-${index}`, message: "Continue" }, - output: { threadId: `thread-${index}`, messageId: `message-${index}` }, + output: { + threadId: `thread-${index}`, + messageId: `message-${index}`, + }, }, index + 1, ), @@ -971,7 +1063,11 @@ describe("retained v2 feed presentation", () => { rows[0]!, rows[1]!, projected( - { ...assistantMessage("2026-06-20T00:00:04.000Z"), text: "Still working", streaming: true }, + { + ...assistantMessage("2026-06-20T00:00:04.000Z"), + text: "Still working", + streaming: true, + }, 2, ), ]); @@ -1073,7 +1169,12 @@ describe("retained v2 feed presentation", () => { ), ); const feed = buildThreadFeed(rows); - const latestRun = { runId, status: "running" as const, startedAt: null, completedAt: null }; + const latestRun = { + runId, + status: "running" as const, + startedAt: null, + completedAt: null, + }; const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); const toggle = collapsed[0]; if (toggle?.type !== "work-toggle") throw new Error("Expected a collapsed work group"); @@ -1119,15 +1220,89 @@ describe("retained v2 feed presentation", () => { ]); const rows = deriveThreadFeedPresentation( feed, - { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null }, + { + runId, + status: "running", + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: null, + }, new Set(), new Set(), "2026-06-20T00:00:01.000Z", ); - expect(rows[0]).toMatchObject({ type: "work-toggle", summary, hasFailure, shimmer: false }); + expect(rows[0]).toMatchObject({ + type: "work-toggle", + summary, + hasFailure, + shimmer: false, + }); }, ); + it("folds adjacent V2 subagents into one stable live batch card", () => { + const subagent = ( + id: string, + title: string, + status: "running" | "completed", + progress: string, + second: number, + ): OrchestrationV2TurnItem => ({ + ...base(id, `2026-06-20T00:00:0${second}.000Z`, second), + type: "subagent", + status, + subagentId: NodeId.make(id), + origin: "provider_native", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: null, + title, + prompt: `Prompt for ${title}`, + progress, + result: status === "completed" ? `${title} finished` : null, + }); + const feed = buildThreadFeed([ + projected(subagent("agent-a", "Agent A", "completed", "Done", 2), 0), + projected(subagent("agent-b", "Agent B", "running", "Grepping", 3), 1), + ]); + expect(feed).toHaveLength(1); + + const latestRun = { + runId, + status: "running" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: null, + }; + const rows = deriveThreadFeedPresentation( + feed, + latestRun, + new Set(), + new Set(), + latestRun.startedAt, + ); + expect(rows.map((entry) => entry.type)).toEqual(["agent-spawn"]); + expect(rows[0]).toMatchObject({ + id: `agent-spawn:${runId}:root`, + summary: { + title: "2 subagents", + status: "Grepping", + tone: "working", + members: [ + { title: "Agent A", status: "completed", tone: "completed" }, + { title: "Agent B", status: "working", tone: "working" }, + ], + }, + }); + + const expanded = deriveThreadFeedPresentation( + feed, + latestRun, + new Set(), + new Set([`agent-spawn:${runId}:root`]), + latestRun.startedAt, + ); + expect(expanded[0]).toMatchObject({ type: "agent-spawn", expanded: true }); + }); + it("shows an idle native subagent without claiming completion", () => { const rows = buildThreadFeed([ projected( @@ -1152,7 +1327,7 @@ describe("retained v2 feed presentation", () => { activities: [{ status: "neutral", lifecycleStatus: "idle", prominent: true }], }); expect(deriveThreadFeedPresentation(rows, null, new Set())).toMatchObject([ - { type: "activity-group", activities: [{ lifecycleStatus: "idle" }] }, + { type: "agent-spawn", summary: { status: "stopped", tone: "stopped" } }, ]); }); }); @@ -1208,7 +1383,10 @@ describe("pending user input answers", () => { undefined, " Orders ", ); - expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] }); + expect(paddedOrders).toEqual({ + customAnswer: "", + selectedOptionValues: ["Orders"], + }); expect( togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), ).toEqual({ customAnswer: "" }); @@ -1277,14 +1455,21 @@ describe("provider question values", () => { it("rejects arbitrary text when the provider only accepts offered options", () => { expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({}); expect( - buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }), + buildPendingUserInputAnswers([question], { + runtime: { customAnswer: "Other" }, + }), ).toBeNull(); expect( - buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }), + buildPendingUserInputAnswers([question], { + runtime: { selectedOptionValues: ["unknown"] }, + }), ).toBeNull(); expect( buildPendingUserInputAnswers([question], { - runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, + runtime: { + selectedOptionValues: ["second"], + customAnswer: "stale draft", + }, }), ).toEqual({ runtime: "second" }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 56136d8118c3..e59275cf22f9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -85,6 +85,19 @@ export interface ThreadFeedActivity { readonly projectedItem: OrchestrationV2ProjectedTurnItem; } +export interface AgentSpawnSummary { + readonly title: string; + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly members: ReadonlyArray<{ + readonly title: string; + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly detail: string | undefined; + readonly updatedAt: string; + }>; +} + export interface ThreadFeedMessage { readonly id: MessageId; readonly role: "user" | "assistant"; @@ -150,6 +163,21 @@ export type ThreadFeedEntry = readonly runId: RunId; readonly label: string; readonly expanded: boolean; + } + | { + readonly type: "thinking"; + readonly id: string; + readonly createdAt: string; + readonly runId: RunId | null; + } + | { + readonly type: "agent-spawn"; + readonly id: string; + readonly createdAt: string; + readonly runId: RunId | null; + readonly activity: ThreadFeedActivity; + readonly expanded: boolean; + readonly summary: AgentSpawnSummary; }; export interface ThreadFeedLatestRun { @@ -187,6 +215,7 @@ const runFoldRowsCache = new WeakMap< ThreadFeedEntry, Extract >(); +let cachedThinkingRow: Extract | null = null; export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup): boolean { return ( @@ -531,7 +560,11 @@ function toWorkLogEntry( toolData: item, }; case "checkpoint": - return { ...common, changedFiles: item.files.map((file) => file.path), toolData: item }; + return { + ...common, + changedFiles: item.files.map((file) => file.path), + toolData: item, + }; case "approval_request": return { ...common, @@ -550,6 +583,77 @@ function toWorkLogEntry( } } +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +function workEntryPreview( + entry: Pick, +): string | null { + if (entry.command) return entry.command; + if (entry.detail) return entry.detail; + const [firstPath] = entry.changedFiles ?? []; + if (!firstPath) return null; + return entry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${entry.changedFiles!.length - 1} more`; +} + +/** The one-line text shown by a collapsed work row. */ +export function workEntryRowLabel(entry: WorkLogPresentationEntry): string { + const presentation = resolveWorkEntryToolPresentation(entry); + if (presentation) return presentation.displayName; + const preview = workEntryPreview(entry); + const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview)); + return compactPreview || capitalizePhrase(entry.toolTitle ?? entry.label); +} + +function workEntryHasExpandedBody( + entry: WorkLogPresentationEntry, + collapsedText: string, + row: OrchestrationV2ProjectedTurnItem, +): boolean { + if (entry.itemType === "dynamic_tool" && entry.toolData !== undefined) return true; + if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; + if (row.visibility !== "local") return true; + const parts = [entry.rawCommand ?? entry.command, entry.detail] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + if (parts.length === 0) return false; + if (parts.length > 1 && new Set(parts).size > 1) return true; + const only = parts[0]!; + return only.includes("\n") || collapseWhitespace(only) !== collapseWhitespace(collapsedText); +} + +function buildWorkEntryExpandedBody( + entry: WorkLogPresentationEntry, + row: OrchestrationV2ProjectedTurnItem, +): string | null { + const blocks: string[] = []; + const appendBlock = (value: string | null | undefined) => { + const trimmed = value?.trim(); + if (trimmed && !blocks.includes(trimmed)) blocks.push(trimmed); + }; + if (entry.itemType === "dynamic_tool" && entry.toolData !== undefined) { + appendBlock(`Tool call\n${JSON.stringify(entry.toolData, null, 2)}`); + } + appendBlock(entry.rawCommand ?? entry.command); + appendBlock(entry.detail); + if (entry.changedFiles?.length) appendBlock(entry.changedFiles.join("\n")); + if (row.visibility !== "local") { + appendBlock( + `${row.visibility === "inherited" ? "Inherited" : "Synthetic"} from ${row.sourceThreadId}`, + ); + } + return blocks.length > 0 ? blocks.join("\n\n") : null; +} + function toFeedActivity( row: OrchestrationV2ProjectedTurnItem, attemptId: RunAttemptId | null, @@ -560,8 +664,10 @@ function toFeedActivity( const detail = itemPreview(item); const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); const workEntry = toWorkLogEntry(item, createdAt, summary, detail); - const getFullDetail = memoizeValue(() => - JSON.stringify( + const collapsedText = workEntryRowLabel(workEntry); + const getFullDetail = memoizeValue(() => { + const expandedBody = buildWorkEntryExpandedBody(workEntry, row); + const projectedItem = JSON.stringify( { visibility: row.visibility, sourceThreadId: row.sourceThreadId, @@ -570,8 +676,9 @@ function toFeedActivity( }, null, 2, - ), - ); + ); + return expandedBody ? `${expandedBody}\n\n${projectedItem}` : projectedItem; + }); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] .filter( @@ -587,7 +694,7 @@ function toFeedActivity( attemptId, summary, detail, - canExpand: true, + canExpand: workEntryHasExpandedBody(workEntry, collapsedText, row), getFullDetail, getCopyText, icon: workEntry.toolSurface ?? itemIcon(item), @@ -656,10 +763,14 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th continue; } - const isCompaction = entry.activity.projectedItem.item.type === "compaction"; + const itemType = entry.activity.projectedItem.item.type; + const isCompaction = itemType === "compaction"; + const isSubagent = itemType === "subagent"; + const openGroupIsSubagent = firstActivityEntry?.activity.projectedItem.item.type === "subagent"; if ( isCompaction || - entry.activity.prominent || + (entry.activity.prominent && !isSubagent) || + (firstActivityEntry !== null && openGroupIsSubagent !== isSubagent) || firstActivityEntry?.runId !== entry.runId || firstActivityEntry?.activity.attemptId !== entry.activity.attemptId ) { @@ -667,7 +778,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th } firstActivityEntry ??= entry; openGroupActivities.push(entry.activity); - if (isCompaction || entry.activity.prominent) { + if (isCompaction || (entry.activity.prominent && !isSubagent)) { flushGroup(); } } @@ -853,7 +964,11 @@ export function deriveThreadFeedPresentation( activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "run-fold" && entry.type !== "work-toggle", + (entry) => + entry.type !== "run-fold" && + entry.type !== "work-toggle" && + entry.type !== "thinking" && + entry.type !== "agent-spawn", ); const activeTailGroup = sourceFeed.at(-1); const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun); @@ -908,12 +1023,40 @@ export function deriveThreadFeedPresentation( ); } } + if ( + sourceFeed.length > 0 && + activeWorkStartedAt !== null && + !result.some( + (row) => + (row.type === "work-toggle" && row.shimmer) || + (row.type === "agent-spawn" && row.summary.tone === "working" && row.runId === activeRunId), + ) + ) { + result.push(thinkingRow(activeWorkStartedAt, activeRunId)); + } return result; } +export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + +function thinkingRow(createdAt: string, runId: RunId | null) { + if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.runId !== runId) { + cachedThinkingRow = { + type: "thinking", + id: LIVE_ACTIVITY_ROW_ID, + createdAt, + runId, + }; + } + return cachedThinkingRow; +} + function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude< + ThreadFeedEntry, + { readonly type: "agent-spawn" | "run-fold" | "thinking" | "work-toggle" } + >, expandedWorkGroupIds: ReadonlySet, activeRunId: RunId | null, isWorking: boolean, @@ -935,7 +1078,9 @@ function appendPresentedFeedEntry( cached.isWorking !== isWorking || cached.activeTail !== activeTail || cached.rows.some( - (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, + (row) => + (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) || + (row.type === "agent-spawn" && expandedWorkGroupIds.has(row.id) !== row.expanded), ) ) { const rows: ThreadFeedEntry[] = []; @@ -948,6 +1093,79 @@ function appendPresentedFeedEntry( } } +function agentSpawnTone(status: WorkLogToolLifecycleStatus): AgentSpawnSummary["tone"] { + switch (status) { + case "inProgress": + return "working"; + case "completed": + return "completed"; + case "failed": + case "declined": + return "failed"; + case "idle": + case "stopped": + return "stopped"; + } +} + +function summarizeSubagentActivities( + activities: ReadonlyArray, +): AgentSpawnSummary { + const members = activities.map((activity) => { + const item = activity.projectedItem.item; + if (item.type !== "subagent") { + throw new Error("Expected a subagent activity"); + } + const tone = agentSpawnTone(activity.lifecycleStatus); + const detail = (tone === "working" ? item.progress : (item.result ?? item.progress))?.trim(); + return { + title: item.title?.trim() || "Subagent", + status: tone === "working" ? "working" : activity.lifecycleStatus, + tone, + ...(detail ? { detail } : { detail: undefined }), + updatedAt: DateTime.formatIso(item.updatedAt), + }; + }); + const title = members.length === 1 ? members[0]!.title : `${members.length} subagents`; + const working = members.filter((member) => member.tone === "working"); + if (working.length > 0) { + const latest = working + .filter((member) => member.detail !== undefined) + .reduce<(typeof working)[number] | undefined>( + (newest, member) => + newest === undefined || member.updatedAt > newest.updatedAt ? member : newest, + undefined, + ); + return { + title, + status: + latest?.detail ?? + (members.length > 1 ? `${working.length} of ${members.length} working` : "Working"), + tone: "working", + members, + }; + } + const failed = members.filter((member) => member.tone === "failed").length; + if (failed > 0) { + return { + title, + status: members.length > 1 ? `${failed} failed` : "failed", + tone: "failed", + members, + }; + } + const stopped = members.filter((member) => member.tone === "stopped").length; + if (stopped > 0) { + return { + title, + status: members.length > 1 ? `${stopped} stopped` : "stopped", + tone: "stopped", + members, + }; + } + return { title, status: "completed", tone: "completed", members }; +} + function appendActivityGroupRows( result: ThreadFeedEntry[], entry: ThreadFeedActivityGroup, @@ -976,6 +1194,36 @@ function appendActivityGroupRows( return; } + if (activities.every((activity) => activity.projectedItem.item.type === "subagent")) { + const anchor = activities[0]!; + const anchorItem = anchor.projectedItem.item; + if (anchorItem.type !== "subagent") return; + const groupId = `agent-spawn:${entry.runId ?? anchorItem.subagentId}:${anchor.attemptId ?? "root"}`; + const summary = summarizeSubagentActivities(activities); + const copyActivity: ThreadFeedActivity = { + ...anchor, + getCopyText: () => + [ + summary.title, + summary.status, + ...summary.members.map( + (member) => + `${member.title} · ${member.status}${member.detail ? `\n${member.detail}` : ""}`, + ), + ].join("\n"), + }; + result.push({ + type: "agent-spawn", + id: groupId, + createdAt: entry.createdAt, + runId: entry.runId, + activity: copyActivity, + expanded: expandedWorkGroupIds.has(groupId), + summary, + }); + return; + } + let groupableRun: ThreadFeedActivity[] = []; const flushGroupableRun = (isTrailingRun: boolean) => { if (groupableRun.length === 0) return; @@ -1027,6 +1275,11 @@ function appendToolGroupRows( ); const live = activeTail || latestInProgressActivity !== undefined; const latestActivity = latestInProgressActivity ?? activities.at(-1)!; + const shimmer = + activeTail && + (latestInProgressActivity !== undefined || + (latestActivity.status === "success" && + !workEntryDisplayIndicatesToolFailure(latestActivity.workEntry))); const singleActivity = activities.length === 1 ? latestActivity : null; const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); const summary = live @@ -1068,7 +1321,7 @@ function appendToolGroupRows( : undefined; result.push({ type: "work-toggle", - id: `${live ? "work-live" : "work-toggle"}:${groupId}`, + id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`, createdAt: sourceGroup.createdAt, runId: sourceGroup.runId, groupId, @@ -1088,10 +1341,7 @@ function appendToolGroupRows( ); })(), live, - shimmer: - isWorking && - latestActivity.lifecycleStatus === "inProgress" && - latestActivity.runId === activeRunId, + shimmer, }); if (!expanded) return; result.push({ diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 71876932b789..aca830f24c71 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ "connection-vcs-refs", ] as const; -export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export const ClientCacheKind = Schema.Literals([ + "shell", + "thread", + "server-config", + "vcs-refs", + "project-favicon", +]); export type ClientCacheKind = typeof ClientCacheKind.Type; export interface ClientCacheSummaryRow { @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ "open", "migrate", "load-cache", + "list-cache", "save-cache", "remove-cache", "clear-cache-kind", @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect, MobileDatabaseError>; + readonly listCache: ( + kind: ClientCacheKind, + ) => Effect.Effect, MobileDatabaseError>; readonly saveCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("load-cache"), }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), ), + listCache: Effect.fn("MobileDatabase.listCache")((kind) => + Effect.tryPromise({ + try: () => + database.getAllAsync<{ readonly payload: string }>( + "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", + kind, + ), + catch: databaseError("list-cache"), + }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), + ), saveCache: Effect.fn("MobileDatabase.saveCache")( (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.tryPromise({ @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] const fail = Effect.fail(error); return MobileDatabase.of({ loadCache: () => fail, + listCache: () => fail, saveCache: () => fail, removeCache: () => fail, clearCacheKind: () => fail, diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 400bdb6b705a..15cbd1d9a89f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -6,6 +6,7 @@ import { import { assetUrlStateFromResult, createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; @@ -15,14 +16,21 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection } from "./session"; +import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); + const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-connection-state:empty"), ); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index 3912857b5751..c210c54f2fdd 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => - MobileDatabase.pipe( + Effect.promise(() => + scope.type === "all" + ? projectFaviconCache.clearAll() + : projectFaviconCache.clearEnvironment(scope.environmentId), + ).pipe( + Effect.andThen(MobileDatabase), Effect.flatMap((database) => scope.type === "all" ? database.clearAllCaches diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 2157c72e13ef..28cd2af57062 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -8,6 +8,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 53d19abd95a4..fd7a171810ee 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -20,7 +20,7 @@ export interface ThreadPrPresentation { const PR_STATE_TEXT_CLASS: Record = { open: "text-adaptive-emerald-600-400", merged: "text-adaptive-violet-600-400", - closed: "text-adaptive-zinc-500-400", + closed: "text-foreground-muted", }; export function presentThreadPr( @@ -37,6 +37,6 @@ export function presentThreadPr( url: pr.url, label: String(pr.number), accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`, - textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state], + textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index f5bdc0d0858b..d49c26a40a44 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -30,6 +30,7 @@ export interface EnvironmentUsageStatus { readonly environmentId: EnvironmentId; readonly label: string; readonly isPending: boolean; + readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; } @@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => environmentId, label: presentation.entry.target.label, isPending: result.waiting, + isConnected: presentation.connection.phase === "connected", error: result._tag === "Failure" ? "This environment could not report usage." : null, summary: Option.getOrNull(AsyncResult.value(result)), }); @@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) => export interface UsageView { readonly merged: MergedUsage; readonly environments: readonly EnvironmentUsageStatus[]; + readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; /** True until at least one environment has answered. */ readonly isPending: boolean; /** * True while environments that have not failed are still answering. Failed - * environments are reported through their own error rows: totals will not + * environments are reported in the environment menu: totals will not * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (input?: UsageSummaryInput) => Promise; } -export function useUsage(input: UsageSummaryInput): UsageView { +export function useUsage( + input: UsageSummaryInput, + selectedEnvironmentIds: ReadonlySet | null = null, +): UsageView { const windowKey = useMemo( () => JSON.stringify({ @@ -97,30 +103,28 @@ export function useUsage(input: UsageSummaryInput): UsageView { ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); + const selectedEnvironments = useMemo( + () => + selectedEnvironmentIds === null + ? environments + : environments.filter(({ environmentId }) => selectedEnvironmentIds.has(environmentId)), + [environments, selectedEnvironmentIds], + ); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so pull-to-refresh always rescans. - // - // Each environment refetches model pricing first, so a model released since - // its last daily fetch gets priced by the rescan. The rescan runs whether or - // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of environments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [environments, windowKey]); + const refresh = useCallback( + (nextInput?: UsageSummaryInput) => + refreshUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + presentations: environmentPresentations, + environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + }), + [selectedEnvironments, windowKey], + ); const merged = useMemo(() => { - const answered: EnvironmentUsage[] = environments.flatMap((environment) => + const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => environment.summary === null ? [] : [ @@ -132,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView { ], ); return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [environments]); + }, [selectedEnvironments]); - const answeredCount = environments.filter((environment) => environment.summary !== null).length; - const stillReporting = environments.filter( + const answeredCount = selectedEnvironments.filter( + (environment) => environment.summary !== null, + ).length; + const stillReporting = selectedEnvironments.filter( (environment) => environment.summary === null && environment.error === null, ).length; return { merged, environments, + selectedEnvironments, isPending: answeredCount === 0 && stillReporting > 0, isPartial: answeredCount > 0 && stillReporting > 0, refresh, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index e355e0e6dd7f..57c0ac91d147 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load, })); +import type { DraftComposerAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { @@ -159,7 +160,6 @@ import { copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, - decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, @@ -252,12 +252,12 @@ describe("mobile composer drafts", () => { }; expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); @@ -987,7 +987,7 @@ describe("mobile composer drafts", () => { it("rejects persisted images without image bytes or a file URI", () => { expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { @@ -1010,7 +1010,7 @@ describe("mobile composer drafts", () => { it("hydrates selector state even when the message content is empty", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": { @@ -1030,7 +1030,7 @@ describe("mobile composer drafts", () => { }, }, }, - }), + }).drafts, ).toEqual({ "new-task:environment-1:project-1": { text: "", @@ -1053,18 +1053,18 @@ describe("mobile composer drafts", () => { it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": DRAFT, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": DRAFT, }); expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { @@ -1434,6 +1434,48 @@ describe("mobile composer drafts", () => { expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); }); + it("drops another environment's upload stamp when carrying attachments across machines", () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-2:project-2"; + const uploadedElsewhere: DraftComposerAttachment = { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + uploadedAttachmentId: "upload-1", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + const uploadedOnTarget: DraftComposerAttachment = { + ...uploadedElsewhere, + id: "image-2", + uploadedAttachmentId: "upload-2", + uploadEnvironmentId: EnvironmentId.make("environment-2"), + }; + + const next = copyComposerDraftContentState( + { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } }, + sourceKey, + targetKey, + ); + + expect(next[targetKey]?.attachments).toEqual([ + { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + }, + uploadedOnTarget, + ]); + expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]); + }); + it("merges shared content into a project draft without duplicating retries", () => { const draftKey = "new-task:environment-1:project-1"; const sharedAttachment = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 065331d67064..bf25866b14ce 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -238,10 +238,6 @@ export function decodePersistedComposerState(value: unknown): { }; } -export function decodePersistedComposerDrafts(value: unknown): Record { - return decodePersistedComposerState(value).drafts; -} - async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); @@ -1053,17 +1049,35 @@ export function copyComposerDraftContentState( if (!sourceHasContent || targetHasContent) { return current; } + // Pending uploads live on one server. Crossing environments keeps the local + // bytes (the upload worker re-sends them to the new key's environment) but + // drops the old stamp, so it cannot pin the source environment's pending + // upload alive from the copy. + const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []); + const attachments = source.attachments.map((attachment) => + attachment.uploadEnvironmentId !== undefined && + attachment.uploadEnvironmentId !== targetEnvironmentId + ? stripAttachmentUploadReference(attachment) + : attachment, + ); return { ...current, [targetDraftKey]: { ...target, text: source.text, - attachments: source.attachments, + attachments, ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), }, }; } +function stripAttachmentUploadReference( + attachment: DraftComposerAttachment, +): DraftComposerAttachment { + const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment; + return rest; +} + export async function copyComposerDraftContentIfEmpty( sourceDraftKey: string, targetDraftKey: string, diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index ddda3b1acd96..f6fddfdc5578 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -39,7 +39,7 @@ describe("presentThreadPr", () => { presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined), ).toMatchObject({ accessibilityLabel: "#3774 pull request draft", - textClassName: "text-adaptive-zinc-500-400", + textClassName: "text-foreground-muted", }); }); }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 53e33cf7fd5b..b83b8684432c 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -83,6 +83,30 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("reports pixel dimensions from an image header and nothing for other files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-dimensions-" }); + const png = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, + 0x06, 0x40, 0, 0, 0x03, 0x84, + ]); + yield* fs.writeFile(path.join(root, "shot.png"), png); + yield* fs.writeFileString(path.join(root, "clip.mp4"), "video"); + yield* fs.writeFileString(path.join(root, "broken.png"), "not a png"); + const issue = (name: string) => + issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: name }, + workspaceRoot: root, + }); + + expect((yield* issue("shot.png")).imageDimensions).toEqual({ width: 1600, height: 900 }); + expect((yield* issue("clip.mp4")).imageDimensions).toBeUndefined(); + expect((yield* issue("broken.png")).imageDimensions).toBeUndefined(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("resolves relative media paths from the thread workspace, including outside it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index a0d849bf603a..956c4ac44211 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,6 +21,11 @@ import { WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; +import { + IMAGE_DIMENSIONS_HEADER_BYTES, + readImageDimensions, + type ImageDimensions, +} from "@t3tools/shared/imageDimensions"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -44,7 +49,7 @@ import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; -import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; +import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { Effect.orElseSucceed(() => null), ); +/** + * Reads pixel dimensions from an image's header so clients can reserve the + * exact box before the bytes arrive. Best effort: an unreadable or unsupported + * file just leaves the field out, and the client measures after decode. Only + * formats the parser understands are opened; SVG and the rest are skipped. + */ +const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); + +/** From the identity-checked, non-blocking handle the caller already holds. */ +const readImageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) => + readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe( + Effect.map(readImageDimensions), + Effect.orElseSucceed((): ImageDimensions | null => null), + ); + +/** + * Opens through `openMediaFile` so a path swapped for a FIFO cannot block the + * request; a regular open would wait for a writer that never comes. + */ +const readImageDimensionsFromHeader = (filePath: string) => + openMediaFile(filePath).pipe( + Effect.flatMap((file) => + file === null ? Effect.succeed(null) : readImageDimensionsFromOpenFile(filePath, file), + ), + Effect.scoped, + Effect.orElseSucceed((): ImageDimensions | null => null), + ); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; @@ -236,6 +269,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let claims: AssetClaims; let fileName: string; let sourcePath: string | undefined; + let imageDimensions: ImageDimensions | null = null; switch (input.resource._tag) { case "media-file": { @@ -265,18 +299,33 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } - const identity = yield* openMediaFile(canonicalFile).pipe( - Effect.map((file) => - file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null, + const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has( + path.extname(canonicalFile).toLowerCase(), + ); + const opened = yield* openMediaFile(canonicalFile).pipe( + Effect.flatMap((file) => + file === null + ? Effect.succeed(null) + : Effect.map( + wantsDimensions + ? readImageDimensionsFromOpenFile(canonicalFile, file) + : Effect.succeed(null), + (dimensions) => ({ + identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() }, + dimensions, + }), + ), ), Effect.scoped, Effect.mapError( (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), ), ); - if (!identity) { + if (!opened) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); } + const identity = opened.identity; + imageDimensions = opened.dimensions; claims = { version: 1, kind: "media-file-exact", @@ -347,6 +396,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (HEADER_IMAGE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { + imageDimensions = yield* readImageDimensionsFromHeader(canonicalFile); + } claims = isWorkspaceImagePreviewPath(resolved.relativePath) ? { version: 1, @@ -390,6 +442,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i INLINE_DOCUMENT_EXTENSIONS.has(extension) ? INLINE_DOCUMENT_MIME_TYPES[extension] : undefined; + if (!isGenericFile) { + imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); + } claims = { version: 1, kind: "attachment", @@ -547,6 +602,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, ...(sourcePath !== undefined ? { sourcePath } : {}), + ...(imageDimensions !== null ? { imageDimensions } : {}), }; }); diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts index e1053555b052..7fb0c1135607 100644 --- a/apps/server/src/assets/MediaFile.ts +++ b/apps/server/src/assets/MediaFile.ts @@ -19,6 +19,18 @@ class MediaFileOpenError extends Schema.TaggedErrorClass()( } } +class MediaFileReadError extends Schema.TaggedErrorClass()( + "MediaFileReadError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read media file '${this.path}'.`; + } +} + class MediaFileStatError extends Schema.TaggedErrorClass()( "MediaFileStatError", { @@ -95,6 +107,17 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* ( ); }); +/** Reads the leading bytes of an already-validated media file, never past the end. */ +export const readMediaFileHeader = (filePath: string, file: OpenMediaFile, byteCount: number) => + Effect.tryPromise({ + try: async () => { + const buffer = new Uint8Array(byteCount); + const { bytesRead } = await file.handle.read(buffer, 0, byteCount, 0); + return buffer.subarray(0, bytesRead); + }, + catch: (cause) => new MediaFileReadError({ path: filePath, cause }), + }); + export const statMediaFile = Effect.fn("statMediaFile")(function* ( filePath: string, file: OpenMediaFile, diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..7262239577b4 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => { ); }); + it("requires write access to import agent session history", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 86a051828b6b..2c3e3f426aa1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -57,6 +57,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, @@ -109,6 +110,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.assetsPersistChatAttachments]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index cc74966c41e2..39304d17bef6 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -233,7 +233,6 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; - return handlers .handle( "session", diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 50493af0d25e..5798e17fc74d 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -66,7 +66,10 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => { const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) => Effect.succeed("diff --git a/file b/file"), ); - const layer = makeLayer({ projection: Effect.succeed(makeProjection()), diffCheckpoints }); + const layer = makeLayer({ + projection: Effect.succeed(makeProjection()), + diffCheckpoints, + }); return Effect.gen(function* () { const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 1e0c88c24e84..f3cc88d1b58a 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -13,31 +13,11 @@ import * as Terminal from "effect/Terminal"; import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, - formatHeadlessAuthorizationPrompt, - formatRelayClientReady, headlessSessionConfig, - isPublishAgentActivityEnabledValue, reportCloudDisconnectResults, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; -it("explains how to complete headless authorization", () => { - assert.equal( - formatHeadlessAuthorizationPrompt("https://example.test/connect"), - [ - "Headless authorization", - "Open this URL on a device with a browser:", - " https://example.test/connect", - "", - "After signing in, return here and enter the code shown in your browser.", - ].join("\n"), - ); -}); - -it("formats relay readiness without printing its installation path", () => { - assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); -}); - const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); @@ -209,10 +189,3 @@ it.effect("keeps disconnect causes in structured logs and out of console warning ), ); }); - -it("treats only the literal 'true' as publish-enabled", () => { - assert.equal(isPublishAgentActivityEnabledValue("true"), true); - assert.equal(isPublishAgentActivityEnabledValue("false"), false); - assert.equal(isPublishAgentActivityEnabledValue(null), false); - assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false); -}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 25cfb18f3402..b7c78e5ea68b 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -86,7 +86,7 @@ const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_ }, ); -export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { +function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { return [ "Headless authorization", "Open this URL on a device with a browser:", @@ -144,10 +144,6 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -export function isPublishAgentActivityEnabledValue(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - interface CloudCliStatus { readonly desired: boolean; readonly authenticated: boolean; @@ -464,7 +460,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (identity ? ` as ${identity}` : ""); -export function formatRelayClientReady(version: string): string { +function formatRelayClientReady(version: string): string { return `✓ Relay client ready · cloudflared ${version}`; } @@ -573,7 +569,7 @@ const connectStatusCommand = Command.make("status", { linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, - publishAgentActivity: isPublishAgentActivityEnabledValue( + publishAgentActivity: isAgentActivityPublishingEnabledValue( Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null, ), relayClient: executable, diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index c01a2caa49b5..370a8977fc4c 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,49 +1,62 @@ import { assert, it } from "@effect/vitest"; -import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; +import { formatCliCommand } from "./invocation.ts"; -it("detects package runners from their cache entry paths", () => { - assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx"); - assert.equal( - detectCliRunner( +it("formats package runner commands from their cache entry paths", () => { + for (const [entryPath, expected] of [ + ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "npx", - ); - assert.equal( - detectCliRunner("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner( + "npx t3 serve", + ], + ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"], + [ + "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + "pnpm dlx t3 serve", + ], + [ "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "pnpm dlx", - ); - assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx"); - assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx"); - assert.equal( - detectCliRunner( + "pnpm dlx t3 serve", + ], + ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"], + ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - ), - "bunx", - ); + "bunx t3 serve", + ], + ] as const) { + assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); + } }); it("treats stable installs as direct invocations", () => { - assert.isNull(detectCliRunner("/usr/local/lib/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("")); + for (const entryPath of [ + "/usr/local/lib/node_modules/t3/dist/bin.mjs", + "/home/theo/Code/work/t3code/apps/server/dist/bin.mjs", + "/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs", + "", + ]) { + assert.equal( + formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), + "t3 serve", + ); + } }); it("re-suggests the nightly channel only for nightly builds", () => { - assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly"); - assert.equal(suggestedPackageSpec("0.0.31"), "t3"); + for (const [version, expected] of [ + ["0.0.31-nightly.20260729", "npx t3@nightly serve"], + ["0.0.31", "npx t3 serve"], + ] as const) { + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", + version, + }), + expected, + ); + } }); it("formats serve suggestions to match the launching command", () => { diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index e1b03552948d..55f5b66ad9dd 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -18,7 +18,7 @@ export type CliRunner = "npx" | "pnpm dlx" | "bunx"; * Global installs and repo checkouts match none of these and return null. * Detection is best-effort; callers must fail closed to a plain `t3` command. */ -export function detectCliRunner(entryPath: string): CliRunner | null { +function detectCliRunner(entryPath: string): CliRunner | null { const path = entryPath.replaceAll("\\", "/"); if (path.includes("/_npx/")) { return "npx"; @@ -42,7 +42,7 @@ export function detectCliRunner(entryPath: string): CliRunner | null { * from the running version: nightly builds re-suggest the nightly channel, * anything else suggests the bare package. */ -export function suggestedPackageSpec(version: string): string { +function suggestedPackageSpec(version: string): string { return version.includes("-nightly.") ? "t3@nightly" : "t3"; } diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index dd15c41fdd91..20c15b93f59e 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -7,9 +7,11 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { LocalServerPairCommandOutput } from "@t3tools/contracts"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; @@ -196,6 +198,47 @@ describe("t3 pair", () => { ), ); + it.effect("prints one machine-readable object with --json", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-json-test-")); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + + const output = yield* captureStdout( + runCli(["pair", "--base-dir", baseDir, "--label", "Desktop", "--json"]), + ); + const decoded = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(LocalServerPairCommandOutput), + )(output); + + assert.deepEqual(Object.keys(decoded), [ + "pairingUrl", + "token", + "expiresAt", + "origin", + "environmentId", + "label", + ]); + assert.equal(decoded.origin, origin); + assert.equal(decoded.environmentId, testDescriptor.environmentId); + assert.equal(decoded.label, testDescriptor.label); + assert.match(String(decoded.pairingUrl), new RegExp(`^${origin}/pair#token=`)); + assert.equal( + new URL(String(decoded.pairingUrl)).hash.slice("#token=".length), + decoded.token, + ); + assert.isFalse(/Pairing with|Note:|[█▀▄]/.test(output)); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("pairs through the recorded dev web URL for dev servers", () => withDescriptorServer((origin) => Effect.gen(function* () { diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index d40e0d97e484..b90f7517430f 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -12,6 +12,7 @@ import { AuthStandardClientScopes, ExecutionEnvironmentDescriptor, + type LocalServerPairCommandOutput, PortSchema, } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; @@ -193,6 +194,23 @@ export const formatPairOutput = (input: { "", ].join("\n"); +export const formatPairJsonOutput = (input: { + readonly pairingUrl: string; + readonly token: string; + readonly expiresAt: DateTime.Utc; + readonly origin: string; + readonly environmentId: LocalServerPairCommandOutput["environmentId"]; + readonly label: string; +}): string => + JSON.stringify({ + pairingUrl: input.pairingUrl, + token: input.token, + expiresAt: DateTime.formatIso(input.expiresAt), + origin: input.origin, + environmentId: input.environmentId, + label: input.label, + } satisfies LocalServerPairCommandOutput); + /** * Three outcomes, because they drive different decisions: a T3 descriptor * (pair with it), nothing answering (safe to configure Tailscale Serve), or @@ -458,6 +476,11 @@ const labelFlag = Flag.string("label").pipe( Flag.optional, ); +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + const tailscaleFlag = Flag.boolean("tailscale").pipe( Flag.withDescription( "Publish the server over Tailscale Serve HTTPS and pair through the tailnet URL.", @@ -475,6 +498,7 @@ export const pairCommand = Command.make("pair", { baseDir: baseDirFlag, ttl: ttlFlag, label: labelFlag, + json: jsonFlag, tailscale: tailscaleFlag, tailscaleServePort: tailscaleServePortFlag, }).pipe( @@ -484,9 +508,11 @@ export const pairCommand = Command.make("pair", { Command.withHandler((flags) => Effect.gen(function* () { const cliLogLevel = yield* GlobalFlag.LogLevel; - // Default to Warn so storage/migration chatter cannot bury the QR code; - // an explicit --log-level still wins. - const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const); + // JSON is consumed by other processes, so keep storage/migration chatter + // off stdout. Human output defaults to Warn so it cannot bury the QR. + const logLevel = flags.json + ? ("Error" as const) + : Option.getOrElse(cliLogLevel, () => "Warn" as const); const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir)); @@ -518,14 +544,23 @@ export const pairCommand = Command.make("pair", { const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); yield* Console.log( - formatPairOutput({ - serverLabel: target.descriptor.label, - origin: target.state.origin, - pairingUrl, - token: issued.credential, - expiresAt: issued.expiresAt, - notes, - }), + flags.json + ? formatPairJsonOutput({ + pairingUrl, + token: issued.credential, + expiresAt: issued.expiresAt, + origin: target.state.origin, + environmentId: target.descriptor.environmentId, + label: target.descriptor.label, + }) + : formatPairOutput({ + serverLabel: target.descriptor.label, + origin: target.state.origin, + pairingUrl, + token: issued.credential, + expiresAt: issued.expiresAt, + notes, + }), ); }).pipe(Effect.provide(FetchHttpClient.layer)), ), diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index e6eb6b7cd6fd..33a0f1224961 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -93,19 +93,6 @@ class PromptRejectedError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -it("formats loopback authorization with a headless-host fallback", () => { - assert.equal( - CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), - [ - "Open this URL to authorize T3 Connect:", - " https://clerk.example.test/authorize", - "", - "Press \u001b[1mEnter\u001b[22m to open it in your browser.", - "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", - ].join("\n"), - ); -}); - const makeTestTerminal = (queue: Queue.Queue) => Terminal.make({ columns: Effect.succeed(80), diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index c4443a7301cb..8c3869accc76 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,7 +44,7 @@ const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; -export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { +function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { return [ "Open this URL to authorize T3 Connect:", ` ${authorizationUrl}`, diff --git a/apps/server/src/cloud/cliAuthHtml.test.ts b/apps/server/src/cloud/cliAuthHtml.test.ts deleted file mode 100644 index 1104927b9800..000000000000 --- a/apps/server/src/cloud/cliAuthHtml.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "@effect/vitest"; - -import { - renderLoopbackAuthorizationCompleteHtml, - resolveLoopbackAuthorizationStage, -} from "./cliAuthHtml.ts"; - -it("renders the branded loopback authorization completion page", () => { - const html = renderLoopbackAuthorizationCompleteHtml(); - - expect(resolveLoopbackAuthorizationStage()).toBe("dev"); - expect(html).toContain("T3 Code (Dev)"); - expect(html).toContain('class="stage stage-dev"'); - expect(html).not.toContain("Secure terminal handoff"); - expect(html).toContain("You're connected"); - expect(html).toContain("Return to your terminal"); - expect(html).not.toContain('class="next"'); - expect(html).toContain('name="viewport"'); - expect(html).not.toContain('class="status"'); -}); - -it("renders the matching header treatment for each release channel", () => { - const nightly = renderLoopbackAuthorizationCompleteHtml("nightly"); - const latest = renderLoopbackAuthorizationCompleteHtml("latest"); - - expect(nightly).toContain("T3 Code (Nightly)"); - expect(nightly).toContain('class="stage stage-nightly"'); - expect(latest).toContain('

T3 Code

'); - expect(latest).not.toContain("(Latest)"); - expect(latest).toContain('class="stage stage-latest"'); -}); diff --git a/apps/server/src/cloud/cliAuthHtml.ts b/apps/server/src/cloud/cliAuthHtml.ts index 5a22a25993a9..69d3b471ae32 100644 --- a/apps/server/src/cloud/cliAuthHtml.ts +++ b/apps/server/src/cloud/cliAuthHtml.ts @@ -2,7 +2,7 @@ export type LoopbackAuthorizationStage = "dev" | "nightly" | "latest"; declare const __T3CODE_BUILD_CHANNEL__: "nightly" | "latest" | undefined; -export function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { +function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { return typeof __T3CODE_BUILD_CHANNEL__ === "undefined" ? "dev" : __T3CODE_BUILD_CHANNEL__; } diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index f34f0f5cf4d7..a0ca9e5f0fa0 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -38,6 +39,84 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("installs through pnpm when its Node runtime has no npm executable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); + const commands: Array = []; + const install = successfulRunner(fs, path); + const paths = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input); + return input.command === "npm" + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "npm", + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ) + : install.run(input); + }, + }), + validate: (staging) => + fs.exists(staging.entryPath).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), + Effect.orDie, + ), + }); + assert.deepEqual( + commands.map((command) => command.command), + ["npm", "pnpm"], + ); + assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + }), + ); + + it.effect("does not try a different installer for npm permission failures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const commands: string[] = []; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input.command); + return Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + }), + }), + ); + }, + }), + validate: () => Effect.die("must not validate a failed install"), + }).pipe(Effect.flip); + assert.deepEqual(commands, ["npm"]); + }), + ); + it.effect("validates a staging tree before atomically publishing it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 06628d5cc12f..cf8a7c3cab6b 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -2,6 +2,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; @@ -152,14 +153,35 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + const installArgs = [ + "install", + "--prefix", + stagingDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ]; yield* runner .run({ command: "npm", - args: ["install", "--prefix", stagingDir, "--no-fund", "--no-audit", `t3@${input.version}`], + args: installArgs, // Native dependencies may compile from source on slower machines. timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, }) .pipe( + Effect.catchTags({ + ProcessSpawnError: (error) => + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? // pnpm-managed Node installations do not include npm. Keep npm + // installation semantics for the pinned runtime and native builds. + runner.run({ + command: "pnpm", + args: ["--package=npm@11", "dlx", "npm", ...installArgs], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + : Effect.fail(error), + }), Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), Effect.filterOrFail( (result) => result.code === 0, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 42df3814b070..3b3499996dc1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -14,6 +14,7 @@ import * as Layer from "effect/Layer"; import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { deriveServerRuntimeStatePath } from "@t3tools/shared/serverRuntimeState"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; @@ -133,7 +134,11 @@ export const deriveServerPaths = Effect.fn(function* ( terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), environmentIdPath: join(stateDir, "environment-id"), - serverRuntimeStatePath: join(stateDir, "server-runtime.json"), + serverRuntimeStatePath: deriveServerRuntimeStatePath({ + baseDir, + variant: devUrl !== undefined && !options.baseDirIsExplicit ? "dev" : "userdata", + joinPath: join, + }), secretsDir: join(stateDir, "secrets"), }; }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 7043d7add0fc..c95d36c9a63a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -221,6 +221,7 @@ export const make = Effect.gen(function* () { pullRequests: true, threadSettlement: true, threadAutoSettlement: true, + threadRestartContinuation: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 643e1e7a0d5b..b8f4090453be 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -511,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -555,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -1148,6 +1148,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "open", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-03T15:00:00.000Z", }); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); @@ -1179,6 +1181,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "develop", headRefName: "main", state: "MERGED", + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00Z", }, ]), @@ -1190,6 +1193,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00.000Z", }); }), @@ -1241,6 +1246,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-04T15:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( @@ -1305,6 +1312,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-05T15:00:00.000Z", }); expect( @@ -1691,7 +1700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1757,7 +1766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2142,6 +2151,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-05-02T10:00:00.000Z", }); }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 47534dc6d393..ac8ff289542e 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -100,7 +100,12 @@ export class GitManager extends Context.Service< readonly cwd: string; readonly branch: string; }) => Effect.Effect< - { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + } | null, GitManagerServiceError >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; @@ -171,6 +176,8 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; + closedAt?: string | null; + mergedAt?: string | null; updatedAt: Option.Option; } @@ -406,6 +413,8 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -2157,7 +2166,12 @@ export const make = Effect.gen(function* () { return null; } const statusPr = toStatusPr(latest); - return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + return { + state: statusPr.state, + updatedAt: statusPr.updatedAt, + closedAt: latest.closedAt ?? null, + mergedAt: latest.mergedAt ?? null, + }; }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 160755a8f9b2..ec7070809435 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,33 +188,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("ships configurable thread navigation defaults", () => - Effect.sync(() => { - const defaultsByCommand = new Map( - Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), - ); - - assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); - assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); - assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); - assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); - assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); - assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); - assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); - assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); - assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); - assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); - assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); - assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); - assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); - assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); - assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); - assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); - }), - ); - it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 05fb16203e0b..61c512e6fd91 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -6,7 +6,7 @@ import { TurnId, type OrchestrationThreadShell, } from "@t3tools/contracts"; -import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; +import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const makeThread = ( @@ -36,7 +36,7 @@ const makeThread = ( const decide = ( thread: OrchestrationThreadShell, - pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + pullRequest: SettlementPullRequest | null = null, settings: { days?: number | null; merge?: boolean } = {}, ) => resolveAutoSettlementAt({ @@ -77,7 +77,7 @@ describe("resolveAutoSettlementAt", () => { latestTurn: null, updatedAt: "2026-08-27T00:00:00.000Z", }), - pullRequest: { state: "closed", updatedAt: NOW }, + pullRequest: { state: "closed", closedAt: NOW }, now: NOW, autoSettleAfterDays: null, autoSettleOnMerge: true, @@ -100,10 +100,10 @@ describe("resolveAutoSettlementAt", () => { }); it("settles closed requests and honors the merge setting", () => { - expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); - expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "closed", closedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false })).toBe(true); expect( - decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false, days: null }), ).toBe(false); }); @@ -111,17 +111,36 @@ describe("resolveAutoSettlementAt", () => { expect( decide( makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), - { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" }, { days: null }, ), ).toBe(false); }); + it.each(["closed", "merged"] as const)( + "ignores metadata edits after resumed work for %s requests", + (state) => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { + state, + closedAt: "2026-08-26T00:00:00.000Z", + mergedAt: "2026-08-26T00:00:00.000Z", + updatedAt: NOW, + }, + { days: null }, + ), + ).toBe(false); + expect(decide(makeThread(), { state, updatedAt: NOW }, { days: null })).toBe(false); + }, + ); + it("does not inherit a terminal pull request older than the thread", () => { expect( decide( makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), - { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { state: "closed", closedAt: "2026-08-19T00:00:00.000Z" }, { days: null }, ), ).toBe(false); @@ -129,9 +148,9 @@ describe("resolveAutoSettlementAt", () => { it("requires a comparable PR timestamp for immediate settlement", () => { const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); - expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); - expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); - expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + expect(decide(recentThread, { state: "closed", closedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", mergedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", closedAt: null })).toBe(true); }); it("uses user request time instead of completion time as the PR anchor", () => { @@ -145,7 +164,7 @@ describe("resolveAutoSettlementAt", () => { assistantMessageId: null, }, }); - expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + expect(decide(thread, { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); }); it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index eac5a960a482..7c55fa37d1f3 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -2,7 +2,9 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: string | null; } const DAY_MS = 24 * 60 * 60 * 1_000; @@ -49,14 +51,15 @@ function pullRequestSettles( if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { return false; } - if (pullRequest.updatedAt === null) return false; + const terminalAt = pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt; + if (terminalAt == null) return false; const userAnchor = latestTimestamp([ thread.createdAt, thread.latestUserMessageAt, thread.latestTurn?.requestedAt, ]); if (userAnchor === null) return false; - const pullRequestAt = Date.parse(pullRequest.updatedAt); + const pullRequestAt = Date.parse(terminalAt); const userAnchorAt = Date.parse(userAnchor); if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; return pullRequestAt >= userAnchorAt; diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index f9f6432a4a55..47dd6d3e7922 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -17,7 +17,7 @@ function invariantError(commandType: string, detail: string): OrchestrationComma }); } -export function findThreadById( +function findThreadById( readModel: OrchestrationReadModel, threadId: ThreadId, ): OrchestrationThread | undefined { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 75d190166403..3334d8e7b88c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1323,6 +1323,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.history.import": + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Thread history import is unavailable with orchestration v2.", + }); + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index c8fa16158bae..d4a70af59be5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,12 +12,24 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { - it.effect("finds the latest user-message time within one thread", () => + it.effect("finds the latest live user-message time within one thread", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-latest-user-message"); assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + yield* repository.upsert({ + messageId: MessageId.make("import:codex:latest-user-message:000000"), + threadId, + turnId: null, + role: "user", + text: "Imported prompt", + isStreaming: false, + createdAt: "2026-02-28T19:05:06.000Z", + updatedAt: "2026-02-28T19:05:06.000Z", + }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + const messages = [ { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index ce28e11b8601..be20fb37f36d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -191,6 +191,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { SELECT MAX(created_at) AS "latestUserMessageAt" FROM projection_thread_messages WHERE thread_id = ${threadId} AND role = 'user' + AND message_id NOT GLOB 'import:*' `, }); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index fbde96ff58b1..184c8b519a96 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -45,7 +45,6 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, - isExternalLauncherError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 734627df0957..a04c94158a6c 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectService from "./ProjectService.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; @@ -55,6 +56,7 @@ it.effect("resolves setup scripts through the standalone project service", () => getById: () => Effect.succeed(Option.some(project)), }), Layer.mock(TerminalManager.TerminalManager)({ open, write }), + ServerSettings.layerTest(), ), ), ); @@ -77,3 +79,75 @@ it.effect("resolves setup scripts through the standalone project service", () => assert.equal(write.mock.calls[0]?.[0].data, "vp install\r"); }).pipe(Effect.provide(layer)); }); + +it.effect("inherits the machine setup script when the project has no override", () => { + const open = vi.fn((input: Parameters[0]) => + Effect.succeed({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "Shell", + updatedAt: "2026-06-20T00:00:00.000Z", + }), + ); + const write = vi.fn( + (_input: Parameters[0]) => Effect.void, + ); + const projectId = ProjectId.make("project:setup-runner-default"); + const project = { + id: projectId, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T00:00:00.000Z", + deletedAt: null, + }; + const layer = ProjectSetupScriptRunner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(TerminalManager.TerminalManager)({ open, write }), + ServerSettings.layerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "pnpm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), + ), + ), + ); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId, + worktreePath: "/repo-worktree", + }); + assert.deepEqual(result, { + status: "started", + scriptId: "default-setup", + scriptName: "Setup", + terminalId: "setup-default-setup", + cwd: "/repo-worktree", + }); + assert.equal(write.mock.calls[0]?.[0].data, "pnpm install\r"); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index eb997265eb16..44ba0e26951f 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,11 +1,16 @@ import { ProjectId, type ProjectScript } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectService from "./ProjectService.ts"; @@ -32,6 +37,7 @@ export interface ProjectSetupScriptRunnerInput { readonly worktreePath: string; readonly preferredTerminalId?: string; readonly project?: { + readonly id?: ProjectId; readonly workspaceRoot: string; readonly scripts: ReadonlyArray; }; @@ -44,7 +50,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); + const projectId = project.id ?? (input.projectId ? ProjectId.make(input.projectId) : null); + const scripts = + projectId === null + ? project.scripts.length > 0 + ? project.scripts + : settings.defaultProjectScripts + : resolveProjectScripts(settings, { id: projectId, scripts: project.scripts }); + const script = setupProjectScript(scripts); if (!script) { return { status: "no-script", diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index f009255e90a2..88961641b248 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -61,7 +61,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( } = {}, ) { const authenticated = yield* Deferred.make(); - const discovered = yield* Deferred.make(); + const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); const events: string[] = []; let receiveAuthorizationUrl: @@ -224,6 +224,33 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect( + "distinguishes a post-authentication session failure without exposing its payload", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.fail( + harness.discovered, + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Internal error ${callbackUrl}`, + method: "session/new", + }), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.equal( + failed.message, + "Antigravity authenticated, but could not initialize a session or load models.", + ); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* Deferred.await(harness.closed); + }), + ); + it.effect("does not call callback HTTP success a successful Google sign-in", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index c7118bccad83..170e5c32f47a 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -114,6 +114,9 @@ function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): str if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) { return "Google sign-in was not approved. Start sign-in again."; } + if (error.value.method === "session/new" && error.value.code === -32603) { + return "Antigravity authenticated, but could not initialize a session or load models."; + } if (!usesBrowser && error.value.code === -32602) { return "Antigravity rejected the configured credentials. Check the provider settings."; } diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 6b70f95e866a..32af6db871a7 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,4 +1,5 @@ import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -52,7 +53,7 @@ import { } from "../ProviderDriver.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -98,6 +99,7 @@ export const AntigravityDriver: ProviderDriver !enabled ? provider.snapshot.getSnapshot - : discoverAntigravitySkills({ cwd, profileDirectory }).pipe( + : discoverAntigravitySkills({ cwd, userHome }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index bb3381216d72..180ef9968fac 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { @@ -24,20 +24,57 @@ const makeWorkspace = Effect.fn("makeWorkspace")(function* () { }); return { cwd: path.join(temporaryDirectory, "workspace"), - profileDirectory: path.join(temporaryDirectory, "profile"), + userHome: path.join(temporaryDirectory, "home"), }; }); it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { + it.effect("does not read user skills from a nested project or from ~/.agents", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const nested = { ...input, cwd: path.join(input.userHome, "AI", "Projects", "Something") }; + const skillPath = yield* writeSkill( + path.join(input.userHome, ".gemini", "config", "skills", "review"), + "---\nname: review\ndescription: Review changes.\n---\n", + ); + yield* writeSkill( + path.join(input.userHome, ".agents", "skills", "ignored"), + "---\nname: ignored\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(nested), [ + { + name: "review", + description: "Review changes.", + path: skillPath, + scope: "user", + enabled: true, + }, + ]); + // A project rooted at the home directory sees ~/.agents/skills as its own. + assert.deepEqual( + (yield* discoverAntigravitySkills({ ...input, cwd: input.userHome })).map((skill) => [ + skill.name, + skill.scope, + ]), + [ + ["ignored", "project"], + ["review", "user"], + ], + ); + }), + ); + it.effect("reads skill names, descriptions and paths from the current native roots", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); const roots = [ - { directory: path.join(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: path.join(input.userHome, ".gemini", "config", "skills"), scope: "user" }, { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" }, { - directory: path.join(input.profileDirectory, "antigravity-cli", "skills"), + directory: path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), scope: "user", }, { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" }, @@ -91,9 +128,9 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const path = yield* Path.Path; const input = yield* makeWorkspace(); const roots = [ - path.join(input.profileDirectory, "config", "skills"), + path.join(input.userHome, ".gemini", "config", "skills"), path.join(input.cwd, ".gemini", "skills"), - path.join(input.profileDirectory, "antigravity-cli", "skills"), + path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), path.join(input.cwd, ".agents", "skills"), path.join(input.cwd, ".agent", "skills"), ]; @@ -218,7 +255,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const input = yield* makeWorkspace(); const root = path.join(input.cwd, ".agents", "skills"); yield* writeSkill( - path.join(input.profileDirectory, "config", "skills", "review"), + path.join(input.userHome, ".gemini", "config", "skills", "review"), "---\nname: [invalid\n---\n", ); const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"]; @@ -247,7 +284,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const input = yield* makeWorkspace(); - const sourceDirectory = path.join(input.profileDirectory, "shared-review"); + const sourceDirectory = path.join(input.userHome, "shared-review"); yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); const root = path.join(input.cwd, ".agents", "skills"); const linkedDirectory = path.join(root, "review"); @@ -304,3 +341,20 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); }); + +it("resolves the home the agent expands ~ against", () => { + assert.equal( + resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "/home/user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "C:\\Users\\user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }), + "D:\\Users\\alice", + ); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b "); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true); +}); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index a8b206a89279..bb238e4e0fa6 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import type { ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -7,6 +9,45 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { parse as parseYamlDocument } from "yaml"; +/** + * The home directory the agent expands `~` against, matching Python's + * `os.path.expanduser` in the launch environment T3 hands the process: + * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` + * elsewhere. Values are used verbatim; a path may contain spaces. + */ +export function resolveAntigravityUserHome( + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): string { + if (platform === "win32") { + if (environment.USERPROFILE) return environment.USERPROFILE; + if (environment.HOMEDRIVE && environment.HOMEPATH) { + return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; + } + return NodeOS.homedir(); + } + return environment.HOME || NodeOS.homedir(); +} + +/** + * The agent's two user-global skill directories under a Gemini home, in + * native precedence order: `config/skills` is shared with the Antigravity IDE + * and CLI, and `antigravity-cli/skills` is where the `agy` CLI installs + * skills. The agent resolves both under `GEMINI_HOME`, which T3 points at a + * private profile, so the profile links these back to the user's `~/.gemini`. + * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a + * project directory. + */ +export function antigravityUserSkillDirectories( + path: Path.Path, + geminiHome: string, +): readonly [configSkills: string, cliSkills: string] { + return [ + path.join(geminiHome, "config", "skills"), + path.join(geminiHome, "antigravity-cli", "skills"), + ]; +} + const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; @@ -118,7 +159,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( */ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { readonly cwd: string; - readonly profileDirectory: string; + readonly userHome: string; }): Effect.fn.Return< ReadonlyArray, AntigravitySkillsProbeError, @@ -126,13 +167,14 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + path, + path.join(input.userHome, ".gemini"), + ); const roots = [ - { directory: path.resolve(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: configSkills, scope: "user" }, { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, - { - directory: path.resolve(input.profileDirectory, "antigravity-cli", "skills"), - scope: "user", - }, + { directory: cliSkills, scope: "user" }, { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, ]; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index d36d47a81b4e..704abbcfdb8e 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -26,7 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { createClaudeAdapterV2, type ClaudeAdapterV2DriverEnv, @@ -39,6 +39,7 @@ import { makePendingClaudeProvider, probeClaudeCapabilities, } from "../Layers/ClaudeProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; @@ -122,7 +123,11 @@ export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, instanceId, }); - const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath), + } satisfies ClaudeSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 59c7bffc9cae..4034eacedde5 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -8,7 +8,10 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -17,6 +20,11 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; +import { + createProviderVersionAdvisory, + ProviderVersionCache, + resolveLatestProviderVersion, +} from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; import { CodexAppServerClientFactory } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { layer as idAllocatorLayer } from "../../orchestration-v2/IdAllocator.ts"; @@ -110,4 +118,263 @@ it.layer(testLayer)("CodexDriver", (it) => { expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), ); + + for (const fixture of [ + { + name: "leaves mise npm-backend installations manual-only", + installSegments: ["mise", "installs", "npm-openai-codex", "0.110.0"], + npmOwned: false, + }, + { + name: "leaves mise tool aliases backed by npm manual-only", + installSegments: ["mise", "installs", "codex", "0.110.0"], + npmOwned: false, + }, + { + name: "keeps npm updates for globals in a mise Node installation", + installSegments: ["mise", "installs", "node", "24.0.0"], + npmOwned: true, + }, + { + name: "keeps npm updates for ordinary global installations", + installSegments: ["npm-global"], + npmOwned: true, + }, + ] as const) { + it.effect.skipIf(windowsHost)(fixture.name, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }); + const installPath = NodePath.join(tempDir, ...fixture.installSegments); + const realBinaryPath = NodePath.join( + installPath, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + const binaryPath = NodePath.join(tempDir, "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + yield* fs.symlink(realBinaryPath, binaryPath); + + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-installer"), + displayName: "Codex installer test", + enabled: false, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + binaryPath, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }); + + const update = (yield* instance.snapshot.resolveMaintenance()).update; + if (fixture.npmOwned) { + expect(update).toMatchObject({ + executable: "npm", + args: [ + "install", + "-g", + "--prefix", + installPath, + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + }); + } else { + expect(update).toBeNull(); + } + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + } + + for (const layout of ["direct", "wrapper"] as const) { + it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }); + const binaryPath = + layout === "direct" + ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") + : NodePath.join(tempDir, "omarchy", "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString( + binaryPath, + layout === "direct" + ? "#!/bin/sh\n" + : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', + ); + yield* fs.chmod(binaryPath, 0o755); + + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), + displayName: "Codex mise test", + enabled: false, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + binaryPath, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }); + + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + } + + it.effect.each([ + { + name: "conventional shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "custom data directory", + dataRoot: "custom-tool-data", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "renamed configured command", + dataRoot: "mise", + commandName: "custom-codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "outdated provider", + dataRoot: "mise", + commandName: "codex", + version: "0.153.3", + nodeFirst: false, + }, + { + name: "npm before shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: true, + }, + ])( + "does not mistake Homebrew mise for Codex's installer: $name", + (fixture) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const brewPrefix = NodePath.join(tempDir, "homebrew"); + const brewPath = NodePath.join(brewPrefix, "bin", "brew"); + const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); + const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); + const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); + const npmBin = NodePath.join(npmPrefix, "bin"); + const npmEntry = NodePath.join( + npmPrefix, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + for (const file of [brewPath, misePath, npmEntry]) { + yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "#!/bin/sh\n"); + yield* fs.chmod(file, 0o755); + } + yield* fs.makeDirectory(shimDir, { recursive: true }); + yield* fs.makeDirectory(npmBin, { recursive: true }); + yield* fs.symlink(misePath, NodePath.join(shimDir, fixture.commandName)); + yield* fs.symlink(npmEntry, NodePath.join(npmBin, fixture.commandName)); + const lookupPath = [ + ...(fixture.nodeFirst ? [npmBin, shimDir] : [shimDir, npmBin]), + NodePath.dirname(brewPath), + ].join(NodePath.delimiter); + const probes: Array> = []; + const metadataSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + return Effect.die("Provider resolution must not execute a provider or updater"); + } + probes.push(command.args); + const stdout = + command.args[0] === "--prefix" + ? brewPrefix + : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-mise-shim"), + displayName: "Codex shim test", + enabled: false, + environment: [{ name: "PATH", value: lookupPath, sensitive: false }], + config: { + ...CodexDriver.defaultConfig(), + binaryPath: fixture.commandName, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); + const capabilities = yield* instance.snapshot.resolveMaintenance(); + const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + ["@openai/codex", { expiresAt: Number.MAX_SAFE_INTEGER, version: "0.153.4" }], + ]), + ), + ); + expect(probes).toEqual([]); + expect(latestVersion).toBe("0.153.4"); + expect( + createProviderVersionAdvisory({ + driver: CodexDriver.driverKind, + currentVersion: fixture.version, + latestVersion, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ + status: fixture.version === "0.153.4" ? "current" : "behind_latest", + currentVersion: fixture.version, + latestVersion: "0.153.4", + canUpdate: fixture.nodeFirst, + }); + if (fixture.nodeFirst) { + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + }); + } else { + expect(capabilities.update).toBeNull(); + } + }).pipe(Effect.scoped), + { skip: windowsHost }, + ); }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 6058aafcb6d0..c9e7c676039a 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -33,7 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { createCodexAdapterV2, type CodexAdapterV2DriverEnv, @@ -50,6 +50,7 @@ import { probeCodexSkillsForCwd, withCodexAppServerClient, } from "../Layers/CodexProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; @@ -160,6 +161,7 @@ export const CodexDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, + binaryPath: expandHomePath(config.binaryPath), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 30383d93a874..6a82c98b6d84 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -25,6 +25,7 @@ import { ProviderDriverError } from "../Errors.ts"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus, + makeCursorModelDiscovery, } from "../Layers/CursorProvider.ts"; import { CursorSdkCatalogLive } from "../Layers/CursorSdkCatalog.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -105,10 +106,14 @@ export const CursorDriver: ProviderDriver = { ); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( - Effect.map(stampIdentity), + const discoverModels = yield* makeCursorModelDiscovery().pipe( Effect.provide(CursorSdkCatalogLive), ); + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + processEnv, + discoverModels, + ).pipe(Effect.map(stampIdentity), Effect.provide(CursorSdkCatalogLive)); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 13415bc35de3..ce8a31b51985 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,141 +1,172 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); -describe("parseGrokInspectSkills", () => { - it("maps inspect entries onto provider skills, sorted by name", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { - name: "writing-docs", - description: "Write user docs.", - source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, - userInvocable: true, - }, +const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array) => + ChildProcessSpawner.make((command) => { + spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("discoverGrokSkills", () => { + it.effect("maps inspect entries onto provider skills, sorted by name", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + + expect(skills).toEqual([ { name: "deploy", description: "Deploy the app.", - source: { - type: "plugin", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - }, - userInvocable: true, + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, }, - ]), - ); + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ), + ), + ), + ); - expect(skills).toEqual([ - { - name: "deploy", - description: "Deploy the app.", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - scope: "plugin", - enabled: true, - }, - { - name: "writing-docs", - description: "Write user docs.", - path: "/home/dev/.grok/skills/writing-docs/SKILL.md", - scope: "user", - enabled: true, - }, - ]); - }); + it.effect("disables skills the CLI marks as not user-invocable", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); - it("disables skills the CLI marks as not user-invocable", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ + expect(skills).toEqual([ { name: "internal-helper", - source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, - userInvocable: false, + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, }, - ]), - ); - - expect(skills).toEqual([ - { - name: "internal-helper", - path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", - scope: "bundled", - enabled: false, - }, - ]); - }); - - it("skips entries without a name or a filesystem path", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, - { name: "no-path", source: { type: "user" } }, - { name: "no-source" }, - "not-an-object", - { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, - ]), - ); + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "internal-helper", + source: { + type: "bundled", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + }, + userInvocable: false, + }, + ]), + ), + ), + ), + ); - expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - }); + it.effect("skips entries without a name or a filesystem path", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ), + ), + ), + ); - it("returns an empty list for malformed or unexpected output", () => { - expect(parseGrokInspectSkills("not json")).toEqual([]); - expect(parseGrokInspectSkills("null")).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); - }); -}); + it.effect("rejects malformed or unexpected output as a decode failure", () => + Effect.gen(function* () { + for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) { + const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe( + Effect.flip, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout), + ), + ); + expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" }); + } + }), + ); -describe("discoverGrokSkills", () => { it.effect("spawns in the configured cwd and rejects a failed probe", () => { const spawnCwds: Array = []; - let exitCode = 0; - const spawner = ChildProcessSpawner.make((command) => { - spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.make( - inspectPayload([ - { - name: "kept", - source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, - }, - ]), - ), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); + const stdout = inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]); return Effect.gen(function* () { const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 0, spawnCwds), + ), ); expect(spawnCwds).toEqual(["/workspaces/demo"]); expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - exitCode = 1; const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( Effect.result, - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 1), + ), ); expect(failed._tag).toBe("Failure"); }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index b7962205d346..53a20049ad5d 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -91,10 +91,6 @@ function decodeGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } -export function parseGrokInspectSkills(stdout: string): ReadonlyArray { - return decodeGrokInspectSkills(stdout) ?? []; -} - /** * Run `grok inspect --json` and map the reported catalog onto provider * skills. Callers that need best-effort discovery can recover this effect to diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 363afbee1106..34fd81a424ca 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -264,6 +264,23 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { ), ); + it.effect("publishes the configured sign-in method before any account is checked", () => + Effect.scoped( + Effect.gen(function* () { + const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Effect.succeed(initializeResult), + supportsTextGeneration: Effect.succeed(true), + auth: { type: "gemini-api-key", label: "Gemini API key" }, + }); + expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ + status: "unknown", + type: "gemini-api-key", + }); + }), + ), + ); + it.effect("treats initialize as installation proof, not account or model discovery", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index bd886a123808..956a5d81d3f4 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -144,7 +144,9 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func installed: false, version: null, status: "warning", - auth: { status: "unknown" }, + // The configured method rides along so the registry can tell a saved + // account for this method from one left by a previous configuration. + auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, message: settings.enabled ? "Checking Antigravity availability." : "Antigravity is disabled in T3 Code settings.", diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index fd4d66dd497b..0136c3fbf170 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,11 +9,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { - buildCodexDeveloperInstructions, - codexDefaultModeDeveloperInstructions, - codexPlanModeDeveloperInstructions, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -459,7 +455,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "high", }); - NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Collaboration Mode: Default/); NodeAssert.match(instructions, /T3 Code/); NodeAssert.match(instructions, /Codex harness/); NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); @@ -484,7 +480,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "medium", }); - NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Plan Mode/); NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); }); @@ -513,11 +509,11 @@ describe("buildCodexDeveloperInstructions", () => { }); describe("T3 browser developer instructions", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + it("prefers the product-native preview tools in both collaboration modes", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(true), - codexPlanModeDeveloperInstructions(true), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, true); NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); @@ -526,10 +522,8 @@ describe("T3 browser developer instructions", () => { }); it("omits the browser block entirely when the preview tools are not attached", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(false), - codexPlanModeDeveloperInstructions(false), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, false); NodeAssert.doesNotMatch(instructions, /preview_status/); NodeAssert.doesNotMatch(instructions, /preview_open/); NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); @@ -543,7 +537,6 @@ describe("T3 browser developer instructions", () => { }); it("tracks the turn's MCP configuration rather than defaulting to on", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); NodeAssert.doesNotMatch( buildCodexDeveloperInstructions("default", runtime, false), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 2192c0696ef0..b415761a5bd0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -12,7 +12,7 @@ import { buildCursorProviderSnapshot, buildInitialCursorProviderSnapshot, checkCursorProviderStatus, - getCursorFallbackModels, + makeCursorModelDiscovery, } from "./CursorProvider.ts"; import { CursorSdkCatalogError, makeCursorSdkCatalogTestLayer } from "./CursorSdkCatalog.ts"; @@ -93,16 +93,6 @@ const sdkParameterizedModel = { ], } satisfies SDKModel; -describe("getCursorFallbackModels", () => { - it("does not publish any built-in cursor models before SDK discovery", () => { - expect( - getCursorFallbackModels({ - customModels: ["internal/cursor-model"], - }).map((model) => model.slug), - ).toEqual(["internal/cursor-model"]); - }); -}); - describe("buildInitialCursorProviderSnapshot", () => { it.effect("uses SDK-specific pending status copy", () => Effect.gen(function* () { @@ -138,6 +128,34 @@ describe("buildCursorProviderSnapshot", () => { }); describe("Cursor SDK model discovery", () => { + it.effect("reuses successful discovery until the API key changes", () => + Effect.gen(function* () { + let requests = 0; + const discover = yield* makeCursorModelDiscovery().pipe( + Effect.provide( + makeCursorSdkCatalogTestLayer(() => { + requests += 1; + return Effect.succeed({ + user: { + apiKeyName: "test-key", + userEmail: "cursor@example.com", + createdAt: "2026-01-01T00:00:00.000Z", + }, + models: [sdkParameterizedModel], + }); + }), + ), + ); + + const first = yield* discover("first-key"); + expect(yield* discover("first-key")).toEqual(first); + expect(requests).toBe(1); + + yield* discover("second-key"); + expect(requests).toBe(2); + }), + ); + it("maps native SDK parameter ids and default variant values to model capabilities", () => { expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( createModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 3e9631c83eab..165ce88c5553 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -8,8 +8,11 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; @@ -21,7 +24,7 @@ import { providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { CursorSdkCatalog } from "./CursorSdkCatalog.ts"; +import { CursorSdkCatalog, type CursorSdkCatalogShape } from "./CursorSdkCatalog.ts"; const CURSOR_PRESENTATION = { displayName: "Cursor", @@ -72,7 +75,7 @@ export function buildInitialCursorProviderSnapshot( }); } -export function getCursorFallbackModels( +function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); @@ -240,9 +243,21 @@ export function buildCursorProviderSnapshot(input: { }); } +// Each driver instance owns its cache; API-key changes invalidate it. +export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(function* () { + const sdkCatalog = yield* CursorSdkCatalog; + const cache = yield* Cache.makeWith((apiKey: string) => sdkCatalog.read(apiKey), { + capacity: 1, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.models.length > 0 ? Duration.minutes(30) : Duration.zero, + }); + return (apiKey: string) => Cache.get(cache, apiKey); +}); + export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + discoverModels?: CursorSdkCatalogShape["read"], ): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = getCursorFallbackModels(cursorSettings); @@ -280,10 +295,11 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } - const sdkCatalog = yield* CursorSdkCatalog; - const catalogResult = yield* sdkCatalog - .read(sdkApiKey) - .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); + const readCatalog = discoverModels ?? (yield* CursorSdkCatalog).read; + const catalogResult = yield* readCatalog(sdkApiKey).pipe( + Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), + Effect.result, + ); if (Result.isFailure(catalogResult)) { yield* Effect.logWarning("Cursor SDK catalog probe failed", { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 0d2feeb93524..b5d476fa2ef7 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -25,7 +25,6 @@ import * as CodexResetCredit from "./codexResetCredit.ts"; */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Path from "effect/Path"; import { type ClaudeSettings, type CodexSettings, @@ -36,9 +35,12 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -46,8 +48,9 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; +import { ClaudeDriver, type ClaudeDriverEnv } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; @@ -138,6 +141,80 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeTildeProviderFixtures = Effect.fn( + "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", +)(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = expandHomePath("~"); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: homePath, + prefix: ".t3-provider-path-test-", + }); + const codexPath = path.join(fixtureDir, "codex"); + const claudePath = path.join(fixtureDir, "claude"); + const claudeHomePath = path.join(fixtureDir, "claude-home"); + const codexScriptPath = path.join(fixtureDir, "codex-script.json"); + const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); + + yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), + path.join(fixtureDir, "codexCollabMockPeer.mjs"), + ); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexMultiAgentWire.json"), + path.join(fixtureDir, "codexMultiAgentWire.json"), + ); + yield* fileSystem.writeFileString( + codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), + ); + yield* fileSystem.chmod(codexPath, 0o755); + + yield* fileSystem.writeFileString( + claudePath, + [ + "#!/usr/bin/env node", + 'import * as NodeReadline from "node:readline";', + 'if (process.argv.includes("--version")) {', + ' process.stdout.write("claude 2.1.219\\n");', + " process.exit(0);", + "}", + "const lines = NodeReadline.createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [], agents: [], models: [],", + ' output_style: "default", available_output_styles: ["default"],', + ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(claudePath, 0o755); + yield* fileSystem.makeDirectory(claudeHomePath); + + const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + return { + codexBinaryPath: asTildePath(codexPath), + claudeBinaryPath: asTildePath(claudePath), + claudeHomePath, + codexScriptPath, + }; +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -264,6 +341,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + + const fixtures = yield* makeTildeProviderFixtures(); + + const codexId = ProviderInstanceId.make("codex_tilde"); + const claudeId = ProviderInstanceId.make("claude_tilde"); + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "T3_CODEX_COLLAB_SCRIPT", + value: fixtures.codexScriptPath, + sensitive: false, + }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + [claudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver], + configMap, + }); + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + expect(codex).toBeDefined(); + expect(claude).toBeDefined(); + + const [codexSnapshot, claudeSnapshot] = yield* Effect.all( + [codex!.snapshot.refresh, claude!.snapshot.refresh], + { concurrency: "unbounded" }, + ); + expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + expect(claudeSnapshot).toMatchObject({ + status: "ready", + installed: true, + version: "2.1.219", + }); + }).pipe(Effect.provide(testLayer)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1b419e1d1dac..7020b5c9b000 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -43,7 +43,6 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { - haveProvidersChanged, mergeProviderSnapshot, mergeProviderSnapshots, upsertProviderWorkspaceSnapshot, @@ -557,39 +556,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); - it("stores workspace skills and commands without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), @@ -1215,6 +1181,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); }); + describe("Antigravity saved account", () => { + const signedIn = { + instanceId: ProviderInstanceId.make("antigravity-personal"), + driver: ProviderDriverKind.make("antigravity"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "oauth-personal", label: "Google account" }, + checkedAt: "2026-09-05T00:00:00.000Z", + version: "agy_acp_server_1.1.1", + models: [ + { + slug: "gemini-3.7-flash-high", + name: "Gemini 3.7 Flash", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "plan" }], + skills: [], + } as const satisfies ServerProvider; + const uncheckedMessage = + "Antigravity is installed. Google account access is not checked yet."; + const restartProbe = { + ...signedIn, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-09-05T00:01:00.000Z", + message: uncheckedMessage, + models: [], + } as const satisfies ServerProvider; + + it("keeps the saved Google account through restart health checks", () => { + const merged = mergeProviderSnapshot(signedIn, restartProbe); + const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe; + assert.deepStrictEqual(merged, { + ...probeWithoutMessage, + status: "ready", + auth: signedIn.auth, + models: signedIn.models, + }); + assert.equal("message" in merged, false); + // The next periodic probe reads the merged snapshot as its previous state. + assert.deepStrictEqual(mergeProviderSnapshot(merged, restartProbe), merged); + }); + + it("carries the account through the boot probe and a failed probe without hiding them", () => { + const booting = { + ...restartProbe, + installed: false, + version: null, + message: "Checking Antigravity availability.", + } satisfies ServerProvider; + assert.deepStrictEqual(mergeProviderSnapshot(signedIn, booting), { + ...booting, + auth: signedIn.auth, + models: signedIn.models, + }); + + const failed = { + ...restartProbe, + status: "error", + message: "Antigravity did not respond to its local health check within 90 seconds.", + } satisfies ServerProvider; + assert.deepStrictEqual(mergeProviderSnapshot(signedIn, failed), { + ...failed, + auth: signedIn.auth, + models: signedIn.models, + }); + }); + + it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { + const untouched = [ + { ...restartProbe, auth: { status: "unauthenticated" } }, + { ...restartProbe, status: "disabled", enabled: false }, + { ...restartProbe, status: "error", installed: false }, + { ...restartProbe, driver: ProviderDriverKind.make("codex") }, + // The instance was rebuilt with another sign-in method. + { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } }, + ] satisfies ReadonlyArray; + for (const next of untouched) { + const merged = mergeProviderSnapshot(signedIn, next); + assert.deepStrictEqual(merged.auth, next.auth); + assert.equal(merged.status, next.status); + assert.equal(merged.message, next.message); + } + assert.deepStrictEqual( + mergeProviderSnapshot({ ...signedIn, auth: { status: "unknown" } }, restartProbe).auth, + { status: "unknown" }, + ); + assert.equal( + mergeProviderSnapshot( + { ...signedIn, driver: ProviderDriverKind.make("codex") }, + restartProbe, + ).auth.status, + "unknown", + ); + assert.deepStrictEqual( + mergeProviderSnapshot(signedIn, { + ...restartProbe, + auth: { status: "unknown", type: "oauth-personal" }, + }).auth, + signedIn.auth, + ); + }); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 073cbfbad2e7..a15d93e2c9d6 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -162,31 +162,69 @@ const mergeProviderModels = ( : mergedModels; }; +/** + * Antigravity's health check only initializes the agent, so after a server + * restart it reports the account as unchecked. The saved Google login still + * works, and the previous snapshot proves it. Carry that account state until + * a session, refresh, or sign-out reports something new. A confirmed missing + * installation, sign-out, disabled instance, or a changed sign-in method is + * never overridden. + */ +const carrySavedAntigravityAccount = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): Pick | undefined => { + const antigravity = ProviderDriverKind.make("antigravity"); + if ( + nextProvider.driver !== antigravity || + previousProvider.driver !== antigravity || + !nextProvider.enabled || + nextProvider.auth.status !== "unknown" || + previousProvider.auth.status !== "authenticated" || + (nextProvider.auth.type !== undefined && + nextProvider.auth.type !== previousProvider.auth.type) || + (!nextProvider.installed && nextProvider.status !== "warning") + ) { + return undefined; + } + // The pending boot probe (`installed: false`, warning) and a failed probe + // keep their own status; only a passed health check reads as ready. + const status = + nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status; + return { auth: previousProvider.auth, status }; +}; + export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, -): ServerProvider => - !previousProvider - ? nextProvider - : { - ...nextProvider, - models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), - ...(nextProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: nextProvider.workspaceSnapshots } - : previousProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: previousProvider.workspaceSnapshots } - : {}), - ...(shouldRetainMissingOpenCodeMetadata(nextProvider) - ? { - slashCommands: - nextProvider.slashCommands.length === 0 - ? previousProvider.slashCommands - : nextProvider.slashCommands, - skills: - nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, - } - : {}), - }; +): ServerProvider => { + if (!previousProvider) { + return nextProvider; + } + const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); + // "Google account access is not checked yet" describes the probe, not the + // account; it must not outlive the state it explained. + const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; + return { + ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), + ...savedAccount, + models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(nextProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + : previousProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : {}), + ...(shouldRetainMissingOpenCodeMetadata(nextProvider) + ? { + slashCommands: + nextProvider.slashCommands.length === 0 + ? previousProvider.slashCommands + : nextProvider.slashCommands, + skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, + } + : {}), + }; +}; export const mergeProviderSnapshots = ( previousProviders: ReadonlyArray, @@ -212,7 +250,7 @@ export const selectProvidersByKind = ( ): ReadonlyArray => providers.filter((provider) => providerKinds.has(provider.driver)); -export const haveProvidersChanged = ( +const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 73049ad01c30..bb592a0a6fc8 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -17,7 +17,6 @@ import { make, resolveProviderCatalog, type ModelManifestData, - manifestUpdatedAtMs, encodeManifestCache, } from "./ModelManifest.ts"; @@ -382,7 +381,6 @@ describe("ModelManifest service", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; - assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0); const cachePath = path.join(config.stateDir, "model-manifest.json"); // A cache of the manifest as it was before the release edited it. The // fetch time is irrelevant: the remote may be unreachable now, so diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index c3cb36566c02..67a7334f613d 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -138,7 +138,7 @@ export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); /** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ -export function manifestUpdatedAtMs(manifest: ModelManifestData): number { +function manifestUpdatedAtMs(manifest: ModelManifestData): number { if (manifest.updatedAt === undefined) return 0; const parsed = Date.parse(manifest.updatedAt); return Number.isNaN(parsed) ? 0 : parsed; diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f2837d..7d6bbe61a2aa 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it.effect.each([ + { value: "~/.account", tail: ".account" }, + { value: "~\\.account\\work", tail: ".account\\work" }, + ])("expands configured provider homes set to $value", ({ value, tail }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnv = { + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }; + const environment = mergeProviderInstanceEnvironment( + [ + { name: "CODEX_HOME", value, sensitive: false }, + { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, + { name: "CUSTOM_VALUE", value, sensitive: false }, + ], + baseEnv, + ); + + expect(environment).toEqual({ + CODEX_HOME: path.join(NodeOS.homedir(), tail), + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), + CUSTOM_VALUE: value, + }); + expect(baseEnv).toEqual({ + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("leaves inherited provider homes unchanged", () => { + const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; + + expect( + mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], + baseEnv, + ), + ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); + }); + it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e6..77c0c6c2dc88 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,7 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { expandHomePath } from "../pathExpansion.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { - next[variable.name] = variable.value; + // Child processes do not apply shell expansion to environment values. + next[variable.name] = + variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" + ? expandHomePath(variable.value) + : variable.value; } return next; } diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 350d8c73c150..320aa332c937 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -2,17 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { - it.each(["Codex", "Claude Code", "Cursor", "Grok", "OpenCode", "Antigravity"])( - "identifies the %s harness and describes media embedding", - (harness) => { - const instructions = buildRuntimeInstructions({ harness }); - expect(instructions).toContain(`running in T3 Code through the ${harness} harness.`); - expect(instructions).toContain("embed images and videos"); - expect(instructions).toContain("Markdown with absolute file paths"); - expect(instructions).not.toContain("undefined"); - }, - ); - it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 03189018bd31..7f065caa4f6a 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -15,6 +15,7 @@ import * as Ndjson from "effect/unstable/encoding/Ndjson"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ANTIGRAVITY_AUTH_BROWSER_MARKER, @@ -510,6 +511,42 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "links the user's global skill directories into the profile without touching real content", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const userHome = path.join(temporaryDirectory, "home"); + const profileDirectory = path.join(temporaryDirectory, "profile"); + const configSkills = path.join(userHome, ".gemini", "config", "skills"); + const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); + yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); + + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + const configLink = path.join(profileDirectory, "config", "skills"); + const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); + expect(yield* fs.readLink(configLink)).toBe(configSkills); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); + // Only the skill directories are shared; the rest of the profile stays private. + expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( + false, + ); + + // A stale link is repointed; a real directory the user placed there is kept. + yield* fs.remove(cliLink); + yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); + yield* fs.remove(configLink); + yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); + expect((yield* fs.stat(configLink)).type).toBe("Directory"); + }), + ); + it.effect("rewrites the GCP block on every launch and never stores the API key", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index dd55df5973ba..8e0040ae0c3e 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -1,4 +1,6 @@ import * as NodeCrypto from "node:crypto"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. +import * as NodeFSP from "node:fs/promises"; // @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. import * as NodePath from "node:path"; @@ -16,6 +18,10 @@ import * as AcpErrors from "effect-acp/errors"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; +import { + antigravityUserSkillDirectories, + resolveAntigravityUserHome, +} from "./Drivers/AntigravitySkills.ts"; export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = "Open the following link to authenticate the ACP server: "; @@ -219,6 +225,56 @@ function antigravityEnvironment( }; } +/** + * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points + * at the private profile. Link the two skill directories back to the user's + * real `~/.gemini` so global skills load, while MCP servers, hooks, and + * credentials stay isolated. Best effort: a link that cannot be made only + * costs global skills, never the session. A real directory at the link path + * is the user's own content and is left alone. + */ +const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { + readonly profileDirectory: string; + readonly userHome: string; + readonly platform: NodeJS.Platform; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const links = antigravityUserSkillDirectories(path, input.profileDirectory); + const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini")); + for (const [link, target] of [ + [links[0], targets[0]], + [links[1], targets[1]], + ] as const) { + yield* Effect.gen(function* () { + const existing = yield* fs.readLink(link).pipe( + Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (existing === target) return; + if (existing !== undefined) { + yield* fs.remove(link); + } + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* Effect.tryPromise(() => + NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"), + ); + }).pipe( + // A non-symlink at the link path fails `readLink`; anything else is a + // filesystem refusal. Both leave the profile usable. + Effect.catch((error) => + Effect.logWarning("Antigravity user skills are not linked into the profile.", { + link, + target, + error, + }), + ), + ); + } +}); + /** Prepares a private profile without reading or copying Google credentials. */ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { readonly profileDirectory: string; @@ -226,12 +282,16 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( readonly runtimeExecutablePath?: string; readonly platform?: NodeJS.Platform; readonly auth?: AntigravityAuthConfig; + /** Home the agent expands `~` against. Defaults to the launch environment's. */ + readonly userHome?: string; }) { const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = input.platform ?? (yield* HostProcessPlatform); + const userHome = + input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); const helperExecutable = platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; @@ -326,6 +386,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( authSupportError("The Antigravity profile settings could not be written."), ), ); + yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); return profile; }); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 062dbaa9de4b..642b8695b4e7 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -595,25 +595,29 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect.skipIf(!symlinksSupported)( - "upgrades the Homebrew cask that owns the binary and compares against its version", - () => + it.effect.each([ + { directory: "Caskroom", name: "package-tool", kind: "cask" }, + { directory: "Cellar", name: "package-tool", kind: "formula" }, + { directory: "Cellar", name: "package-tool@latest", kind: "formula" }, + ] as const)( + "upgrades the owning Homebrew $kind $name through an executable alias", + (fixture) => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-homebrew-capabilities"); const brewBinDir = NodePath.join(tempDir, "brew-bin"); const brewPath = NodePath.join(brewBinDir, "brew"); writeExecutable(brewPath); - const caskBinary = NodePath.join( + const ownedBinary = NodePath.join( tempDir, - "Caskroom", - "package-tool", + fixture.directory, + fixture.name, "0.148.0", - "package-tool", + "package-tool-0.148.0", ); - writeExecutable(caskBinary); - const link = NodePath.join(tempDir, "bin", "package-tool"); + writeExecutable(ownedBinary); + const link = NodePath.join(tempDir, "bin", "custom-package-tool"); NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); - NodeFS.symlinkSync(caskBinary, link); + NodeFS.symlinkSync(ownedBinary, link); const spawned: Array> = []; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( @@ -630,27 +634,38 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { spawned.push([command, ...args]); return args[0] === "--prefix" ? `${tempDir}\n` - : JSON.stringify({ casks: [{ version: "0.148.0,42" }] }); + : JSON.stringify( + fixture.kind === "cask" + ? { casks: [{ version: "0.148.0,42" }] } + : { formulae: [{ versions: { stable: "0.148.0" } }] }, + ); }), ), ); expect(spawned).toEqual([ [brewPath, "--prefix"], - [brewPath, "info", "--json=v2", "package-tool"], + [brewPath, "info", "--json=v2", fixture.name], ]); expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", latestVersion: "0.148.0", update: { - command: "brew upgrade --cask package-tool", + command: + fixture.kind === "cask" + ? `brew upgrade --cask ${fixture.name}` + : `brew upgrade ${fixture.name}`, executable: brewPath, - args: ["upgrade", "--cask", "package-tool"], + args: + fixture.kind === "cask" + ? ["upgrade", "--cask", fixture.name] + : ["upgrade", fixture.name], lockKey: "homebrew", }, }); }), + { skip: !symlinksSupported }, ); it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index a09c94b61f60..e8ff090a4ec9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -240,6 +240,12 @@ export function npmGlobalPrefixFromCommandPath( if (packageIndex < 0 || normalized.slice(0, packageIndex).includes("/node_modules/")) { return null; } + // Mise's npm backend uses a global-looking layout inside a tool version. + // Globals under its Node installation still belong to npm. + const miseTool = /\/mise\/installs\/([^/]+)\/[^/]+$/.exec(normalized.slice(0, packageIndex))?.[1]; + if (miseTool && miseTool !== "node") { + return null; + } return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex); } @@ -426,6 +432,10 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { + // Mise shims resolve to the version manager, not the provider. + if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { + return manual; + } const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), ); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 4e6d1b261947..fa567d75cf8a 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,6 +57,14 @@ rl.on("line", (line) => { }); return; } + if (method === "account/read") { + write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + return; + } + if (method === "skills/list" || method === "model/list") { + write({ id, result: { data: [] } }); + return; + } if (method === "thread/start") { write({ id, result: fixture.responses.threadStart }); return; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted file mode 100644 index 51d8f74bbc45..000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { AZURE_DEVOPS_VIEWER_PERMISSIONS } from "./AzureDevOpsPullRequestProvider.ts"; - -describe("azure devops viewer permissions", () => { - it("offers every action to whoever is signed in, because Azure names no permission", () => { - // The same answer for a viewer who can write, one who can only read, and an author with read - // access: `az repos pr show` and `az repos pr list` carry nothing about the caller's standing, - // and an unknown permission is granted rather than guessed away. Azure refuses the ones it - // will not allow, at the moment they are taken, in words this could not have written. - expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], - // False because the host itself cannot post one, not because this viewer may not. - comment: false, - resolve: false, - verdicts: [], - // True because `az repos pr reviewer` does take one, and Azure says nothing about who may. - requestReviewers: true, - }); - }); -}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 8461e57d5685..ae586ee0a61c 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -56,7 +56,7 @@ const CAPABILITIES: PullRequestCapabilities = { * they try. That is the safer half of an unknown: hiding a control from someone entitled to it * leaves them no way through and no reason given. */ -export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { +const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { actions: CAPABILITIES.actions, comment: CAPABILITIES.comment, resolve: CAPABILITIES.review.resolve, @@ -90,6 +90,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq additions: 0, deletions: 0, createdAt: pullRequest.createdAt, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, // Azure keeps labels on work items rather than on the pull request. diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 47d41eeee6d9..3b5b93d11c46 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -175,8 +175,8 @@ export const make = Effect.gen(function* () { deletions: diffStat.deletions, changedFiles: diffStat.changedFiles, body: pullRequest.body, - mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, + mergedAt: null, + closedAt: null, reviewers: pullRequest.reviewers, checks, // Bitbucket publishes no per-repository list of allowed strategies, so the ones it diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 1e6ca0ed43a6..f61b7c3233f6 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -192,7 +192,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", baseRefName: "main", headRefName: "feat/summary", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }), ); @@ -211,7 +213,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", headBranch: "feat/summary", baseBranch: "main", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }); expect(mockedGetPullRequest).toHaveBeenCalledOnce(); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5d5c2062c08c..89a50f93ced7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -469,6 +469,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly baseBranch: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; }, GitHubPullRequestCliError @@ -1652,6 +1654,8 @@ export const make = Effect.gen(function* () { baseBranch: summary.baseRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 22028ced5ddf..5f1aba8ba9b6 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -78,6 +78,8 @@ export interface ProviderChangeRequest { readonly additions: number; readonly deletions: number; readonly createdAt: string; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; /** Accounts with a review requested. Team-level requests are excluded by each provider. */ readonly reviewRequestLogins: ReadonlyArray; @@ -98,6 +100,8 @@ export interface ProviderChangeRequestSummary { readonly state: PullRequestState; /** Present when the host says an open pull request is still a draft. */ readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a8d3dc2fdeaf..2229a4f652c0 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1257,6 +1257,8 @@ export const make = Effect.gen(function* () { ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, })), ); @@ -2316,6 +2318,8 @@ export const make = Effect.gen(function* () { ...(detail.isDraft === true ? { isDraft: true } : {}), headBranch: detail.headBranch, baseBranch: detail.baseBranch, + closedAt: detail.closedAt, + mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d19d819e595c..e8f4b7aa113b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -46,12 +46,19 @@ function shell(overrides: Partial = {}): Orchestrati projectId: PROJECT_ID, title: "Thread", providerInstanceId: ProviderInstanceId.make("codex"), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "test-model", + }, runtimeMode: "full-access", interactionMode: "default", worktreePath: null, activeProviderThreadId: null, - lineage: { rootThreadId: THREAD_ID, parentThreadId: null, relationshipToParent: null }, + lineage: { + rootThreadId: THREAD_ID, + parentThreadId: null, + relationshipToParent: null, + }, forkedFrom: null, createdBy: "user", creationSource: "web", @@ -85,7 +92,9 @@ function shell(overrides: Partial = {}): Orchestrati }; } -const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) }); +const PublishPayload = Schema.Struct({ + state: Schema.NullOr(RelayAgentActivityState), +}); const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload)); const unused = () => Effect.die("Unexpected test dependency call"); @@ -108,7 +117,10 @@ const makeTestRelay = Effect.fnUntraced(function* ( secretReads.push(name); if (options.failSecretRead?.(name)) { return Effect.fail( - new SecretStoreReadError({ resource: name, cause: "temporary read failure" }), + new SecretStoreReadError({ + resource: name, + cause: "temporary read failure", + }), ); } return Effect.succeed(Option.fromUndefinedOr(values.get(name))); @@ -159,7 +171,11 @@ const makeTestRelay = Effect.fnUntraced(function* ( ); publications.push({ url: String(input), - authorization: new Headers(init?.headers).get("authorization"), + authorization: ( + new Headers(init?.headers) as unknown as { + get(name: string): string | null; + } + ).get("authorization"), state: payload.state, }); return Promise.resolve( @@ -199,7 +215,14 @@ const makeTestRelay = Effect.fnUntraced(function* ( Effect.provideService(FetchHttpClient.Fetch, fetch), Effect.provide(NodeCrypto.layer), ); - return { relay, secrets, secretReads, currentShell, shellReads, publications }; + return { + relay, + secrets, + secretReads, + currentShell, + shellReads, + publications, + }; }); describe("AgentAwarenessRelay", () => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 488bcad1cd03..e0ba8d6d37fb 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -137,10 +137,6 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n return JSON.stringify(meaningfulState); } -export function isAgentActivityPublishingEnabled(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - export function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -244,7 +240,10 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { threadId: input.threadId, state: input.state, } satisfies RelayAgentActivityPublishProofPayload; - return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); + return yield* signRelayAgentActivityPublishProof({ + privateKey: input.privateKey, + payload, + }); }); // Compact, log-safe view of the fields the awareness phase ladder reads. @@ -358,7 +357,7 @@ export const make = Effect.gen(function* () { }); const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( - Effect.map(isAgentActivityPublishingEnabled), + Effect.map(isAgentActivityPublishingEnabledValue), ); const makeRelayClient = (relayConfig: { @@ -390,7 +389,9 @@ export const make = Effect.gen(function* () { if (retry?.timer !== undefined) yield* Fiber.interrupt(retry.timer); }); const cancelPublishRetries = Effect.suspend(() => - Effect.forEach([...publishRetries.keys()], cancelPublishRetry, { discard: true }), + Effect.forEach([...publishRetries.keys()], cancelPublishRetry, { + discard: true, + }), ); let schedulePublishRetry: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const resetPublishedConnection = Effect.gen(function* () { @@ -666,7 +667,9 @@ export const make = Effect.gen(function* () { yield* Effect.logInfo("publishing active agent activity snapshot", { count: activeThreadIds.length, }); - yield* Effect.forEach(activeThreadIds, enqueueThreadPublish, { discard: true }); + yield* Effect.forEach(activeThreadIds, enqueueThreadPublish, { + discard: true, + }); yield* worker.drain; return true; }); diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts new file mode 100644 index 000000000000..032832dd4869 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -0,0 +1,93 @@ +import * as NodeOS from "node:os"; +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class HostResources extends Context.Service< + HostResources, + { readonly read: Effect.Effect } +>()("t3/resourceTelemetry/HostResources") {} + +function readCpu() { + const cpus = NodeOS.cpus(); + const cpu = cpus.reduce( + (sum, { times }) => ({ + idle: sum.idle + times.idle, + total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, + }), + { idle: 0, total: 0 }, + ); + return { ...cpu, count: cpus.length }; +} + +function darwinAvailableMemory(output: string): number | null { + const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; + const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; + const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; + const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; + if (!pageSize || !free || !inactive || !speculative) return null; + // vm_stat subtracts speculative pages from its printed "Pages free" count. + // Adding them here counts each reclaimable page once; purgeable pages overlap. + const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); + return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; +} + +export const make = Effect.fn("makeHostResources")(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const sample = Effect.fn("HostResources.sample")(function* () { + const previousCpu = readCpu(); + // CPU counters need two readings; idle servers do no polling or process scans. + yield* Effect.sleep("200 millis"); + const cpu = readCpu(); + const totalDelta = cpu.total - previousCpu.total; + const idleDelta = cpu.idle - previousCpu.idle; + const cpuUtilization = + previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 + ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) + : null; + const totalMemoryBytes = NodeOS.totalmem(); + // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. + let availableMemoryBytes = NodeOS.freemem(); + if (platform === "linux") { + const meminfo = yield* fs + .readFileString("/proc/meminfo") + .pipe(Effect.catch(() => Effect.succeed(""))); + const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; + if (available) availableMemoryBytes = Number(available) * 1024; + } else if (platform === "darwin") { + const output = yield* spawner + .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed("")), + ); + availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; + } + return { + sampledAt: DateTime.toEpochMillis(yield* DateTime.now), + cpuUtilization, + cpuCount: cpu.count, + availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), + totalMemoryBytes, + }; + }); + + // One server-lifetime cache deduplicates simultaneous requests from all sockets. + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_key: "host") => sample(), + timeToLive: "5 seconds", + }); + return HostResources.of({ read: Cache.get(cache, "host") }); +}); + +export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 8a595bc8b480..7d365d0c0bac 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -1,6 +1,5 @@ import type { HostPowerSnapshot } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -9,12 +8,9 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import { - NativeTelemetryRequestTimedOut, - NativeTelemetryStreamClosed, canCommandNativeTelemetrySidecar, canRequestNativeTelemetryRetry, commitCollectionControlUpdate, - nativeTelemetrySupervisorFailureMessage, retainRecentNativeTelemetryFailures, resolveNativeSampleIntervalMs, synchronizeCollectionControlOnStart, @@ -82,44 +78,6 @@ describe("canCommandNativeTelemetrySidecar", () => { }); }); -describe("NativeTelemetryRequestTimedOut", () => { - it("models history and sample request deadlines without a fabricated cause", () => { - const historyTimeout = new NativeTelemetryRequestTimedOut({ - operation: "readHistory", - timeoutMs: 15_000, - }); - const sampleTimeout = new NativeTelemetryRequestTimedOut({ - operation: "sampleNow", - timeoutMs: 5_000, - }); - - expect(historyTimeout.message).toBe( - "Resource monitor 'readHistory' request timed out after 15000ms.", - ); - expect(sampleTimeout.message).toBe( - "Resource monitor 'sampleNow' request timed out after 5000ms.", - ); - expect("cause" in historyTimeout).toBe(false); - expect("cause" in sampleTimeout).toBe(false); - }); -}); - -describe("native telemetry supervisor failures", () => { - it("distinguishes a closed event stream from a process exit", () => { - expect(new NativeTelemetryStreamClosed().message).toBe( - "Resource monitor event stream closed unexpectedly.", - ); - }); - - it("keeps defect details out of the caller-visible health message", () => { - const secret = "credential=do-not-expose"; - const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret))); - - expect(message).toBe("Resource monitor supervisor stopped unexpectedly."); - expect(message).not.toContain(secret); - }); -}); - describe("retainRecentNativeTelemetryFailures", () => { it("expires old failures so an isolated crash restarts from the initial backoff", () => { expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 232079d9dc9b..4af8f1b762d5 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -73,7 +73,7 @@ export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass()( +class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass()( "NativeTelemetryRequestTimedOut", { operation: Schema.Literals(["readHistory", "sampleNow"]), @@ -131,7 +131,7 @@ export class NativeTelemetryExited extends Schema.TaggedErrorClass()( +class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass()( "NativeTelemetryStreamClosed", {}, ) { @@ -340,10 +340,6 @@ function errorMessage(error: NativeTelemetryClientError): string { return error.message; } -export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause): string { - return "Resource monitor supervisor stopped unexpectedly."; -} - export function canRequestNativeTelemetryRetry( status: ResourceTelemetrySourceStatus, hasHandle: boolean, @@ -734,7 +730,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu ...current, status: "unavailable" as const, hello: Option.none(), - lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)), + lastError: Option.some("Resource monitor supervisor stopped unexpectedly."), })).pipe( Effect.andThen(publishHealth), Effect.andThen( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index eeb77998f3db..252335d1106a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -104,6 +104,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -196,6 +197,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); const ResourceDiagnosticsLayerLive = Layer.mergeAll( + HostResources.layer, ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -288,7 +290,9 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( ); const GitManagerLayerLive = GitManager.layer.pipe( - Layer.provideMerge(ProjectSetupScriptRunnerLayerLive), + Layer.provideMerge( + ProjectSetupScriptRunnerLayerLive.pipe(Layer.provide(ServerSettingsLayerLive)), + ), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -324,12 +328,10 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), - // Auto-pull reads the projected project row. The orchestration runtime - // also consumes the broadcaster (run finalization), so the policy gets - // its own snapshot-query build instead of the runtime-level one. Layer.provide( VcsStatusBroadcaster.autoPullPolicyLayer.pipe( Layer.provide(OrchestrationInfrastructureLayerLive), + Layer.provide(ServerSettingsLayerLive), ), ), ), @@ -601,14 +603,18 @@ export const makeServerLayer = Layer.unwrap( state, }).pipe( Effect.catchCause((cause) => - Effect.logWarning("Failed to persist server runtime state", { cause }), + Effect.logWarning("Failed to persist server runtime state", { + cause, + }), ), ); }), () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath).pipe( Effect.catchCause((cause) => - Effect.logWarning("Failed to clear server runtime state", { cause }), + Effect.logWarning("Failed to clear server runtime state", { + cause, + }), ), ), ), @@ -649,7 +655,9 @@ export const makeServerLayer = Layer.unwrap( }), (configured) => configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( + ? disableTailscaleServe({ + servePort: configured.servePort, + }).pipe( Effect.tap(() => Effect.logInfo("Tailscale Serve disabled", { servePort: configured.servePort, @@ -680,7 +688,9 @@ export const makeServerLayer = Layer.unwrap( Effect.catchCause((cause) => Effect.logWarning( "Failed to release the managed tunnel on shutdown; the next link reuses it", - { errors: Cause.prettyErrors(cause).map((error) => error.message) }, + { + errors: Cause.prettyErrors(cause).map((error) => error.message), + }, ), ), Effect.asVoid, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 4591729811c6..9396bb024efd 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,22 +1,21 @@ -import { assert, it } from "@effect/vitest"; -import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it, vi } from "@effect/vitest"; +import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as ServerConfig from "./config.ts"; +import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "./project/ProjectService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; - -it("uses the canonical Codex model for auto-bootstrap", () => { - assert.deepEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); +import * as ServerSettings from "./serverSettings.ts"; it.effect("runs projection repair, recovery, worker startup, and bootstrap in order", () => Effect.gen(function* () { @@ -164,7 +163,11 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; const project = (workspaceRoot: string, autoPull = true) => - ({ workspaceRoot, autoPull }) as never; + ({ + id: ProjectId.make(workspaceRoot), + workspaceRoot, + autoPull, + }) as never; yield* ServerRuntimeStartup.autoPullProjects([ project("/clean"), @@ -176,5 +179,130 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); + + pulled.length = 0; + yield* ServerRuntimeStartup.autoPullProjects( + [project("/inherited", false), project("/opted-out"), project("/dirty", false)], + { + defaultAutoPull: true, + projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, + }, + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + + assert.deepStrictEqual(pulled, ["/inherited"]); + }), +); + +it.effect("auto-bootstrap uses machine defaults and reports what it created", () => { + const projectId = ProjectId.make("project:auto-bootstrap-v2"); + const threadId = ThreadId.make("thread:auto-bootstrap-v2"); + const machineSelection = { + instanceId: ProviderInstanceId.make("claude-code"), + model: "claude-sonnet-4-6", + }; + const launch = vi.fn((_input: ThreadLaunch.ThreadLaunchInput) => + Effect.succeed({ threadId, projection: {}, resumed: false } as never), + ); + const project = { + id: projectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }; + const layer = Layer.mergeAll( + NodeServices.layer, + ServerSettings.layerTest({ defaultModelSelection: machineSelection }), + Layer.succeed(ServerConfig.ServerConfig, { + cwd: project.workspaceRoot, + autoBootstrapProjectFromCwd: true, + } as never), + Layer.mock(ProjectService.ProjectService)({ + bootstrap: () => Effect.succeed({ project, created: true }), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getShellSnapshot: () => Effect.succeed({ threads: [] } as never), + }), + Layer.mock(ThreadLaunch.ThreadLaunchService)({ launch }), + ); + + return Effect.gen(function* () { + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets; + assert.deepStrictEqual(targets, { + bootstrapProjectId: projectId, + bootstrapThreadId: threadId, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + }); + assert.deepStrictEqual(launch.mock.calls[0]?.[0].modelSelection, machineSelection); + }).pipe(Effect.provide(layer)); +}); + +it.effect("auto-bootstrap preserves a project created before thread launch fails", () => { + const projectId = ProjectId.make("project:auto-bootstrap-thread-failure"); + const project = { + id: projectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }; + const layer = Layer.mergeAll( + NodeServices.layer, + ServerSettings.layerTest(), + Layer.succeed(ServerConfig.ServerConfig, { + cwd: project.workspaceRoot, + autoBootstrapProjectFromCwd: true, + } as never), + Layer.mock(ProjectService.ProjectService)({ + bootstrap: () => Effect.succeed({ project, created: true }), + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getShellSnapshot: () => Effect.succeed({ threads: [] } as never), + }), + Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: () => Effect.die("thread launch failed"), + }), + ); + + return Effect.gen(function* () { + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets; + assert.deepStrictEqual(targets, { + bootstrapProjectId: projectId, + bootstrapProjectCreated: true, + }); + }).pipe(Effect.provide(layer)); +}); + +it.effect("completeAutoBootstrapWelcome settles bootstrap failures", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.die("bootstrap failed"), + ); + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome preserves successful targets", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.succeed({ + bootstrapProjectId: ProjectId.make("project:existing"), + }), + ); + assert.deepStrictEqual(completion, { + bootstrapProjectId: ProjectId.make("project:existing"), + bootstrapStatus: "complete", + }); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 0ed8529355b6..03ff14a5a7ac 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -2,12 +2,14 @@ import { CommandId, DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_SERVER_SETTINGS, type ModelSelection, type Project, ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -171,15 +173,7 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - Effect.asVoid, -); - -export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -187,16 +181,22 @@ export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ interface AutoBootstrapWelcomeTargets { readonly bootstrapProjectId?: ProjectId; readonly bootstrapThreadId?: ThreadId; + readonly bootstrapProjectCreated?: boolean; + readonly bootstrapThreadCreated?: boolean; } export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( - projects: ReadonlyArray>, + projects: ReadonlyArray>, + settings: Pick< + typeof DEFAULT_SERVER_SETTINGS, + "defaultAutoPull" | "projectAutoPullOverrides" + > = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => project.autoPull === true) + .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) .map((project) => project.workspaceRoot), ), ]; @@ -266,52 +266,91 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const threads = yield* ThreadManagement.ThreadManagementService; const threadLaunch = yield* ThreadLaunch.ThreadLaunchService; + const serverSettings = yield* ServerSettings.ServerSettingsService; const path = yield* Path.Path; let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; + let bootstrapProjectCreated = false; + let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { // Project creation has no user model choice; only the bootstrap thread // gets an automatic selection, and an explicit project default wins. - const threadModelSelection = getAutoBootstrapThreadModelSelection(); - const { project } = yield* projects.bootstrap({ + const settings = yield* serverSettings.getSettings; + const threadModelSelection = + settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); + const { project, created } = yield* projects.bootstrap({ commandId: CommandId.make(yield* randomUUID), projectId: ProjectId.make(yield* randomUUID), title: path.basename(serverConfig.cwd) || "project", workspaceRoot: serverConfig.cwd, }); - const shell = yield* threads.getShellSnapshot(); - const existingThread = shell.threads.find( - (thread) => - thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", + bootstrapProjectId = project.id; + bootstrapProjectCreated = created; + yield* Effect.gen(function* () { + const shell = yield* threads.getShellSnapshot(); + const existingThread = shell.threads.find( + (thread) => + thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", + ); + if (existingThread === undefined) { + const launched = yield* threadLaunch.launch({ + commandId: CommandId.make(yield* randomUUID), + projectId: project.id, + title: "New thread", + modelSelection: project.defaultModelSelection ?? threadModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + workspaceStrategy: { type: "root" }, + createdBy: "system", + creationSource: "server", + }); + bootstrapThreadId = launched.threadId; + bootstrapThreadCreated = true; + } else { + bootstrapThreadId = existingThread.id; + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: project.id, + cause, + }), + ), ); - if (existingThread === undefined) { - const launched = yield* threadLaunch.launch({ - commandId: CommandId.make(yield* randomUUID), - projectId: project.id, - title: "New thread", - modelSelection: project.defaultModelSelection ?? threadModelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - workspaceStrategy: { type: "root" }, - createdBy: "system", - creationSource: "server", - }); - bootstrapProjectId = project.id; - bootstrapThreadId = launched.threadId; - } else { - bootstrapProjectId = project.id; - bootstrapThreadId = existingThread.id; - } } - return { + const targets: AutoBootstrapWelcomeTargets = { ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), - } satisfies AutoBootstrapWelcomeTargets; + ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}), + ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}), + }; + return targets; }); +export const completeAutoBootstrapWelcome = ( + bootstrap: Effect.Effect, +) => + bootstrap.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe( + Effect.as({ bootstrapStatus: "complete" as const }), + ), + onSuccess: (targets) => + Effect.succeed({ + ...targets, + bootstrapStatus: "complete" as const, + }), + }), + ); + const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -517,7 +556,7 @@ export const make = (options?: StartupOptions) => }, }); } - const { recovery, bootstrap: bootstrapTargets } = yield* runOrderedV2StartupPhases({ + const { recovery } = yield* runOrderedV2StartupPhases({ importLegacyShells: runStartupPhase( "orchestration-v2.legacy-v1.import-shells", legacyV1ThreadImporter.reconcileShells.pipe( @@ -561,13 +600,7 @@ export const make = (options?: StartupOptions) => workerFiberRef: effectWorkerFiber, }), ), - autoBootstrap: (serverConfig.autoBootstrapProjectFromCwd - ? runStartupPhase( - "welcome.autobootstrap", - resolveAutoBootstrapWelcomeTargets.pipe(Effect.provideService(Crypto.Crypto, crypto)), - ) - : Effect.succeed({}) - ).pipe(Effect.map((targets): AutoBootstrapWelcomeTargets => targets)), + autoBootstrap: Effect.succeed({}), }); yield* Effect.logInfo("V2 orchestration recovery completed", recovery); yield* runStartupPhase( @@ -575,10 +608,35 @@ export const make = (options?: StartupOptions) => Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery; const projects = yield* snapshots.getProjectShellsWithoutEnrichment(); - yield* autoPullProjects(projects); + const settings = yield* serverSettings.getSettings; + yield* autoPullProjects(projects, settings); }), ); + if (serverConfig.autoBootstrapProjectFromCwd) { + yield* forkParked( + runStartupPhase( + "welcome.autobootstrap", + Effect.gen(function* () { + const bootstrapCompletion = yield* completeAutoBootstrapWelcome( + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + ); + yield* lifecycleEvents.publish({ + version: 1, + type: "welcome", + payload: { + environment, + ...welcomeBase, + ...bootstrapCompletion, + }, + }); + }).pipe(Effect.ignoreCause({ log: true })), + ), + ); + } + const importPendingTranscripts = legacyV1ThreadImporter.importPendingTranscripts.pipe( Effect.tap((summary) => summary.importedThreadCount === 0 @@ -668,8 +726,6 @@ export const make = (options?: StartupOptions) => environmentId: environment.environmentId, cwd: welcomeBase.cwd, projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, }); yield* runStartupPhase( "welcome.publish", @@ -679,7 +735,7 @@ export const make = (options?: StartupOptions) => payload: { environment, ...welcomeBase, - ...bootstrapTargets, + bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", }, }), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index c08e5a82902e..91715cd54490 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,42 +1,21 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; +import { + type PersistedServerRuntimeState, + ServerRuntimeStateError, +} from "@t3tools/shared/serverRuntimeState"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import type * as ServerConfig from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; -export const PersistedServerRuntimeState = Schema.Struct({ - version: Schema.Literal(1), - pid: Schema.Int, - host: Schema.optional(Schema.String), - port: Schema.Int, - origin: Schema.String, - // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). - // Dev is single-origin: browsers must pair through this URL, not `origin`. - devUrl: Schema.optional(Schema.String), - startedAt: Schema.String, -}); -export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; - -export class ServerRuntimeStateError extends Schema.TaggedErrorClass()( - "ServerRuntimeStateError", - { - operation: Schema.Literals(["persist", "read", "decode", "clear"]), - statePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to ${this.operation} server runtime state at ${this.statePath}.`; - } -} - -const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( - Schema.fromJsonString(PersistedServerRuntimeState), -); +export { + PersistedServerRuntimeState, + readPersistedServerRuntimeState, + isProcessAlive, + ServerRuntimeStateError, +} from "@t3tools/shared/serverRuntimeState"; const runtimeOriginForConfig = ( config: Pick, @@ -103,70 +82,3 @@ export const clearPersistedServerRuntimeState = (path: string) => }), ); }); - -/** - * Report whether the pid recorded in a persisted runtime state is still - * running. Signal 0 delivers nothing; it only reports whether the pid exists. - * EPERM means it exists but belongs to another user, which still counts as - * alive. - */ -export const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } -}; - -export const readPersistedServerRuntimeState = (path: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const raw = yield* fs.readFileString(path).pipe( - Effect.matchEffect({ - onFailure: (cause) => - cause.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) - : Effect.fail( - new ServerRuntimeStateError({ - operation: "read", - statePath: path, - cause, - }), - ), - onSuccess: (contents) => Effect.succeed(Option.some(contents)), - }), - ); - if (Option.isNone(raw)) { - return Option.none(); - } - - const trimmed = raw.value.trim(); - if (trimmed.length === 0) { - return Option.none(); - } - - return yield* decodePersistedServerRuntimeState(trimmed).pipe( - Effect.map(Option.some), - Effect.mapError( - (cause) => - new ServerRuntimeStateError({ - operation: "decode", - statePath: path, - cause, - }), - ), - ); - }).pipe( - Effect.catchTags({ - ServerRuntimeStateError: (error) => - Effect.logWarning(error.message).pipe( - Effect.annotateLogs({ - operation: error.operation, - statePath: error.statePath, - cause: error, - }), - Effect.as(Option.none()), - ), - }), - ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4526e2c988a4..6e837308e8cb 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; 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 PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("materializes provider secrets for terminal environment resolution", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("codex_terminal"); + + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true }, + ], + config: { homePath: "~/.codex-terminal" }, + }, + }, + }); + + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: instanceId, + env: undefined, + }); + const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); + + assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret"); + assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/); + assert.notInclude(persisted, "sk-terminal-secret"); + assert.include(persisted, '"valueRedacted": true'); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f0cb52003029..24b28af13fe4 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -163,6 +163,8 @@ describe("AzureDevOpsCli.layer", () => { }); assert.strictEqual(result[0]?.state, "merged"); + assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); + assert.strictEqual(result[0]?.closedAt, null); expect(mockRun).toHaveBeenCalledWith({ operation: "AzureDevOpsCli.execute", command: "az", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index cacdd1a3cd97..8e55d453b224 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -22,7 +22,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), }); @@ -39,7 +40,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: false, }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8a840c524eba..20a74cc8a5d7 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -62,6 +62,8 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -73,6 +75,8 @@ function toChangeRequest(summary: { headRefName: summary.headRefName, state: summary.state, ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 3f08e92e2c1e..f72259b677eb 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -93,6 +93,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isDraft: true, updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, @@ -107,7 +109,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, @@ -154,6 +156,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -207,6 +211,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-list", state: "open", + closedAt: null, + mergedAt: null, }, ]); }).pipe(Effect.provide(layer)), @@ -259,6 +265,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "t3code/codex-turn-mapping", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 49b2ea31a08c..85736a95c5dd 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -206,6 +206,8 @@ export interface GitHubPullRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -367,7 +369,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -399,7 +401,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 7faa2fe351ef..a025ce5ec800 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -60,6 +60,8 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", @@ -125,6 +127,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = baseRefName: "main", headRefName: "feature/merged", state: "merged", + mergedAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00.000Z", }, ]), @@ -150,10 +153,11 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); assert.deepStrictEqual( changeRequests[0]?.updatedAt, Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 1a20b587256a..74f08a9a9127 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -31,6 +31,8 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt === undefined ? Option.none() @@ -154,7 +156,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index eb56b434b2f8..c5f22fe3088f 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -46,7 +46,8 @@ layer("GitLabCli.layer", (it) => { web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", target_branch: "main", source_branch: "feature/mr-threads", - state: "opened", + state: "closed", + closed_at: "2026-08-23T10:00:00Z", source_project_id: 101, target_project_id: 100, source_project: { @@ -71,7 +72,9 @@ layer("GitLabCli.layer", (it) => { url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/mr-threads", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/t3code", headRepositoryOwnerLogin: "octocat", @@ -107,6 +110,7 @@ layer("GitLabCli.layer", (it) => { target_branch: " main ", source_branch: " feature/mr-list ", state: "merged", + merged_at: "2026-08-23T11:00:00Z", }, ]), ), @@ -130,6 +134,8 @@ layer("GitLabCli.layer", (it) => { baseRefName: "main", headRefName: "feature/mr-list", state: "merged", + closedAt: null, + mergedAt: "2026-08-23T11:00:00Z", }, ]); expect(mockedRun).toHaveBeenCalledWith( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index ab8dfbb5f334..9f76a6182ce4 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -247,6 +247,8 @@ export interface GitLabMergeRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e0665214..3cd442a6e169 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -24,7 +24,8 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", headRepositoryOwnerLogin: "fork", @@ -43,7 +44,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2ec1f9b9a228..28211c6b8509 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -27,6 +27,8 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8ac682399e1d..24c0e49fd8f4 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; } @@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const state = normalizeAzureDevOpsPullRequestState(raw.status); + const terminalAt = Option.match(raw.closedDate ?? Option.none(), { + onNone: () => null, + onSome: DateTime.formatIso, + }); return { number: raw.pullRequestId, title: raw.title, url: normalizeAzureDevOpsPullRequestUrl(raw), baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), - state: normalizeAzureDevOpsPullRequestState(raw.status), + state, ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 9e4f282e1c8a..822de1e02797 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -96,6 +99,8 @@ function normalizeGitHubPullRequestRecord( headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: raw.closedAt ?? null, + mergedAt: raw.mergedAt ?? null, updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index 3b032e245bbc..0525260df51b 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitLabMergeRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -44,6 +46,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -112,6 +116,8 @@ function normalizeGitLabMergeRequestRecord( headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index ab151821789a..92d3223267d7 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -33,26 +33,6 @@ const findIdentityLog = ( errorTag: string, ) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); -it("preserves exact telemetry identity causes without deriving messages from them", () => { - const decodeCause = new Error("private nested decode details"); - const decodeError = new Identify.TelemetryIdentityDecodeError({ - source: "codex", - filePath: "/tmp/auth.json", - cause: decodeCause, - }); - const readCause = new Error("private nested read details"); - const readError = new Identify.TelemetryIdentityReadError({ - source: "anonymous", - filePath: "/tmp/anonymous-id", - cause: readCause, - }); - - assert.strictEqual(decodeError.cause, decodeCause); - assert.strictEqual(readError.cause, readCause); - assert.notInclude(decodeError.message, decodeCause.message); - assert.notInclude(readError.message, readCause.message); -}); - it.layer(NodeServices.layer)("telemetry identity", (it) => { it.effect("uses the persisted anonymous id when provider identities are absent", () => Effect.gen(function* () { diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index b6c3d0066dff..15d3bf13f782 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -23,7 +23,7 @@ const ClaudeJsonSchema = Schema.Struct({ export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; -export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( "TelemetryIdentityReadError", { source: TelemetryIdentitySource, @@ -36,7 +36,7 @@ export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( "TelemetryIdentityDecodeError", { source: Schema.Literals(["codex", "claude"]), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index deea39631788..e480e11588b0 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,9 +7,14 @@ import { type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, + ProviderDriverKind, + ProviderInstanceId, + ServerSettingsError, + TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -23,11 +28,16 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -215,6 +225,9 @@ interface CreateManagerOptions { maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; } interface ManagerFixture { @@ -259,6 +272,9 @@ const createManager = ( ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.resolveProviderInstanceEnvironment !== undefined + ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } + : {}), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1736,6 +1752,26 @@ it.layer( }), ); + it.effect("expands provider home paths passed to setup terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5); + + yield* manager.open({ + ...openInput(), + env: { + CODEX_HOME: "~/.codex-work", + CLAUDE_CONFIG_DIR: "~/.claude-work", + CUSTOM_ACCOUNT: "~/leave-this-value-alone", + }, + }); + + const environment = ptyAdapter.spawnInputs[0]?.env; + expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); + expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); + expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); + }), + ); + it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -1822,6 +1858,382 @@ it.layer( }), ); + it.effect("resolves a provider instance environment before spawning", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: (requestedId, env) => + Effect.succeed({ + ...env, + PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", + CODEX_HOME: "/accounts/codex-work", + }), + }); + + const snapshot = yield* manager.open( + openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), + ); + + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); + expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); + expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(snapshot).not.toHaveProperty("providerInstanceId"); + }), + ); + + it.effect("fails closed when a provider instance is missing", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + + it.effect("preserves the settings failure when provider environment resolution fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const settingsCause = new Error("secret store read failed"); + const settingsError = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-secret", + providerInstanceId, + environmentVariable: "OPENROUTER_API_KEY", + cause: settingsCause, + }); + const serverSettings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(settingsError), + updateSettings: () => Effect.fail(settingsError), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }); + + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: providerInstanceId, + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId, + }); + expect(error.cause).toBe(settingsError); + expect(error.message).not.toContain(settingsError.message); + expect(error.message).not.toContain("OPENROUTER_API_KEY"); + }), + ); + + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "configured_home", + env: undefined, + }); + + expect(environment[variable]).toBe(path.resolve(expectedHome)); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, + }, + }, + }), + ), + ), + ); + + it.effect("resolves the legacy Codex default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { codex: { homePath: "~/.codex-legacy" } }, + }), + ), + ), + ); + + it.effect("resolves the legacy Claude default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "claudeAgent", + env: undefined, + }); + + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + }), + ), + ), + ); + + it.effect("prefers an explicit default instance over legacy provider settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + config: { homePath: "~/.codex-explicit" }, + }, + }, + }), + ), + ), + ); + + it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex_unknown", + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderInstanceNotFoundError", + providerInstanceId: "codex_unknown", + }); + }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), + ); + + it.effect("restarts a running terminal when the resolved provider environment changes", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerSecret = "first-secret"; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: () => + Effect.succeed({ PROVIDER_SECRET: providerSecret }), + }); + + yield* manager.open(openInput({ providerInstanceId })); + providerSecret = "second-secret"; + yield* manager.open(openInput({ providerInstanceId })); + + expect(ptyAdapter.processes[0]?.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); + }), + ); + + it.effect("restarts with current provider secrets and clears bounded history", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_restart"); + const { manager, ptyAdapter, logsDir } = yield* createManager(2, { + historyByteLimit: 8, + resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + }); + const homePath = path.join(logsDir, "codex"); + const updateSecret = (value: string) => + serverSettings.updateSettings({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath }, + environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], + }, + }, + }); + const input = { + providerInstanceId, + env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, + }; + const outputProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* updateSecret("first-secret"); + yield* manager.restart(restartInput(input)); + const firstProcess = ptyAdapter.processes[0]!; + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); + firstProcess.emitData("discarded\nold-one\nold-two\n"); + yield* Deferred.await(outputProcessed); + expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); + + yield* updateSecret("second-secret"); + const restarted = yield* manager.restart(restartInput(input)); + + expect(firstProcess.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ + PROVIDER_SECRET: "second-secret", + CODEX_HOME: homePath, + CLIENT_FLAG: "1", + }); + expect(restarted.history).toBe(""); + expect(restarted.status).toBe("running"); + expect(restarted).not.toHaveProperty("env"); + expect(restarted).not.toHaveProperty("providerInstanceId"); + const logPath = yield* historyLogPath(logsDir); + expect(yield* readFileString(logPath)).toBe(""); + + ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(logPath)).toBe("new-two\n"); + }).pipe( + Effect.provide( + ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), + ), + ), + ), + ), + ); + + it.effect("attaches to a running provider terminal without resolving the provider again", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerAvailable = true; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + providerAvailable + ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) + : Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + yield* manager.open(openInput({ providerInstanceId })); + providerAvailable = false; + const events: TerminalAttachStreamEvent[] = []; + + const unsubscribe = yield* manager.attachStream( + { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, + (event) => Effect.sync(() => events.push(event)), + ); + unsubscribe(); + + expect(events[0]?.type).toBe("snapshot"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + }), + ); + + it.effect("fails closed when attaching would create a missing provider terminal", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager + .attachStream(openInput({ providerInstanceId }), () => Effect.void) + .pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index f04e3c2d897b..d9bdc6bcd92a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -15,6 +15,8 @@ import { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -31,6 +33,9 @@ import { type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, + ClaudeSettings, + CodexSettings, + ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../observability/Metrics.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -69,6 +80,8 @@ export { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv( } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; + spawnEnv[key] = + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; } } // Both PTY backends feed truecolor-capable terminal clients. @@ -1306,17 +1322,78 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + resolveProviderInstanceEnvironment?: ( + providerInstanceId: string, + env: Record | undefined, + ) => Effect.Effect< + Record, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + >; } +export const resolveProviderInstanceTerminalEnvironment = Effect.fn( + "terminal.resolveProviderInstanceTerminalEnvironment", +)(function* (input: { + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly path: Path.Path; + readonly rawProviderInstanceId: string; + readonly env: Record | undefined; +}) { + const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); + const settings = yield* input.serverSettings.getSettings.pipe( + Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), + ); + const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (instance === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); + } + + let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + if (instance.driver === "codex") { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isSome(config)) { + const layout = yield* resolveCodexHomeLayout(config.value).pipe( + Effect.provideService(Path.Path, input.path), + ); + if (layout.effectiveHomePath) + resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + } + } else if (instance.driver === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isSome(config)) { + resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( + Effect.provideService(Path.Path, input.path), + ); + } + } + + return Object.fromEntries( + Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +}); + export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const resolveProviderInstanceEnvironment = Effect.fn( + "terminal.resolveProviderInstanceEnvironment", + )((rawProviderInstanceId: string, env: Record | undefined) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + ); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + resolveProviderInstanceEnvironment, }); }); @@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( + function* ( + input: Input, + ): Effect.fn.Return< + Input, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + > { + if (input.providerInstanceId === undefined) return input; + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + return { ...input, env }; + }, + ); // One process-table snapshot per poll tick, shared across every terminal. // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and // can exhaust the PID space on hosts with many sessions (#6332). @@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const open: TerminalManager["Service"]["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } const session = existing.value; @@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } if ( @@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const restartResolved = (input: TerminalRestartInput) => + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); + const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)), ); const close: TerminalManager["Service"]["close"] = (input) => diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 253d88779827..05f45b4cb3b0 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message and the title guidance rules", () => { + it("includes the user message without absent attachment metadata", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); @@ -154,18 +154,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("User message:"); expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); expect(result.prompt).not.toContain("Attachment metadata:"); - expect(result.prompt).toContain( - "Generate a title that will help the user recognize this T3 Code thread weeks later.", - ); - expect(result.prompt).toContain( - "Title the subject and outcome. Discard incidental instructions.", - ); - expect(result.prompt).toContain( - "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", - ); - expect(result.prompt).not.toContain( - "Title should summarize the user's request, not restate it verbatim.", - ); }); it("includes attachment metadata when attachments are provided", () => { @@ -188,24 +176,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("67890 bytes"); }); - it.each([ - { mode: "initial", previousTitle: undefined }, - { mode: "regeneration", previousTitle: "Open Projects in Desktop App" }, - ])( - "tells the $mode prompt not to title linked PRs from local git history", - ({ previousTitle }) => { - const result = buildThreadTitlePrompt({ - message: "$takeover https://github.com/pingdotgg/t3code/pull/8588", - ...(previousTitle === undefined ? {} : { previousTitle }), - }); - - expect(result.prompt).toContain( - "Local git history is not evidence of what a linked PR or issue is about.", - ); - expect(result.prompt).toContain('such as "Take Over PR 8588"'); - }, - ); - it("regenerates from recent thread contents and identifies the previous title", () => { const result = buildThreadTitlePrompt({ message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, @@ -216,15 +186,6 @@ describe("buildThreadTitlePrompt", () => { "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); - expect(result.prompt).toContain( - "Read the USER messages first. Identify the latest explicit durable goal.", - ); - expect(result.prompt).toContain( - "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", - ); - expect(result.prompt).toContain( - 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', - ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); }); diff --git a/apps/server/src/usage/cliproxyUsageLimits.test.ts b/apps/server/src/usage/cliproxyUsageLimits.test.ts index 19767f3a9270..5e8d1b1fff7a 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.test.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.test.ts @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { }, ]); }); + + it("names a Codex five-hour window `primary`, as the Codex driver does", () => { + const accounts = cliproxyStatusToAccounts( + { + accounts: { + "codex-abc-someone@example.com-pro.json": { + provider: "codex", + plan: "pro", + five_hour: { hard_limited: false, known: true, used_percent: 40 }, + }, + }, + }, + checkedAt, + ); + expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]); + }); }); describe("accountEmailFromAuthFile", () => { diff --git a/apps/server/src/usage/cliproxyUsageLimits.ts b/apps/server/src/usage/cliproxyUsageLimits.ts index cd2b1e277da6..47ed200c3f22 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.ts @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( if (!window || window.known === false) continue; const resetsAt = isoFromHub(window.reset_at); windows.push({ - id: spec.id, + // Codex names its five-hour window by position, so a hub row and a + // native row for the same account pool together. + id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id, kind: spec.kind, label: spec.label, windowDurationMins: spec.windowDurationMins, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index d45dfe2dd09b..713d860999cb 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -4,7 +4,6 @@ import { cacheSavingsUsd, createOverrideRateTable, lookupRate, - normalizeModelName, parseRateTable, priceUsage, } from "./usagePricing.ts"; @@ -83,10 +82,6 @@ describe("usage pricing", () => { } }); - it("keeps the existing model-name normalization contract", () => { - expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); - }); - it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 5ca75a68cb32..6c94be424827 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -127,16 +127,6 @@ function normalizeRateKey(model: string): string { return model.trim().toLowerCase(); } -/** - * Canonicalises a model name for lookup. - * - * Strips a `provider/` prefix and lowercases, since transcripts are - * inconsistent about casing. - */ -export function normalizeModelName(model: string): string { - return bareModelName(normalizeRateKey(model)); -} - function bareModelName(key: string): string { const slash = key.lastIndexOf("/"); return slash === -1 ? key : key.slice(slash + 1); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 04f7fcffcda0..88f48e9e8afa 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -14,22 +14,6 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { - it("keeps operation context and the original cause on config errors", () => { - const cause = new Error("permission denied"); - const error = new VcsProjectConfig.VcsProjectConfigError({ - operation: "read", - cwd: "/repo/packages/app", - configPath: "/repo/.t3code/vcs.json", - cause, - }); - - assert.equal(error.operation, "read"); - assert.equal(error.cwd, "/repo/packages/app"); - assert.equal(error.configPath, "/repo/.t3code/vcs.json"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); - }); - it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 04d320c03bf9..c00a07f2a7a9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,10 +22,12 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect( VcsAutoPullPolicy, Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; return { - isEnabled: (cwd: string) => - snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( - Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), - Effect.orElseSucceed(() => false), - ), + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); + if (project._tag === "None") return false; + const settings = yield* serverSettings.getSettings; + return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + }, + Effect.orElseSucceed(() => false), + ), }; }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 10b593a863fc..1cc77a9dfe2b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,7 @@ +import { + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, +} from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Encoding from "effect/Encoding"; @@ -13,6 +17,7 @@ import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, + AgentSessionScanError, AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, @@ -58,6 +63,7 @@ import { type RelayClientInstallProgressEvent, type ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -166,7 +172,9 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; +import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -607,6 +615,7 @@ const makeWsRpcLayer = ( const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; @@ -662,6 +671,7 @@ const makeWsRpcLayer = ( const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const relayClient = yield* RelayClient.RelayClient; @@ -731,59 +741,67 @@ const makeWsRpcLayer = ( ), ); - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; - const settings = ServerSettings.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ); - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; + // Only clients that answer /usage-limits themselves see it in the catalogs; + // an older client would send the injected command to the provider. + const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const currentProviders = yield* providerRegistry.getProviders; + const providers = options.usageLimitsCommand + ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) + : currentProviders; + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; - return { - environment, - auth, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors, - // Same discovery-with-timeout treatment as editors: a slow probe - // must not stall server.getConfig, so it degrades to no targets. - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined - ? { otlpMetricsUrl: config.otlpMetricsUrl } - : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, - shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), - threadResumeCompletionMarker: true, - threadSnapshotPagination: true, - }; - }); + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors, + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined + ? { otlpTracesUrl: config.otlpTracesUrl } + : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + threadResumeCompletionMarker: true, + threadSnapshotPagination: true, + }; + }); const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster @@ -1001,7 +1019,9 @@ const makeWsRpcLayer = ( const base = yield* sql.withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getProjectShellsWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot({ location: "active" }); + const threads = yield* threadManagement.getShellSnapshot({ + location: "active", + }); return buildActiveShellSnapshot({ projects, threads, @@ -1011,7 +1031,10 @@ const makeWsRpcLayer = ( ); const enriched = yield* enrichProjectShells(base.projects); return { - snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, + snapshot: { + ...base, + projects: enriched.projects, + } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, }; }); @@ -1204,7 +1227,9 @@ const makeWsRpcLayer = ( .withTransaction( Effect.gen(function* () { const projects = yield* projectionSnapshotQuery.getProjectShellsWithoutEnrichment(); - const threads = yield* threadManagement.getShellSnapshot({ location: "archive" }); + const threads = yield* threadManagement.getShellSnapshot({ + location: "archive", + }); return { schemaVersion: threads.schemaVersion, snapshotSequence: yield* applicationEvents.latestApplicationSequence, @@ -1240,13 +1265,14 @@ const makeWsRpcLayer = ( Effect.forEach( coalesceStoredThreadEvents(Array.from(events)), (stored) => - threadManagement - .getThreadShell(stored.event.threadId) - .pipe( - Effect.map((shell) => - archivedShellStreamItemFromThreadShell({ stored, shell }), - ), + threadManagement.getThreadShell(stored.event.threadId).pipe( + Effect.map((shell) => + archivedShellStreamItemFromThreadShell({ + stored, + shell, + }), ), + ), { concurrency: 8 }, ), ), @@ -1274,7 +1300,9 @@ const makeWsRpcLayer = ( workspaceRoot: mutation.workspaceRoot, ...(mutation.createWorkspaceRootIfMissing === undefined ? {} - : { createWorkspaceRootIfMissing: mutation.createWorkspaceRootIfMissing }), + : { + createWorkspaceRootIfMissing: mutation.createWorkspaceRootIfMissing, + }), ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), @@ -1374,7 +1402,9 @@ const makeWsRpcLayer = ( ? command.parentThreadId : command.threadId, ...(command.type === "thread.fork" || command.type === "thread.merge_back" - ? { "orchestration_v2.source_thread_id": command.sourceThreadId } + ? { + "orchestration_v2.source_thread_id": command.sourceThreadId, + } : {}), }, ), @@ -1486,7 +1516,10 @@ const makeWsRpcLayer = ( ? undefined : claimed === null ? input.initialMessage - : { ...input.initialMessage, attachments: claimed.attachments }; + : { + ...input.initialMessage, + attachments: claimed.attachments, + }; return yield* startup .enqueueCommand( threadLaunch.launch({ @@ -1613,9 +1646,11 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }), [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverGetConfig, + loadServerConfig({ usageLimitsCommand: false }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -1868,6 +1903,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostResources]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, @@ -2220,6 +2259,28 @@ const makeWsRpcLayer = ( deletePendingAttachment(input.attachmentId), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect( + WS_METHODS.agentSessionsScan, + Effect.fail( + new AgentSessionScanError({ + operation: "read-projects", + cause: "Agent session import is unavailable under orchestration v2.", + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.agentSessionsImport]: () => + observeRpcEffect( + WS_METHODS.agentSessionsImport, + Effect.fail( + new AgentSessionScanError({ + operation: "read-projects", + cause: "Agent session import is unavailable under orchestration v2.", + }), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2548,6 +2609,8 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { + const usageLimitsCommand = input.usageLimitsCommand === true; + const config = yield* loadServerConfig({ usageLimitsCommand }); const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -2558,7 +2621,26 @@ const makeWsRpcLayer = ( }, })), ); - const providerStatuses = providerRegistry.streamChanges.pipe( + const providerStatuses = Stream.zipLatestWith( + // Seed both sides with their current values so a source refresh + // can update the catalog before the registry emits a change. + Stream.concat( + Stream.fromEffect(providerRegistry.getProviders), + providerRegistry.streamChanges, + ), + usageLimitSources.streamChanges.pipe( + Stream.changesWith( + usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, + ), + ), + (providers, sources) => + usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + ).pipe( + (updates) => Stream.concat(Stream.make(config.providers), updates), + Stream.changesWith( + (previous, next) => JSON.stringify(previous) === JSON.stringify(next), + ), + Stream.drop(1), Stream.map((providers) => ({ version: 1 as const, type: "providerStatuses" as const, @@ -2608,7 +2690,7 @@ const makeWsRpcLayer = ( Stream.make({ version: 1 as const, type: "snapshot" as const, - config: yield* loadServerConfig, + config, }), liveUpdates, ); @@ -2619,11 +2701,18 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerLifecycle, Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + lifecycleEvents.stream.pipe( + Stream.runForEach((event) => Queue.offer(liveBuffer, event)), + ), + { startImmediately: true }, + ); const snapshot = yield* lifecycleEvents.snapshot; const snapshotEvents = Array.from(snapshot.events).toSorted( (left, right) => left.sequence - right.sequence, ); - const liveEvents = lifecycleEvents.stream.pipe( + const liveEvents = Stream.fromQueue(liveBuffer).pipe( Stream.filter((event) => event.sequence > snapshot.sequence), ); return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx deleted file mode 100644 index 791004b74fad..000000000000 --- a/apps/web/src/AppRoot.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; -import { RouterProvider } from "@tanstack/react-router"; -import { describe, expect, it } from "vite-plus/test"; - -import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; -import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; -import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; -import type { AppRouter } from "./router"; -import { AppRoot } from "./AppRoot"; - -describe("AppRoot", () => { - it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { - const root = AppRoot({ router: {} as AppRouter }); - - expect(root.type).toBe(AppAtomRegistryProvider); - const children = Children.toArray( - (root as ReactElement<{ readonly children: ReactNode }>).props.children, - ); - expect(children).toHaveLength(4); - expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); - expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); - }); -}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 84ff979e4e89..5a9738c9fbfa 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -32,14 +32,6 @@ export function useAssetUrlState( ); } -export function useAssetUrl( - environmentId: EnvironmentId | null, - resource: AssetResource | null, -): string | null { - const result = useAssetUrlState(environmentId, resource); - return result._tag === "Success" ? result.url : null; -} - export function useAssetUrlRefresh( environmentId: EnvironmentId | null, resource: AssetResource | null, diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts new file mode 100644 index 000000000000..b44e919af4b3 --- /dev/null +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -0,0 +1,85 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, +} from "@t3tools/client-runtime/project-favicon-cache"; + +const DATABASE_NAME = "t3code:project-favicons"; +const DATABASE_VERSION = 2; +const STORE_NAME = "images"; +let database: Promise | undefined; + +function openDatabase() { + return (database ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + for (const name of request.result.objectStoreNames) { + if (name !== STORE_NAME) request.result.deleteObjectStore(name); + } + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); + })); +} + +function completed(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); +} + +async function withStore( + mode: IDBTransactionMode, + use: (store: IDBObjectStore) => IDBRequest | void, +) { + const transaction = (await openDatabase()).transaction(STORE_NAME, mode); + const request = use(transaction.objectStore(STORE_NAME)); + await completed(transaction); + return request?.result; +} + +/** Rasterizes a bitmap that is too large to inline, retrying at half size. */ +async function downscaleProjectFavicon( + image: { readonly mimeType: string; readonly bytes: Uint8Array }, + signal: AbortSignal, +) { + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); + try { + signal.throwIfAborted(); + const canvas = document.createElement("canvas"); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + const scale = Math.min(1, size / bitmap.width, size / bitmap.height); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable."); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/webp", 0.85); + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } + throw new Error("Project icon thumbnail exceeds the cache limit."); + } finally { + bitmap.close(); + } +} + +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], + put: async (key, entry) => { + await withStore("readwrite", (store) => store.put(entry, key)); + }, + remove: async (key) => { + await withStore("readwrite", (store) => store.delete(key)); + }, + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..dfe2b51d0400 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("keeps manual token submission pending until the session is authenticated", async () => { + vi.useFakeTimers(); + let authenticated = false; + let settled = false; + try { + const testApi = await installAuthApi({ + session: () => + authenticated + ? authenticatedSession(LOOPBACK_AUTH) + : unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { submitServerAuthCredential } = await import("./environments/primary"); + + const submission = submitServerAuthCredential("retry-token").finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBe(1); + expect(settled).toBe(false); + + authenticated = true; + await vi.advanceTimersByTimeAsync(100); + await expect(submission).resolves.toBeUndefined(); + expect(testApi.calls.session).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("fails manual token submission when the session is not established", async () => { + vi.useFakeTimers(); + try { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const submission = submitServerAuthCredential("retry-token"); + const failure = submission.then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it("rejects a blank pairing token with a structured validation error", async () => { const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = await import("./environments/primary/auth"); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts index ee4987794c33..087b5d109b40 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.test.ts +++ b/apps/web/src/browser/BrowserDeviceToolbar.test.ts @@ -1,10 +1,7 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { - commitViewportAndAspectRatio, - reconcileLockedAspectRatio, -} from "./browserDeviceToolbarState"; +import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; describe("commitViewportAndAspectRatio", () => { it("commits the aspect ratio only after the viewport succeeds", async () => { @@ -39,11 +36,3 @@ describe("commitViewportAndAspectRatio", () => { expect(onAspectRatioChange).not.toHaveBeenCalled(); }); }); - -describe("reconcileLockedAspectRatio", () => { - it("tracks external viewport ratios only while the lock remains active", () => { - expect(reconcileLockedAspectRatio(1.5, 16 / 9)).toBe(16 / 9); - expect(reconcileLockedAspectRatio(null, 16 / 9)).toBeNull(); - expect(reconcileLockedAspectRatio(1.5, null)).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx new file mode 100644 index 000000000000..4a241befef74 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -0,0 +1,200 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type ClientSettings, + type DesktopPreviewBridge, +} from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), + createTab: vi.fn(), + closeTab: vi.fn(), + registerWebview: vi.fn(), + getPreviewConfig: vi.fn(), + activeRecordings: new Set(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { + createTab: mocks.createTab, + closeTab: mocks.closeTab, + registerWebview: mocks.registerWebview, + getPreviewConfig: mocks.getPreviewConfig, + }, +})); + +vi.mock("~/components/preview/usePreviewBridge", () => ({ + usePreviewBridge: () => undefined, +})); + +vi.mock("./browserRecording", () => ({ + useActiveBrowserRecordingTabIds: () => mocks.activeRecordings, + stopBrowserRecording: async () => null, +})); + +import { + __resetClientSettingsPersistenceForTests, + ensureClientSettingsHydrated, +} from "~/hooks/useSettings"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import * as desktopTabLifetime from "./desktopTabLifetime"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +let renderer: ReactTestRenderer | undefined; + +function deferred() { + let resolve!: (value: A) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + mocks.getClientSettings.mockReset(); + mocks.setClientSettings.mockReset().mockResolvedValue(undefined); + mocks.createTab.mockReset().mockResolvedValue(undefined); + mocks.closeTab.mockReset().mockResolvedValue(undefined); + mocks.registerWebview.mockReset().mockResolvedValue(undefined); + mocks.getPreviewConfig.mockReset().mockResolvedValue({ + partition: "persist:t3-preview-work", + webPreferences: "contextIsolation=yes", + preloadUrl: null, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 0), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + vi.useFakeTimers(); + await act(() => renderer?.unmount()); + renderer = undefined; + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("HostedBrowserWebview settings hydration", () => { + it("starts a retained background tab only after a settings read succeeds on retry", async () => { + const firstRead = deferred(); + const retryRead = deferred(); + const tabCreation = deferred(); + mocks.getClientSettings + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(retryRead.promise); + mocks.createTab.mockReturnValueOnce(tabCreation.promise); + const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab"); + const createGuest = vi.fn((_attributes: unknown) => + Object.assign(new EventTarget(), { getWebContentsId: () => 41 }), + ); + const threadRef = { + environmentId: EnvironmentId.make("host-settings-retry"), + threadId: ThreadId.make("thread-settings-retry"), + }; + const runtimeTabId = "retained-background-tab"; + useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? createGuest(element.props) + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + const failure = new Error("Saved settings are unavailable"); + await act(async () => { + const hydration = ensureClientSettingsHydrated(); + firstRead.reject(failure); + await expect(hydration).rejects.toBe(failure); + }); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + let retry!: Promise; + await act(() => { + retry = ensureClientSettingsHydrated(); + }); + expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + await act(async () => { + retryRead.resolve({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", + }); + await retry; + }); + + expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); + expect(createGuest).toHaveBeenCalledOnce(); + expect(createGuest).toHaveBeenCalledWith( + expect.objectContaining({ + partition: "persist:t3-preview-work", + src: "https://example.com", + }), + ); + expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { + zoomFactor: 1.25, + colorScheme: "dark", + }); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + + await act(async () => { + tabCreation.resolve(); + await tabCreation.promise; + }); + expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); + expect(mocks.closeTab).not.toHaveBeenCalled(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0f01960ce52b..42d5bcfb35b8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { zoomFactor, profileId, } = props; + const clientSettingsHydrated = useClientSettingsHydrated(); const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { + if (!clientSettingsHydrated) return; crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; - }, [runtimeTabId]); + }, [clientSettingsHydrated, runtimeTabId]); const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { useEffect(() => { const webview = webviewRef.current; const bridge = previewBridge; - if (!webview || !config || !bridge) return; + if (!clientSettingsHydrated || !webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; const register = () => { @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, initialSrc, runtimeTabId, webviewGeneration]); + }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index bac9600c182b..ed86cde1c9c8 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ getClientSettings: () => settings.current, useClientSettings: () => undefined, - ensureClientSettingsHydrated: () => Promise.resolve(), + ensureClientSettingsHydrated: vi.fn(async () => undefined), })); -const { getBrowserDefaults } = await import("./browserDefaults"); +const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); const withDefaultProfile = (browserDefaultProfileId: string) => { settings.current = { @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { ); }); }); + +describe("resolveBrowserDefaults", () => { + it("rejects failed reads and uses the saved profile after a successful retry", async () => { + withDefaultProfile("work"); + settings.current.browserDefaultZoomFactor = 1.25; + settings.current.browserDefaultAppearance = "dark"; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserDefaults()).rejects.toBe(failure); + await expect(resolveBrowserDefaults()).resolves.toMatchObject({ + viewport: { _tag: "fill" }, + zoomFactor: 1.25, + appearance: "dark", + autoShowFloatingPreview: true, + profileId: "work", + }); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index eaae409568a2..6141b1a52fa9 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults { * Opening a preview is asynchronous anyway, and before hydration the snapshot * is the schema defaults rather than the user's — a tab opened in that window * would be born at the wrong viewport, zoom and appearance and never corrected. + * Read failures reject so a new tab cannot use the wrong profile or viewport. */ export async function resolveBrowserDefaults(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts index 9986ee022829..70a8e597428b 100644 --- a/apps/web/src/browser/browserDeviceToolbarState.ts +++ b/apps/web/src/browser/browserDeviceToolbarState.ts @@ -1,12 +1,5 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; -export function reconcileLockedAspectRatio( - current: number | null, - viewportAspectRatio: number | null, -): number | null { - return current === null || viewportAspectRatio === null ? null : viewportAspectRatio; -} - export async function commitViewportAndAspectRatio( setting: PreviewViewportSetting, aspectRatio: number | null, diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 94f97001c96f..a60362c43bd5 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { BrowserLinkTarget } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveLinkTarget } from "./browserLinkTarget"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + +import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; + +const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); + +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: vi.fn(async () => undefined), + getClientSettings: () => settings, +})); const click = { metaKey: false, ctrlKey: false }; @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { } }); }); + +describe("resolveBrowserLinkTargetPreference", () => { + it.each(["system", "app"] as const)( + "rejects failed reads instead of using the current %s preference", + async (preference) => { + settings.browserLinkTarget = preference; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); + await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); + }, + ); +}); diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index d03775572747..7ecffb4593d5 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { * hydration the snapshot is the schema default ("system"), so a link clicked * in the first moments after launch would ignore a persisted "app" — opening * is asynchronous anyway, so waiting costs nothing the user can see. + * Read failures reject rather than choosing a browser without the saved preference. */ export async function resolveBrowserLinkTargetPreference(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 49145f314e98..5cfe614f2985 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const { clientSettings, events, @@ -241,6 +243,35 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); + it("clears a failed settings read before retrying recording", async () => { + const tabId = "settings-read-failure-tab"; + const error = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); + + await expect(startBrowserRecording(tabId)).rejects.toBe(error); + + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + expect(animationFrameCount).toBe(0); + expect(startScreencast).not.toHaveBeenCalled(); + expect(stopScreencast).not.toHaveBeenCalled(); + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(FakeMediaRecorder.instances).toHaveLength(0); + + clientSettings.browserRecordingFrameRate = 60; + await startBrowserRecording(tabId); + + expect(getDisplayMedia).toHaveBeenCalledWith({ + audio: false, + video: { frameRate: { max: 60 } }, + }); + await stopBrowserRecording(tabId); + + expect(startScreencast).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + }); + it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValueOnce({ diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 73bc2708ddf6..c7825961abbc 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -516,10 +516,12 @@ export async function startBrowserRecording( activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); try { - const frameRatePromise = ensureClientSettingsHydrated().then( - () => getClientSettings().browserRecordingFrameRate, - ); - const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); + await ensureClientSettingsHydrated().catch((cause: unknown) => { + clearActiveRecording(recording); + throw cause; + }); + const frameRate = getClientSettings().browserRecordingFrameRate; + await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. // Only a contended start can be cancelled before it reaches native capture. diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index b80f68af3f00..64a4345dfb0d 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -4,7 +4,7 @@ type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; -export class BrowserViewportCommitTimeoutError extends Error { +class BrowserViewportCommitTimeoutError extends Error { override readonly name = "BrowserViewportCommitTimeoutError"; constructor(readonly tabId: string) { diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 80bfa0d275d7..c5338ecf4ccf 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, + DEFAULT_CLIENT_SETTINGS, EnvironmentId, ThreadId, } from "@t3tools/contracts"; @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; +import * as browserDefaults from "./browserDefaults"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; -/** Client settings are unset in tests, so creation carries the schema defaults. */ +/** Tests load default settings unless they select other preferences. */ const DEFAULT_TAB_STATE = { zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; describe("desktopTabLifetime", () => { beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); closeTab.mockClear(); createTab.mockClear(); stopBrowserRecording.mockClear(); @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { + vi.useFakeTimers(); + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const failed = acquireDesktopTab("tab_settings_retry"); + + await expect(failed.ready).rejects.toBe(failure); + expect(createTab).not.toHaveBeenCalled(); + failed.release(); + await vi.advanceTimersByTimeAsync(0); + + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + }); + createTab.mockResolvedValueOnce(undefined); + const retry = acquireDesktopTab("tab_settings_retry"); + await retry.ready; + + expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { + zoomFactor: 1.25, + colorScheme: "dark", + }); + retry.release(); + await vi.advanceTimersByTimeAsync(0); }); it("shares tab creation readiness across concurrent leases", async () => { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index f506e42e73e5..a320e3ba34da 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( readonly message: string; }> {} +export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{ + readonly cause: unknown; +}> { + override get message(): string { + return "Saved browser settings could not be loaded."; + } +} + export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; readonly input: PreviewOpenInput; @@ -47,8 +55,13 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; -}): Promise> { - const defaults = await resolveBrowserDefaults(); +}): Promise> { + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -82,7 +95,12 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError + > +> { if (!isPreviewSupportedInRuntime()) { return AsyncResult.failure( Cause.fail( diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 0e9bf721f82d..2a1d122eedbf 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,5 +1,8 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; @@ -12,7 +15,7 @@ import { resolveBrowserLinkTargetPreference, resolveLinkTarget, } from "./browserLinkTarget"; -import { openUrlInPreview } from "./openFileInPreview"; +import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview"; const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; * * An in-app open that fails falls back to the system browser rather than * dropping the click: the user asked for the link, and the setting only says - * where it should go first. The returned promise rejects only when that - * fallback fails too, the same way `shell.openExternal` does. + * where it should go first. Failed settings reads reject without opening a + * browser. The promise also rejects if the system-browser fallback fails. */ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url: string, @@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( recordVisitForThread(targetThreadRef, url); return; } + const failure = squashAtomCommandFailure(result); + if (failure instanceof BrowserSettingsReadError) throw failure; console.error(result.cause); } const api = readLocalApi(); diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts index 2267f4a812dc..606244d43643 100644 --- a/apps/web/src/browser/webviewCrashRecovery.ts +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -1,6 +1,6 @@ export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; -export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; -export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; +const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; +const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; export interface WebviewCrashRecoveryState { readonly attempts: number; diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts index 695bcff20e95..55adc129c3b2 100644 --- a/apps/web/src/browserFaviconLogic.ts +++ b/apps/web/src/browserFaviconLogic.ts @@ -9,7 +9,7 @@ export type BrowserFaviconEntry = { }; export const BROWSER_FAVICON_MAX_ENTRIES = 40; -export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; +const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4; const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255; diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index 4c0a560817bb..7909fef95700 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -14,7 +14,7 @@ export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; export const BROWSER_HISTORY_MAX_PROJECTS = 20; -export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; const MAX_VALID_DATE_MS = 8_640_000_000_000_000; @@ -35,7 +35,7 @@ export function normalizeHistoryUrl(raw: string): string | null { return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; } -export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { +function titleLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(visitLookupKey(normalized, environmentHostname)); if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index db69fe96c80a..a86177b48eb3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); - it("reports structured decode failures while preserving the fallback", async () => { + it.each(["not-json", '{"wordWrap":"invalid"}'])( + "does not treat invalid saved settings as absent: %s", + async (value) => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", value); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(() => readBrowserClientSettings()).toThrow( + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + }), + ); + expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); + }, + ); + + it("preserves saved settings across a transient read failure", async () => { const testWindow = getTestWindow(); - testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + const write = vi.spyOn(testWindow.localStorage, "setItem"); + const failure = new Error("storage unavailable"); + vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { + throw failure; + }); const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - expect(readBrowserClientSettings()).toBeNull(); - expect(consoleError).toHaveBeenCalledWith( - "Could not read persisted client settings.", + expect(() => readBrowserClientSettings()).toThrow( expect.objectContaining({ _tag: "LocalStorageOperationError", - operation: "decode", + operation: "read", storageKey: "t3code:client-settings:v1", - cause: expect.anything(), + cause: failure, }), ); + expect(readBrowserClientSettings()).toEqual(settings); + expect(write).not.toHaveBeenCalled(); }); it("defaults word wrap on and discards obsolete wrapping preferences", async () => { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 5c0ba7c6eccf..e1c1459facb3 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -2,7 +2,7 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; -export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; function hasWindow(): boolean { return typeof window !== "undefined"; @@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null { return null; } - try { - return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch (error) { - console.error("Could not read persisted client settings.", error); - return null; - } + return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); } export function writeBrowserClientSettings(settings: ClientSettings): void { diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 815715da2499..0bc65080cf8c 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -12,7 +12,7 @@ import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./ const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; -export function resolveConnectCliOAuthClientId(): string | null { +function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 7ae5e7ed03a9..3ae0dbd74289 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -26,10 +26,7 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { - collectCloudLinkTargets, linkPrimaryEnvironmentToCloud, - listManagedCloudEnvironments, - normalizeRelayBaseUrl, readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, @@ -155,48 +152,6 @@ afterEach(() => { }); describe("web cloud link environment client", () => { - it("normalizes relay URLs and de-duplicates cloud link targets", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - expect( - collectCloudLinkTargets({ - primary: TARGET, - saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], - }).map((target) => target.environmentId), - ).toEqual(["environment-1", "environment-2"]); - }); - - it.effect("lists relay-managed environments through the typed relay client", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - environments: [ - { - environmentId: "environment-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-06T00:00:00.000Z", - }, - ], - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const environments = yield* withServices( - listManagedCloudEnvironments({ clerkToken: "clerk-token" }), - ); - - expect(environments).toHaveLength(1); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - }), - ); - it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 29353e480f50..f88e7863969d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -16,7 +16,6 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { - type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; @@ -33,14 +32,6 @@ import { requestRelayClientInstallConfirmation, } from "./relayClientInstallDialog"; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function relayUrl(): string | null { return resolveCloudPublicConfig().relayUrl; } @@ -194,53 +185,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export function collectCloudLinkTargets(input: { - readonly primary: CloudLinkTarget | null; - readonly saved: ReadonlyArray; -}): ReadonlyArray { - const byId = new Map(); - if (input.primary) { - byId.set(input.primary.environmentId, input.primary); - } - for (const environment of input.saved) { - if (!byId.has(environment.environmentId)) { - byId.set(environment.environmentId, environment); - } - } - return [...byId.values()]; -} - -export function listManagedCloudEnvironments(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list relay-managed environments.", - cause, - }), - ), - ); - }); -} - export function readPrimaryCloudLinkState(input: { readonly target: CloudLinkTarget; }): Effect.Effect { diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 52f9b6496c95..b5ce11e842f5 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -13,7 +13,7 @@ import { type BrowserDpopKey, } from "./dpop"; -export const relayDpopSignerLayer = Layer.effect( +const relayDpopSignerLayer = Layer.effect( ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 9a56bde88514..c8f33d1d9d3d 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -33,7 +33,7 @@ const managedRelayAtomRuntime = Atom.runtime( ), ); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); const managedRelayMutationScheduler = createAtomCommandScheduler(); @@ -114,10 +114,3 @@ export function useManagedRelayDevices() { refresh, }; } - -export function refreshManagedRelayEnvironments(): void { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session) { - managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); - } -} diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 34fdacd214af..c5871fa65d66 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -42,7 +42,7 @@ function targetKey(target: CloudLinkTarget): string { return JSON.stringify(target); } -export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { +function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { if (target) { appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 046aadfda7ff..5b9f466013ec 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,6 +6,7 @@ import { FolderGitIcon, FolderIcon, HistoryIcon, + ScaleIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -58,6 +59,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -68,6 +71,8 @@ interface BranchToolbarProps { } interface MobileRunContextSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; @@ -83,6 +88,8 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, envModeLocked, environmentId, @@ -116,10 +123,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + {autoEnvironmentLabel ? ( + ) : ( @@ -136,7 +147,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ data-composer-label-motion className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" > - {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {autoEnvironmentLabel ?? + (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} @@ -169,9 +181,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Run on onEnvironmentChange(value as EnvironmentId)} + value={autoEnvironmentLabel ? "auto" : environmentId} + onValueChange={(value) => + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange(value as EnvironmentId) + } > + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + + + )} {availableEnvironments.map((env) => ( { + (branch: string | null, worktreePath: string | null, automatic = false) => { if (!activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ @@ -194,6 +195,7 @@ export function BranchToolbarBranchSelector({ branch, worktreePath, envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, @@ -209,6 +211,7 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, ], @@ -515,7 +518,7 @@ export function BranchToolbarBranchSelector({ ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null); + setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, @@ -905,7 +908,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - void) | undefined; envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; @@ -30,6 +33,8 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, environmentId, availableEnvironments, @@ -41,12 +46,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir }, [availableEnvironments, environmentId]); const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ + () => [ + ...(onAutoEnvironment + ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + : []), + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label, })), - [availableEnvironments], + ], + [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], ); // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -80,8 +89,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir return ( } - {colors.map((color) => { - const colorValue = getColorValue(color); - return ( -
handleColorSelect(color)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleColorSelect(color); - } - }} - tabIndex={0} - role="button" - aria-label={`Select ${color} color`} - aria-pressed={selectedColor === color} - /> - ); - })} -
- ); -} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 82ef5850cc1f..7c85ead29b27 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -104,6 +104,7 @@ describe("shouldUseRestingComposerLayout", () => { isScrollCollapsed: false, hasExpandedChrome: false, collapseOnBlur: true, + timelineOverflows: true, }; it("uses the resting layout for an unfocused desktop composer", () => { @@ -131,6 +132,17 @@ describe("shouldUseRestingComposerLayout", () => { ).toBe(true); }); + it("keeps the composer expanded while the timeline fits above it", () => { + expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false); + expect( + shouldUseRestingComposerLayout({ + ...resting, + isScrollCollapsed: true, + timelineOverflows: false, + }), + ).toBe(false); + }); + it("keeps new-thread composers expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); }); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index b7b7d91a033c..5dd6000dbc75 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,6 +1,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; -export const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; +const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; export function getRestingComposerImagePreviewCounts(imageCount: number): { visibleCount: number; @@ -30,6 +30,8 @@ export function shouldUseRestingComposerLayout(input: { isScrollCollapsed: boolean; hasExpandedChrome: boolean; collapseOnBlur: boolean; + /** Whether the timeline has more content than fits above the composer. */ + timelineOverflows: boolean; }): boolean { // Passive draft content is deliberately absent here. Resting only clamps // the prompt row and overlays its actions; non-image attachment and context @@ -44,8 +46,18 @@ export function shouldUseRestingComposerLayout(input: { // the user asked for it with the gesture, and it lifts on the next // composer interaction. With blur collapse off, losing focus alone never // rests the composer. + // + // Resting exists to give reading space back to the timeline. A thread that + // fits above the composer has nothing to reclaim, so it stays expanded and + // never shows the collapsed row that a fresh thread would otherwise open on. const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused); - return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; + return ( + input.isExistingThread && + !input.isMobileViewport && + input.timelineOverflows && + collapsed && + !input.hasExpandedChrome + ); } /** diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index e97a46a2a258..3e941e69dd53 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; import { projectEnvironment } from "../../state/projects"; @@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() { ) ?? null, createProject: async (environmentId, workspaceRoot) => { const projectId = newProjectId(); - const providers = - primaryEnvironment?.environmentId === environmentId - ? (primaryEnvironment.serverConfig?.providers ?? []) - : []; const result = await createProject({ environmentId, input: { @@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() { title: inferProjectTitleFromPath(workspaceRoot), workspaceRoot, createWorkspaceRootIfMissing: false, - defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + defaultModelSelection: null, }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index dd05693d28b2..fcc97825b681 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -12,7 +12,6 @@ import { isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; @@ -42,7 +41,6 @@ describe("desktop update button state", () => { status: "available", availableVersion: "1.1.0", }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); }); @@ -55,7 +53,6 @@ describe("desktop update button state", () => { errorContext: "download", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -70,7 +67,6 @@ describe("desktop update button state", () => { errorContext: "install", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -85,7 +81,6 @@ describe("desktop update button state", () => { errorContext: null, canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to restart and install"); }); @@ -111,7 +106,7 @@ describe("desktop update button state", () => { expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); - it("hides the button for non-actionable check errors", () => { + it("has no action for non-actionable check errors", () => { const state: DesktopUpdateState = { ...baseState, status: "error", @@ -119,7 +114,6 @@ describe("desktop update button state", () => { errorContext: "check", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(false); expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); @@ -130,7 +124,6 @@ describe("desktop update button state", () => { availableVersion: "1.1.0", downloadPercent: 42.5, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(isDesktopUpdateButtonDisabled(state)).toBe(true); expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 656ffbea8198..4a169cb3ef40 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -47,16 +47,6 @@ export function resolveDesktopUpdateButtonAction( return "none"; } -export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { - if (!state || !state.enabled) { - return false; - } - if (state.status === "downloading") { - return true; - } - return resolveDesktopUpdateButtonAction(state) !== "none"; -} - export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { return state?.hostArch === "arm64" && state.appArch === "x64"; } diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx deleted file mode 100644 index 81cd5625e62b..000000000000 --- a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - codeViewOptions: null as Record | null, -})); - -vi.mock("@pierre/diffs/react", () => ({ - CodeView: (props: { options: Record }) => { - testState.codeViewOptions = props.options; - return null; - }, -})); - -vi.mock("../DiffWorkerPoolProvider", () => ({ - DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, -})); - -vi.mock("~/composerDraftStore", () => ({ - useComposerDraftStore: (selector: (store: Record) => unknown) => - selector({ - addReviewComment: vi.fn(), - removeReviewComment: vi.fn(), - getComposerDraft: () => undefined, - }), -})); - -vi.mock("./DiffCommentAnnotation", () => ({ - DiffCommentAnnotation: () => null, -})); - -vi.mock("../files/fileCommentAnnotations", () => ({ - nextFileCommentId: () => "comment-test", -})); - -import { AnnotatableCodeView } from "./AnnotatableCodeView"; - -describe("AnnotatableCodeView", () => { - beforeEach(() => { - testState.codeViewOptions = null; - }); - - it("opens comments from Pierre's gutter action without ending line selection", () => { - renderToStaticMarkup( - null} - renderHeaderFilenameSuffix={() => null} - />, - ); - - expect(testState.codeViewOptions).toMatchObject({ - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: expect.any(Function), - }); - expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd"); - }); -}); diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx new file mode 100644 index 000000000000..3a97c60254ab --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx @@ -0,0 +1,193 @@ +import type { CodeViewScrollTarget } from "@pierre/diffs"; +import type { FileTree as FileTreeModel } from "@pierre/trees"; +import { FileTree } from "@pierre/trees/react"; +import { act, type MouseEvent, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; +import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; + +vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +// Tooltip positioning is unrelated to the tree's actual model and activation path. +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, + TooltipPopup: () => null, +})); + +const entries: DiffFileTreeEntry[] = [ + { path: "01-tall.ts", status: "modified" }, + { path: "02-short.ts", status: "modified" }, + { path: "03-medium.ts", status: "modified" }, +]; + +class TreeRow { + constructor(readonly path: string) {} + + getAttribute(name: string) { + return name === "data-item-path" ? this.path : null; + } +} + +describe("diff tree file activation", () => { + let renderer: ReactTestRenderer | undefined; + const targets: CodeViewScrollTarget[] = []; + const viewer = { + getInstance: () => viewer, + scrollTo: (target: CodeViewScrollTarget) => targets.push(target), + }; + + function Panel({ + files = entries, + selectedPath = null, + }: { + files?: DiffFileTreeEntry[]; + selectedPath?: string | null; + }) { + const reveal = useCodeViewFileReveal(viewer, "working-tree"); + return ( + reveal(`${path}\0${path}`)} + /> + ); + } + + const model = (): FileTreeModel => renderer!.root.findByType(FileTree).props.model; + + async function mount(props: Parameters[0] = {}) { + await act(async () => { + renderer = create(); + }); + } + + // Exercise T3's capture handler before the real Pierre model's selection transition. + // Only DOM hit testing is represented here; native pointer/keyboard dispatch and diff + // geometry are verified separately in the integrated client. + async function activate(path: string, modifiers: Partial> = {}) { + const event = { + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + defaultPrevented: false, + nativeEvent: { composedPath: () => [{}, new TreeRow(path), {}] }, + ...modifiers, + } as MouseEvent; + await act(async () => { + renderer!.root + .find((node) => String(node.type) === "file-tree-container") + .props.onClickCapture?.(event); + const tree = model(); + const item = tree.getItem(path)!; + if (event.ctrlKey || event.metaKey) { + item.toggleSelect(); + } else { + for (const selected of tree.getSelectedPaths()) { + if (selected !== path) tree.getItem(selected)?.deselect(); + } + item.select(); + } + item.focus(); + if ("toggle" in item && !event.ctrlKey && !event.metaKey && !event.shiftKey) item.toggle(); + }); + } + + beforeEach(() => { + targets.length = 0; + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("HTMLElement", TreeRow); + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("reissues the reveal when the sole selected file is activated again", async () => { + await mount(); + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + await activate("02-short.ts"); + expect(targets).toEqual([ + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + ]); + }); + + it("reveals newly selected files once in either direction", async () => { + await mount(); + await activate("02-short.ts"); + await activate("01-tall.ts"); + await activate("03-medium.ts"); + expect(targets.map((target) => ("id" in target ? target.id : null))).toEqual( + ["02-short.ts", "01-tall.ts", "03-medium.ts"].map((path) => `${path}\0${path}`), + ); + }); + + it("keeps focus-only navigation separate from button activation", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.focus()); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + await activate("01-tall.ts", { detail: 0 }); + await activate("01-tall.ts", { detail: 0 }); + expect(targets).toHaveLength(3); + }); + + it.each(["ctrlKey", "metaKey"] as const)( + "does not reveal a selected file that a %s click deselects", + async (modifier) => { + await mount(); + await activate("02-short.ts"); + await activate("02-short.ts", { [modifier]: true }); + expect(model().getSelectedPaths()).toEqual([]); + expect(targets).toHaveLength(1); + }, + ); + + it("lets a click narrow multiple selected files without a second reveal", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.select()); + expect(model().getSelectedPaths()).toHaveLength(2); + targets.length = 0; + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + }); + + it("leaves directory selection and expansion to the tree", async () => { + await mount({ files: [{ path: "src/app.ts", status: "modified" }] }); + const directory = model().getItem("src/")!; + if (!("isExpanded" in directory)) throw new Error("Expected the directory handle"); + expect(directory.isExpanded()).toBe(true); + await activate("src/"); + expect(directory.isExpanded()).toBe(false); + await activate("src/"); + expect(directory.isExpanded()).toBe(true); + expect(targets).toEqual([]); + }); + + it("does not echo controlled selection, but lets the reader activate it", async () => { + await mount({ selectedPath: "02-short.ts" }); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toEqual([]); + await activate("02-short.ts"); + expect(targets).toHaveLength(1); + await act(async () => { + renderer!.update(); + }); + expect(model().getSelectedPaths()).toEqual(["03-medium.ts"]); + expect(targets).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 3715b62ca15a..0d200853bcb4 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -177,6 +177,29 @@ export function DiffFileTree({ { + if ( + event.defaultPrevented || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + return; + } + // Pierre does not emit a selection change for its sole selected row. + // Read selection before the row handles the click so new selections reveal only once. + const selected = model.getSelectedPaths(); + const path = selected.length === 1 ? selected[0] : undefined; + if (!path || !filePathsRef.current.has(path)) return; + const clickedSelectedRow = event.nativeEvent + .composedPath() + .some( + (node) => node instanceof HTMLElement && node.getAttribute("data-item-path") === path, + ); + if (clickedSelectedRow) onSelectFileRef.current(path); + }} className="min-h-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} /> diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index 6a746b40f9af..ee249888a415 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -18,13 +18,10 @@ import { useState, type Ref, } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ - codeViewClassName: null as string | null, - codeViewOptions: null as Record | null, workers: [] as NodeWorkerThreads.Worker[], terminations: [] as Promise[], requests: [] as WorkerRequest[], @@ -102,8 +99,6 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => { vi.mock("@pierre/diffs/react", async (importOriginal) => ({ ...(await importOriginal()), CodeView: (props: CodeViewProps) => { - testState.codeViewClassName = props.className ?? null; - testState.codeViewOptions = props.options ? { ...props.options } : null; return props.items?.map((item) => item.type === "file" ? : null, ); @@ -158,49 +153,6 @@ function renderViews(count: number) { ); } -describe("StyledDiffCodeView", () => { - beforeEach(() => { - testState.codeViewClassName = null; - testState.codeViewOptions = null; - }); - - it("always pairs the shared diff styling with its virtualized geometry", () => { - const loadDiffFiles = vi.fn(async () => ({ - oldFile: { name: "before.ts", contents: "before\n" }, - newFile: { name: "after.ts", contents: "after\n" }, - })); - renderToStaticMarkup( - , - ); - - expect(testState.codeViewClassName).toBe( - "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", - ); - expect(testState.codeViewOptions).toMatchObject({ - theme: "pierre-dark", - stickyHeaders: true, - loadDiffFiles, - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - paddingTop: 0, - paddingBottom: 8, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, - }); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining("[data-unmodified-lines]::before"), - ); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), - ); - }); -}); - describe("code-view worker lifecycle", () => { let renderer: ReactTestRenderer | undefined; diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index 7c7bfe8e9ff9..4896c9ce2df0 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -1,5 +1,7 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { Spinner } from "~/components/ui/spinner"; import type { EnvironmentId } from "@t3tools/contracts"; -import { ArrowLeftIcon, ChevronRightIcon, LoaderCircleIcon, RotateCwIcon } from "lucide-react"; +import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon"; @@ -124,12 +126,12 @@ function BreadcrumbMenuContent(props: { {entriesQuery.isPending && entriesQuery.data === null ? ( - + Loading folder… ) : entriesQuery.error && entriesQuery.data === null ? ( - + Retry loading folder ) : !directoryAvailable && !entriesTruncated ? ( @@ -177,7 +179,7 @@ function BreadcrumbMenuContent(props: { <> - + Refresh failed — retry diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index dc3cfcef0137..49894db3c8cf 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { ContextMenuItem as TreeContextMenuItem, ContextMenuOpenContext as TreeContextMenuOpenContext, @@ -5,7 +6,7 @@ import type { import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; @@ -16,7 +17,6 @@ import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; -import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; @@ -57,7 +57,7 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void } /> } > - + {props.isPending ? "Refreshing…" : "Refresh files"} @@ -373,7 +373,7 @@ export default function FileBrowserPanel({ data-file-browser-panel={`${environmentId}:${cwd}`} >
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 59c81166de05..5c89d1462d73 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { ChatFileAttachment, EditorId, @@ -18,7 +19,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import { Code2, Eye, FolderTree, Globe2 } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -198,7 +199,7 @@ function WorkspaceImagePreview(props: {
) : (
- +
); } @@ -252,7 +253,7 @@ function AttachmentBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
- +
); } @@ -308,7 +309,7 @@ function WorkspaceBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
- +
); } @@ -1261,11 +1262,15 @@ export default function FilePreviewPanel({
) : relativePath && file.data === null ? (
- +
) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( + // Markdown reconciles in place across text updates, so a file + // switch needs a new key or the previous file's disclosure and + // wrap state carries into the next document. { +describe("file cache identity", () => { it("changes for same-length edits", () => { - expect(fileContentRevision("nodeVersion")).not.toBe(fileContentRevision("nodeVeasdrs")); - }); - - it("keeps identical contents stable", () => { - expect(projectFileCacheKey("/repo", "file.json", "contents")).toBe( - projectFileCacheKey("/repo", "file.json", "contents"), + expect(projectFileCacheKey("/repo", "file.json", "nodeVersion")).not.toBe( + projectFileCacheKey("/repo", "file.json", "nodeVeasdrs"), ); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index e51d464925bd..b4e1698a34dc 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -1,4 +1,4 @@ -export function fileContentRevision(contents: string): string { +function fileContentRevision(contents: string): string { let hash = 2_166_136_261; for (let index = 0; index < contents.length; index += 1) { hash ^= contents.charCodeAt(index); diff --git a/apps/web/src/components/files/fileEditorHighlight.test.ts b/apps/web/src/components/files/fileEditorHighlight.test.ts index 4de146835246..39624d953c09 100644 --- a/apps/web/src/components/files/fileEditorHighlight.test.ts +++ b/apps/web/src/components/files/fileEditorHighlight.test.ts @@ -64,6 +64,7 @@ let renderer: FileRenderer; let tokenizer: Tokenizer; let file: FileContents; let document: TextDocument; +const animationFrames = new Set>(); function nextResponse(): Promise { const response = responses.shift(); @@ -147,10 +148,18 @@ beforeEach(async () => { responses = []; responseWaiters = []; terminationPromises = []; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => - setImmediate(() => callback(0)), - ); - vi.stubGlobal("cancelAnimationFrame", clearImmediate); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const frame = setImmediate(() => { + animationFrames.delete(frame); + callback(0); + }); + animationFrames.add(frame); + return frame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType) => { + animationFrames.delete(frame); + clearImmediate(frame); + }); vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); pool = new WorkerPoolManager( // Adapt browser transport only; Pierre's real worker produces each response. @@ -176,15 +185,41 @@ beforeEach(async () => { renderContents(); }); -afterEach(async () => { +async function cleanUpFixture() { tokenizer?.cleanUp(); renderer?.cleanUp(); pool?.terminate(); await Promise.all(terminationPromises); + // Pool termination can queue a final broadcast after its workers have exited. + for (const frame of animationFrames) clearImmediate(frame); + animationFrames.clear(); vi.unstubAllGlobals(); -}); +} + +afterEach(cleanUpFixture); describe("editable file highlighting", () => { + it("cleans up an already terminated worker pool", async () => { + (await nextResponse()).deliver(); + expect(pool.getStats().totalWorkers).toBe(1); + pool.terminate(); + await Promise.all(terminationPromises); + expect(pool.getStats().totalWorkers).toBe(0); + + const animationFrame = globalThis.requestAnimationFrame; + const cancelFrame = globalThis.cancelAnimationFrame; + const window = globalThis.window; + try { + await cleanUpFixture(); + // Deliver the real Immediate queue after cleanup has removed the browser globals. + await new Promise((resolve) => setImmediate(resolve)); + } finally { + vi.stubGlobal("requestAnimationFrame", animationFrame); + vi.stubGlobal("cancelAnimationFrame", cancelFrame); + vi.stubGlobal("window", window); + } + }); + it("still accepts an asynchronous highlight when the file has not changed", async () => { expect(renderContents()).not.toContain('style="color:'); (await nextResponse()).deliver(); diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts new file mode 100644 index 000000000000..520c0fa82d0e --- /dev/null +++ b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts @@ -0,0 +1,186 @@ +import { + FileRenderer, + disposeHighlighter, + getSharedHighlighter, + type BaseCodeOptions, + type DiffsHighlighter, + type FileContents, + type HighlightedToken, +} from "@pierre/diffs"; +import { TextDocument } from "@pierre/diffs/editor"; +import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; +import * as NodeWorkerThreads from "node:worker_threads"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DocumentChange = NonNullable["applyEdits"]>>; +interface Tokenizer { + tokenize(change: DocumentChange): Map; + cleanUp(): void; +} + +const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); +const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { + EditorTokenizer: new (options: { + codeOptions: BaseCodeOptions; + highlighter: DiffsHighlighter; + textDocument: TextDocument; + setStyle: (style: string) => void; + onDeferTokenize: () => void; + }) => Tokenizer; +}; + +const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); +const options = { + theme: "pierre-dark", + themeType: "dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, +} as const; +const source = "export const View = () =>
Ready
;"; +let pool: WorkerPoolManager; +let renderer: FileRenderer; +let terminationPromises: Promise[]; + +class WorkerTransport { + private readonly worker = new NodeWorkerThreads.Worker( + `const { parentPort, workerData } = require("node:worker_threads"); + globalThis.self = { + addEventListener(type, listener) { + if (type === "message") parentPort.on("message", data => listener({ data })); + if (type === "error") process.on("uncaughtException", listener); + } + }; + globalThis.postMessage = data => parentPort.postMessage(data); + import(workerData.moduleUrl);`, + { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, + ); + + addEventListener( + type: "message" | "error", + listener: (event: { data: WorkerResponse } | Error) => void, + ) { + if (type === "error") this.worker.on("error", listener); + else this.worker.on("message", (data: WorkerResponse) => listener({ data })); + } + + postMessage(message: WorkerRequest) { + this.worker.postMessage(message, []); + } + + terminate() { + terminationPromises.push(this.worker.terminate()); + } +} + +function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: string) { + const document = new TextDocument(file.name, file.contents, language); + const tokenizer = new EditorTokenizer({ + codeOptions: options, + highlighter, + textDocument: document, + setStyle: () => {}, + onDeferTokenize: () => {}, + }); + try { + const end = document.positionAt(file.contents.length); + const change = document.applyEdits([{ range: { start: end, end }, newText: "\n" }]); + expect(change).toBeDefined(); + // This is the synchronous first edit, before the tokenizer's debounced prebuild. + const dirtyLines = tokenizer.tokenize(change!); + expect([...dirtyLines.keys()]).toEqual([0, 1]); + expect(document.getText()).toBe(`${file.contents}\n`); + } finally { + tokenizer.cleanUp(); + } +} + +beforeEach(async () => { + terminationPromises = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setImmediate(() => callback(0)), + ); + vi.stubGlobal("cancelAnimationFrame", clearImmediate); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + await disposeHighlighter(); + pool = new WorkerPoolManager( + // Adapt transport only. The installed Pierre worker resolves and highlights the file. + { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, + options, + ); + await pool.initialize(); + renderer = new FileRenderer(options, undefined, pool); +}); + +afterEach(async () => { + renderer?.cleanUp(); + pool?.terminate(); + await Promise.all(terminationPromises); + await disposeHighlighter(); + vi.unstubAllGlobals(); +}); + +describe("editable file language readiness", () => { + it.each(["hydrate", "renderFile"] as const)( + "%s prepares the inferred language before the first edit of a worker-highlighted file", + async (method) => { + const file = { name: "cold.tsx", contents: source, cacheKey: "cold-tsx" }; + await pool.primeFileHighlightCache(file); + expect(pool.getFileResultCache(file)).toBeDefined(); + const mainHighlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + }); + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + renderer[method](file); + // Read-only worker rendering must not load editor grammars on the main thread. + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "tsx"); + }, + ); + + it.each(["hydrate", "renderFile"] as const)( + "%s respects an explicit language when the filename suggests plain text", + async (method) => { + const file: FileContents = { + name: "source.txt", + lang: "tsx", + contents: source, + cacheKey: "explicit-tsx", + }; + await pool.primeFileHighlightCache(file); + renderer[method](file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }, + ); + + it("loads a newly opened language after reusing a worker-backed renderer", async () => { + const previousFile: FileContents = { + name: "previous.ts", + contents: "export const value = 1;", + cacheKey: "previous-ts", + }; + await getSharedHighlighter({ themes: ["pierre-dark"], langs: ["typescript"] }); + renderer.renderFile(previousFile); + firstEnter(await renderer.initializeHighlighter(), previousFile, "typescript"); + const nextFile = { name: "next.tsx", contents: source, cacheKey: "next-tsx" }; + renderer.renderFile(nextFile); + firstEnter(await renderer.initializeHighlighter(), nextFile, "tsx"); + }); + + it("prepares a hydrated non-worker file even when its theme was already loaded", async () => { + renderer.cleanUp(); + renderer = new FileRenderer(options); + const file = { name: "local.tsx", contents: source, cacheKey: "local-tsx" }; + renderer.hydrate(file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }); + + it("keeps plain text editable without loading an unrelated grammar", async () => { + const file = { name: "notes.txt", contents: "Plain text", cacheKey: "plain-text" }; + renderer.renderFile(file); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "text"); + expect(highlighter.getLoadedLanguages()).not.toContain("tsx"); + }); +}); diff --git a/apps/web/src/components/files/fileEditorVirtualization.test.ts b/apps/web/src/components/files/fileEditorVirtualization.test.ts new file mode 100644 index 000000000000..cf293dd47254 --- /dev/null +++ b/apps/web/src/components/files/fileEditorVirtualization.test.ts @@ -0,0 +1,723 @@ +import { + getSharedHighlighter, + VirtualizedFile, + Virtualizer, + type FileContents, +} from "@pierre/diffs"; +import { Editor, TextDocument } from "@pierre/diffs/editor"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const renderingManagerUrl = new URL( + "./managers/UniversalRenderingManager.js", + import.meta.resolve("@pierre/diffs"), +); +const { clearRenderQueue } = (await import(/* @vite-ignore */ renderingManagerUrl.href)) as { + clearRenderQueue(): void; +}; + +// Layout measurements are controlled here. The real reconciler, document and +// renderer calculate positions. This does not simulate native CSS wrapping. +class MeasuredElement { + static geometryReads = 0; + children: MeasuredElement[] = []; + dataset: Record = {}; + nextElementSibling: MeasuredElement | null = null; + width = 283; + + constructor(readonly height = 0) {} + + getBoundingClientRect() { + MeasuredElement.geometryReads += 1; + return { top: 0, height: this.height, width: this.width }; + } +} + +class MeasuredCodeElement extends MeasuredElement { + readonly tagName = "CODE"; + + get firstElementChild() { + return this.children[0] ?? null; + } +} + +const observers: RecordedResizeObserver[] = []; +const animationFrames = new Map(); +let nextFrameId = 0; + +class RecordedResizeObserver { + readonly targets = new Set(); + + constructor(readonly callback: ResizeObserverCallback) { + observers.push(this); + } + + observe(target: Element) { + this.targets.add(target); + } + + unobserve(target: Element) { + this.targets.delete(target); + } + + disconnect() { + this.targets.clear(); + } + + deliver(target: HTMLElement) { + const rect = target.getBoundingClientRect(); + const size = { inlineSize: rect.width, blockSize: rect.height }; + this.callback( + [ + { + target, + contentRect: rect, + contentBoxSize: [size], + borderBoxSize: [size], + devicePixelContentBoxSize: [size], + }, + ], + this as unknown as ResizeObserver, + ); + } +} + +function drainRenderFrames() { + const errors = vi.spyOn(console, "error"); + try { + for (let frame = 0; animationFrames.size > 0; frame += 1) { + if (frame === 10) throw new Error("The production render queue did not settle"); + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + for (const callback of callbacks) callback(frame); + } + expect(errors).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + } +} + +function measuredElement(element: MeasuredElement): HTMLElement { + return element as unknown as HTMLElement; +} + +class LayoutVirtualizer extends Virtualizer { + override getOffsetInScrollContainer(_element: HTMLElement) { + return 0; + } +} + +class MeasuredFile extends VirtualizedFile { + override top = 0; + + override attachEditor(editor: Parameters[0]) { + this.editor = editor; + return () => { + this.editor = undefined; + }; + } + + async initialize(file: FileContents) { + this.prepareCodeViewItem(file, 0); + await this.fileRenderer.initializeHighlighter(); + expect( + this.fileRenderer.renderFile(file, { + startingLine: 5950, + totalLines: 51, + bufferBefore: 0, + bufferAfter: 0, + }), + ).toBeDefined(); + this.fileContainer = measuredElement(new MeasuredElement()); + } + + measure( + rows: ReadonlyArray, + contentWidth = 226.25, + ) { + const content = new MeasuredElement(); + content.width = contentWidth; + content.children = rows.map(([lineIndex, height]) => { + const row = new MeasuredElement(height); + row.dataset.lineIndex = String(lineIndex); + return row; + }); + const code = new MeasuredCodeElement(); + code.width = contentWidth + 56.75; + code.children = [new MeasuredElement(), content]; + this.code = measuredElement(code); + this.reconcileHeights(); + } + + resizeContent(contentWidth: number, codeWidth = contentWidth + 56.75) { + const code = this.code; + const content = code?.children[1]; + if (!(code instanceof MeasuredElement) || !(content instanceof MeasuredElement)) { + throw new Error("Expected measured code and content"); + } + code.width = codeWidth; + content.width = contentWidth; + } + + observeLayout() { + const pre = new MeasuredElement(); + if (!(this.code instanceof MeasuredElement)) throw new Error("Expected measured code"); + pre.children = [this.code]; + this.resizeManager.setup(measuredElement(pre) as HTMLPreElement, { + disableAnnotations: true, + columnVariables: "measure", + }); + const code = this.code; + const observer = observers.find((candidate) => candidate.targets.has(code)); + if (observer === undefined) throw new Error("The real resize manager did not observe code"); + return () => observer.deliver(code); + } + + dispose() { + this.fileContainer = undefined; + this.code = undefined; + this.cleanUp(); + } +} + +const instances: MeasuredFile[] = []; +const editors: Editor[] = []; + +beforeAll(async () => { + await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); +}); + +beforeEach(() => { + observers.length = 0; + animationFrames.clear(); + MeasuredElement.geometryReads = 0; + vi.stubGlobal("HTMLElement", MeasuredElement); + vi.stubGlobal("Document", MeasuredElement); + vi.stubGlobal("ResizeObserver", RecordedResizeObserver); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const id = ++nextFrameId; + animationFrames.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => animationFrames.delete(id)); +}); + +afterEach(() => { + for (const editor of editors.splice(0)) editor.cleanUp(); + for (const instance of instances.splice(0)) instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + vi.unstubAllGlobals(); +}); + +async function makeFixture( + overflow: "wrap" | "scroll" = "wrap", + lineCount = 6001, + contentWidth = 226.25, +) { + const contents = Array.from({ length: lineCount }, (_, index) => `line ${index}`).join("\n"); + const file: FileContents = { + name: "wrapped.txt", + contents, + cacheKey: `wrapped:${overflow}`, + lang: "text", + }; + const document = new TextDocument(file.name, contents, "text"); + const instance = new MeasuredFile( + { + overflow, + disableFileHeader: true, + theme: "pierre-dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, + controlledSelection: true, + }, + new LayoutVirtualizer(), + ); + instances.push(instance); + await instance.initialize(file); + instance.measure( + [ + [0, 80], + [120, 60], + [4999, 100], + [5000, 80], + [5999, 100], + [6000, 60], + ], + contentWidth, + ); + const apply = (change: { startLine: number } | undefined, passStartLine = true) => { + if (change === undefined) throw new Error("Expected a document change"); + file.contents = document.getText(); + instance.applyDocumentChange( + document, + undefined, + false, + passStartLine ? change.startLine : undefined, + ); + }; + const append = () => { + const position = document.positionAt(document.getText().length); + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + }; + return { instance, document, file, apply, append }; +} + +describe("wrapped editor document changes", () => { + it("preserves the position above an EOF insertion across layout checkpoints", async () => { + const { instance, document, append } = await makeFixture(); + const previousLastLine = document.lineCount; + const before = instance.getLinePosition(previousLastLine); + expect(before).toEqual({ top: 120328, height: 60 }); + const viewport = { top: before!.top - 100, bottom: before!.top + 80 }; + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 }); + + append(); + + expect(document.lineCount).toBe(previousLastLine + 1); + expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 }); + expect(instance.getVirtualizedHeight()).toBe(120376); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 }); + }); + + it("invalidates changed and shifted rows after an insertion in the middle", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5001); + const position = { line: 5000, character: 2 }; + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + + expect(instance.getLinePosition(5001)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(4999)).toBe(100); + expect(instance.getLineHeight(5000)).toBe(20); + expect(instance.getLineHeight(5999)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120208, height: 20 }); + }); + + it("keeps preceding measurements when a deletion crosses a checkpoint", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5000); + apply( + document.applyEdits([ + { + range: { start: { line: 4999, character: 2 }, end: { line: 5001, character: 2 } }, + newText: "", + }, + ]), + ); + + expect(document.lineCount).toBe(5999); + expect(instance.getLinePosition(5000)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLineHeight(4999)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120068, height: 20 }); + }); + + it("uses the earliest changed line for edits at multiple selections", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(121); + apply( + document.applyEdits( + [120, 5000].map((line) => ({ + range: { start: { line, character: 2 }, end: { line, character: 2 } }, + newText: "\n", + })), + ), + ); + + expect(document.lineCount).toBe(6003); + expect(instance.getLinePosition(121)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(4999)).toBe(20); + }); + + it("retains the unchanged prefix through repeated Enter, undo and redo", async () => { + const { instance, document, apply, append } = await makeFixture(); + const before = instance.getLinePosition(6001); + for (let count = 0; count < 60; count += 1) append(); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.undo()?.[0]); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.redo()?.[0]); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + }); + + it("keeps unwrapped positions unchanged", async () => { + const { instance, append } = await makeFixture("scroll"); + const before = instance.getLinePosition(6001); + append(); + expect(instance.getLinePosition(6001)).toEqual(before); + expect(instance.getLinePosition(6002)).toEqual({ top: 120028, height: 20 }); + }); + + it("fully invalidates measurements when the first changed line is unknown", async () => { + const { instance, document, apply } = await makeFixture(); + const position = document.positionAt(document.getText().length); + apply( + document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]), + false, + ); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it("still discards all measured rows after a metric change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 }); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 }); + }); + + it("still discards all measured rows when annotations change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }); +}); + +describe("wrapped measurement widths", () => { + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "drops offscreen measurements when content changes from %spx to %spx", + async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(60); + expect(instance.getVirtualizedHeight()).toBe(120076); + }, + ); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "does not retain old-width prefix heights after a %spx to %spx resize and edit", + async (before, after) => { + const { instance, append } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + append(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }, + ); + + it("repairs an edit before resize delivery when the real resize and render queues drain", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.25); + const readsBeforeEdit = MeasuredElement.geometryReads; + append(); + expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit); + // No synchronous geometry read: the resize entry owns invalidation. + expect(instance.getLineHeight(0)).toBe(80); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 }); + }); + + it("keeps measured prefixes through same-width reconciliation and editing", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + append(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); + }); + + it("preserves measurements on first and repeated same-width resize deliveries", async () => { + const { instance } = await makeFixture(); + const before = instance.getVirtualizedHeight(); + const deliverResize = instance.observeLayout(); + deliverResize(); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getVirtualizedHeight()).toBe(before); + }); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 20]], before); + const deliverResize = instance.observeLayout(); + instance.resizeContent(after); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("keeps width validity when a new editor attaches to the same file", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + const first = new Editor(); + editors.push(first); + first.edit(instance); + first.cleanUp(); + const second = new Editor(); + editors.push(second); + second.edit(instance); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("ignores stale resize deliveries after cleanup", async () => { + const { instance } = await makeFixture(); + const deliverResize = instance.observeLayout(); + instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + deliverResize(); + expect(animationFrames.size).toBe(0); + }); + + it("does not discard a stable code width for gutter subpixel rounding", async () => { + const { instance } = await makeFixture("wrap", 6001, 482.25); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.234375, 539); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + }); + + it("waits for a visible width instead of caching measurements while hidden", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(0, 0); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); +}); + +// Supply inert DOM transport so public Editor edits execute its real tokenizer +// and layout handoff. No native wrapping, observer delivery or scrolling is modeled. +class EditorElement extends MeasuredElement { + style: Record = {}; + parentElement: EditorElement | null = null; + + appendChild(child: EditorElement) { + child.parentElement = this; + this.children.push(child); + return child; + } + + prepend(child: EditorElement) { + child.parentElement = this; + this.children.unshift(child); + } + + replaceChildren(...children: (EditorElement | string)[]) { + this.children = []; + for (const child of children) if (typeof child !== "string") this.appendChild(child); + } + + setAttribute() {} + removeAttribute() {} + addEventListener() {} + removeEventListener() {} + after() {} + + remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((child) => child !== this); + } + } + + set innerHTML(value: string) { + expect(value.startsWith(" "code" in child.dataset); + } + + querySelector(selector: string) { + expect(selector).toBe("[data-deletions]"); + return null; + } + + getContext() { + return { measureText: (text: string) => ({ width: text.length * 8 }) }; + } +} + +async function makeEditorFixture(lineCount: number) { + const { instance, file } = await makeFixture("wrap", lineCount); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal("Document", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("getComputedStyle", () => ({ + paddingTop: "0px", + fontSize: "13px", + fontFamily: "monospace", + tabSize: "2", + lineHeight: "20px", + })); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + instance.setOptions({ + ...instance.options, + useTokenTransformer: true, + controlledSelection: true, + themeType: "dark", + }); + const content = new EditorElement(); + content.dataset.content = ""; + const gutter = new EditorElement(); + gutter.dataset.gutter = ""; + const code = new EditorElement(); + code.dataset.code = ""; + code.appendChild(gutter); + code.appendChild(content); + const shadow = new EditorElement(); + shadow.appendChild(code); + const host = Object.assign(new EditorElement(), { shadowRoot: shadow }); + const highlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); + const editor = new Editor(); + editors.push(editor); + editor.edit(instance); + editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, { + startingLine: 0, + totalLines: 1, + bufferBefore: 0, + bufferAfter: 0, + }); + const append = (count: number) => { + const lines = editor.getText().split("\n"); + const end = { line: lines.length - 1, character: lines.at(-1)!.length }; + editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]); + }; + const remove = (count: number) => { + const lines = editor.getText().split("\n"); + const startLine = lines.length - count - 1; + editor.applyEdits([ + { + range: { + start: { line: startLine, character: lines[startLine]!.length }, + end: { line: lines.length - 1, character: lines.at(-1)!.length }, + }, + newText: "", + }, + ]); + }; + return { instance, editor, append, remove }; +} + +describe("editor gutter-width changes", () => { + it.each([ + [9999, 1], + [9998, 3], + ])( + "clears prefix measurements when %i lines grow by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + expect(instance.getLineHeight(0)).toBe(80); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it.each([ + [10000, 1], + [10002, 4], + ])( + "clears prefix measurements when %i lines shrink by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, remove } = await makeEditorFixture(lines); + remove(count); + expect(editor.getText().split("\n")).toHaveLength(lines - count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it("clears newly measured prefixes on undo and redo across a digit boundary", async () => { + const { instance, editor, append } = await makeEditorFixture(9999); + append(1); + instance.measure([[0, 100]]); + editor.undo(); + expect(editor.getText().split("\n")).toHaveLength(9999); + expect(instance.getLineHeight(0)).toBe(20); + instance.measure([[0, 80]]); + editor.redo(); + expect(editor.getText().split("\n")).toHaveLength(10000); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it.each([ + [9998, 1], + [10000, 2], + ])( + "retains prefix measurements when %i lines grow by %i without changing digit width", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(5000)).toBe(80); + editor.undo(); + expect(instance.getLineHeight(0)).toBe(80); + editor.redo(); + expect(instance.getLineHeight(0)).toBe(80); + }, + ); +}); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index d02ec99605ba..a12772920956 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -33,7 +33,7 @@ interface ProjectQueryState
{ readonly refresh: () => void; } -export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { +function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { return projectEnvironment.listEntries({ environmentId, input: { cwd } }); } diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index cf79c81b9f60..d6c5cca70153 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -33,7 +33,7 @@ function mediaFileName(source: MediaActionSource): string { } /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ -export function useMediaActions(source: MediaActionSource) { +function useMediaActions(source: MediaActionSource) { const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index f436beeb3855..90c18984e17b 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -127,7 +127,9 @@ export function MediaVideoPlayer({ diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx new file mode 100644 index 000000000000..6debb0376bd9 --- /dev/null +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -0,0 +1,244 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useLayoutEffect, useState } from "react"; + +import { + ensureClientSettingsHydrated, + useClientSettings, + useClientSettingsHydrationStatus, +} from "../../hooks/useSettings"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, + type FirstRunGateState, +} from "../../onboarding/firstRun.logic"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { environmentProjects } from "../../state/projects"; +import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server"; +import { environmentShell } from "../../state/shell"; +import { environmentThreadShells } from "../../state/threads"; +import { Button } from "../ui/button"; + +/** + * Holds back authenticated and hosted app trees until the first-run decision + * is known, so a fresh install never flashes the main screen before the wizard. + * Nothing renders while pending — no shell, no EventRouter (whose welcome + * payload would otherwise navigate into a thread), no dialogs. + * + * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as + * settings hydrate (the common case, no server round-trip). A `null` flag also + * covers installs that predate the field, so it alone is not enough — the gate + * waits for environment shells to bootstrap and inspects the workspace. + * Hosted mode instead checks its saved environment catalog. A timeout shows + * recovery for an unreachable primary server without mounting the app tree. + */ + +const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; + +const primaryShellLiveAtom = Atom.make((get) => { + const serverConfig = get(primaryServerConfigAtom); + return ( + serverConfig !== null && + get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live" + ); +}).pipe(Atom.withLabel("web-onboarding-primary-shell-live")); + +const workspaceEvidenceLiveAtom = Atom.make((get) => { + const environmentIds = new Set([ + ...get(environmentProjects.projectsAtom).map((project) => project.environmentId), + ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId), + ]); + + for (const environmentId of environmentIds) { + if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") { + return false; + } + } + + return true; +}).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live")); + +export function FirstRunGate({ + enabled, + hostedStatic, + children, +}: { + readonly enabled: boolean; + readonly hostedStatic: boolean; + readonly children: React.ReactNode; +}) { + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const hydrationStatus = useClientSettingsHydrationStatus(); + const hydrated = hydrationStatus === "ready"; + const completeOnboarding = useCompleteOnboarding(); + const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const projects = useProjects(); + const threads = useThreadShells(); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); + const primaryShellLive = useAtomValue(primaryShellLiveAtom); + const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom); + // Within a session settings stay hydrated, so remounts (e.g. returning from + // the wizard) resolve synchronously instead of blanking a frame. + const [gateState, setGateState] = useState(() => ({ + decision: + (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null) + ? "app" + : "pending", + stalled: false, + })); + const { decision, stalled } = gateState; + const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying"; + const ownsOnboardingTheme = settingsReadFailed || stalled || decision === "wizard"; + + useLayoutEffect(() => { + if (!ownsOnboardingTheme) return; + return mountOnboardingTheme(); + }, [ownsOnboardingTheme]); + + // A workspace still counts as fresh when its only content is the server's + // own cwd auto-bootstrap: web mode creates a project + thread from cwd at + // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no + // projects at all" would mean `npx t3` users never see the wizard. Any + // other project, more than one thread, or state in a non-primary + // environment is real user state — the aggregate hooks span every + // environment, and a saved remote's project must never read as "the + // bootstrap project" just because its root string matches the primary cwd. + const serverCwd = serverConfig?.cwd ?? null; + const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null; + const workspaceFresh = isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd, + bootstrapProjectId: serverWelcome?.bootstrapProjectId, + bootstrapThreadId: serverWelcome?.bootstrapThreadId, + bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated, + bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated, + projects, + threads, + }); + + const { decision: nextDecision, persistCompletion } = hostedStatic + ? resolveHostedFirstRunDecision({ + hydrated, + completed: onboardingCompletedAt !== null, + catalogReady: environmentCatalogReady, + environmentCount: environments.length, + }) + : resolveFirstRunDecision({ + enabled, + hydrated, + completed: onboardingCompletedAt !== null, + bootstrapped, + authoritative: primaryShellLive, + workspaceAuthoritative: workspaceEvidenceLive, + workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: serverWelcome !== null, + bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, + }), + catalogReady: environmentCatalogReady, + serverConfigAvailable: serverConfig !== null, + workspaceFresh, + projectCount: projects.length, + threadCount: threads.length, + }); + + useEffect(() => { + if (decision === "wizard" || !hydrated) return; + + if (persistCompletion && onboardingCompletedAt === null) { + void completeOnboarding().catch(() => undefined); + } + + setGateState((state) => + transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }), + ); + }, [ + completeOnboarding, + decision, + hydrated, + nextDecision, + onboardingCompletedAt, + persistCompletion, + ]); + + // A stalled server read gets a recovery screen, but never mounts the app. + // The timer starts after settings hydrate so slow local hydration does not + // show a false connection failure. + useEffect(() => { + if (!enabled || decision !== "pending" || !hydrated) return; + const timer = window.setTimeout( + () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })), + FIRST_RUN_DECISION_TIMEOUT_MS, + ); + return () => window.clearTimeout(timer); + }, [decision, enabled, hydrated]); + + useEffect(() => { + if (decision === "wizard" && pathname !== "/welcome") { + void navigate({ to: "/welcome", replace: true }); + } + }, [decision, navigate, pathname]); + + if (settingsReadFailed) { + return ; + } + if (decision !== "app") { + return stalled ? : null; + } + return children; +} + +function FirstRunRecovery({ + reason, + retrying = false, +}: { + readonly reason: "settings" | "connection"; + readonly retrying?: boolean; +}) { + const settingsReadFailed = reason === "settings"; + return ( +
+
+

+ {settingsReadFailed ? "Could not read settings" : "Still connecting"} +

+

+ {settingsReadFailed + ? "Your saved settings could not be loaded." + : "T3 Code could not confirm this workspace."} +

+ +
+
+ ); +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx new file mode 100644 index 000000000000..62665aa86ebf --- /dev/null +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -0,0 +1,1473 @@ +import { useAuth } from "@clerk/react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentSessionProjectCandidate, + EnvironmentId, + ProjectId, + ScopedProjectRef, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + ArrowRightIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CloudIcon, + CopyIcon, + LinkIcon, + MonitorIcon, + TerminalIcon, + type LucideIcon, +} from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "../../onboarding/projectImport.logic"; +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "../../onboarding/providerReadiness.logic"; +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "../../onboarding/targetEnvironment.logic"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { newProjectId, randomUUID } from "../../lib/utils"; +import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; +import { readProjects, useProjects } from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { projectEnvironment } from "../../state/projects"; +import { serverEnvironment } from "../../state/server"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { connectPairing } from "../../connection/onboarding"; +import { isElectron } from "../../env"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { getProviderSummary } from "../settings/providerStatus"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { TerminalViewport } from "../ThreadTerminalDrawer"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { cn } from "../../lib/utils"; + +/** + * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a + * fresh install (no completed-onboarding flag, empty workspace). Flow per the + * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → + * agent setup with inline install terminal → project import → main screen. + * Every step past the connection gate is skippable; the whole wizard is + * re-runnable by clearing the flag. + */ + +type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import"; + +type ConnectionMode = "local" | "connect" | "direct"; + +/** + * The machine the agent and import steps run against. Local mode targets the + * primary environment; the remote modes prefer the machine the user just + * connected (the most recently added connected non-primary environment), so + * probing and import happen where their code lives rather than on the local + * server that happens to serve the app. Deliberately not a persisted + * "primary machine" concept — just whichever machine fits the chosen path + * right now, labeled inline on each step. + */ +function useOnboardingTargetEnvironment( + mode: ConnectionMode, + pairedEnvironmentId: EnvironmentId | null, +) { + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + return resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, + }); +} + +const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); +const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const; +const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing."; + +export function WelcomeWizard({ + localAvailable, + onDone, +}: { + /** + * Whether the "Local Only" card is offered. True whenever the app is served + * by an authenticated primary server — desktop, `npx t3`, or a dev server — + * since that server is "this machine" regardless of the hostname the app + * was opened from. Only hosted-static (app.t3.codes) has no local server. + */ + readonly localAvailable: boolean; + readonly onDone: (projectRef?: ScopedProjectRef) => void; +}) { + useLayoutEffect(() => mountOnboardingTheme(), []); + const completeOnboarding = useCompleteOnboarding(); + const [step, setStep] = useState("connection"); + const [mode, setMode] = useState("local"); + const [pairedEnvironmentId, setPairedEnvironmentId] = useState(null); + const finishingPromiseRef = useRef | null>(null); + const completionErrorToastIdRef = useRef | null>(null); + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; + const finish = useCallback( + (projectRef?: ScopedProjectRef) => { + if (finishingPromiseRef.current !== null) return finishingPromiseRef.current; + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + + const completion = completeOnboarding() + .then(() => { + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + onDone(projectRef); + return true; + }) + .catch(() => { + const errorToast = { + type: "error", + title: "Could not finish setup", + description: "Your settings could not be saved. Try again.", + } as const; + if (completionErrorToastIdRef.current === null) { + completionErrorToastIdRef.current = toastManager.add(errorToast); + } else { + toastManager.update(completionErrorToastIdRef.current, errorToast); + } + return false; + }) + .finally(() => { + if (finishingPromiseRef.current === completion) { + finishingPromiseRef.current = null; + } + }); + finishingPromiseRef.current = completion; + return completion; + }, + [completeOnboarding, onDone], + ); + + return ( +
+ {isElectron ? ( +
+ ) : null} +
+
+ + +
+ {step === "connection" ? ( + { + setMode("local"); + setPairedEnvironmentId(null); + setStep("agents"); + }} + onConnect={() => { + setMode("connect"); + setPairedEnvironmentId(null); + setStep("connect-machines"); + }} + onDirect={() => { + setMode("direct"); + setPairedEnvironmentId(null); + setStep("pair-direct"); + }} + /> + ) : step === "connect-machines" ? ( + setStep("connection")} + onContinue={() => setStep("agents")} + /> + ) : step === "pair-direct" ? ( + setStep("connection")} + onPaired={(environmentId) => { + setPairedEnvironmentId(environmentId); + setStep("agents"); + }} + /> + ) : step === "agents" ? ( + + setStep( + mode === "local" + ? "connection" + : mode === "connect" + ? "connect-machines" + : "pair-direct", + ) + } + onContinue={() => setStep("import")} + onSkip={() => setStep("import")} + /> + ) : ( + setStep("agents")} + onDone={finish} + /> + )} +
+
+
+
+ ); +} + +// ── Step 1: connection choice ──────────────────────────────── + +function ConnectionStep({ + localAvailable, + localLabel, + onLocal, + onConnect, + onDirect, +}: { + readonly localAvailable: boolean; + readonly localLabel: string; + readonly onLocal: () => void; + readonly onConnect: () => void; + readonly onDirect: () => void; +}) { + const cloudEnabled = hasCloudPublicConfig(); + const [choice, setChoice] = useState<"local" | "connect" | "direct">( + localAvailable ? "local" : cloudEnabled ? "connect" : "direct", + ); + + const advance = () => { + if (choice === "local") onLocal(); + else if (choice === "connect") onConnect(); + else onDirect(); + }; + + return ( + <> +

Where is your code?

+

Choose where your agents will run.

+
+ {localAvailable ? ( + setChoice("local")} + /> + ) : null} + {cloudEnabled ? ( + setChoice("connect")} + /> + ) : null} + setChoice("direct")} + /> +
+
+ +
+ + ); +} + +function ConnectionOption({ + icon: Icon, + title, + description, + truncateDescription = false, + detail, + selected, + onSelect, +}: { + readonly icon: LucideIcon; + readonly title: string; + readonly description: string; + readonly truncateDescription?: boolean; + readonly detail: string; + readonly selected: boolean; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +// ── Step 2: T3 Connect (sign in, then connect machines) ────── + +const CONNECT_LOGIN_COMMAND = "npx t3 connect"; + +/** + * Sign-in and machine-connection combined: signed out shows the Clerk prompt, + * signed in forks on account state — zero connected machines blocks on the + * `npx t3 connect` command and auto-advance is left to the user pressing + * Continue once their machine appears; existing machines show a confirmation + * list with the command folded away. There is deliberately no "primary + * machine" selection. + */ +function ConnectMachinesStep({ + onBack, + onContinue, +}: { + readonly onBack: () => void; + readonly onContinue: () => void; +}) { + // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read + // as signed-out mid-transition. + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const savedEnvironments = environments.filter(isOnboardingRelayEnvironment); + // Only a live connection counts: a saved-but-offline machine must not show + // the "connected" confirmation (the agents step would find nothing to + // probe). Its row still renders in the list either way. + const hasRemoteMachines = savedEnvironments.some( + (environment) => environment.connection.phase === "connected", + ); + + if (!isLoaded) { + return ; + } + + if (!isSignedIn) { + return ( + +
+ +
+
+ ); + } + + return ( + + {hasRemoteMachines ? ( + <> +
+ +
+ + + + Add another machine + + + +

+ Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

+
+
+
+ +
+ + ) : ( + <> + +

+ Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

+
+ + Waiting for your computer to connect. +

+ } + /> +
+
+ +
+ + Waiting for connection + + +
+
+ + )} +
+ ); +} + +// ── Step 2′: Direct pairing ────────────────────────────────── + +/** + * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the + * server, paste the URL here. Registers the remote environment in this + * browser's catalog (same path the hosted /pair surface uses). + */ +function PairDirectStep({ + onBack, + onPaired, +}: { + readonly onBack: () => void; + readonly onPaired: (environmentId: EnvironmentId) => void; +}) { + const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false }); + const [pairingUrl, setPairingUrl] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isPairing, setIsPairing] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const submit = async () => { + setIsPairing(true); + setErrorMessage(""); + const result = await connectPairingEnvironment({ pairingUrl }); + if (!mountedRef.current) return; + setIsPairing(false); + if (result._tag === "Success") { + onPaired(result.value); + return; + } + if (isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed."); + }; + + return ( + +
+
+

+ 01 Run this on your server +

+ +

+ Start the server with npx t3 serve first. Add{" "} + --tailscale to use your tailnet. +

+
+
+ + setPairingUrl(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit(); + }} + /> +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ +
+
+ ); +} + +// ── Step 3: agents ─────────────────────────────────────────── + +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; + +const AGENT_INSTALL_COMMANDS: Record = { + claudeAgent: "npm install -g @anthropic-ai/claude-code", + codex: "npm install -g @openai/codex", +}; + +/** Setup values stay fixed while provider probes refresh the surrounding cards. */ +interface AgentTerminalSession { + readonly environmentId: EnvironmentId; + readonly driver: OnboardingAgentDriver; + readonly providerInstanceId: ServerProvider["instanceId"]; + readonly cwd: string; + readonly command: string; + readonly keybindings: ServerConfig["keybindings"]; +} + +/** + * Claude Code and Codex use live probe status. Install opens the built-in terminal inline + * with the command pre-typed — the update RPC can't install a binary that + * isn't there yet (it infers the package manager from the installed binary's + * path), and the terminal also handles the interactive login that follows. + */ +function AgentsStep({ + mode, + pairedEnvironmentId, + onBack, + onContinue, + onSkip, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + if (targetEnvironment === null) { + return ( + +
+ +
+
+ ); + } + return ( + + ); +} + +function ConnectedAgentsStep({ + environmentId, + machineLabel, + onBack, + onContinue, + onSkip, +}: { + readonly environmentId: EnvironmentId; + readonly machineLabel: string; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const [terminalSession, setTerminalSession] = useState(null); + + // Re-probe on entry so freshly installed CLIs show up without a manual + // refresh; harmless when nothing changed (single-flighted per environment). + useEffect(() => { + void refreshProviders({ environmentId, input: {} }); + }, [environmentId, refreshProviders]); + + const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); + + const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ + driver, + provider: byDriver.get(driver), + })); + const readyCount = primaryAgents.filter( + ({ provider }) => getOnboardingProviderState(provider) === "ready", + ).length; + return ( + +
+ {primaryAgents.map(({ driver, provider }) => ( + { + if (provider === undefined || serverConfig === null) return; + setTerminalSession({ + environmentId, + driver, + providerInstanceId: provider.instanceId, + cwd: serverConfig.cwd, + command: provider.installed + ? resolveOnboardingProviderLoginCommand( + provider, + serverConfig.settings, + serverConfig.environment.platform.os, + ) + : AGENT_INSTALL_COMMANDS[driver], + keybindings: serverConfig.keybindings, + }); + }} + /> + ))} +
+ {terminalSession !== null ? ( + { + setTerminalSession(null); + void refreshProviders({ environmentId, input: {} }); + }} + /> + ) : null} +
+ +
+ + {readyCount} of {primaryAgents.length} ready + + +
+
+
+ ); +} + +function AgentCard({ + driver, + provider, + terminalOpen, + terminalAvailable, + onOpenTerminal, +}: { + readonly driver: OnboardingAgentDriver; + readonly provider: ServerProvider | undefined; + readonly terminalOpen: boolean; + readonly terminalAvailable: boolean; + readonly onOpenTerminal: () => void; +}) { + const meta = getDriverOption(ProviderDriverKind.make(driver)); + const Icon = meta?.icon; + const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); + const summary = getProviderSummary(provider); + const providerState = getOnboardingProviderState(provider); + + return ( +
+ {Icon ? ( + + ) : null} +
+ {displayName} +

+ {summary.headline} + {summary.detail ? ` · ${summary.detail}` : ""} +

+
+
+ {providerState === "ready" ? ( + + + Ready + + ) : providerState === "checking" ? ( + Checking... + ) : providerState === "disabled" ? ( + Disabled + ) : providerState === "attention" ? ( + {summary.headline} + ) : ( + + )} +
+
+ ); +} + +/** + * Inline install terminal. Opens a PTY on the connected environment under a + * synthetic onboarding thread id (terminals are keyed by free-form thread id; + * the server validates only the cwd) and pre-types the install or login + * command without submitting, so the user reviews and presses Enter. + */ +function AgentInstallTerminal({ + session, + onClose, +}: { + readonly session: AgentTerminalSession; + readonly onClose: () => void; +}) { + const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session; + // Same terminal typography preference the thread drawer honors. + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false }); + const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false }); + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const setupQueueRef = useRef(Promise.resolve()); + const setupGenerationRef = useRef(0); + const activeSetupGenerationRef = useRef(null); + const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID), + [environmentId], + ); + const [setupAttempt, setSetupAttempt] = useState(0); + const [setupState, setSetupState] = useState< + "preparing" | "ready" | "openFailed" | "writeFailed" + >("preparing"); + const terminalReady = setupState === "ready" || setupState === "writeFailed"; + + // Keep each setup generation distinct. In Strict Mode, a canceled open can + // finish after the replacement setup starts; it must not close or pre-type + // into the replacement session that shares this terminal id. + useEffect(() => { + const generation = setupGenerationRef.current + 1; + setupGenerationRef.current = generation; + activeSetupGenerationRef.current = generation; + setSetupState("preparing"); + + setupQueueRef.current = setupQueueRef.current.then(async () => { + if (activeSetupGenerationRef.current !== generation) return; + const opened = await openTerminal({ + environmentId, + input: { + threadId: AGENT_ONBOARDING_THREAD_ID, + terminalId, + cwd, + providerInstanceId, + }, + }); + if (opened._tag !== "Success") { + if (activeSetupGenerationRef.current === generation) setSetupState("openFailed"); + return; + } + + if (activeSetupGenerationRef.current !== generation) return; + + const wrote = await writeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command }, + }); + if (activeSetupGenerationRef.current !== generation) return; + setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed"); + }); + + // Every exit path unmounts the drawer (Done, Continue/Skip, card switch, + // session exit), so this cleanup is the single place the PTY dies — + // nothing is left running behind the wizard. An interrupted install is + // re-runnable from the card. + return () => { + if (activeSetupGenerationRef.current === generation) { + activeSetupGenerationRef.current = null; + } + setupQueueRef.current = setupQueueRef.current.then(async () => { + await closeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true }, + }); + }); + }; + }, [ + closeTerminal, + command, + cwd, + environmentId, + openTerminal, + providerInstanceId, + setupAttempt, + terminalId, + writeTerminal, + ]); + + return ( +
+
+ + {setupState === "writeFailed" ? ( + <> + Run {command} in this + terminal. + + ) : setupState === "ready" ? ( + "Review the command, then press Enter to run it." + ) : setupState === "openFailed" ? ( + "Could not open the setup terminal." + ) : ( + "Preparing command..." + )} + +
+ {setupState === "openFailed" ? ( + + ) : null} + +
+
+
+ {terminalReady ? ( + + ) : null} +
+
+ ); +} + +// ── Step 4: import ─────────────────────────────────────────── + +/** + * One-decision import (4B): a summary line with Import recent / Choose / + * Skip. The default imports only projects touched in the last 30 days; + * Choose expands a checklist including older ones. Imported projects also + * receive Codex and Claude threads active within the last 30 days. + */ +function ImportStep({ + mode, + pairedEnvironmentId, + onBack, + onDone, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onDone: (projectRef?: ScopedProjectRef) => Promise; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const environmentId = targetEnvironment?.environmentId ?? null; + const machineLabel = targetEnvironment?.label ?? "this machine"; + const scan = useEnvironmentQuery( + environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), + ); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); + const projects = useProjects(); + const [choosing, setChoosing] = useState(false); + const [deselected, setDeselected] = useState>(new Set()); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(""); + const [landingProject, setLandingProject] = useState(null); + // Keep project creation attempts separate from completed history imports so both can retry. + const importedProjectsRef = useRef(new Map()); + const projectsWithImportedHistoryRef = useRef(new Map()); + const lastImportSelectionRef = useRef>([]); + const projectAttemptsRef = useRef( + new Map(), + ); + const importGenerationRef = useRef(0); + + // Candidate paths are per-environment; a target switch would otherwise + // leave stale entries in the deselection set (and stale success records). + useEffect(() => { + importGenerationRef.current += 1; + setDeselected(new Set()); + setIsImporting(false); + setImportError(""); + setLandingProject(null); + importedProjectsRef.current = new Map(); + projectsWithImportedHistoryRef.current = new Map(); + lastImportSelectionRef.current = []; + projectAttemptsRef.current = new Map(); + return () => { + importGenerationRef.current += 1; + }; + }, [environmentId]); + + useEffect(() => { + if ( + landingProject !== null && + landingProject.environmentId === environmentId && + projects.some( + (project) => + project.id === landingProject.projectId && + project.environmentId === landingProject.environmentId, + ) + ) { + setLandingProject(null); + void onDone(landingProject).then((completed) => { + if (!completed) setIsImporting(false); + }); + } + }, [environmentId, landingProject, onDone, projects]); + + const { available: candidates, recent } = useMemo( + () => partitionOnboardingProjects(scan.data?.candidates ?? []), + [scan.data], + ); + const more = candidates.length - recent.length; + const scanTruncated = scan.data?.truncated === true; + const scanLimitNotice = scanTruncated ? ( +

+ {SCAN_LIMIT_MESSAGE} +

+ ) : null; + + const finishAfterImport = () => { + const projectRef = resolveOnboardingLandingProject( + lastImportSelectionRef.current, + projectsWithImportedHistoryRef.current, + importedProjectsRef.current, + ); + if (projectRef === undefined) { + void onDone(); + return; + } + setIsImporting(true); + setLandingProject(projectRef); + }; + + const runImport = async (selection: ReadonlyArray) => { + if (environmentId === null || selection.length === 0) { + void onDone(); + return; + } + setIsImporting(true); + setImportError(""); + lastImportSelectionRef.current = selection.map((candidate) => candidate.path); + const importGeneration = importGenerationRef.current; + const importedProjects = importedProjectsRef.current; + const projectAttempts = projectAttemptsRef.current; + // Interrupted imports are neither failures nor successes — the command was + // superseded or the environment dropped — but they still didn't land, so + // they must not read as "imported everything". Retries skip paths that + // already landed this session (re-creating them would only trip the + // duplicate-root invariant and read as a failure). + let importedProjectsCount = + importedProjects.size > 0 + ? selection.filter((candidate) => importedProjects.has(candidate.path)).length + : 0; + let importedThreadCount = 0; + let skippedThreadCount = 0; + let shouldRefreshScan = false; + for (const candidate of selection) { + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (importedProjects.has(candidate.path)) continue; + let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate); + if (projectId === null) { + let attempt = projectAttempts.get(candidate.path); + if (attempt === undefined) { + const nextProjectId = newProjectId(); + attempt = { + projectId: nextProjectId, + commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`), + }; + projectAttempts.set(candidate.path, attempt); + } + projectId = attempt.projectId; + const result = await createProject({ + environmentId, + input: { + projectId, + commandId: attempt.commandId, + title: candidate.title, + workspaceRoot: candidate.path, + createWorkspaceRootIfMissing: false, + defaultModelSelection: null, + }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (result._tag !== "Success") { + if (!isAtomCommandInterrupted(result)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + continue; + } + } + + const threadImportResult = await importThreads({ + environmentId, + input: { projectId, expectedWorkspaceRoot: candidate.path }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (threadImportResult._tag === "Success") { + importedThreadCount += threadImportResult.value.importedCount; + skippedThreadCount += threadImportResult.value.skippedCount; + if (threadImportResult.value.importedCount > 0) { + projectsWithImportedHistoryRef.current.set( + candidate.path, + scopeProjectRef(environmentId, projectId), + ); + } + if (threadImportResult.value.skippedCount === 0) { + importedProjectsCount += 1; + importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId)); + } + } else if (!isAtomCommandInterrupted(threadImportResult)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + } + if (shouldRefreshScan) scan.refresh(); + setIsImporting(false); + if (importedProjectsCount < selection.length) { + if (importedThreadCount > 0 && skippedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`, + ); + } else if (skippedThreadCount > 0) { + setImportError( + `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`, + ); + } else if (importedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`, + ); + } else { + setImportError("Could not import thread history."); + } + return; + } + finishAfterImport(); + }; + + if (environmentId === null || (scan.isPending && scan.data === null)) { + return ( + +
+ +
+
+ ); + } + + if (scan.error !== null || candidates.length === 0) { + return ( + + {scan.error !== null ? ( +

You can add projects later.

+ ) : null} +
+ {scan.error !== null ? ( + + ) : null} + +
+
+ ); + } + + if (choosing) { + const selected = candidates.filter((candidate) => !deselected.has(candidate.path)); + return ( + setChoosing(false)} + backDisabled={isImporting} + description={`${candidates.length} found on ${machineLabel}.`} + > + {scanLimitNotice} +
+ {candidates.map((candidate) => ( + + ))} +
+ {importError ?

{importError}

: null} +
+ + +
+
+ ); + } + + return ( + 0 ? ` ${more} more available.` : ""}`} + onBack={onBack} + backDisabled={isImporting} + > + {scanLimitNotice} +
+ {recent.slice(0, 4).map((candidate) => ( +
+ + + {candidate.path} + + + {candidate.sources.map(formatSource).join(", ")} + +
+ ))} + {recent.length > 4 ? ( +

+ {recent.length - 4} more projects +

+ ) : null} +
+ {importError ?

{importError}

: null} +
+ +
+ + +
+
+
+ ); +} + +// ── Shared bits ────────────────────────────────────────────── + +function StepShell({ + title, + description, + onBack, + backDisabled = false, + children, +}: { + readonly title: string; + readonly description?: string; + readonly onBack?: () => void; + readonly backDisabled?: boolean; + readonly children?: React.ReactNode; +}) { + return ( + <> + {onBack ? ( + + ) : null} +

{title}

+ {description ? ( +

{description}

+ ) : null} + {children} + + ); +} + +function CommandBlock({ + command, + className, + prominent = false, +}: { + readonly command: string; + readonly className?: string; + readonly prominent?: boolean; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1500, + target: "command", + }); + return ( +
+ + $ + {command} + + +
+ ); +} + +function formatSource(source: "claudeAgent" | "codex"): string { + return source === "claudeAgent" ? "Claude" : "Codex"; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx new file mode 100644 index 000000000000..75ed20cc4fe7 --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -0,0 +1,190 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ThreadId, + type ClientSettings, + type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, + type PreviewOpenInput, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; + +import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn(), + open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ), + list: vi.fn(async () => AsyncResult.success(emptyList)), + resize: vi.fn(), + respond: + vi.fn< + (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise + >(), + focus: vi.fn(async () => undefined), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId }] }), +})); +vi.mock("~/state/preview", () => ({ + previewEnvironment: { + automationRequests: () => requestsAtom, + list: () => listAtom, + open: mocks.open, + resize: mocks.resize, + respondToAutomation: mocks.respond, + focusAutomationHost: mocks.focus, + }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => mocks.list, +})); +vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); + +const environmentId = EnvironmentId.make("automation-environment"); +const threadId = ThreadId.make("automation-thread"); +const threadRef = { environmentId, threadId }; +const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; +const savedSettings: ClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], +}; +const snapshot: PreviewSessionSnapshot = { + threadId, + tabId: "automation-tab", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport, + profileId: "work", + updatedAt: "2026-09-05T00:00:00.000Z", +}; +const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; +const listAtom = Atom.make(AsyncResult.success(emptyList)); +const requestsAtom = Atom.make>( + AsyncResult.initial(false), +); +const requestEvent: PreviewAutomationStreamEvent = { + type: "request", + connectionId: "automation-connection", + request: { + requestId: "open-request", + threadId, + operation: "open", + input: { open: false, reuseExistingTab: false }, + timeoutMs: 15_000, + }, +}; + +function deferred
() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +let renderer: ReactTestRenderer | null = null; + +beforeEach(async () => { + vi.clearAllMocks(); + mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); + mocks.respond.mockReset(); + __resetClientSettingsPersistenceForTests(); + resetPreviewStateForTests(); + appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); + await act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("PreviewAutomationHosts open", () => { + it("waits for saved settings before opening a tab with the configured profile and viewport", async () => { + const readStarted = deferred(); + const read = deferred(); + const response = deferred(); + mocks.getClientSettings.mockImplementationOnce(() => { + readStarted.resolve(); + return read.promise; + }); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await readStarted.promise; + }); + expect(mocks.open).not.toHaveBeenCalled(); + + await act(async () => { + read.resolve(savedSettings); + await response.promise; + }); + + expect(mocks.open).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { threadId, viewport, profileId: "work" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); + + it("reports a settings read failure without opening a tab", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed")); + const response = deferred(); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await response.promise; + }); + + await expect(response.promise).resolves.toMatchObject({ + requestId: "open-request", + ok: false, + error: { _tag: "PreviewAutomationExecutionError" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 08b31640906e..fd87f7e80c79 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -41,7 +41,11 @@ import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, } from "~/browser/browserSurfaceStore"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; @@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const defaults = await resolveBrowserDefaults(); const result = await open({ environmentId, input: { @@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), // An agent that didn't state a size gets the user's // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 8dbf9f0904f0..7ca0c496a04b 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ArrowLeft, ArrowRight, @@ -5,7 +6,6 @@ import { ExternalLink, MousePointerClick, PictureInPicture2, - RotateCw, } from "lucide-react"; import { type FormEvent, @@ -166,7 +166,7 @@ export function PreviewChromeRow({ /> } > - + {loading ? "Loading…" : "Refresh"} diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx index d950a99b59fc..5ab8552b36fe 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx @@ -1,51 +1,43 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ favicon: null as string | null })); - -vi.mock("~/browserFaviconStore", () => ({ - useFaviconForThreadUrl: () => mocks.favicon, -})); - -import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon"; - -const threadRef = { - environmentId: EnvironmentId.make("env-1"), - threadId: ThreadId.make("thread-1"), -}; - -describe("preview favicon image", () => { - it("renders a captured source before later fallback sources", () => { - expect( - renderToStaticMarkup( - fallback} - />, - ), - ).toContain('src="data:image/png;base64,AAAA"'); - const captured = "data:image/png;base64,AAAA"; - const google = "https://public.example/icon"; - expect(selectFaviconSource([captured, google], new Set())).toBe(captured); - expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google); - expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull(); - expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe( - "data:image/png;base64,BBBB", +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/browserFaviconStore", () => ({ useFaviconForThreadUrl: () => null })); + +import { FaviconImage } from "./PreviewFaviconIcon"; + +let renderer: ReactTestRenderer | undefined; + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("falls through failed favicon sources and retries when the source list changes", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const captured = "data:image/png;base64,AAAA"; + const remote = "https://public.example/icon"; + await act(async () => { + renderer = create( + fallback} />, ); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); - it("uses a stored project icon or falls back to the browser mockup", () => { - mocks.favicon = null; - const html = renderToStaticMarkup( - , - ); - expect(html).not.toContain(", + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findByType("img").props.src).toBe(remote); + + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + expect(renderer!.root.findByType("span").children).toEqual(["fallback"]); + + await act(async () => { + renderer!.update( + fallback} + />, ); - expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"'); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); }); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx index 111facfd82dd..b2e1fee3639e 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx @@ -6,13 +6,6 @@ import { cn } from "~/lib/utils"; import { BrowserMockup } from "./BrowserMockup"; -export function selectFaviconSource( - sources: ReadonlyArray, - failed: ReadonlySet, -): string | null { - return sources.find((candidate) => !failed.has(candidate)) ?? null; -} - export function FaviconImage(props: { sources: ReadonlyArray; fallback: ReactNode; @@ -35,7 +28,7 @@ function FaviconImageAttempt(props: { className?: string | undefined; }) { const [failed, setFailed] = useState>(() => new Set()); - const source = selectFaviconSource(props.sources, failed); + const source = props.sources.find((candidate) => !failed.has(candidate)); if (!source) return props.fallback; return ( ({ useThreadRecentHistory: () => EMPTY_HISTORY, })); -vi.mock("~/state/session", () => ({ +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), readPreparedConnection: mocks.readPreparedConnection, })); @@ -251,7 +252,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); -import { PreviewView, previewProfileName } from "./PreviewView"; +import { PreviewView } from "./PreviewView"; import { toastManager } from "~/components/ui/toast"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -352,12 +353,6 @@ describe("PreviewView navigation", () => { mocks.recordVisitForThread.mockClear(); }); - it("labels a tab whose saved profile was removed", () => { - expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe( - "Removed profile", - ); - }); - it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 640690854e53..e6ad2758bc48 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,7 +1,10 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, @@ -46,6 +49,7 @@ import { } from "~/browser/browserViewportActions"; import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -76,7 +80,7 @@ interface Props { ) => void; } -export function previewProfileName( +function previewProfileName( profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>, profileId: string, ): string { @@ -186,6 +190,16 @@ export function PreviewView({ return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add({ + type: "error", + title: "Unable to open browser", + description: error.message, + }); + } + } return result._tag === "Success"; }, [open, runtimeTabId, threadRef], diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index 7a79ccae5b16..de54c2014b2a 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -14,6 +15,7 @@ import { resetPreviewStateForTests, } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { addBrowserSurface } from "./addBrowserSurface"; @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ }); beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); resetPreviewStateForTests(); useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} }); }); diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 622cdbec2f1c..e0cd83501201 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -4,7 +4,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -15,7 +15,7 @@ export async function addBrowserSurface(input: { readonly openPreview: OpenPreviewMutation; /** Omit to use the configured default profile. */ readonly profileId?: string | undefined; -}): Promise> { +}): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index a49acbd86104..288db101e7a5 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,7 +14,7 @@ export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise> { const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); const result = await openPreviewSession({ openPreview: input.openPreview, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index ef3d51a9e7fa..fe14211280c2 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -7,8 +8,11 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as browserDefaults from "~/browser/browserDefaults"; +import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("openPreviewSession", () => { it("creates an idle tab without recording a recently visited URL", async () => { @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); }); + + it.each(["session", "link"] as const)( + "does not open a %s with unread settings and uses the saved profile on retry", + async (entryPoint) => { + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const viewport = { _tag: "freeform", width: 1280, height: 720 } as const; + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const input = { openPreview, threadRef, url: "https://t3.chat/" }; + const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview; + + const result = await open(input); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError); + expect(Cause.squash(result.cause)).toMatchObject({ cause: failure }); + } + expect(openPreview).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); + + await expect(open(input)).resolves.toMatchObject({ _tag: "Success" }); + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: input.url, + viewport, + profileId: "work", + }, + }); + }, + ); }); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index deb5465ebc28..07dab9a0b36d 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -6,12 +6,15 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { browserDefaultOpenProfileId, browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput { export async function openPreviewSession( input: OpenPreviewSessionInput, -): Promise> { +): Promise> { // Resolved once: a tab opened before client settings hydrate would otherwise // be born at the schema defaults and never corrected. - const defaults = await resolveBrowserDefaults(); + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 46dd33f7beb4..2ce81cb06af2 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -68,6 +68,33 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it.each(["target", "defaults"] as const)( + "does not open either browser when reading %s fails", + async (setting) => { + const failure = new Error("Settings read failed"); + if (setting === "target") { + linkTargetMocks.preference.mockImplementationOnce(() => { + throw failure; + }); + } else { + browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); + } + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await expect( + openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + }), + ).rejects.toBe(failure); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + expect(openPreview).not.toHaveBeenCalled(); + }, + ); + it("opens in the system browser while that is the configured target", async () => { linkTargetMocks.preference.mockReturnValue("system"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index dcf35de53f2d..97a099ec72eb 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -216,7 +216,7 @@ export const PreviewAutomationHostError = Schema.Union([ ]); export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type; -export const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); +const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); export function serializePreviewAutomationHostError( error: PreviewAutomationHostError, diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index ac3e95934837..66a1c4e5f1f3 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -4,7 +4,7 @@ export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; -export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; +const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; export function clampPreviewMiniPlayerSize( size: PreviewMiniPlayerSize, diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4b728c2e07eb..4ffd453955e9 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -49,7 +49,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Switch } from "./ui/switch"; import { Textarea } from "./ui/textarea"; -export const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ +const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ { id: "play", label: "Play" }, { id: "test", label: "Test" }, { id: "lint", label: "Lint" }, diff --git a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx index d87dfa45b0ff..2aa1413438ee 100644 --- a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx @@ -1,4 +1,4 @@ -import { RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { cn } from "~/lib/utils"; @@ -23,7 +23,7 @@ export function PullRequestActivityUnavailableState({

Could not load pull request activity

{error}

diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index aa2d278ce249..b77a3711d90f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -187,7 +187,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): { * host sit under the line they were written on, and a new comment joins the review being * drafted rather than being posted as it is typed. */ -export function PullRequestCodeTab({ +function PullRequestCodeTab({ environmentId, reference, detail, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 76e50b60003b..227d13d18bb7 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { @@ -36,7 +37,6 @@ import { PanelRightIcon, PencilIcon, PlayIcon, - RefreshCwIcon, RotateCcwIcon, TriangleAlertIcon, } from "lucide-react"; @@ -627,6 +627,14 @@ export function PullRequestDetailPanel({ : { ...resolvedCoreDetail, ...sharedSummary, + closedAt: + sharedSummary.closedAt === undefined + ? resolvedCoreDetail.closedAt + : sharedSummary.closedAt, + mergedAt: + sharedSummary.mergedAt === undefined + ? resolvedCoreDetail.mergedAt + : sharedSummary.mergedAt, // A summary may come from an older server that does not report draft state. Keep the // detail's required value instead of making the complete detail shape partial. isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, @@ -728,10 +736,16 @@ export function PullRequestDetailPanel({ // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const [isInvalidating, setIsInvalidating] = useState(false); const refreshFromHost = useCallback(async () => { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - setRefreshToken((token) => token + 1); + setIsInvalidating(true); + try { + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); + setRefreshToken((token) => token + 1); + } finally { + setIsInvalidating(false); + } }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); @@ -1670,8 +1684,14 @@ export function PullRequestDetailPanel({ - void refreshFromHost()}> - + void refreshFromHost()} + > + Refresh @@ -2315,6 +2335,7 @@ export function PullRequestDetailPanel({ {detailQuery.error && !detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx index 4dd92dbf2243..8c5f862700bc 100644 --- a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; /** * What the list shows when it has no rows to show. * @@ -11,7 +12,7 @@ * with no project to read from — leave the button out, since pressing it could only repeat what * is already happening or ask nobody. */ -import { PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { PlusIcon, SearchIcon } from "lucide-react"; import { openCommandPalette } from "../../commandPaletteBus"; import { Button } from "../ui/button"; @@ -149,7 +150,7 @@ export function PullRequestListEmptyState({ {/* The hosts answered this query once; a pull request opened since then would answer differently, and nothing on screen says which of the two the reader is looking at. */} @@ -175,7 +176,7 @@ export function PullRequestListEmptyState({ ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index e45a687e981d..1c703bce3e2b 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { EnvironmentId, ProjectId, @@ -17,7 +18,6 @@ import { GitPullRequestDraftIcon, LayersIcon, ListFilterIcon, - LoaderIcon, SearchIcon, TagIcon, UserRoundIcon, @@ -37,6 +37,7 @@ import { MenuPopup, MenuRadioGroup, MenuRadioItem, + MenuRadioItemIndicator, MenuSeparator, MenuSub, MenuSubPopup, @@ -119,7 +120,7 @@ export function PullRequestSearchInput({ return ( - {busy ? : } + {busy ? : } ({ {option.label} {option.unavailable ? · Unavailable : null} + ); @@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({ readonly environmentId: EnvironmentId; readonly title: string; readonly workspaceRoot: string; - readonly faviconPath?: string | null; - readonly projectIcon?: ProjectIconOverride | null; + readonly faviconPath?: string | null | undefined; + readonly projectIcon?: ProjectIconOverride | null | undefined; }>; projectId: ProjectId | undefined; /** diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 70f5c845d2ca..26c80326aec0 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -1,4 +1,5 @@ -import { ExternalLinkIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react"; import { Button } from "../ui/button"; import { @@ -14,11 +15,13 @@ export function PullRequestsUnavailableState({ title = "Could not load pull requests", error, onRetry, + refreshing = false, gitHubUrl, }: { title?: string; error: string; onRetry?: () => void; + refreshing?: boolean; gitHubUrl?: string; }) { return ( @@ -35,8 +38,14 @@ export function PullRequestsUnavailableState({ {onRetry || gitHubUrl ? ( {onRetry ? ( - ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 3a849074fdca..9bbdaeb94c58 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -41,7 +41,6 @@ import { shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, - describePullRequestState, editPullRequestThreadComment, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; @@ -202,15 +201,6 @@ describe("pull request primary control", () => { }); }); -describe("pull request state description", () => { - it("keeps draft and conflicts orthogonal to the terminal states", () => { - expect(describePullRequestState("open", true)).toBe("Draft"); - expect(describePullRequestState("open", false)).toBe("Ready for review"); - expect(describePullRequestState("merged", true)).toBe("Merged"); - expect(describePullRequestState("closed", false)).toBe("Closed"); - }); -}); - describe("pull request handoff labels", () => { it("names the open thread when actions write to its composer", () => { expect(pullRequestHandoffLabels(true)).toEqual({ diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 35038aa6e525..04f31c7b1eb5 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -190,13 +190,6 @@ export function isStackedPullRequestBase( return defaultBranch !== baseBranch; } -/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ -export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return isDraft ? "Draft" : "Ready for review"; -} - /** The slice of a detail that decides which actions it offers. */ export type PullRequestActionableDetail = Pick< PullRequestDetail, @@ -320,7 +313,6 @@ export function resolveThreadPanelPullRequestAction( ? "merge" : null; } - /** Chronological ascending, oldest to newest — reversed for the "newest" reading order. */ export function orderPullRequestComments( comments: ReadonlyArray, diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index 720a5669178f..d3d5d958a49f 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -1,7 +1,7 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { diffFileTier, orderDiffFiles } from "./pullRequestFileOrder.logic"; +import { orderDiffFiles } from "./pullRequestFileOrder.logic"; /** Only the path and the patch's own lines matter here; the viewer fills the rest in. */ function file(name: string, additionLines: ReadonlyArray = []): FileDiffMetadata { @@ -12,34 +12,31 @@ function order(files: ReadonlyArray): Array { return orderDiffFiles(files).map((entry) => entry.name); } -describe("diffFileTier", () => { - it("puts lockfiles, snapshots and build output last", () => { - expect(diffFileTier("pnpm-lock.yaml")).toBe("generated"); - expect(diffFileTier("apps/web/package-lock.json")).toBe("generated"); - expect(diffFileTier("src/__snapshots__/app.ts")).toBe("generated"); - expect(diffFileTier("src/app.test.ts.snap")).toBe("generated"); - expect(diffFileTier("src/api.generated.ts")).toBe("generated"); - expect(diffFileTier("public/app.min.js")).toBe("generated"); - expect(diffFileTier("dist/app.js")).toBe("generated"); - expect(diffFileTier("packages/core/vendor/lib.js")).toBe("generated"); - }); - - it("recognises a test by its name or by the directory holding it", () => { - expect(diffFileTier("src/app.test.ts")).toBe("test"); - expect(diffFileTier("src/app.spec.tsx")).toBe("test"); - expect(diffFileTier("src/__tests__/app.ts")).toBe("test"); - expect(diffFileTier("test/app.ts")).toBe("test"); - expect(diffFileTier("tests/helpers/app.ts")).toBe("test"); - }); - - it("treats everything else as source, including files merely named like a directory", () => { - expect(diffFileTier("src/app.ts")).toBe("source"); - expect(diffFileTier("src/testing.ts")).toBe("source"); - expect(diffFileTier("src/dist.ts")).toBe("source"); +describe("orderDiffFiles", () => { + it("places source before tests and generated files across path conventions", () => { + const source = ["src/app.ts", "src/dist.ts", "src/testing.ts"]; + const tests = [ + "src/__tests__/app.ts", + "src/app.spec.tsx", + "src/app.test.ts", + "test/app.ts", + "tests/helpers/app.ts", + ]; + const generated = [ + "apps/web/package-lock.json", + "dist/app.js", + "packages/core/vendor/lib.js", + "pnpm-lock.yaml", + "public/app.min.js", + "src/__snapshots__/app.ts", + "src/api.generated.ts", + "src/app.test.ts.snap", + ]; + expect( + order([...generated, ...tests, ...source].toReversed().map((path) => file(path))), + ).toEqual([...source, ...tests, ...generated]); }); -}); -describe("orderDiffFiles", () => { it("answers an empty diff with an empty order", () => { expect(order([])).toEqual([]); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts index b46ed88539c2..1a0df9d9beda 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts @@ -31,7 +31,7 @@ const GENERATED_DIRECTORIES = new Set([ const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]); const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; -export function diffFileTier(path: string): DiffFileTier { +function diffFileTier(path: string): DiffFileTier { const segments = path.split("/"); const name = segments.at(-1) ?? ""; if ( diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts index db105f97fe95..eb6f47b4c803 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts @@ -1,15 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { openOnHostLabel, pullRequestLinkContextMenuItems } from "./pullRequestLinkContextMenu"; +import { openOnHostLabel } from "./pullRequestLinkContextMenu"; describe("pull request link context menu", () => { - it("offers the copy first and the host's own page after it", () => { - expect(pullRequestLinkContextMenuItems("Open on GitHub")).toEqual([ - { id: "copy-link", label: "Copy link", icon: "copy" }, - { id: "open-external", label: "Open on GitHub" }, - ]); - }); - it("names every host it knows, and says nothing false about one it does not", () => { expect(openOnHostLabel("github")).toBe("Open on GitHub"); expect(openOnHostLabel("gitlab")).toBe("Open on GitLab"); diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16b749445d4c..1ccdb64b73f4 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -8,7 +8,7 @@ import { toastManager } from "../ui/toast"; export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; /** Named for the host rather than "externally": the point is where you will land. */ -export const OPEN_ON_HOST_LABELS: Partial> = { +const OPEN_ON_HOST_LABELS: Partial> = { github: "Open on GitHub", gitlab: "Open on GitLab", bitbucket: "Open on Bitbucket", @@ -19,7 +19,7 @@ export const openOnHostLabel = (provider: string): string => OPEN_ON_HOST_LABELS[provider] ?? "Open on host"; /** Copy first: it is the reason to right-click a number rather than click it. */ -export function pullRequestLinkContextMenuItems( +function pullRequestLinkContextMenuItems( openLabel: string, ): readonly ContextMenuItem[] { return [ diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index cee672489389..c2fdde3d011f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -67,7 +67,7 @@ export type PullRequestViewers = PullRequestListResult["viewers"]; /** A row plus the environment that read it, where the caller has one to give. */ type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string }; -export const pullRequestViewerKey = (entry: ScopedEntry): string => +const pullRequestViewerKey = (entry: ScopedEntry): string => `${entry.environmentId ?? ""} ${entry.host}`; const GROUP_LABELS: Record = { diff --git a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts index bc2bc6f272cd..95c4bf02e743 100644 --- a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts +++ b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts @@ -37,7 +37,7 @@ export type PullRequestListPreferencePatch = { [Key in keyof PullRequestListPreferences]?: PullRequestListPreferences[Key] | undefined; }; -export const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { +const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { involvement: "all", state: "open", } as const satisfies PullRequestListPreferences; diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 8611ddc28dde..3c41e0956fed 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { PullRequestActor, PullRequestCheck, @@ -15,7 +16,6 @@ import { GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, - LoaderIcon, TriangleAlertIcon, } from "lucide-react"; import { Children, isValidElement, type ReactNode } from "react"; @@ -119,7 +119,7 @@ export function PullRequestStateGlyph({ } const CHECK_STATUS_PRESENTATION = { - pending: { label: "Running", Icon: LoaderIcon, toneClassName: "animate-spin text-amber-500" }, + pending: { label: "Running", Icon: Spinner, toneClassName: "text-amber-500" }, "action-required": { label: "Awaiting action", Icon: CircleDotIcon, @@ -136,7 +136,7 @@ const CHECK_STATUS_PRESENTATION = { neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, } as const satisfies Record< PullRequestCheckStatus, - { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } + { label: string; Icon: typeof CircleCheckIcon | typeof Spinner; toneClassName: string } >; function isWorkflowApprovalCheck(check: Pick): boolean { diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts new file mode 100644 index 000000000000..5c660118ed28 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -0,0 +1,158 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { findScopedProject } from "./pullRequestList.logic"; +import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; + +const cups = EnvironmentId.make("env-cups"); +const nucbox = EnvironmentId.make("env-nucbox"); +const labels = new Map([ + [cups, "cups"], + [nucbox, "nucbox-1"], +]); + +function project( + id: string, + environmentId = nucbox, + canonicalKey: string | null = "github.com/pingdotgg/t3code", +) { + return { + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `/work/${id}`, + repositoryIdentity: canonicalKey === null ? null : { canonicalKey }, + faviconPath: `${id}/favicon.png`, + }; +} + +describe("pull request project filter choices", () => { + it("collapses three checkouts on one server without dropping another server's copy", () => { + const projects = [ + project("main"), + project("worktree-1"), + project("worktree-2"), + project("main", cups), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map(({ id, environmentId, title }) => ({ id, environmentId, title }))).toEqual([ + { id: "main", environmentId: cups, title: "t3code · cups" }, + { id: "main", environmentId: nucbox, title: "t3code · nucbox-1" }, + ]); + expect(choices[1]?.workspaceRoot).toBe("/work/main"); + expect(choices[1]?.faviconPath).toBe("main/favicon.png"); + }); + + it("keeps a saved worktree selection as the repository's only choice", () => { + const projects = [project("main"), project("worktree"), project("worktree", cups)]; + const selected = findScopedProject(projects, nucbox, "worktree"); + + const choices = pullRequestFilterProjects(projects, labels, selected); + + expect(choices.filter((choice) => choice.environmentId === nucbox)).toEqual([ + { ...projects[1], title: "t3code · nucbox-1" }, + ]); + expect(findScopedProject(choices, nucbox, "worktree")).toBeDefined(); + expect(findScopedProject(choices, nucbox, "main")).toBeUndefined(); + expect(findScopedProject(choices, cups, "worktree")).toBeDefined(); + }); + + it("matches canonical repositories regardless of casing", () => { + const main = project("main"); + const worktree = project("worktree", nucbox, "GitHub.com/PingDotGG/T3Code"); + + expect(pullRequestFilterProjects([main, worktree], labels)).toEqual([main]); + }); + + it("does not add a server suffix after duplicate checkouts have collapsed", () => { + const main = project("main"); + + expect(pullRequestFilterProjects([main, project("worktree")], labels)).toEqual([main]); + expect(main.title).toBe("t3code"); + }); + + it("distinguishes same-named repositories on one server by checkout path", () => { + const projects = [ + project("upstream"), + project("fork", nucbox, "github.com/juliusmarminge/t3code"), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/fork", + "t3code · nucbox-1 · /work/upstream", + ]); + }); + + it("keeps repositories on different hosts separate", () => { + const choices = pullRequestFilterProjects( + [project("github"), project("enterprise", nucbox, "git.example.com/pingdotgg/t3code")], + labels, + ); + + expect(choices.map((choice) => choice.id)).toEqual(["enterprise", "github"]); + }); + + it("does not merge projects whose repository identity is unknown", () => { + const choices = pullRequestFilterProjects( + [project("first", nucbox, null), project("second", nucbox, null)], + labels, + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/first", + "t3code · nucbox-1 · /work/second", + ]); + }); + + it("distinguishes servers with the same display name and checkout path", () => { + const first = project("main"); + const second = project("main", cups); + const repeatedLabels = new Map([ + [cups, "nucbox-1"], + [nucbox, "nucbox-1"], + ]); + + const choices = pullRequestFilterProjects([first, second], repeatedLabels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/main · env-cups", + "t3code · nucbox-1 · /work/main · env-nucbox", + ]); + }); + + it("uses the environment id when its label is unavailable", () => { + const choices = pullRequestFilterProjects( + [project("main"), project("remote", cups)], + new Map(), + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · env-cups", + "t3code · env-nucbox", + ]); + }); + + it("can distinguish unresolved project records that also share a checkout path", () => { + const first = project("first", nucbox, null); + const second = { ...project("second", nucbox, null), workspaceRoot: first.workspaceRoot }; + + const choices = pullRequestFilterProjects([first, second], labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/first · env-nucbox · first", + "t3code · nucbox-1 · /work/first · env-nucbox · second", + ]); + }); + + it("leaves unrelated names unchanged and orders them alphabetically", () => { + const app = { ...project("app"), title: "Zebra" }; + const tools = { ...project("tools", nucbox, "github.com/acme/tools"), title: "Alpha" }; + + expect(pullRequestFilterProjects([app, tools], labels)).toEqual([tools, app]); + expect(pullRequestFilterProjects([], labels)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts new file mode 100644 index 000000000000..5b214e3f37f6 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts @@ -0,0 +1,53 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; + +interface FilterProject extends AssignableProject { + readonly title: string; + readonly workspaceRoot: string; +} + +function distinguishTitles( + projects: ReadonlyArray, + suffix: (project: Project) => string, +) { + const counts = new Map(); + for (const project of projects) { + counts.set(project.title, (counts.get(project.title) ?? 0) + 1); + } + return projects.map((project) => + (counts.get(project.title) ?? 0) > 1 + ? { ...project, title: `${project.title} · ${suffix(project)}` } + : project, + ); +} + +/** One choice per repository per server, retaining the selected checkout for saved scopes. */ +export function pullRequestFilterProjects( + projects: ReadonlyArray, + environmentLabels: ReadonlyMap, + selectedProject?: Pick, +) { + const byRepository = new Map(); + for (const project of projects) { + const repository = project.repositoryIdentity?.canonicalKey?.toLowerCase(); + const key = JSON.stringify([ + project.environmentId, + repository ? ["repository", repository] : ["project", project.id], + ]); + const selected = + project.id === selectedProject?.id && project.environmentId === selectedProject.environmentId; + if (!byRepository.has(key) || selected) byRepository.set(key, project); + } + + const byServer = distinguishTitles( + [...byRepository.values()], + (project) => environmentLabels.get(project.environmentId) ?? project.environmentId, + ); + const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); + // Separate environments can share both their display name and their checkout path. + const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); + return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + left.title.localeCompare(right.title), + ); +} diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 6be17ed33243..26015c5b3393 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,5 +1,6 @@ +import { Spinner } from "~/components/ui/spinner"; import type { ProjectContentMatch } from "@t3tools/contracts"; -import { LoaderCircle } from "lucide-react"; + import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -225,7 +226,7 @@ function OpenContentSearchDialog(props: {
{search.isPending ? ( - Searching… + Searching… ) : search.error ? ( {search.error} diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 74283796a8e2..181dec74aa25 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,12 +1,82 @@ -import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; +import { + BearerConnectionProfile, + BearerConnectionTarget, + SshConnectionProfile, + SshConnectionTarget, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + type AdvertisedEndpoint, + type DesktopWslState, + type RunningLocalServer, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, + environmentPairingBaseUrl, isQrShareableEndpoint, isWslSettingsRowVisible, + selectLocalServerPairingCandidates, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; +const savedEnvironmentId = EnvironmentId.make("saved-environment"); + +function connectionEntry( + target: ConnectionCatalogEntry["target"], + profile?: ConnectionCatalogEntry["profile"] extends Option.Option ? A : never, +): ConnectionCatalogEntry { + return { + target, + profile: profile === undefined ? Option.none() : Option.some(profile), + }; +} + +describe("environmentPairingBaseUrl", () => { + it("uses the reachable origin from a bearer environment profile", () => { + expect( + environmentPairingBaseUrl( + connectionEntry( + new BearerConnectionTarget({ + environmentId: savedEnvironmentId, + label: "headless", + connectionId: "bearer:saved-environment", + }), + new BearerConnectionProfile({ + connectionId: "bearer:saved-environment", + environmentId: savedEnvironmentId, + label: "headless", + httpBaseUrl: "https://box.tail.ts.net/", + wsBaseUrl: "wss://box.tail.ts.net/", + }), + ), + ), + ).toBe("https://box.tail.ts.net/"); + }); + + it("does not share an SSH tunnel's client-local address", () => { + expect( + environmentPairingBaseUrl( + connectionEntry( + new SshConnectionTarget({ + environmentId: savedEnvironmentId, + label: "box", + connectionId: "ssh:box", + }), + new SshConnectionProfile({ + connectionId: "ssh:box", + environmentId: savedEnvironmentId, + label: "box", + target: { alias: "box", hostname: "box", username: "ivan", port: 22 }, + }), + ), + ), + ).toBeNull(); + }); +}); + const baseWslState: DesktopWslState = { enabled: false, distro: null, @@ -183,3 +253,31 @@ describe("selectQrEndpointOption", () => { expect(selectQrEndpointOption([], "anything", "anything")).toBeNull(); }); }); + +describe("selectLocalServerPairingCandidates", () => { + const server = { + statePath: "/home/user/.t3/userdata/server-runtime.json", + baseDir: "/home/user/.t3", + variant: "userdata", + pid: 1234, + startedAt: "2026-01-01T00:00:00.000Z", + httpBaseUrl: "http://127.0.0.1:3773/", + environmentId: EnvironmentId.make("environment-local"), + label: "Local server", + } satisfies RunningLocalServer; + + it("marks connected servers as paired and reconnecting servers for pairing again", () => { + expect( + selectLocalServerPairingCandidates( + [server], + [{ environmentId: server.environmentId, connection: { phase: "connected" } }], + ), + ).toEqual([{ server, pairAgain: false, alreadyPaired: true }]); + expect( + selectLocalServerPairingCandidates( + [server], + [{ environmentId: server.environmentId, connection: { phase: "reconnecting" } }], + ), + ).toEqual([{ server, pairAgain: true, alreadyPaired: false }]); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index d683efab3a4a..cad58c4a6c29 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,7 +1,55 @@ -import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; +import type { + AdvertisedEndpoint, + DesktopBridge, + DesktopWslState, + EnvironmentId, + RunningLocalServer, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +export function environmentPairingBaseUrl(entry: ConnectionCatalogEntry): string | null { + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return entry.target.httpBaseUrl; + case "BearerConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile" + ? entry.profile.value.httpBaseUrl + : null; + case "RelayConnectionTarget": + case "SshConnectionTarget": + return null; + } +} type WslEnableBridge = Pick; +export interface LocalServerPairingCandidate { + readonly server: RunningLocalServer; + readonly pairAgain: boolean; + readonly alreadyPaired: boolean; +} + +export function selectLocalServerPairingCandidates( + servers: ReadonlyArray, + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; + }>, +): ReadonlyArray { + return servers.map((server) => { + const savedEnvironment = environments.find( + (environment) => environment.environmentId === server.environmentId, + ); + return { + server, + pairAgain: + savedEnvironment !== undefined && savedEnvironment.connection.phase !== "connected", + alreadyPaired: savedEnvironment?.connection.phase === "connected", + }; + }); +} + /** * A QR code encoding a loopback URL makes the scanning device dial itself, so * loopback endpoints stay copyable from the endpoint menu but are never diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5a6f3ebd70b0..95a141c4e500 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,4 +1,10 @@ -import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; +import { + ChevronsLeftRightEllipsisIcon, + PlusIcon, + QrCodeIcon, + RefreshCwIcon, + TerminalIcon, +} from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { type KeyboardEvent, @@ -28,6 +34,9 @@ import { type AuthPairingCredentialResult, type AdvertisedEndpoint, type DesktopDiscoveredSshHost, + type DesktopBackendMode, + type DesktopBackendModeState, + type RunningLocalServer, type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type DesktopWslState, @@ -48,10 +57,13 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { applyWslEnableSelection, + environmentPairingBaseUrl, isQrShareableEndpoint, isWslSettingsRowVisible, + selectLocalServerPairingCandidates, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; +import { SettingsEnvironmentSelector } from "./SettingsEnvironmentSelector"; import { SettingsPageContainer, SettingsRow, @@ -60,6 +72,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; +import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -146,6 +159,7 @@ import { } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; @@ -160,6 +174,7 @@ import { const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; +const EMPTY_RUNNING_LOCAL_SERVERS: ReadonlyArray = []; // Sentinels for the consolidated WSL backend picker. The colon is // rejected by DISTRO_NAME_PATTERN (validated on the desktop side) so @@ -1010,17 +1025,20 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ }); type AuthorizedClientsHeaderActionProps = { - onPairingLinkCreated: (result: AuthPairingCredentialResult) => void; clientSessions: ReadonlyArray; isRevokingOtherClients: boolean; onRevokeOtherClients: () => void; + onCreatePairingLink: (input: { + readonly label: string; + readonly scopes: ReadonlyArray; + }) => Promise; }; const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({ - onPairingLinkCreated, clientSessions, isRevokingOtherClients, onRevokeOtherClients, + onCreatePairingLink, }: AuthorizedClientsHeaderActionProps) { const [dialogOpen, setDialogOpen] = useState(false); const [pairingLabel, setPairingLabel] = useState(""); @@ -1032,11 +1050,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio const handleCreatePairingLink = useCallback(async () => { setIsCreatingPairingLink(true); try { - const created = await createServerPairingCredential({ - label: pairingLabel, - scopes: pairingScopes, - }); - onPairingLinkCreated(created); + await onCreatePairingLink({ label: pairingLabel, scopes: pairingScopes }); setPairingLabel(""); setPairingScopes([...AuthStandardClientScopes]); setDialogOpen(false); @@ -1052,7 +1066,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio } finally { setIsCreatingPairingLink(false); } - }, [onPairingLinkCreated, pairingLabel, pairingScopes]); + }, [onCreatePairingLink, pairingLabel, pairingScopes]); const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => { setPairingScopes((current) => @@ -1448,14 +1462,12 @@ function SavedBackendListRow({ const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); const resumingServerUpdate = serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; - const sshTarget = - environment.entry.target._tag === "SshConnectionTarget" && - Option.isSome(environment.entry.profile) && - environment.entry.profile.value._tag === "SshConnectionProfile" - ? environment.entry.profile.value.target - : null; const metadataBits = [ - sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + environment.displayUrl + ? environment.entry.target._tag === "SshConnectionTarget" + ? `SSH ${environment.displayUrl}` + : environment.displayUrl + : null, environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); @@ -1770,14 +1782,47 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const [desktopBackendModeState, setDesktopBackendModeState] = + useState(() => desktopBridge?.getBackendModeState?.() ?? null); + const [desktopBackendModeError, setDesktopBackendModeError] = useState(null); + const [isUpdatingDesktopBackendMode, setIsUpdatingDesktopBackendMode] = useState(false); + const [runningLocalServers, setRunningLocalServers] = useState>( + EMPTY_RUNNING_LOCAL_SERVERS, + ); + const [activeLocalServerDiscoveryCount, setActiveLocalServerDiscoveryCount] = useState(0); + const isDiscoveringLocalServers = activeLocalServerDiscoveryCount > 0; + const [pairingLocalServerEnvironmentId, setPairingLocalServerEnvironmentId] = + useState(null); + const [localServerDiscoveryError, setLocalServerDiscoveryError] = useState(null); + const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const { + environment: accessEnvironment, + environmentId: accessEnvironmentId, + environments: settingsEnvironments, + primaryEnvironmentId: settingsPrimaryEnvironmentId, + selectEnvironment: selectSettingsEnvironment, + } = useSettingsEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, }); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const createEnvironmentPairingLink = useAtomCommand(authEnvironment.createPairingCredential, { + reportFailure: false, + }); + const revokeEnvironmentPairingLink = useAtomCommand(authEnvironment.revokePairingLink, { + reportFailure: false, + }); + const revokeEnvironmentClientSession = useAtomCommand(authEnvironment.revokeClientSession, { + reportFailure: false, + }); + const revokeOtherEnvironmentClientSessions = useAtomCommand( + authEnvironment.revokeOtherClientSessions, + { reportFailure: false }, + ); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const primarySessionState = usePrimarySessionState(); const currentSessionScopes = desktopBridge @@ -1793,6 +1838,86 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); + const hasUsableClientOnlyEnvironment = savedEnvironments.some( + (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), + ); + const isClientOnlyDesktop = desktopBackendModeState?.effectiveMode === "client-only"; + const localServerPairingCandidates = useMemo( + () => selectLocalServerPairingCandidates(runningLocalServers, environments), + [environments, runningLocalServers], + ); + const hasLocalServerDiscoveryContent = + isDiscoveringLocalServers || + localServerPairingCandidates.length > 0 || + localServerDiscoveryError !== null; + const refreshRunningLocalServers = useCallback(async () => { + const discoverLocalServers = desktopBridge?.discoverLocalServers; + if (!discoverLocalServers) { + setRunningLocalServers(EMPTY_RUNNING_LOCAL_SERVERS); + return EMPTY_RUNNING_LOCAL_SERVERS; + } + setActiveLocalServerDiscoveryCount((count) => count + 1); + try { + const discovered = await discoverLocalServers(); + setRunningLocalServers(discovered); + setLocalServerDiscoveryError(null); + return discovered; + } catch (error) { + setRunningLocalServers(EMPTY_RUNNING_LOCAL_SERVERS); + setLocalServerDiscoveryError( + error instanceof Error ? error.message : "Could not scan for local T3 Code servers.", + ); + return EMPTY_RUNNING_LOCAL_SERVERS; + } finally { + setActiveLocalServerDiscoveryCount((count) => Math.max(0, count - 1)); + } + }, [desktopBridge]); + + useEffect(() => { + void refreshRunningLocalServers(); + }, [refreshRunningLocalServers]); + + const handlePairLocalServer = useCallback( + async (server: RunningLocalServer, pairAgain: boolean) => { + const pairLocalServer = desktopBridge?.pairLocalServer; + if (!pairLocalServer) return; + setPairingLocalServerEnvironmentId(server.environmentId); + setLocalServerDiscoveryError(null); + try { + const { pairingUrl } = await pairLocalServer(server.environmentId); + + const result = await connectPairing({ pairingUrl }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + setPairingLocalServerEnvironmentId(null); + return; + } + throw squashAtomCommandFailure(result); + } + + setAddBackendDialogOpen(false); + toastManager.add({ + type: "success", + title: pairAgain ? "Environment paired again" : "Environment paired", + description: `${server.label} is saved and will reconnect on app startup.`, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Could not pair the local T3 Code server."; + setLocalServerDiscoveryError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not pair local server", + description: message, + }), + ); + } finally { + setPairingLocalServerEnvironmentId(null); + } + }, + [connectPairing, desktopBridge], + ); const savedDesktopSshEnvironmentKeys = useMemo(() => { const keys = new Set(); for (const environment of savedEnvironments) { @@ -1830,7 +1955,6 @@ export function ConnectionsSettings() { string | null >(null); const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); - const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote"); const [savedBackendHost, setSavedBackendHost] = useState(""); const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); @@ -1892,12 +2016,32 @@ export function ConnectionsSettings() { const setDefaultAdvertisedEndpointKey = useUiStateStore( (state) => state.setDefaultAdvertisedEndpointKey, ); - const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const canManageLocalBackend = + !isClientOnlyDesktop && (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false); const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + const isAccessEnvironmentPrimary = + accessEnvironmentId !== null && accessEnvironmentId === primaryEnvironmentId; + const accessEnvironmentSession = useEnvironmentQuery( + !isAccessEnvironmentPrimary && + accessEnvironmentId !== null && + accessEnvironment?.connection.phase === "connected" + ? authEnvironment.sessionState({ environmentId: accessEnvironmentId, input: null }) + : null, + ); + const accessSessionScopes = isAccessEnvironmentPrimary + ? currentSessionScopes + : accessEnvironmentSession.data?.authenticated + ? (accessEnvironmentSession.data.scopes ?? null) + : null; + const canManageEnvironmentAccess = accessSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const accessEnvironmentPairingBaseUrl = + isAccessEnvironmentPrimary || accessEnvironment === null + ? null + : environmentPairingBaseUrl(accessEnvironment.entry); const authAccessChanges = useEnvironmentQuery( - canManageLocalBackend && primaryEnvironmentId !== null + canManageEnvironmentAccess && accessEnvironmentId !== null ? authEnvironment.accessChanges({ - environmentId: primaryEnvironmentId, + environmentId: accessEnvironmentId, input: null, }) : null, @@ -2098,32 +2242,83 @@ export function ConnectionsSettings() { setDisableTailscaleServeDialogOpen(true); }, []); - const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { - setRevokingDesktopPairingLinkId(id); - setDesktopAccessManagementMutationError(null); - try { - await revokeServerPairingLink(id); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; - setDesktopAccessManagementMutationError(message); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not revoke pairing link", - description: message, - }), - ); - } finally { - setRevokingDesktopPairingLinkId(null); - } - }, []); + const handleCreateAccessPairingLink = useCallback( + async (input: { + readonly label: string; + readonly scopes: ReadonlyArray; + }) => { + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + handlePairingLinkCreated(await createServerPairingCredential(input)); + return; + } + const result = await createEnvironmentPairingLink({ + environmentId: accessEnvironmentId, + input, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + if (result._tag === "Success") { + handlePairingLinkCreated(result.value); + } + }, + [ + accessEnvironmentId, + createEnvironmentPairingLink, + handlePairingLinkCreated, + isAccessEnvironmentPrimary, + ], + ); + + const handleRevokeDesktopPairingLink = useCallback( + async (id: string) => { + setRevokingDesktopPairingLinkId(id); + setDesktopAccessManagementMutationError(null); + try { + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await revokeServerPairingLink(id); + } else { + const result = await revokeEnvironmentPairingLink({ + environmentId: accessEnvironmentId, + input: { id }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; + setDesktopAccessManagementMutationError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revoke pairing link", + description: message, + }), + ); + } finally { + setRevokingDesktopPairingLinkId(null); + } + }, + [accessEnvironmentId, isAccessEnvironmentPrimary, revokeEnvironmentPairingLink], + ); const handleRevokeDesktopClientSession = useCallback( async (sessionId: ServerClientSessionRecord["sessionId"]) => { setRevokingDesktopClientSessionId(sessionId); setDesktopAccessManagementMutationError(null); try { - await revokeServerClientSession(sessionId); + if (isAccessEnvironmentPrimary || accessEnvironmentId === null) { + await revokeServerClientSession(sessionId); + } else { + const result = await revokeEnvironmentClientSession({ + environmentId: accessEnvironmentId, + input: { sessionId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + } } catch (error) { const message = error instanceof Error ? error.message : "Failed to revoke client access."; setDesktopAccessManagementMutationError(message); @@ -2138,14 +2333,27 @@ export function ConnectionsSettings() { setRevokingDesktopClientSessionId(null); } }, - [], + [accessEnvironmentId, isAccessEnvironmentPrimary, revokeEnvironmentClientSession], ); const handleRevokeOtherDesktopClients = useCallback(async () => { setIsRevokingOtherDesktopClients(true); setDesktopAccessManagementMutationError(null); try { - const revokedCount = await revokeOtherServerClientSessions(); + const revokedCount = + isAccessEnvironmentPrimary || accessEnvironmentId === null + ? await revokeOtherServerClientSessions() + : await (async () => { + const result = await revokeOtherEnvironmentClientSessions({ + environmentId: accessEnvironmentId, + input: null, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return 0; + throw squashAtomCommandFailure(result); + } + return result.value.revokedCount; + })(); toastManager.add({ type: "success", title: revokedCount === 1 ? "Revoked 1 other client" : `Revoked ${revokedCount} clients`, @@ -2164,14 +2372,14 @@ export function ConnectionsSettings() { } finally { setIsRevokingOtherDesktopClients(false); } - }, []); + }, [accessEnvironmentId, isAccessEnvironmentPrimary, revokeOtherEnvironmentClientSessions]); // Shared by manual SSH submission and discovered-host selection. const connectSavedBackendSshTarget = useCallback( async (target: DesktopSshEnvironmentTarget) => { setIsAddingSavedBackend(true); setSavedBackendError(null); - const result = await connectSshEnvironment({ target, label: "" }); + const result = await connectSshEnvironment({ target, label: target.alias }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); @@ -2424,8 +2632,6 @@ export function ConnectionsSettings() { : visibleDesktopNetworkAdvertisedEndpoints, [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints], ); - const isLocalBackendRemotelyReachable = - isLocalBackendNetworkAccessible || tailscaleHttpsEndpoint?.status === "available"; const defaultDesktopNetworkAdvertisedEndpoint = useMemo( () => selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey), @@ -2547,6 +2753,48 @@ export function ConnectionsSettings() {
); + const renderLocalServerPairingCandidates = () => ( +
+ {isDiscoveringLocalServers && localServerPairingCandidates.length === 0 ? ( +

+ + Scanning for local T3 Code servers… +

+ ) : null} + {localServerPairingCandidates.map(({ server, pairAgain, alreadyPaired }) => ( +
+ + + + + + {server.label} + + + {server.httpBaseUrl} · process {server.pid} + + + +
+ ))} + {localServerDiscoveryError ? ( +

{localServerDiscoveryError}

+ ) : null} +
+ ); const renderSshFields = () => (
@@ -3040,28 +3288,92 @@ export function ConnectionsSettings() { } /> ); - const renderAuthorizedClients = (presentation: AccessSectionPresentation) => ( - <> - {desktopAccessManagementError ? ( -
-

{desktopAccessManagementError}

+ const accessEnvironmentConnected = accessEnvironment?.connection.phase === "connected"; + const accessEnvironmentSessionLoading = + !isAccessEnvironmentPrimary && + accessEnvironmentSession.isPending && + accessEnvironmentSession.data === null; + const accessEndpointUrl = isAccessEnvironmentPrimary + ? desktopServerExposureState?.endpointUrl + : accessEnvironmentPairingBaseUrl; + const accessEndpoints = isAccessEnvironmentPrimary + ? visibleDesktopAdvertisedEndpoints + : EMPTY_ADVERTISED_ENDPOINTS; + const accessDefaultEndpointKey = isAccessEnvironmentPrimary + ? defaultDesktopAdvertisedEndpointKey + : null; + const renderAccessManagement = () => ( + + {accessEnvironmentId === null ? null : ( + + )} + {canManageEnvironmentAccess ? ( + + ) : null}
- ) : null} - - + } + > + {accessEnvironment === null ? ( + + ) : !accessEnvironmentConnected ? ( + + ) : accessEnvironmentSessionLoading ? ( + + ) : !canManageEnvironmentAccess ? ( + + ) : ( + <> + {desktopAccessManagementError ? ( +
+

{desktopAccessManagementError}

+
+ ) : null} + + + + + )} + ); const renderNetworkAccessRow = () => ( ); + const handleDesktopBackendModeChange = async (mode: DesktopBackendMode) => { + if (!desktopBridge || !desktopBackendModeState) return; + if (mode === desktopBackendModeState.configuredMode) return; + if (mode === "client-only" && !hasUsableClientOnlyEnvironment) { + setDesktopBackendModeError( + "Pair and save an environment before switching to client-only mode.", + ); + setAddBackendDialogOpen(true); + void refreshRunningLocalServers(); + return; + } + + setIsUpdatingDesktopBackendMode(true); + setDesktopBackendModeError(null); + try { + const next = await desktopBridge.setBackendMode(mode); + setDesktopBackendModeState(next); + setIsUpdatingDesktopBackendMode(false); + } catch (error) { + setDesktopBackendModeError( + error instanceof Error ? error.message : "Could not update the desktop backend mode.", + ); + setIsUpdatingDesktopBackendMode(false); + } + }; + return ( - {canManageLocalBackend ? ( + {desktopBridge && desktopBackendModeState ? ( + + {desktopBackendModeError} + ) : desktopBackendModeState.source === "existing-server" ? ( + + The saved backend preference remains unchanged. Pair the running server below to + save this connection. + + ) : desktopBackendModeState.cliOverride !== null ? ( + + This launch is overridden by --backend-mode= + {desktopBackendModeState.cliOverride}. The saved preference applies when launched + without that flag. + + ) : null + } + control={ + + } + /> + + ) : null} + + {renderAccessManagement()} + + {isClientOnlyDesktop ? null : canManageLocalBackend ? ( <> {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( @@ -3208,27 +3609,6 @@ export function ConnectionsSettings() { )} - {isLocalBackendRemotelyReachable ? ( - - } - > - - {renderAuthorizedClients("current")} - - - ) : null} { @@ -3513,6 +3893,36 @@ export function ConnectionsSettings() { )} + {hasLocalServerDiscoveryContent ? ( + void refreshRunningLocalServers()} + > + {isDiscoveringLocalServers ? ( + + ) : ( + + )} + Scan again + + } + > + {localServerPairingCandidates.length > 0 ? ( +

+ These servers are running from this app's local data directory. Pairing uses the + bundled T3 command to create a one-time credential, then saves the connection; this + desktop will not manage the server process. +

+ ) : null} + {renderLocalServerPairingCandidates()} +
+ ) : null} + { setAddBackendDialogOpen(open); + if (open) { + void refreshRunningLocalServers(); + } if (!open) { setSavedBackendError(null); } @@ -3552,6 +3965,30 @@ export function ConnectionsSettings() {
+ {hasLocalServerDiscoveryContent ? ( +
0 + ? "border-primary/20 bg-primary/5" + : localServerDiscoveryError + ? "border-destructive/30 bg-destructive/5" + : "border-border/70 bg-muted/20", + )} + > + {localServerPairingCandidates.length > 0 ? ( +
+

+ A local T3 Code server is running +

+

+ Pair explicitly to save it without copying its URL. +

+
+ ) : null} + {renderLocalServerPairingCandidates()} +
+ ) : null}
{renderConnectionModeCard({ mode: "remote", @@ -3591,6 +4028,7 @@ export function ConnectionsSettings() { savedEnvironments={savedEnvironments} /> + ); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 0b23fb2d2072..bd921db93df9 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { AlertTriangleIcon, ChevronDownIcon, @@ -5,15 +6,15 @@ import { CopyIcon, FolderOpenIcon, InfoIcon, - RefreshCwIcon, } from "lucide-react"; -import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import type { + EnvironmentId, + ServerConfig, ServerProcessDiagnosticsEntry, ServerProcessResourceHistorySummary, ServerProcessSignal, @@ -26,13 +27,8 @@ import { ensureLocalApi } from "../../localApi"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; -import { - primaryServerAvailableEditorsAtom, - primaryServerObservabilityAtom, - serverEnvironment, -} from "../../state/server"; +import { serverEnvironment } from "../../state/server"; import { shellEnvironment } from "../../state/shell"; -import { usePrimaryEnvironment } from "../../state/environments"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; @@ -41,6 +37,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { ExpandableText } from "./ExpandableText"; import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; +import { SettingsEnvironmentScope } from "./SettingsEnvironmentSelector"; import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -766,7 +763,7 @@ function DiagnosticsRefreshButton({ onClick={onClick} aria-label={label} > - + } /> @@ -776,10 +773,30 @@ function DiagnosticsRefreshButton({ } export function DiagnosticsSettingsPanel() { - const observability = useAtomValue(primaryServerObservabilityAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const primaryEnvironment = usePrimaryEnvironment(); - const environmentId = primaryEnvironment?.environmentId ?? null; + return ( + + + {(environment, serverConfig) => ( + + )} + + + ); +} + +function DiagnosticsSettingsContent({ + environmentId, + serverConfig, +}: { + readonly environmentId: EnvironmentId; + readonly serverConfig: ServerConfig; +}) { + const observability = serverConfig.observability; + const availableEditors = serverConfig.availableEditors; const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -791,58 +808,44 @@ export function DiagnosticsSettingsPanel() { RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? RESOURCE_HISTORY_WINDOWS[1]; const { data, error, isPending, refresh } = useEnvironmentQuery( - environmentId === null - ? null - : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), + serverEnvironment.traceDiagnostics({ environmentId, input: {} }), ); const { data: processData, error: processError, isPending: isProcessPending, refresh: refreshProcesses, - } = useEnvironmentQuery( - environmentId === null - ? null - : serverEnvironment.processDiagnostics({ environmentId, input: {} }), - ); + } = useEnvironmentQuery(serverEnvironment.processDiagnostics({ environmentId, input: {} })); const { data: resourceData, error: resourceError, isPending: isResourcePending, refresh: refreshResources, } = useEnvironmentQuery( - environmentId === null - ? null - : serverEnvironment.processResourceHistory({ - environmentId, - input: { - windowMs: selectedResourceWindow.windowMs, - bucketMs: selectedResourceWindow.bucketMs, - }, - }), + serverEnvironment.processResourceHistory({ + environmentId, + input: { + windowMs: selectedResourceWindow.windowMs, + bucketMs: selectedResourceWindow.bucketMs, + }, + }), ); const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState(null); const [signalingPid, setSignalingPid] = useState(null); const signalingPidRef = useRef(null); - const environmentIdRef = useRef(environmentId); const processDataRef = useRef(processData); - environmentIdRef.current = environmentId; processDataRef.current = processData; const openLogsDirectory = useCallback(() => { const logsDirectoryPath = observability?.logsDirectoryPath ?? null; if (!logsDirectoryPath) return; - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + const editor = resolveAndPersistPreferredEditor(availableEditors); if (!editor) { setOpenLogsDirectoryError("No available editors found."); return; } - if (environmentId === null) { - setOpenLogsDirectoryError("No environment is selected."); - return; - } setIsOpeningLogsDirectory(true); setOpenLogsDirectoryError(null); @@ -869,6 +872,8 @@ export function DiagnosticsSettingsPanel() { const signalProcess = useCallback( async (pid: number, signal: ServerProcessSignal) => { if (signalingPidRef.current !== null) return; + const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); + if (process === undefined) return; signalingPidRef.current = pid; setSignalingPid(pid); const clearSignaling = () => { @@ -896,20 +901,9 @@ export function DiagnosticsSettingsPanel() { return; } } - const currentEnvironmentId = environmentIdRef.current; - if (currentEnvironmentId === null) { - clearSignaling(); - return; - } - const process = processDataRef.current?.processes.find((entry) => entry.pid === pid); - if (process === undefined) { - clearSignaling(); - return; - } - try { const result = await signalServerProcess({ - environmentId: currentEnvironmentId, + environmentId, input: { pid, startTimeMs: process.startTimeMs, signal }, }); if (result._tag === "Failure") { @@ -948,7 +942,7 @@ export function DiagnosticsSettingsPanel() { clearSignaling(); } }, - [refreshProcesses, signalServerProcess], + [environmentId, refreshProcesses, signalServerProcess], ); const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; @@ -957,10 +951,9 @@ export function DiagnosticsSettingsPanel() { const traceDiagnosticsPartialFailure = data ? Option.getOrElse(data.partialFailure, () => false) : false; - return ( - - + <> + )} - + ); } diff --git a/apps/web/src/components/settings/ExpandableText.tsx b/apps/web/src/components/settings/ExpandableText.tsx index de18739e5090..fa5fb94aadd1 100644 --- a/apps/web/src/components/settings/ExpandableText.tsx +++ b/apps/web/src/components/settings/ExpandableText.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { cn } from "../../lib/utils"; @@ -17,12 +17,14 @@ export function ExpandableText({ collapsedClassName?: string; expandLabel?: string; }) { + const textId = useId(); const [expanded, setExpanded] = useState(false); const canExpand = text.length > 180 || text.includes("\n"); return (
setExpanded((value) => !value)} > diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx new file mode 100644 index 000000000000..5d185fee5824 --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -0,0 +1,77 @@ +import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { listBrowserImportSources } = vi.hoisted(() => ({ + listBrowserImportSources: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../preview/previewBridge", () => ({ + previewBridge: { listBrowserImportSources }, +})); +vi.mock("../../env", () => ({ isElectron: true })); +vi.mock("../../state/environments", () => ({ + useEnvironments: () => ({ environments: [], isReady: true }), + usePrimaryEnvironment: () => null, +})); +vi.mock("../../hooks/useSettings", () => ({ + PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment", + useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector(DEFAULT_CLIENT_SETTINGS), + useClientSettingsHydrated: () => true, + usePrimarySettingsAvailable: () => true, + usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS, + useUpdatePrimarySettings: () => vi.fn(), +})); +vi.mock("./settingsLayout", async (importOriginal) => ({ + ...(await importOriginal()), + SettingsPageContainer: ({ children }: { children: ReactNode }) => children, +})); + +import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; + +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + listBrowserImportSources.mockClear(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +async function openSettings() { + const router = createRouter({ + routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), + history: createMemoryHistory(), + }); + await router.load(); + await act(() => { + renderer = create( + + + , + ); + }); + expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined(); +} + +describe("Integrations browser discovery", () => { + it("does not scan browser files when entering or revisiting settings", async () => { + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + + await act(() => renderer?.unmount()); + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a3e96107b29c..514c241a3c57 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -20,7 +20,6 @@ import { DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, - DEFAULT_UNIFIED_SETTINGS, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, @@ -35,8 +34,9 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; +import { Link } from "@tanstack/react-router"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useRef, useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; @@ -86,7 +86,6 @@ import { persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, - usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) } function AgentBrowserAccessSetting() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - return ( - updateSettings({ - enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, - }) - } - /> - ) : null - } + description="Choose whether agents can use the preview browser for all projects or a specific project." control={ - - updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) + } /> ); @@ -804,11 +784,6 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { .catch(() => setSources((previous) => previous ?? [])); }, []); - // Loaded once so the first open is instant instead of flashing a spinner. - useEffect(() => { - loadSources(); - }, [loadSources]); - // Runs one import for the wizard. A new profile is registered only once the // import succeeds — the cookies land in its partition first — so a blocked // attempt never leaves an empty profile behind. diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index d987bc7a83dd..c366a87e7efd 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -291,7 +291,7 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } -export function normalizeShortcutKeyToken(key: string): string | null { +function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( normalized === "meta" || diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index b0beea24cc47..ad99328647af 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1324,7 +1324,7 @@ function KeybindingsList(props: KeybindingsListProps) { /** Shown in the browser build only; the desktop app receives every shortcut. */ function BrowserKeybindingNotice() { return ( -
+
Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx new file mode 100644 index 000000000000..514b146c3bac --- /dev/null +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -0,0 +1,94 @@ +import { connectionStatusText } from "@t3tools/client-runtime/connection"; + +import { + useClientSettings, + useClientSettingsHydrated, + useUpdateClientSettings, +} from "~/hooks/useSettings"; +import type { EnvironmentPresentation } from "~/state/environments"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const preferences = [ + { value: 100, label: "Prefer" }, + { value: 50, label: "Normal" }, + { value: 25, label: "Less often" }, + { value: 0, label: "Manual only" }, +]; + +export function LoadBalancingSettings({ + environments, +}: { + environments: ReadonlyArray; +}) { + const settings = useClientSettings(); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdateClientSettings(); + + return ( + + updateSettings({ loadBalancingEnabled })} + /> + } + /> + {environments.map((environment) => { + const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; + // Keep saved slider weights until the user chooses a different preference. + const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; + + return ( + { + if (value !== null) { + updateSettings({ + loadBalancingWeights: { + ...settings.loadBalancingWeights, + [environment.environmentId]: value, + }, + }); + } + }} + > + + + + + {preferences.map(({ value, label }) => ( + + {label} + + ))} + + + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx new file mode 100644 index 000000000000..1794a5fdaa2e --- /dev/null +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -0,0 +1,69 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { SettingsIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { commandForProjectScript } from "../../projectScripts"; +import { ScriptIcon } from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { SettingsRow } from "./settingsLayout"; + +export function ProjectActionsList({ + scripts, + keybindings, + disabled, + onEdit, +}: { + scripts: readonly ProjectScript[]; + keybindings: ResolvedKeybindingsConfig; + disabled: boolean; + onEdit: (script: ProjectScript) => void; +}) { + if (scripts.length === 0) + return ( +

+ No actions configured. +

+ ); + return scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, commandForProjectScript(script.id)); + return ( + + + {script.name} + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} +
+ } + description={{script.command}} + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> + ); + }); +} diff --git a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx new file mode 100644 index 000000000000..4385a5901b5e --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx @@ -0,0 +1,114 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useEnvironments } from "../../state/environments"; +import { + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + type ProjectScriptEditorRequest, +} from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { useProjectScriptSettings } from "./ProjectSettingsPanel"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +export function ProjectDefaultActionsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const targets = environments.filter( + (environment) => + (environmentId === null || environment.environmentId === environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig !== null, + ); + const representative = targets[0]?.serverConfig; + const scripts = representative?.settings.defaultProjectScripts ?? []; + const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const mixed = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !== + JSON.stringify(scripts), + ); + const [request, setRequest] = useState(null); + const { saving, persist, submit } = useProjectScriptSettings( + targets.flatMap(({ environmentId, serverConfig }) => + serverConfig + ? [ + { + environmentId, + settings: serverConfig.settings, + keybindings: serverConfig.keybindings, + }, + ] + : [], + ), + ); + + return ( + + + Import scripts + + } + /> + (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0, + ) ? ( + void persist(() => [])} + /> + ) : null + } + control={ + + } + /> + {mixed ? ( + + ) : ( + setRequest(editorRequestForScript(script, keybindings))} + /> + )} + + void persist((current) => current.filter((script) => script.id !== id), id, null) + } + onClose={() => setRequest(null)} + /> + + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx new file mode 100644 index 000000000000..938000e01002 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -0,0 +1,474 @@ +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProviderInstanceId, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useNavigate } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { Trash2Icon } from "lucide-react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { Switch } from "../ui/switch"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; +import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; +import { searchableSetting } from "./settingsSearch"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; + +/** Defaults are written only to the machines selected on the projects settings page. */ +export function ProjectDefaultsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clientSettings = useClientSettings(); + const updateClientSettings = useUpdateClientSettings(); + const navigate = useNavigate(); + const updateSettings = useAtomCommand( + serverEnvironment.updateSettings, + "project defaults update", + ); + const savingRef = useRef(new Set()); + const [saving, setSaving] = useState>(new Set()); + const scoped = environments.filter( + (environment) => environmentId === null || environment.environmentId === environmentId, + ); + const targets = scoped.filter( + (environment) => + environment.connection.phase === "connected" && environment.serverConfig !== null, + ); + const representative = + targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; + const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; + const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const settings = { ...serverSettings, ...clientSettings }; + const storedSelection = serverSettings.defaultModelSelection; + const selection = resolveDefaultProviderModelSelection(providers, storedSelection); + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), + ); + const modelOptions = getCustomModelOptionsByInstance( + settings, + providers, + selection?.instanceId, + selection?.model, + ); + const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); + const mixedModel = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !== + JSON.stringify(storedSelection), + ); + const mixedWorkspace = targets.some( + (target) => + target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, + ); + const mixedBrowser = targets.some( + (target) => + target.serverConfig?.settings.enableAgentBrowserAccess !== + serverSettings.enableAgentBrowserAccess, + ); + const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key); + const mixedAutoPull = targets.some( + (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, + ); + + function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { + const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); + for (const target of targets) { + const config = target.serverConfig; + if (!config) continue; + const entry = applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === instanceId); + const options = getCustomModelOptionsByInstance( + { ...config.settings, ...clientSettings }, + config.providers, + ).get(instanceId); + if ( + !entry?.enabled || + !entry.isAvailable || + entry.driverKind !== sourceEntry?.driverKind || + !options?.some((option) => option.slug === model && !option.isUnavailable) + ) { + return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`; + } + } + return null; + } + + async function save(patch: ServerSettingsPatch) { + const keys = Object.keys(patch); + if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return; + const nextModel = patch.defaultModelSelection; + const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null; + if (reason) { + toastManager.add({ type: "error", title: "Default model not saved", description: reason }); + return; + } + for (const key of keys) savingRef.current.add(key); + setSaving(new Set(savingRef.current)); + try { + const results = await Promise.all( + targets.map((target) => + updateSettings({ environmentId: target.environmentId, input: { patch } }), + ), + ); + const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure"); + if (failedTargets.length > 0) { + toastManager.add({ + type: "error", + title: "Project defaults not saved on every machine", + description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`, + }); + } + } finally { + for (const key of keys) savingRef.current.delete(key); + setSaving(new Set(savingRef.current)); + } + } + + const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); + return ( + + + + } + /> + + + +
+ } + /> + {scoped.length > targets.length || targets.length === 0 ? ( +

+ {targets.length === 0 + ? "Connect a machine to change its project defaults." + : "Changes apply to connected machines only. Offline machines keep their current defaults."} +

+ ) : null} + setModel(null)} + /> + ) : null + } + control={ + selection && activeEntry ? ( +
+ { + if (representative) + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} + onInstanceModelChange={(instanceId, model) => + setModel(createModelSelection(instanceId, model)) + } + /> + {!mixedModel ? ( + {}} + modelOptions={selection.options ?? []} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onModelOptionsChange={(options) => + setModel(createModelSelection(selection.instanceId, selection.model, options)) + } + /> + ) : null} +
+ ) : ( + No providers available + ) + } + /> + + void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode }) + } + /> + ) : null + } + control={ + + } + /> + void save({ defaultAutoPull: false })} + /> + ) : null + } + control={ + void save({ defaultAutoPull: enabled })} + /> + } + /> + + void save({ + enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, + }) + } + /> + ) : null + } + control={ + + } + /> + + + + + + + + } + /> + + void updateClientSettings({ + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + Remove checkout + + } + /> + + + + + + Remove project + + } + /> + + + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 7be0f15cd5c2..f4ffe699466e 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,52 +12,53 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; -import type { - ContextMenuItem, - ModelSelection, - ProjectIconOverride, - ProviderDriverKind, - SidebarProjectGroupingMode, - T3ProjectFileScript, - ThreadEnvMode, +import { + type EnvironmentId, + type ModelSelection, + type ProjectIconOverride, + type ProjectId, + type ProjectScript, + type ResolvedKeybindingsConfig, + type ServerSettings, + type ProviderDriverKind, + type SidebarProjectGroupingMode, + type T3ProjectFileScript, + type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { + projectScriptsInheritDefaults, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; +import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; -import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; -import { isElectron } from "../../env"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, - usePrimarySettings, } from "../../hooks/useSettings"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; -import { shortcutLabelForCommand } from "../../keybindings"; -import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; -import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; -import { readLocalApi } from "../../localApi"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { isElectron } from "../../env"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "../../lib/projectScriptKeybindings"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; -import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; +import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; +import { readLocalApi } from "../../localApi"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -98,16 +99,8 @@ import { MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { - const navigate = useNavigate(); - const canGoBack = useCanGoBack(); - const navigateBackWithinApp = useCallback(() => { - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, navigate]); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - event.preventDefault(); - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - activeElement.blur(); - } - navigateBackWithinApp(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateBackWithinApp]); - - return ( - -
- - - - -
-
- ); -} - -function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { - const groups = useSettingsProjectGroups(); - const navigate = useNavigate(); - const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const openProjectMenu = (event: ReactMouseEvent) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const items: ContextMenuItem[] = groups.map((group) => ({ - id: group.projectKey, - label: group.displayName, - })); - void settlePromise(() => - api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), - ).then((clicked) => { - if (clicked._tag === "Failure" || clicked.value === null) return; - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: clicked.value }, - replace: true, - hashScrollIntoView: false, - }); - }); - }; - - return ( - - Projects - - - {selected ? ( - - ) : ( - Unavailable project - )} - - - ); -} - -export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +export function ProjectSettingsPanel({ + projectKey, + environmentId = null, +}: { + projectKey: string; + environmentId?: EnvironmentId | null; +}) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; + const members = useMemo( + () => + selected?.memberProjects.filter( + (member) => environmentId === null || member.environmentId === environmentId, + ) ?? [], + [selected, environmentId], + ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. - const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); + const lastSelectionRef = useRef<{ + key: string; + environmentId: EnvironmentId | null; + memberKeys: string[]; + } | null>(null); useEffect(() => { - if (!selected) return; + if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, - memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), + environmentId, + memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected]); + }, [selected, members, environmentId]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { - if (selected !== null) return; + if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey) return; + if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ - to: "/projects/$projectKey", - params: { projectKey: successor.projectKey }, + to: "/settings/projects", + search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, selected]); + }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( @@ -298,17 +218,185 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
); } - return ; + if (members.length === 0) + return ( +

+ This project has no checkout on this machine. +

+ ); + const scopedGroup = { + ...selected, + memberProjects: members, + environmentId: members[0]!.environmentId, + id: members[0]!.id, + }; + return ( + + ); +} + +function reportScriptFailure(result: AtomCommandResult) { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to save project actions", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return mapAtomCommandResult(result, () => undefined); +} + +export function useProjectScriptSettings( + targets: readonly { + environmentId: EnvironmentId; + settings: ServerSettings; + keybindings: ResolvedKeybindingsConfig; + project?: { id: ProjectId; scripts: readonly ProjectScript[] }; + }[], +) { + const projects = useProjects(); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); + const upsertKeybinding = useAtomCommand( + serverEnvironment.upsertKeybinding, + "action shortcut update", + ); + const removeKeybinding = useAtomCommand( + serverEnvironment.removeKeybinding, + "action shortcut removal", + ); + + async function persist( + transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, + scriptId?: string, + keybinding?: string | null, + ): Promise> { + if (savingRef.current || targets.length === 0) { + const message = "No available machine, or another action change is saving."; + toastManager.add({ type: "error", title: "Actions not saved", description: message }); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + savingRef.current = true; + setSaving(true); + try { + for (const { environmentId, settings, keybindings, project } of targets) { + const current = project + ? resolveProjectScripts(settings, project) + : settings.defaultProjectScripts; + const nextScripts = transform(current); + const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; + const result = await updateSettings({ + environmentId, + input: { + patch: project + ? { projectScriptOverrides: { [project.id]: nextScripts } } + : { defaultProjectScripts: nextScripts ?? [] }, + }, + }); + if (result._tag === "Failure") return reportScriptFailure(result); + if (!isElectron) continue; + const changedIds = scriptId + ? [scriptId] + : current + .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) + .map((script) => script.id); + for (const id of changedIds) { + const command = commandForProjectScript(id); + const previousValue = keybindingValueForCommand(keybindings, command); + const previous = previousValue + ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) + : null; + const next = decodeProjectScriptKeybindingRule({ keybinding, command }); + const retainedElsewhere = + !nextScripts?.some((script) => script.id === id) && + ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || + Object.entries(settings.projectScriptOverrides).some( + ([projectId, scripts]) => + projectId !== project?.id && scripts?.some((script) => script.id === id), + ) || + projects.some( + (other) => + other.environmentId === environmentId && + other.id !== project?.id && + (project ? resolveProjectScripts(settings, other) : other.scripts).some( + (script) => script.id === id, + ), + )); + const bindingResult = next + ? await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== next.key ? { ...next, replace: previous } : next, + }) + : previous && !retainedElsewhere + ? await removeKeybinding({ environmentId, input: previous }) + : null; + if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); + } + } + return AsyncResult.success(undefined); + } finally { + savingRef.current = false; + setSaving(false); + } + } + + function submit(scriptId: string | null, input: NewProjectScriptInput) { + const existingIds = [ + ...projects.flatMap((project) => project.scripts.map((script) => script.id)), + ...targets.flatMap(({ settings, project }) => + [ + ...settings.defaultProjectScripts, + ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), + ...(project?.scripts ?? []), + ].map((script) => script.id), + ), + ]; + const id = scriptId ?? nextProjectScriptId(input.name, existingIds); + const next = buildProjectScript(id, input); + return persist( + (current) => { + const updated = current.map((script) => + script.id === id + ? next + : input.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ); + return scriptId === null ? [...updated, next] : updated; + }, + id, + input.keybinding, + ); + } + + return { saving, persist, submit }; } -function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { +function ProjectDetail({ + group, + hasOtherMembers, +}: { + group: SidebarProjectSnapshot; + hasOtherMembers: boolean; +}) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const representative = group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, + (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; - const settings = usePrimarySettings(); // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. @@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); - const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { - reportFailure: false, - }); - const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { - reportFailure: false, + const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); + const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); + const savingBrowserAccessRef = useRef(false); + const browserOverrides = group.memberProjects.map( + (member) => + environmentById.get(member.environmentId)?.serverConfig?.settings + .projectAgentBrowserAccessOverrides[member.id], + ); + const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; + const browserMixed = group.memberProjects.some((member, index) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; + return ( + browserOverrides[index] !== browserOverride || + (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== + (browserOverride ?? projectSettings.enableAgentBrowserAccess) + ); }); + const setBooleanOverride = async ( + key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", + enabled: boolean | undefined, + ) => { + if (savingBrowserAccessRef.current) return; + savingBrowserAccessRef.current = true; + setSavingBrowserAccess(true); + try { + const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); + for (const environmentId of environmentIds) { + const environment = environmentById.get(environmentId); + if (!environment?.serverConfig || environment.connection.phase !== "connected") { + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: `Connect ${environment?.label ?? "this machine"} and try again.`, + }); + return; + } + } + if (key === "projectAutoPullOverrides" && enabled === undefined) { + const result = await updateAllMembers( + { autoPull: false }, + "Failed to reset automatic pull", + ); + if (result._tag === "Failure") return; + } + for (const environmentId of environmentIds) { + const overrides = Object.fromEntries( + group.memberProjects + .filter((member) => member.environmentId === environmentId) + .map((member) => [member.id, enabled ?? null]), + ); + const result = await updateServerSettings({ + environmentId, + input: { patch: { [key]: overrides } }, + }); + if (result._tag === "Failure") { + reportFailure( + `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, + mapAtomCommandResult(result, () => undefined), + ); + return; + } + } + } finally { + savingBrowserAccessRef.current = false; + setSavingBrowserAccess(false); + } + }; + const setBrowserAccess = (enabled: boolean | undefined) => + setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ type: "success", title: "Path copied", description: path }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ? window.desktopBridge?.pickProjectFavicon : undefined; - const threadCountByMember = useMemo(() => { - const counts = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- default model ----- const storedSelection = representative.defaultModelSelection; - const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); + const resolvedSelection = resolveDefaultProviderModelSelection( + serverProviders, + storedSelection ?? projectSettings.defaultModelSelection, + ); + const mixedModel = group.memberProjects.some((member) => { + const config = environmentById.get(member.environmentId)?.serverConfig; + return ( + !Equal.equals(member.defaultModelSelection, storedSelection) || + (config !== null && + config !== undefined && + environmentById.get(representative.environmentId)?.serverConfig != null && + JSON.stringify( + resolveDefaultProviderModelSelection( + config.providers, + member.defaultModelSelection ?? config.settings.defaultModelSelection, + ), + ) !== JSON.stringify(resolvedSelection)) + ); + }); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( @@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); - const setDefaultModel = useCallback( - (selection: ModelSelection | null) => - void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), - [updateAllMembers], - ); + const setDefaultModel = (selection: ModelSelection | null) => { + if (selection !== null) { + for (const member of group.memberProjects) { + const environment = environmentById.get(member.environmentId); + const config = environment?.serverConfig; + const entry = config + ? applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === selection.instanceId) + : undefined; + const options = config + ? getCustomModelOptionsByInstance( + { ...projectSettings, ...config.settings }, + config.providers, + ).get(selection.instanceId) + : undefined; + if ( + !entry?.enabled || + !entry.isAvailable || + !options?.some((model) => model.slug === selection.model && !model.isUnavailable) + ) { + toastManager.add({ + type: "warning", + title: "Project model not saved", + description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, + }); + return; + } + } + } + void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); + }; // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; + const mixedWorkspace = group.memberProjects.some( + (member) => member.defaultThreadEnvMode !== storedEnvMode, + ); const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( @@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - const autoPull = representative.autoPull ?? false; - const setAutoPull = useCallback( - (enabled: boolean) => - void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), - [updateAllMembers], + const autoPull = resolveProjectAutoPull( + projectSettings, + representative.id, + representative.autoPull, ); + const autoPullOverridden = group.memberProjects.some( + (member) => + member.autoPull || + environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[ + member.id + ] !== undefined, + ); + const mixedAutoPull = group.memberProjects.some((member) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull; + }); + const setAutoPull = (enabled: boolean | undefined) => + setBooleanOverride("projectAutoPullOverrides", enabled); // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); @@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); // ----- checkout selection and scripts ----- - const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); - const selectedCheckout = - group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? - representative; + const hasMultipleCheckouts = group.memberProjects.length > 1; + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); + const selectedCheckoutMatch = group.memberProjects.find( + (member) => member.physicalProjectKey === selectedCheckoutKey, + ); + const selectedCheckout = selectedCheckoutMatch ?? representative; const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const scripts = selectedCheckout.scripts; + const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); + const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); + const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); const [editorRequest, setEditorRequest] = useState(null); - // Script writes replace the whole array, so two overlapping writes computed - // from the same snapshot would drop each other's changes. One at a time. - const [isSavingScripts, setIsSavingScripts] = useState(false); - const savingScriptsRef = useRef(false); + const { + saving: isSavingScripts, + persist: persistScripts, + submit: submitScript, + } = useProjectScriptSettings([ + { + environmentId: selectedCheckout.environmentId, + settings: scriptSettings, + keybindings, + project: selectedCheckout, + }, + ]); const t3File = useT3ProjectFileState( selectedCheckout.environmentId, selectedCheckout.workspaceRoot, ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. - const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; + const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => @@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [scripts, t3File.scripts], ); - const persistScripts = useCallback( - async ( - nextScripts: ReadonlyArray>, - keybinding: string | null | undefined, - keybindingCommand: ReturnType, - ): Promise> => { - if (savingScriptsRef.current) { - return AsyncResult.failure( - Cause.fail(new Error("Another script change is still saving. Try again.")), - ); - } - savingScriptsRef.current = true; - setIsSavingScripts(true); - try { - // Captured before the write so a cleared or deleted binding can be - // removed from the keybindings config afterwards. - const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); - const updateResult = mapAtomCommandResult( - await updateProject({ - environmentId: selectedCheckout.environmentId, - input: { projectId: selectedCheckout.id, scripts: nextScripts }, - }), - () => undefined, - ); - if (updateResult._tag === "Failure") { - reportFailure("Failed to save scripts", updateResult); - return updateResult; - } - - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: keybindingCommand, - }); - if (!isElectron) return updateResult; - const environmentIds = [selectedCheckout.environmentId]; - const previousTarget = previousKeybinding - ? decodeProjectScriptKeybindingRule({ - keybinding: previousKeybinding, - command: keybindingCommand, - }) - : null; - if (keybindingRule) { - // `replace` swaps the command's previous rule instead of appending a - // second one that would keep the old shortcut alive. - const input = - previousTarget && previousTarget.key !== keybindingRule.key - ? { ...keybindingRule, replace: previousTarget } - : keybindingRule; - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await upsertKeybinding({ environmentId, input }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to save keybinding", result); - return result; - } - } - } else if (previousTarget) { - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await removeKeybinding({ environmentId, input: previousTarget }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to remove keybinding", result); - return result; - } - } - } - return updateResult; - } finally { - savingScriptsRef.current = false; - setIsSavingScripts(false); - } - }, - [ - keybindings, - removeKeybinding, - reportFailure, - selectedCheckout.environmentId, - selectedCheckout.id, - updateProject, - upsertKeybinding, - ], - ); - - const submitScript = useCallback( - async ( - scriptId: string | null, - input: NewProjectScriptInput, - ): Promise> => { - if (scriptId === null) { - const nextId = nextProjectScriptId( - input.name, - scripts.map((script) => script.id), - ); - const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...scripts, nextScript]; - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId)); - } - - const updatedScript = buildProjectScript(scriptId, input); - const nextScripts = scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); - - const deleteScript = useCallback( - (scriptId: string) => { - const nextScripts = scripts.filter((script) => script.id !== scriptId); - void persistScripts(nextScripts, null, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); + const deleteScript = (scriptId: string) => + void persistScripts( + (current) => current.filter((script) => script.id !== scriptId), + scriptId, + null, + ); const importFileScript = useCallback( async (fileScript: T3ProjectFileScript) => { @@ -692,7 +772,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); } }, - [submitScript], + [submitScript, setEditorRequest], ); // ----- checkouts ----- @@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; + const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? group.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( [ projectThreads.length > 0 - ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove project "${targetLabel}"?`, + ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` + : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, @@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { "This permanently clears conversation history for those threads and any archived threads.", ] : ["This permanently clears any archived conversation history."]), - isWholeGroup + isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", @@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { draftStore.clearProjectDraftThreadId(projectRef); } - // The project's settings page just deleted itself; there is no projects - // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/", replace: true }); + if (hasOtherMembers) { + void navigate({ + to: "/settings/projects", + search: { project: group.projectKey, machine: undefined }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } } }, [ deleteProject, group.displayName, group.memberProjects.length, + group.projectKey, + hasOtherMembers, navigate, reportFailure, threads, ], ); - const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; const selectedCheckoutGrouping = projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) ] ?? "inherit"; - const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; + const checkoutLabel = (member: SidebarProjectGroupMember) => { + const label = member.environmentLabel ?? "This machine"; + return group.memberProjects.some( + (other) => + other.physicalProjectKey !== member.physicalProjectKey && + (other.environmentLabel ?? "This machine") === label, + ) + ? `${label} · ${member.workspaceRoot}` + : label; + }; + const selectedCheckoutLabel = checkoutLabel(selectedCheckout); return ( <> - - + + member.defaultModelSelection !== null) ? ( setDefaultModel(null)} /> ) : null @@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> member.defaultThreadEnvMode !== null) ? ( setDefaultThreadEnvMode(null)} /> ) : null @@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setAutoPull(false)} /> + autoPullOverridden ? ( + void setAutoPull(undefined)} + /> ) : null } control={ void setAutoPull(enabled)} /> } /> + value !== undefined) ? ( + void setBrowserAccess(undefined)} + /> + ) : null + } + control={ + + } + /> - setSelectedCheckoutKey(String(value))} - > - - {selectedCheckoutLabel} - - - {group.memberProjects.map((member) => ( - - {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} - - ))} - - - } - > -
-
- - - copyPathToClipboard(selectedCheckout.workspaceRoot, { - path: selectedCheckout.workspaceRoot, - }) - } - > - - {selectedCheckout.workspaceRoot} - - - - } - /> - Copy path - -
- {selectedCheckoutThreadCount === 1 - ? "1 thread" - : `${selectedCheckoutThreadCount} threads`} -
-
-
+ + {hasMultipleCheckouts ? ( + { + if (value) setSelectedCheckoutKey(value); + }} + > + + {selectedCheckoutLabel} + + + {group.memberProjects.map((member) => ( + + + {checkoutLabel(member)} + + + ))} + + + } + /> + ) : null} updateGroupingPreference(selectedCheckout, "inherit")} + /> + ) : null + } control={ { + if (next) onChange(next === "all" ? null : next); + }} + > + + + {value === null ? allIcon : selected?.icon} + + {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} + + + + + + + {allIcon}All {label}s + + + {options.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + + ); +} + +export function ProjectsSettings({ + projectKey, + machineId, + onScopeChange, +}: { + projectKey: string | null; + machineId: string | null; + onScopeChange: (project: string | null, machine: string | null) => void; +}) { + const groups = useSettingsProjectGroups(); + const { environments } = useEnvironments(); + const machine = environments.find((environment) => environment.environmentId === machineId); + const machineOptions = environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + icon: ( + + ), + })); + return ( +
+
+ +
+ {environments.length > 3 ? ( + onScopeChange(projectKey, value)} + /> + ) : ( + { + const value = next[0]; + if (value) onScopeChange(projectKey, value === "all" ? null : value); + }} + > + All machines + {machineOptions.map((option) => ( + + {option.icon} + {option.label} + + ))} + + )} +
+ ({ + value: group.projectKey, + label: group.displayName, + icon: ( + + ), + }))} + onChange={(value) => onScopeChange(value, machineId)} + /> +
+
+
+
+ {machineId !== null && !machine ? ( +

This machine is no longer available.

+ ) : projectKey === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 8fe0533a115b..0f3b9a94572c 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,10 +1,11 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { ArrowUpCircleIcon, CopyIcon, DownloadIcon, - LoaderIcon, LockIcon, LockOpenIcon, PlusIcon, @@ -716,15 +717,19 @@ export function ProviderInstanceCard({ selected ? "bg-muted/45" : "hover:bg-muted/25", )} > - + } + /> + Copy update command + + ) : ( + + + + ) ) : null} @@ -755,7 +782,7 @@ export function ProviderInstanceCard({ - +
+
{driverOption?.badgeLabel ? ( {driverOption.badgeLabel} @@ -787,7 +814,7 @@ export function ProviderInstanceCard({ render={ } /> @@ -831,7 +858,7 @@ export function ProviderInstanceCard({ disabled={isUpdating} onClick={onRunUpdate} > - {isUpdating ? : } + {isUpdating ? : } {isUpdating ? "Updating" : "Update now"} ) : null} @@ -876,14 +903,14 @@ export function ProviderInstanceCard({ {onDelete ? ( ) : null} diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index e61f76690991..cf093d69ad8a 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -5,8 +5,6 @@ import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta"; import { deriveProviderSettingsFields, nextProviderConfigWithFieldValue, - readProviderConfigBoolean, - readProviderConfigString, } from "./ProviderSettingsForm"; describe("ProviderSettingsForm helpers", () => { @@ -118,10 +116,6 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); - it("reads non-string config values as blank strings", () => { - expect(readProviderConfigString({ binaryPath: 123 }, "binaryPath")).toBe(""); - }); - it("omits false boolean fields when clearWhenEmpty is omit", () => { const next = nextProviderConfigWithFieldValue( { forkOwned: 1, experimental: true }, @@ -184,12 +178,4 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ experimental: false }); }); - - it("reads non-boolean config values as false booleans", () => { - expect(readProviderConfigBoolean({ experimental: "true" }, "experimental")).toBe(false); - }); - - it("reads missing boolean config values from the supplied default", () => { - expect(readProviderConfigBoolean({}, "experimental", true)).toBe(true); - }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 902fd408b54f..6d644aaf01c5 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -119,17 +119,13 @@ export function deriveProviderSettingsFields( }); } -export function readProviderConfigString(config: unknown, key: string): string { +function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; const value = (config as Record)[key]; return typeof value === "string" ? value : ""; } -export function readProviderConfigBoolean( - config: unknown, - key: string, - defaultValue = false, -): boolean { +function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { if (config === null || typeof config !== "object") return defaultValue; const value = (config as Record)[key]; return typeof value === "boolean" ? value : defaultValue; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index fb4620b054c1..e4c3024669bd 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -24,7 +25,7 @@ import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; -import { PlusIcon, RefreshCwIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; @@ -32,6 +33,7 @@ import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; import { cn } from "../../lib/utils"; import { resolveAppModelSelectionState } from "../../modelSelection"; import { @@ -278,6 +280,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { const { environments, isReady } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const searchTargetId = useSettingsSearchTargetId(); + const { environmentId: settingsEnvironmentId, selectEnvironment } = useSettingsEnvironment(); const options = useMemo( () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), [environments, primaryEnvironmentId], @@ -285,9 +288,26 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { // Raw user intent; the effective selection is re-derived every render so a // device that drops out of the catalog falls back without erasing the pick — // if it reappears (e.g. after a reconnect) the selection is restored. - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( - target.environmentId ?? primaryEnvironmentId, + const [selectedEnvironmentId, setSelectedEnvironmentIdState] = useState( + target.environmentId ?? settingsEnvironmentId ?? primaryEnvironmentId, ); + const setSelectedEnvironmentId = useCallback( + (environmentId: EnvironmentId) => { + setSelectedEnvironmentIdState(environmentId); + selectEnvironment(environmentId); + }, + [selectEnvironment], + ); + const appliedRouteTargetRef = useRef(undefined); + useEffect(() => { + if ( + target.environmentId !== undefined && + appliedRouteTargetRef.current !== target.environmentId + ) { + appliedRouteTargetRef.current = target.environmentId; + selectEnvironment(target.environmentId); + } + }, [selectEnvironment, target.environmentId]); const targetEnvironmentMissing = target.environmentId !== undefined && selectedEnvironmentId === target.environmentId && @@ -318,7 +338,12 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { ) { setSelectedEnvironmentId(searchableEnvironmentId); } - }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]); + }, [ + searchTargetId, + searchableEnvironmentId, + setSelectedEnvironmentId, + selectedEnvironmentCanRenderSettings, + ]); const onlyPrimaryDevice = options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; const deviceTabs = @@ -993,7 +1018,7 @@ export function EnvironmentProviderSettings({ aria-busy={isRefreshingProviders} onClick={() => void refreshProviders()} > - + Refresh provider status {isRefreshingProviders ? ( diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index ca934952f8a9..56c5ddd51acd 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ActivityIcon, AlertTriangleIcon, @@ -9,11 +10,10 @@ import { GaugeIcon, HardDriveIcon, MemoryStickIcon, - RefreshCwIcon, - RotateCcwIcon, } from "lucide-react"; import type { BackgroundBooleanState, + EnvironmentId, ResourceAttributionEntry, ResourceTelemetryAggregate, ResourceTelemetryHistoryBucket, @@ -39,7 +39,6 @@ import { } from "../../lib/resourceTelemetryState"; import { cn } from "../../lib/utils"; import { ensureLocalApi } from "../../localApi"; -import { usePrimaryEnvironment } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatRelativeTime } from "../../timestampFormat"; @@ -832,25 +831,26 @@ function AttributionTable({ entries }: { entries: ReadonlyArray option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; - const telemetry = useResourceTelemetry(); + const telemetry = useResourceTelemetry(environmentId); const retryTelemetry = telemetry.retry; - const history = useResourceTelemetryHistory({ + const history = useResourceTelemetryHistory(environmentId, { windowMs: selectedWindow.windowMs, bucketMs: selectedWindow.bucketMs, }); - const primaryEnvironment = usePrimaryEnvironment(); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); const [signalingKeys, setSignalingKeys] = useState>(() => new Set()); const signalingKeysRef = useRef>(new Set()); signalingKeysRef.current = signalingKeys; - const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId); - primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId; const [isRetrying, setIsRetrying] = useState(false); const snapshot = telemetry.data; const allT3 = snapshot?.groups.allT3; @@ -890,11 +890,6 @@ export function ResourceTelemetryDiagnostics() { return; } } - const environmentId = primaryEnvironmentIdRef.current; - if (environmentId === undefined) { - clearSignaling(); - return; - } void signalServerProcess({ environmentId, input: { @@ -929,7 +924,7 @@ export function ResourceTelemetryDiagnostics() { clearSignaling(); }); }, - [signalServerProcess], + [environmentId, signalServerProcess], ); const retryCollector = useCallback(() => { @@ -982,9 +977,7 @@ export function ResourceTelemetryDiagnostics() { onClick={telemetry.refresh} aria-label="Refresh resource telemetry" > - + } /> @@ -1095,7 +1088,7 @@ export function ResourceTelemetryDiagnostics() { headerAction={ collectorNeedsRetry ? ( ) : null @@ -1233,7 +1226,7 @@ export function ResourceTelemetryDiagnostics() { onClick={history.refresh} aria-label="Refresh resource history" > - +
} diff --git a/apps/web/src/components/settings/SettingsEnvironmentSelector.tsx b/apps/web/src/components/settings/SettingsEnvironmentSelector.tsx new file mode 100644 index 000000000000..58d820f56d2a --- /dev/null +++ b/apps/web/src/components/settings/SettingsEnvironmentSelector.tsx @@ -0,0 +1,143 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import { CloudIcon, MonitorIcon } from "lucide-react"; +import { useMemo, type ReactNode } from "react"; + +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; +import type { EnvironmentPresentation } from "../../state/environments"; +import { Button } from "../ui/button"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { settingsEnvironmentNotice } from "./SettingsPanels.logic"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; + +interface SettingsEnvironmentSelectorProps { + readonly environmentId: EnvironmentId; + readonly environments: ReadonlyArray; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly onEnvironmentChange: (environmentId: EnvironmentId) => void; +} + +export function SettingsEnvironmentSelector({ + environmentId, + environments, + primaryEnvironmentId, + onEnvironmentChange, +}: SettingsEnvironmentSelectorProps) { + const items = useMemo( + () => + environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + })), + [environments], + ); + const selectedEnvironment = + environments.find((environment) => environment.environmentId === environmentId) ?? null; + const SelectedIcon = environmentId === primaryEnvironmentId ? MonitorIcon : CloudIcon; + + return ( + + ); +} + +/** + * Header for a panel whose contents belong to one environment: the picker, the + * current target, and why that target is unusable. `children` only renders + * against an environment that has published its settings, so panels never show + * schema defaults as if they were saved. + */ +export function SettingsEnvironmentScope({ + description, + children, +}: { + readonly description: string; + readonly children: ( + environment: EnvironmentPresentation, + serverConfig: ServerConfig, + ) => ReactNode; +}) { + const { + isReady, + environment, + environmentId, + environments, + primaryEnvironmentId, + selectEnvironment, + } = useSettingsEnvironment(); + const serverConfig = environment?.serverConfig ?? null; + const notice = settingsEnvironmentNotice({ + isReady, + label: environment?.label ?? null, + phase: environment?.connection.phase ?? null, + hasServerConfig: serverConfig !== null, + error: environment?.connection.error ?? null, + }); + + return ( + <> + + ) + } + > + } size="sm" variant="outline"> + Open connections + + ) : null + } + /> + + {environment !== null && serverConfig !== null && notice === null + ? children(environment, serverConfig) + : null} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index b99c69ee331f..ff97502c9407 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -14,12 +14,57 @@ import { formatDiagnosticsDescription, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, - isSamePreviewViewport, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, resolveBackgroundActivityProfileOption, + resolveSettingsEnvironmentId, } from "./SettingsPanels.logic"; +import { EnvironmentId } from "@t3tools/contracts"; + +describe("settings environment resolution", () => { + const primary = EnvironmentId.make("primary"); + const active = EnvironmentId.make("active"); + const selected = EnvironmentId.make("selected"); + + it("prefers an explicit selection, then primary, then active", () => { + expect( + resolveSettingsEnvironmentId({ + selectedEnvironmentId: selected, + primaryEnvironmentId: primary, + activeEnvironmentId: active, + availableEnvironmentIds: [primary, active, selected], + }), + ).toBe(selected); + expect( + resolveSettingsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: primary, + activeEnvironmentId: active, + availableEnvironmentIds: [active, primary], + }), + ).toBe(primary); + expect( + resolveSettingsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: active, + availableEnvironmentIds: [selected, active], + }), + ).toBe(active); + }); + + it("recovers from a stale selection", () => { + expect( + resolveSettingsEnvironmentId({ + selectedEnvironmentId: selected, + primaryEnvironmentId: primary, + activeEnvironmentId: active, + availableEnvironmentIds: [primary, active], + }), + ).toBe(primary); + }); +}); describe("typography settings restore", () => { it("detects family and size changes by font row", () => { @@ -282,25 +327,3 @@ describe("getChangedBrowserSettingLabels", () => { ]); }); }); - -describe("isSamePreviewViewport", () => { - it("separates presets that share a size", () => { - // Two presets can agree on width and height and still be different - // entries in the picker, so the id has to take part in the comparison. - expect( - isSamePreviewViewport( - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - { _tag: "preset", width: 390, height: 844, presetId: "ipad-mini" }, - ), - ).toBe(false); - }); - - it("separates a freeform viewport from a preset of the same size", () => { - expect( - isSamePreviewViewport( - { _tag: "freeform", width: 390, height: 844 }, - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 3ac6bbaa0017..238afc11fbf2 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,6 +1,8 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { BackgroundActivityProfile, BackgroundActivitySettings, + EnvironmentId, ProviderDriverKind, ProviderInstanceConfig, PreviewViewportSetting, @@ -19,6 +21,68 @@ import { import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; +/** + * The environment whose server settings the settings panels address. An + * explicit pick wins; otherwise the client's own environment, then the one the + * user is working in, then whatever is connected. A pick that leaves the + * catalog falls back without being erased, so it is restored on reconnect. + */ +export function resolveSettingsEnvironmentId(input: { + readonly selectedEnvironmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly activeEnvironmentId: EnvironmentId | null; + readonly availableEnvironmentIds: ReadonlyArray; +}): EnvironmentId | null { + const available = new Set(input.availableEnvironmentIds); + if (input.selectedEnvironmentId !== null && available.has(input.selectedEnvironmentId)) { + return input.selectedEnvironmentId; + } + if (input.primaryEnvironmentId !== null && available.has(input.primaryEnvironmentId)) { + return input.primaryEnvironmentId; + } + if (input.activeEnvironmentId !== null && available.has(input.activeEnvironmentId)) { + return input.activeEnvironmentId; + } + return input.availableEnvironmentIds[0] ?? null; +} + +/** + * Why the settings environment cannot be read or written right now, or null + * once it is usable. Shared by every panel scoped to one environment so they + * all describe the same states the same way. + */ +export function settingsEnvironmentNotice(input: { + readonly isReady: boolean; + readonly label: string | null; + readonly phase: EnvironmentConnectionPhase | null; + readonly hasServerConfig: boolean; + readonly error: string | null; +}): string | null { + if (input.label === null || input.phase === null) { + return input.isReady ? "No environment is connected." : "Loading environments..."; + } + switch (input.phase) { + case "connected": + // Settings only exist once the environment has published its config; + // until then a panel would render schema defaults as if they were saved. + return input.hasServerConfig ? null : `Loading ${input.label}...`; + case "connecting": + return `Connecting to ${input.label}...`; + case "reconnecting": + return input.error + ? `Reconnecting to ${input.label}... Reason: ${input.error}` + : `Reconnecting to ${input.label}...`; + case "offline": + return `${input.label} is offline.`; + case "available": + return `${input.label} is not connected.`; + case "error": + return input.error + ? `Could not connect to ${input.label}. Reason: ${input.error}` + : `Could not connect to ${input.label}.`; + } +} + export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { return mode !== "separate"; } @@ -126,7 +190,7 @@ export type BrowserDefaultSettings = Pick< * reports every stored viewport as changed — including one that matches the * default. */ -export function isSamePreviewViewport( +function isSamePreviewViewport( left: PreviewViewportSetting, right: PreviewViewportSetting, ): boolean { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c4c2e61fccd9..ebde50980fbb 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,14 +1,16 @@ -import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; +import { Spinner } from "~/components/ui/spinner"; +import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useAtomValue } from "@effect/atom-react"; import { type BackgroundActivityProfile, type DesktopUpdateChannel, + type EnvironmentId, ProviderDriverKind, type ProviderInstanceId, type ScopedThreadRef, + type ServerConfig, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -70,7 +72,13 @@ import { useTheme, } from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + useEnvironmentSettings, + usePrimarySettings, + useUpdateEnvironmentSettings, + useUpdatePrimarySettings, +} from "../../hooks/useSettings"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -85,13 +93,8 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { - primaryServerConfigAtom, - primaryServerObservabilityAtom, - primaryServerProvidersAtom, -} from "../../state/server"; +import type { EnvironmentPresentation } from "../../state/environments"; import { useProjects } from "../../state/entities"; -import { usePrimaryEnvironmentId } from "../../state/environments"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -161,6 +164,7 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; +import { SettingsEnvironmentScope } from "./SettingsEnvironmentSelector"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -484,7 +488,15 @@ function AboutVersionSection() { ); } -export function useSettingsRestore(onRestored?: () => void) { +export type SettingsOwnership = "client" | "environment"; + +export interface SettingsRestore { + readonly canRestoreDefaults: boolean; + readonly changedSettingLabels: ReadonlyArray; + readonly restoreDefaults: () => Promise; +} + +function useClientSettingsRestore(onRestored?: () => void): SettingsRestore { const { theme, setTheme, @@ -497,12 +509,6 @@ export function useSettingsRestore(onRestored?: () => void) { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); - const isTextGenerationModelDirty = !Equal.equals( - settings.textGenerationModelSelection ?? null, - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, - ); - const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); - const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), @@ -529,13 +535,6 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), - ...(settings.sidebarAutoSettleAfterDays !== - DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays - ? ["Auto-settle inactive threads"] - : []), - ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge - ? ["Auto-settle merged threads"] - : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace @@ -555,29 +554,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), - ...(settings.enableLegacyTokenStreaming !== - DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming - ? ["Stream token by token"] - : []), - ...(settings.enableProviderUpdateChecks !== - DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks - ? ["Provider update checks"] - : []), - ...(settings.continueThreadsAfterServerUpdate !== - DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate - ? ["Continue threads after restarts"] - : []), - ...(isBackgroundActivityDirty ? ["Background activity"] : []), - ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode - ? ["New thread mode"] - : []), - ...(settings.newWorktreesStartFromOrigin !== - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin - ? ["New worktrees start from origin"] - : []), - ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory - ? ["Add project base directory"] - : []), ...(settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin ? ["Unpin confirmation"] : []), @@ -588,37 +564,27 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Delete confirmation"] : []), ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []), - ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ...getChangedBrowserSettingLabels(settings), - ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess - ? ["Agent browser access"] - : []), ], [ - isTextGenerationModelDirty, - isBackgroundActivityDirty, + settings.appearanceContrast, + settings.browserAutoShowFloatingPreview, + settings.browserDefaultAppearance, settings.browserDefaultViewport, settings.browserDefaultZoomFactor, - settings.browserDefaultAppearance, settings.browserRecordingFrameRate, settings.browserLinkTarget, - settings.browserAutoShowFloatingPreview, - settings.appearanceContrast, - settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnBlur, settings.composerCollapseOnScroll, - settings.addProjectBaseDirectory, - settings.defaultThreadEnvMode, - settings.newWorktreesStartFromOrigin, + settings.contextWindowMeterEnabled, settings.diffIgnoreWhitespace, settings.diffLayout, settings.proactivePanelsEnabled, settings.environmentIdentificationMode, - settings.contextWindowMeterEnabled, settings.fontFamilyCode, settings.fontFamilyComposer, settings.fontFamilySans, @@ -629,15 +595,10 @@ export function useSettingsRestore(onRestored?: () => void) { settings.fontSizeTerminal, settings.glassOpacity, settings.panelAnimationDurationMs, - settings.enableLegacyTokenStreaming, settings.persistComposerContextStrip, - settings.enableProviderUpdateChecks, - settings.continueThreadsAfterServerUpdate, - settings.sidebarAutoSettleAfterDays, - settings.sidebarAutoSettleOnMerge, + settings.showSkillsInSlashMenu, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, - settings.showSkillsInSlashMenu, settings.timestampFormat, settings.wordWrap, followSystem, @@ -725,23 +686,10 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, - enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, - continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, - backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, - backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, - automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, - providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, - textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, @@ -756,10 +704,6 @@ export function useSettingsRestore(onRestored?: () => void) { browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate, browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget, browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, - // Re-granted like any other default. The confirmation dialog lists it by - // name, so a user restoring defaults is told the agent regains access - // rather than discovering it later. - enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, }); onRestored?.(); }, [ @@ -775,20 +719,116 @@ export function useSettingsRestore(onRestored?: () => void) { ]); return { + canRestoreDefaults: true, + changedSettingLabels, + restoreDefaults, + }; +} + +function useEnvironmentSettingsRestore(onRestored?: () => void): SettingsRestore { + const { environmentId, environment } = useSettingsEnvironment(); + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const isTextGenerationModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, + ); + + const changedSettingLabels = useMemo( + () => [ + ...(settings.sidebarAutoSettleAfterDays !== + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays + ? ["Auto-settle inactive threads"] + : []), + ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge + ? ["Auto-settle merged threads"] + : []), + ...(settings.enableLegacyTokenStreaming !== + DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming + ? ["Stream token by token"] + : []), + ...(settings.enableProviderUpdateChecks !== + DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks + ? ["Provider update checks"] + : []), + ...(settings.continueThreadsAfterServerUpdate !== + DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate + ? ["Continue threads after restarts"] + : []), + ...(hasChangedBackgroundActivitySettings(settings) ? ["Background activity"] : []), + ...(settings.newWorktreesStartFromOrigin !== + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin + ? ["New worktrees start from origin"] + : []), + ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory + ? ["Add project base directory"] + : []), + ...(isTextGenerationModelDirty ? ["Text generation model"] : []), + ], + [isTextGenerationModelDirty, settings], + ); + + const restoreDefaults = useCallback(async () => { + if (changedSettingLabels.length === 0) return; + const api = readLocalApi(); + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( + ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( + "\n", + ), + { variant: "destructive" }, + ); + if (!confirmed) return; + + updateSettings({ + sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, + enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, + backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, + automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, + providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, + newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + onRestored?.(); + }, [changedSettingLabels, onRestored, updateSettings]); + + return { + // Writes go to a server, so there is nothing to restore while the settings + // environment is unreachable. + canRestoreDefaults: environment?.connection.phase === "connected", changedSettingLabels, restoreDefaults, }; } +/** + * Restore-to-defaults for one settings page. Client and environment settings + * live in separate stores, so a page only resets the keys it owns. + */ +export function useSettingsRestore( + ownership: SettingsOwnership, + onRestored?: () => void, +): SettingsRestore { + const client = useClientSettingsRestore(onRestored); + const environment = useEnvironmentSettingsRestore(onRestored); + return ownership === "client" ? client : environment; +} + function BackgroundActivityAdvancedDialog({ + environmentId, open, onOpenChange, }: { + readonly environmentId: EnvironmentId; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; }) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const activeProfile = resolvedBackgroundActivity.profile; const automaticGitFetchIntervalSeconds = durationToSeconds( @@ -1932,7 +1972,6 @@ function AutoSettleDaysInput({ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ "legacy-plan-mode", "legacy-context-window-indicator", - "legacy-token-streaming", "legacy-sidebar", ]); @@ -2022,33 +2061,6 @@ function LegacyFeaturesSection() { /> } /> - { - if (!checked) { - updateSettings({ enableLegacyTokenStreaming: false }); - return; - } - void (async () => { - const api = readLocalApi(); - const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( - [ - "Turn on token-by-token output?", - "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", - ].join("\n"), - ); - if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); - })(); - }} - aria-label="Stream token by token (legacy)" - /> - } - /> ( readLastEnabledProjectGroupingMode(), ); - const observability = useAtomValue(primaryServerObservabilityAtom); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const supportsAutoSettlement = - useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; const composerCollapseTriggers = useMemo( () => [ ...(settings.composerCollapseOnBlur ? (["blur"] as const) : []), @@ -2089,62 +2094,9 @@ export function GeneralSettingsPanel() { ], [settings.composerCollapseOnBlur, settings.composerCollapseOnScroll], ); - const diagnosticsDescription = formatDiagnosticsDescription({ - localTracingEnabled: observability?.localTracingEnabled ?? false, - otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, - otlpTracesUrl: observability?.otlpTracesUrl, - otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, - otlpMetricsUrl: observability?.otlpMetricsUrl, - }); - - const textGenerationProviders = serverProviders.filter( - (provider) => provider.supportsTextGeneration !== false, - ); - const textGenerationModelSelection = resolveAppModelSelectionState( - settings, - textGenerationProviders, - ); - const textGenInstanceId = textGenerationModelSelection.instanceId; - const textGenModel = textGenerationModelSelection.model; - const textGenModelOptions = textGenerationModelSelection.options; - const textGenerationModelInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings), - ); - const hasTextGenerationProvider = textGenerationModelInstanceEntries.some( - (entry) => entry.enabled && entry.isAvailable, - ); - const textGenInstanceEntry = textGenerationModelInstanceEntries.find( - (entry) => entry.instanceId === textGenInstanceId, - ); - const textGenProvider: ProviderDriverKind = - textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; - const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( - settings, - textGenerationProviders, - textGenInstanceId, - textGenModel, - ); - const isTextGenerationModelDirty = !Equal.equals( - settings.textGenerationModelSelection ?? null, - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, - ); - const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); - const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; - const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); - const backgroundActivityDescription = - backgroundActivityProfileOption === "advanced" - ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${ - BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] - }.` - : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; - const canResetBackgroundActivity = !Equal.equals( - settings.backgroundActivity, - DEFAULT_UNIFIED_SETTINGS.backgroundActivity, - ); return ( - } /> - - {supportsAutoSettlement ? ( - <> - - updateSettings({ - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - }) - } - /> - ) : null - } - control={ - - updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) - } - aria-label="Auto-settle merged threads" - /> - } - /> - - - updateSettings({ - sidebarAutoSettleAfterDays: - DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) - } - aria-label="Auto-settle inactive threads" - /> - } - /> - {settings.sidebarAutoSettleAfterDays !== null ? ( - updateSettings({ sidebarAutoSettleAfterDays: days })} - /> - } - /> - ) : null} - - ) : null} - + } /> + + + + + updateSettings({ + confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmThreadUnpin: Boolean(checked) }) + } + aria-label="Confirm thread unpinning" + /> + } + /> + + + updateSettings({ + confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmThreadArchive: Boolean(checked) }) + } + aria-label="Confirm thread archiving" + /> + } + /> + + + updateSettings({ + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmThreadDelete: Boolean(checked) }) + } + aria-label="Confirm thread deletion" + /> + } + /> + + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + + } + /> + ) : null} + + + + {isElectron || HOSTED_APP_CHANNEL ? ( + + ) : ( + } + description="Current version of the application." + /> + )} + + + + + ); +} + +export function EnvironmentSettingsPanel() { + return ( + + + + {(environment, serverConfig) => ( + + )} + + + ); +} + +function EnvironmentSettingsSections({ + environment, + serverConfig, +}: { + readonly environment: EnvironmentPresentation; + readonly serverConfig: ServerConfig; +}) { + const navigate = useNavigate(); + const environmentId = environment.environmentId; + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); + const observability = serverConfig.observability; + const textGenerationProviders = serverConfig.providers.filter( + (provider) => provider.supportsTextGeneration !== false, + ); + const supportsAutoSettlement = serverConfig.environment.capabilities.threadAutoSettlement; + const diagnosticsDescription = formatDiagnosticsDescription({ + localTracingEnabled: observability?.localTracingEnabled ?? false, + otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, + otlpTracesUrl: observability?.otlpTracesUrl, + otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, + otlpMetricsUrl: observability?.otlpMetricsUrl, + }); + + const textGenerationModelSelection = resolveAppModelSelectionState( + settings, + textGenerationProviders, + ); + const textGenInstanceId = textGenerationModelSelection.instanceId; + const textGenModel = textGenerationModelSelection.model; + const textGenModelOptions = textGenerationModelSelection.options; + const textGenerationModelInstanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings), + ); + const hasTextGenerationProvider = textGenerationModelInstanceEntries.some( + (entry) => entry.enabled && entry.isAvailable, + ); + const textGenInstanceEntry = textGenerationModelInstanceEntries.find( + (entry) => entry.instanceId === textGenInstanceId, + ); + const textGenProvider: ProviderDriverKind = + textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; + const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( + settings, + textGenerationProviders, + textGenInstanceId, + textGenModel, + ); + const isTextGenerationModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, + ); + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; + const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); + const backgroundActivityDescription = + backgroundActivityProfileOption === "advanced" + ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Current shared policy: ${ + BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] + }.` + : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; + const canResetBackgroundActivity = !Equal.equals( + settings.backgroundActivity, + DEFAULT_UNIFIED_SETTINGS.backgroundActivity, + ); + + return ( + <> + + {supportsAutoSettlement ? ( + <> + + updateSettings({ + sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) + } + aria-label="Auto-settle merged threads" + /> + } + /> + + + updateSettings({ + sidebarAutoSettleAfterDays: + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {settings.sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + { + if (!checked) { + updateSettings({ enableLegacyTokenStreaming: false }); + return; + } + void (async () => { + const api = readLocalApi(); + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( + [ + "Turn on token-by-token output?", + "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", + ].join("\n"), + ); + if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); + })(); + }} + aria-label="Stream token by token (legacy)" + /> + } + /> + ) : null} @@ -2612,54 +2815,7 @@ export function GeneralSettingsPanel() { - updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } - control={ - - } - /> - - - - - updateSettings({ - confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, - }) - } - /> - ) : null - } - control={ - - updateSettings({ confirmThreadUnpin: Boolean(checked) }) - } - aria-label="Confirm thread unpinning" - /> - } - /> - - - updateSettings({ - confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, - }) - } - /> - ) : null - } - control={ - - updateSettings({ confirmThreadArchive: Boolean(checked) }) - } - aria-label="Confirm thread archiving" - /> - } - /> - - - updateSettings({ - confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, - }) - } - /> - ) : null - } - control={ - - updateSettings({ confirmThreadDelete: Boolean(checked) }) - } - aria-label="Confirm thread deletion" - /> - } - /> - - {isElectron ? ( - - updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) - } - /> - ) : null - } - control={ - - } - /> - ) : null} - - - - {isElectron || HOSTED_APP_CHANNEL ? ( - - ) : ( - } - description="Current version of the application." - /> - )} + - - - + ); } @@ -3081,7 +3105,7 @@ export function ArchivedThreadsPanel() { title={ {isLoadingArchive ? ( - + ) : ( )} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 1603b87c87c1..9566456587e8 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,16 +6,19 @@ import { useMemo, useRef, useState, + Fragment, type ComponentType, type KeyboardEvent, type ReactNode, } from "react"; import { + ActivityIcon, ArchiveIcon, BlocksIcon, BotIcon, CalendarClockIcon, GitBranchIcon, + PanelsTopLeftIcon, KeyboardIcon, Link2Icon, PaletteIcon, @@ -34,6 +37,7 @@ import { SidebarContent, SidebarFooter, SidebarGroup, + SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -72,17 +76,20 @@ const SETTINGS_SECTION_ICONS: Readonly< Record> > = { "/settings/general": Settings2Icon, + "/settings/environment": Settings2Icon, "/settings/appearance": PaletteIcon, + "/settings/projects": PanelsTopLeftIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, "/settings/integrations": BlocksIcon, "/settings/scheduled-tasks": CalendarClockIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, + "/settings/diagnostics": ActivityIcon, "/settings/archived": ArchiveIcon, }; -export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ +const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; to: SettingsPath; icon: ComponentType<{ className?: string }>; @@ -98,12 +105,16 @@ const SETTINGS_PAGE_SECTIONS: Partial< "/settings/general": [ { label: "Organization", targetId: "organization" }, { label: "Behavior", targetId: "behavior" }, - { label: "Projects & threads", targetId: "projects-and-threads" }, { label: "Confirmations", targetId: "confirmations" }, - { label: "Text generation", targetId: "text-generation" }, { label: "About", targetId: "about" }, { label: "Legacy features", targetId: "legacy-features" }, ], + "/settings/environment": [ + { label: "Workspace", targetId: "environment-workspace" }, + { label: "Projects & threads", targetId: "projects-and-threads" }, + { label: "Text generation", targetId: "text-generation" }, + { label: "Diagnostics", targetId: "environment-diagnostics" }, + ], "/settings/appearance": [ { label: "Colors & themes", targetId: "appearance" }, { label: "Interface", targetId: "appearance-interface" }, @@ -120,6 +131,34 @@ const SETTINGS_PAGE_SECTIONS: Partial< ], }; +const SETTINGS_NAV_GROUP_LABELS = ["Client", "Environments", "Other"] as const; +type SettingsNavGroupLabel = (typeof SETTINGS_NAV_GROUP_LABELS)[number]; + +/** + * Which heading each section sits under. Typed as a total record so a new + * settings section cannot compile without being placed in a group, and + * sections keep the order they are declared in within their group. + */ +const SETTINGS_SECTION_GROUPS: Readonly> = { + "/settings/general": "Client", + "/settings/appearance": "Client", + "/settings/connections": "Environments", + "/settings/environment": "Environments", + "/settings/projects": "Environments", + "/settings/keybindings": "Environments", + "/settings/providers": "Environments", + "/settings/integrations": "Environments", + "/settings/source-control": "Environments", + "/settings/scheduled-tasks": "Environments", + "/settings/diagnostics": "Environments", + "/settings/archived": "Other", +}; + +const SETTINGS_NAV_GROUPS = SETTINGS_NAV_GROUP_LABELS.map((label) => ({ + label, + items: SETTINGS_NAV_ITEMS.filter((item) => SETTINGS_SECTION_GROUPS[item.to] === label), +})); + function SettingsSectionIcon({ to }: { to: SettingsPath }) { const Icon = SETTINGS_SECTION_ICONS[to]; return ; @@ -276,12 +315,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setOpenMobile(false); } const targetId = item.targetId ?? item.id; - if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { + if ( + item.to !== "/settings/projects" && + pathname === item.to && + currentHash.replace(/^#/, "") === targetId + ) { scrollToSettingsTarget(targetId); return; } void navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: targetId, replace: true, hashScrollIntoView: false, @@ -408,45 +453,52 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { ) : ( - {SETTINGS_NAV_ITEMS.map((item) => { - const Icon = item.icon; - const pageSections = SETTINGS_PAGE_SECTIONS[item.to]; - const isActive = activeSettingsPath === item.to; - return ( - - handleSectionClick(item.to)} - > - - {item.label} - - {pageSections ? ( - - - {pageSections.map((section) => ( - - } - size="sm" - data-visible={visiblePageSectionIds.has(section.targetId)} - className={cn( - "w-full text-sidebar-muted-foreground/65", - visiblePageSectionIds.has(section.targetId) && - "font-medium text-sidebar-foreground", - )} - onClick={() => handlePageSectionClick(item.to, section.targetId)} - > - {section.label} - - - ))} - - - ) : null} - - ); - })} + {SETTINGS_NAV_GROUPS.map((group) => ( + + {group.label} + {group.items.map((item) => { + const Icon = item.icon; + const pageSections = SETTINGS_PAGE_SECTIONS[item.to]; + const isActive = activeSettingsPath === item.to; + return ( + + handleSectionClick(item.to)} + > + + {item.label} + + {pageSections ? ( + + + {pageSections.map((section) => ( + + } + size="sm" + data-visible={visiblePageSectionIds.has(section.targetId)} + className={cn( + "w-full text-sidebar-muted-foreground/65", + visiblePageSectionIds.has(section.targetId) && + "font-medium text-sidebar-foreground", + )} + onClick={() => + handlePageSectionClick(item.to, section.targetId) + } + > + {section.label} + + + ))} + + + ) : null} + + ); + })} + + ))} )} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index ee1fa66a3db4..736b7f1b4b99 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,4 +1,5 @@ -import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useEffect, useState, type ReactNode } from "react"; @@ -487,7 +488,7 @@ function EmptySourceControlDiscovery({ @@ -532,7 +533,7 @@ export function SourceControlSettingsPanel() { disabled={discovery.isPending} aria-label="Rescan server environment" > - + } /> diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index b270bf7b8e4d..eb6620a136fb 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -1,10 +1,5 @@ -import { - ExternalLinkIcon, - PackagePlusIcon, - PaletteIcon, - RefreshCwIcon, - SearchIcon, -} from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { importOpenVsxThemeExtension, @@ -417,7 +412,7 @@ export function ThemeSearchSection({ {isInstalling ? ( ) : isInstalled ? ( - + ) : ( )} diff --git a/apps/web/src/components/settings/ThemeWireframe.tsx b/apps/web/src/components/settings/ThemeWireframe.tsx index ce4f13f208e5..895d8d1eecb7 100644 --- a/apps/web/src/components/settings/ThemeWireframe.tsx +++ b/apps/web/src/components/settings/ThemeWireframe.tsx @@ -4,7 +4,7 @@ import type { ThemeCardPreviewColors } from "./ThemePreviewCircles"; // A simple miniature of the app: sidebar, a short conversation, the // composer, and the orchestrator panel floating over the interface as an // island with horizontal agent rows. -export function ThemeWireframePane({ +function ThemeWireframePane({ colors, clip, }: { diff --git a/apps/web/src/components/settings/customModelEditor.logic.ts b/apps/web/src/components/settings/customModelEditor.logic.ts index 0d48057206df..15de96e2f182 100644 --- a/apps/web/src/components/settings/customModelEditor.logic.ts +++ b/apps/web/src/components/settings/customModelEditor.logic.ts @@ -104,7 +104,7 @@ export const DESCRIPTOR_PRESETS_BY_KIND: Partial< }; let nextKey = 0; -export function newEditorKey(): string { +function newEditorKey(): string { nextKey += 1; return `k${nextKey}`; } @@ -140,7 +140,7 @@ export function emptyEditorChoice(): EditorChoice { * by built-in runtime profiles a custom entry does not have, so they are * dropped rather than stored as a plain option value. */ -export function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { +function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { const promptInjected = new Set( descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [], ); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 96c74261df85..245ed6ad234d 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -56,7 +56,7 @@ export interface ProviderEnvironmentFieldDefinition { readonly sensitive?: boolean; } -export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ +const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ { value: ProviderDriverKind.make("codex"), label: "Codex", @@ -112,7 +112,7 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = }, ]; -export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< +const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< Record > = Object.fromEntries( PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]), diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts new file mode 100644 index 000000000000..46dc7e262512 --- /dev/null +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -0,0 +1,71 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderSummary } from "./providerStatus"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", label: "ChatGPT" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getProviderSummary", () => { + it("reports ready providers with unknown authentication as available", () => { + expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({ + headline: "Available", + detail: null, + }); + }); + + it("does not hide a provider error behind a previous authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + message: "The provider process failed to start.", + }), + ).toEqual({ + headline: "Unavailable", + detail: "The provider process failed to start.", + }); + }); + + it("does not hide a provider warning behind an authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "warning", + message: "The provider version is unsupported.", + }), + ).toEqual({ + headline: "Needs attention", + detail: "The provider version is unsupported.", + }); + }); + + it("keeps authentication failures actionable when their provider status is error", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + auth: { status: "unauthenticated" }, + message: "Run codex login.", + }), + ).toEqual({ + headline: "Not authenticated", + detail: "Run codex login.", + }); + }); + + it("treats a disabled provider status as disabled even before its enabled flag updates", () => { + expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 0f39f643f5ce..90c618f5daa7 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; * settings page. Prefers `provider.message` for server-supplied detail and * falls back to generic phrasing when the server has not yet reported any * state — which happens before the first probe or when an instance names a - * driver this build does not ship. + * driver this build does not ship. A ready provider without account metadata + * remains available and does not imply an authentication failure. */ export function getProviderSummary(provider: ServerProvider | undefined) { if (!provider) { @@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: "Waiting for the server to report installation and authentication details.", }; } - if (!provider.enabled) { + if (!provider.enabled || provider.status === "disabled") { return { headline: "Disabled", detail: @@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "CLI not detected on PATH.", }; } - if (provider.auth.status === "authenticated") { - const authLabel = provider.auth.label ?? provider.auth.type; - return { - headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", - detail: provider.message ?? null, - }; - } if (provider.auth.status === "unauthenticated") { return { headline: "Not authenticated", @@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "The provider failed its startup checks.", }; } + if (provider.auth.status === "authenticated") { + const authLabel = provider.auth.label ?? provider.auth.type; + return { + headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", + detail: provider.message ?? null, + }; + } return { headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + detail: provider.message ?? null, }; } diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 82fbed96459f..1bfa8c87146e 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -284,7 +284,11 @@ export function SettingsRow({ ref={targetRef} tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" - className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} + className={cn( + "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", + children ? "pt-3 pb-1" : "py-3", + className, + )} >
@@ -320,10 +324,12 @@ export function SettingsRow({ export function SettingResetButton({ label, + tooltip = "Reset to default", disabled = false, onClick, }: { label: string; + tooltip?: string; disabled?: boolean; onClick: () => void; }) { @@ -345,7 +351,7 @@ export function SettingResetButton({ } /> - Reset to default + {tooltip} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 6cacef60baf1..fd5b6906daf6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -2,7 +2,9 @@ import { isElectron } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; export type SettingsPath = + | "/settings/projects" | "/settings/general" + | "/settings/environment" | "/settings/appearance" | "/settings/keybindings" | "/settings/providers" @@ -10,6 +12,7 @@ export type SettingsPath = | "/settings/scheduled-tasks" | "/settings/source-control" | "/settings/connections" + | "/settings/diagnostics" | "/settings/archived"; export interface SettingsSearchItem { @@ -49,13 +52,16 @@ export interface SettingsSearchAvailability { */ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", + "/settings/environment": "Environment", "/settings/appearance": "Appearance", + "/settings/projects": "Projects", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", "/settings/integrations": "Integrations", "/settings/scheduled-tasks": "Schedule Tasks", "/settings/source-control": "Source Control", "/settings/connections": "Connections", + "/settings/diagnostics": "Diagnostics", "/settings/archived": "Archive", }; @@ -65,6 +71,14 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "project-defaults", + title: "Project defaults and overrides", + to: "/settings/projects", + searchTerms: [ + "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts", + ], + }, { id: "color-scheme", title: "Color scheme", @@ -160,21 +174,21 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", - to: "/settings/general", + to: "/settings/environment", searchTerms: ["sidebar inactivity days no activity automatically"], requiresThreadAutoSettlement: true, }, { id: "auto-settle-merged-threads", title: "Auto-settle merged threads", - to: "/settings/general", + to: "/settings/environment", searchTerms: ["pull request merge closed automatically sidebar"], requiresThreadAutoSettlement: true, }, { id: "days-before-auto-settle", title: "Days of inactivity before auto-settle", - to: "/settings/general", + to: "/settings/environment", targetId: "auto-settle-inactive-threads", searchTerms: ["thread timeout activity sidebar"], requiresThreadAutoSettlement: true, @@ -220,7 +234,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "provider-update-checks", title: "Provider update checks", - to: "/settings/general", + to: "/settings/environment", searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], }, { @@ -234,7 +248,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "background-activity", title: "Background activity", - to: "/settings/general", + to: "/settings/environment", searchTerms: [ "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy", ], @@ -242,20 +256,19 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "new-threads", title: "New threads", - to: "/settings/general", + to: "/settings/projects", searchTerms: ["default workspace mode draft local worktree"], }, { id: "start-from-origin", title: "Start from origin", - to: "/settings/general", - targetId: "new-threads", + to: "/settings/environment", searchTerms: ["new worktrees latest matching remote branch local"], }, { id: "add-project-starts-in", title: "Add project starts in", - to: "/settings/general", + to: "/settings/environment", searchTerms: ["base directory folder browser path home"], }, { @@ -286,13 +299,13 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "text-generation-model", title: "Text generation model", - to: "/settings/general", + to: "/settings/environment", searchTerms: ["generated thread titles source control content default provider"], }, { id: "diagnostics", title: "Diagnostics", - to: "/settings/general", + to: "/settings/diagnostics", searchTerms: ["logs traces processes resource history failures spans cpu memory"], }, { @@ -310,7 +323,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "legacy-token-streaming", title: "Stream token by token (legacy)", - to: "/settings/general", + to: "/settings/environment", searchTerms: ["response output old compatibility"], }, { @@ -352,7 +365,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "agent-browser-access", title: "Agent browser access", - to: "/settings/integrations", + to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, { @@ -512,6 +525,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, + { + id: "load-balancing", + title: "Load balancing", + to: "/settings/connections", + searchTerms: [ + "automatic machine environment resources cpu memory capacity preference weight shared projects", + ], + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/components/settings/themeInspector.ts b/apps/web/src/components/settings/themeInspector.ts index 9306226b8c86..b790c307a3d9 100644 --- a/apps/web/src/components/settings/themeInspector.ts +++ b/apps/web/src/components/settings/themeInspector.ts @@ -14,7 +14,7 @@ const THEME_PAINT_KIND_ORDER: ReadonlyArray = [ "foreground", ]; -export const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; +const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe"; const THEME_TOKEN_PROBE_COLOR = "#01fea7"; diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 60fa379ce30e..833559e1cc27 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,8 +1,7 @@ -import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; import type { AnimationEventHandler } from "react"; -import { cn } from "../../lib/utils"; - const DOWNLOAD_PROGRESS_RADIUS = 14; const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; @@ -118,8 +117,9 @@ export function DesktopUpdateStatusIcon({ if (status === "downloaded") return ; return ( - ); diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 84dd7f4b5634..066b7e583253 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -1,7 +1,8 @@ +import { Spinner } from "~/components/ui/spinner"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import type { ServerProvider } from "@t3tools/contracts"; -import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; import { primaryServerProvidersAtom } from "../../state/server"; @@ -173,7 +174,7 @@ export function SidebarProviderUpdatePill() { onClick={openProviderSettings} > {displayedView.tone === "loading" ? ( - + ) : displayedView.tone === "success" ? ( ) : displayedView.tone === "error" ? ( diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index e5f3db03c3f5..d82c85dd7711 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -19,10 +19,12 @@ function CollapsibleTrigger({ className, ...props }: CollapsiblePrimitive.Trigge } function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Props) { + // Reuses the local shadcn/Base UI panel; skip height travel for reduced motion. + // https://ui.shadcn.com/docs/components/base/collapsible return ( - {children} - + {children} + ); } -function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props) { +function ComboboxChipRemove({ + labelId, + ...props +}: ComboboxPrimitive.ChipRemove.Props & { labelId: string }) { + const removeLabelId = `${labelId}-remove`; + return ( - + + Remove + + ); } diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index d7892cb228ab..fbdcbc03480a 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -1,7 +1,7 @@ "use client"; import { Menu as MenuPrimitive } from "@base-ui/react/menu"; -import { ChevronRightIcon } from "lucide-react"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; @@ -177,6 +177,23 @@ function MenuRadioItem({ ); } +function MenuRadioItemIndicator({ + className, + children, + ...props +}: MenuPrimitive.RadioItemIndicator.Props) { + return ( + + {children ?? } + + ); +} + function MenuGroupLabel({ className, inset, @@ -300,6 +317,7 @@ export { MenuRadioGroup as DropdownMenuRadioGroup, MenuRadioItem, MenuRadioItem as DropdownMenuRadioItem, + MenuRadioItemIndicator, MenuGroupLabel, MenuGroupLabel as DropdownMenuLabel, MenuSeparator, diff --git a/apps/web/src/components/ui/refresh-icon.tsx b/apps/web/src/components/ui/refresh-icon.tsx new file mode 100644 index 000000000000..6fbddeb365a6 --- /dev/null +++ b/apps/web/src/components/ui/refresh-icon.tsx @@ -0,0 +1,20 @@ +import { RefreshCwIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; + +/** Keep the refresh glyph in place while its owning action is running. */ +export function RefreshIcon({ + refreshing = false, + className, + ...props +}: React.ComponentPropsWithoutRef & { refreshing?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index e2d29d607e13..784c5e087963 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -2,7 +2,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { - SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, @@ -89,17 +88,6 @@ describe("sidebar interactive cursors", () => { expect(html).not.toContain("cursor-pointer"); }); - it("uses a pointer cursor for menu actions", () => { - const html = renderToStaticMarkup( - - + - , - ); - - expect(html).toContain('data-slot="sidebar-menu-action"'); - expect(html).toContain("cursor-pointer"); - }); - it("uses a pointer cursor for submenu buttons", () => { const html = renderToStaticMarkup( }>Show more, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 624798e19f54..22cb4808fadd 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -800,7 +800,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", + "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", { defaultVariants: { size: "default", @@ -875,38 +875,6 @@ function SidebarMenuButton({ ); } -function SidebarMenuAction({ - className, - showOnHover = false, - render, - ...props -}: useRender.ComponentProps<"button"> & { - showOnHover?: boolean; -}) { - const defaultProps = { - className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:-inset-2 after:absolute md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground md:opacity-0", - className, - ), - "data-sidebar": "menu-action", - "data-slot": "sidebar-menu-action", - }; - - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">(defaultProps, props), - render, - }); -} - function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return (
) { +function Spinner({ className, ...props }: React.ComponentPropsWithoutRef) { return ( - diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 69fd0ebf3664..0f6483c2ae67 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,5 +1,7 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { Toast } from "@base-ui/react/toast"; import { useEffect, @@ -20,7 +22,6 @@ import { CircleCheckIcon, CopyIcon, InfoIcon, - LoaderCircleIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; @@ -83,7 +84,7 @@ const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, info: InfoIcon, - loading: LoaderCircleIcon, + loading: Spinner, success: CircleCheckIcon, warning: TriangleAlertIcon, } as const; @@ -357,7 +358,7 @@ function ToastBodyContent({ className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" data-slot="toast-icon" > - +
) : null}
= { @@ -52,14 +45,14 @@ const PACE: Record @@ -95,14 +88,16 @@ function WindowBar({ readonly now: number; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); - const used = Math.max(0, Math.min(100, window.usedPercent)); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + // The fill is quota left, so the even-spending mark is the time left. + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) : null; - const summary = `${window.label}: ${Math.round(used)}% used${ - elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed` + const summary = `${window.label}: ${remaining}% left${ + timeLeft === null ? "" : `, ${timeLeft}% of the window left` }${resetsIn ? `, ${resetsIn}` : ""}`; return ( @@ -118,27 +113,26 @@ function WindowBar({ } >
- {used > 0 ? ( + {remaining > 0 ? (
) : null} - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null}
- {Math.round(used)}% used - {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""} + {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""} - {elapsed !== null ? ( + {timeLeft !== null ? ( The line is where even spending would be. ) : null} {resetsAt ? ( @@ -153,24 +147,31 @@ function WindowBar({ ); } -/** One account's windows as rows: label and percent, bar, pace and countdown. */ -function LimitWindows({ +/** + * One account's windows as rows: label and percent, bar, pace and countdown. + * Compact rows fit the composer panel with narrower columns. + */ +export function LimitWindows({ driver, windows, now, + compact = false, }: { readonly driver: ServerProvider["driver"]; readonly windows: ReadonlyArray; readonly now: number; + readonly compact?: boolean; }) { const color = barColor(driver); return ( -
- {windows.map((window, index) => { - // Windows that reset together show the countdown once. - const previous = windows[index - 1]; - const sharesReset = - previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt; +
+ {windows.map((window) => { const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( @@ -178,13 +179,13 @@ function LimitWindows({ {window.label} - {Math.round(window.usedPercent)}% + {remainingPercent(window)}% left - + {pace ? : null} - {sharesReset ? "" : (resetsIn ?? "")} + {resetsIn ?? ""} ); @@ -193,94 +194,6 @@ function LimitWindows({ ); } -/** - * Heading shared by local providers and source accounts: icon, driver, instance, plan, - * and the signed-in email blurred until clicked, as provider settings do. - */ -function AccountHeading({ - driver, - label, - instanceLabel, - plan, - email, - accentColor, -}: { - readonly driver: ServerProvider["driver"]; - readonly label: string; - readonly instanceLabel: string; - readonly plan: string | undefined; - readonly email: string | undefined; - readonly accentColor?: string | undefined; -}) { - return ( -

- - {label} - {instanceLabel !== label ? ( - - · {instanceLabel} - - ) : null} - {plan ? · {plan} : null} - {email ? ( - - ) : null} -

- ); -} - -function ProviderLimits({ - provider, - environmentId, - now, -}: { - readonly provider: ServerProvider; - readonly environmentId: EnvironmentId; - readonly now: number; -}) { - const limits = provider.usageLimits; - if (!limits) return null; - const notice = limitsNotice(limits); - return ( -
- getDriverOption(driver)?.label)} - plan={provider.auth.label} - email={provider.auth.email} - accentColor={provider.accentColor} - /> - {notice ? ( - {notice} - ) : ( - - )} - {limits.resetCredits ? ( - - ) : null} -
- ); -} - const OUTCOME_TEXT: Record = { reset: "Reset applied. Your windows have cleared.", nothingToReset: "Nothing to reset right now.", @@ -288,36 +201,12 @@ const OUTCOME_TEXT: Record = { alreadyRedeemed: "That credit was already redeemed.", }; -/** - * Banked reset credits with a confirmed redeem action. Redeeming spends a - * credit the provider granted the user, so it never fires on a bare click. - */ -function ResetCredits({ - environmentId, - instanceId, - credits, - now, -}: { - readonly environmentId: EnvironmentId; - readonly instanceId: ProviderInstanceId; - readonly credits: ServerProviderResetCredits; - readonly now: number; -}) { +/** Everything a redeem needs: where to send it and what to say afterwards. */ +export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; - - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; const redeem = async () => { setConfirming(false); @@ -336,95 +225,99 @@ function ResetCredits({ ); }; - return ( -
- {summary} - {credits.availableCount > 0 ? ( - - ) : null} - {status ? {status} : null} - - - - Use a reset credit? - - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. - - - - }>Cancel - - - - -
- ); + return { confirming, setConfirming, busy, status, redeem }; } -/** One account pooled by a usage-limit source, drawn like a provider row. */ -function SourceAccountLimits({ - account, - sourceKind, - now, +/** + * The confirm for a redeem. Redeeming spends a credit the provider granted the + * user, so it never fires on a bare click. Mount it outside any popover that + * holds the button: dialogs stack under popovers, and closing the popover + * would unmount a dialog rendered inside it. + */ +export function ResetCreditDialog({ + open, + onOpenChange, + onConfirm, }: { - readonly account: UsageLimitSourceAccount; - readonly sourceKind: string; - readonly now: number; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onConfirm: () => void; }) { - const notice = limitsNotice(account.usageLimits); return ( -
- - {notice ? ( - {notice} - ) : ( - - )} -
+ + + + Use a reset credit? + + This redeems one credit on your account and clears the current rate-limit windows. It + cannot be undone. + + + + }>Cancel + + + + ); } -const SOURCE_KIND_LABEL: Record = { - cliproxy: "CLI Proxy", -}; - -type LimitsSource = ReturnType[number]; +/** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */ +export function resetCreditsSummary( + credits: ServerProviderResetCredits, + now: number, + compact = false, +): string { + const expiresIn = credits.nextExpiresAt + ? formatDuration(Date.parse(credits.nextExpiresAt) - now) + : null; + if (credits.availableCount === 0) return "No reset credits banked"; + if (compact) + return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; + return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ + expiresIn ? ` · next expires in ${expiresIn}` : "" + }`; +} -/** Read-only accounts pooled by a configured usage source. */ -function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { - const kind = SOURCE_KIND_LABEL[source.kind]; +/** Banked reset credits with the redeem button and its confirm, self-contained. */ +export function ResetCredits({ + environmentId, + instanceId, + credits, + now, +}: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly credits: ServerProviderResetCredits; + readonly now: number; +}) { + const { confirming, setConfirming, busy, status, redeem } = useResetCredit( + environmentId, + instanceId, + ); + if (credits.availableCount === 0 && status === null) return null; return ( -
- {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account) => ( - - )) - )} +
+ {resetCreditsSummary(credits, now)} + {credits.availableCount > 0 ? ( + + ) : null} + {status ? {status} : null} + void redeem()} + />
); } /** - * Subscription quota windows from every connected environment's providers. - * Countdowns anchor to render time rather than ticking: a live clock would - * repaint the page every minute for no decision-changing gain. + * Subscription quota across every connected environment's providers and hubs, + * pooled per provider. Countdowns anchor to render time rather than ticking: a + * live clock would repaint the page every minute for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, @@ -432,42 +325,11 @@ export function UsageLimitsSection({ readonly selectedEnvironmentIds: ReadonlySet | null; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); + // Anchored once per mount on purpose: countdowns must not tick (see above). + const [now] = useState(() => Date.now()); const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - const groups = collectLimitsGroups(selected); - const sources = collectLimitSources(selected); - // Anchored once per mount on purpose: countdowns must not tick (see below). - const [now] = useState(() => Date.now()); - - return ( -
- {groups.length === 0 && sources.length === 0 ? ( -

- No provider on the selected environments reports subscription limits. -

- ) : null} - {sources.map((source) => ( - - ))} - {groups.map((group) => ( -
- {group.environmentLabel ? ( -

- {group.environmentLabel} -

- ) : null} - {group.providers.map((provider) => ( - - ))} -
- ))} -
- ); + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..1d57cdb96340 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -0,0 +1,570 @@ +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + type LimitAccount, + type LimitPool, + type LimitPoolMember, + type LimitPoolWindow, + remainingPercent, +} from "@t3tools/shared/usageLimits"; +import { TicketIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { usePrimarySettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + PaceIcon, + ResetCreditDialog, + barColor, + resetCreditsSummary, + useResetCredit, +} from "./UsageLimits"; + +/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ +function accountInitials(email: string): string { + const [local = "", domain = ""] = email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?"; +} + +/** A stable hue per email, so the same account gets the same chip on every visit. */ +function accountHue(email: string): number { + let hash = 0; + for (let index = 0; index < email.length; index += 1) { + hash = (hash * 31 + email.charCodeAt(index)) | 0; + } + return Math.abs(hash) % 360; +} + +/** The two-letter chip for an email, coloured by a stable hue per address. */ +function AccountChip({ email }: { readonly email: string }) { + const hue = accountHue(email); + return ( + + {accountInitials(email)} + + ); +} + +/** + * The same mark the model picker uses for a native instance (provider glyph, + * initials badge, accent); hub accounts have no instance, so they get the chip. + */ +function AccountAvatar({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.redeem) { + return ( + + ); + } + return account.email ? : null; +} + +/** + * Who an account is, without printing the email: the instance name when there + * is one, else a two-letter chip. The address itself is revealed on demand in + * the segment's popover. + */ +function AccountName({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.displayName) return {account.displayName}; + if (account.email) { + return ( + + + + ); + } + return ( + + {getDriverOption(account.driver)?.label ?? String(account.driver)} + + ); +} + +function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** + * Everything about one account in one window: plan, where it is signed in, + * the email on request, reset time and share of the pool it restores, and the + * reset-credit action. Opens on hover for a glance, on click to act. + */ +function SegmentPopover({ + account, + window, + reset, + now, + redeem, + onRedeem, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + /** Redeem state owned by the segment, since the confirm lives outside this popover. */ + readonly redeem: ReturnType | null; + readonly onRedeem: () => void; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const where = + account.environments.length > 0 + ? account.environments.map((environment) => environment.label).join(", ") + : account.sourceLabel; + const credits = + redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + return ( +
+
+ + + + {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} + + + {account.email ? ( + + ) : null} +
+
+ {account.plan ? {account.plan} : null} + {where ? ( + 0 ? "Signed in" : "Via"}>{where} + ) : null} +
+
+ {remaining}% + {window.resetsAt ? ( + + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + +{reset.restoresPercent}% of pool + ) : null} +
+ {credits && redeem ? ( +
+ + {resetCreditsSummary(credits, now, true)} + + +
+ ) : null} +
+ ); +} + +/** + * One account's share of one pooled window: the segment, its popover, and the + * reset confirm. The confirm is a sibling of the popover, not a child: dialogs + * stack under popovers, and the popover closes as the confirm opens. + */ +function PoolSegment({ + account, + window, + reset, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly color: string; + readonly now: number; + /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ + readonly index: number; +}) { + const [open, setOpen] = useState(false); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + } + > + {/* Translucent so the label reads over the fill for any provider colour and theme. */} +
+ {/* The spent share is hatched, not blank: it is what the countdown restores. */} + {remaining < 100 && reset ? ( +
+ ) : null} + + {index} + +
+ + {remaining}% + {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? ( + + · + + ) : null} + + + {credits} + + + ) : null} + +
+ + + {account.redeem ? ( + setOpen(false)} + /> + ) : ( + + {}} + /> + + )} + + ); +} + +/** + * Below the strip at narrow widths: one row per account in bar order, carrying + * the text the segment has no room for. Tapping a row opens the same popover + * as its segment, so the two are one control with two handles. + */ +function LegendRow({ + account, + window, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly color: string; + readonly now: number; + readonly index: number; +}) { + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + + Segment + {index} + + + {remaining}% + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + {credits} reset {credits === 1 ? "credit" : "credits"} banked + + + ) : null} + + + ); +} + +/** Split out so the redeem hook only runs for accounts that can redeem. */ +function RedeemableSegmentPopup({ + account, + window, + reset, + now, + redeemAt, + closePopover, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + readonly redeemAt: NonNullable; + readonly closePopover: () => void; +}) { + const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId); + return ( + <> + + { + closePopover(); + redeem.setConfirming(true); + }} + /> + + void redeem.redeem()} + /> + {/* The popover closed before the confirm, so the outcome needs a home outside it. */} + {redeem.status ? ( + + {redeem.status} + + ) : null} + + ); +} + +/** + * One pooled window as equal-width segments, one per account, each filled by + * the share of that account's quota still open. Equal widths are honest: every + * account contributes the same share of the pool, whatever its plan. + * + * Wide, each segment carries its own label. Narrow, the bar is a bare strip + * and a legend below lists the accounts in the same order; both open the + * same popover. + */ +function PoolBar({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); + return ( +
+
+ {pool.members.map(({ account, window }, position) => ( + + ))} +
+
+ ); +} + +/** + * Big pooled number and the segment bar. The bar is sorted by reset, so who + * refills next is its left edge; the exact time and share restored live in + * each segment's popover rather than a list restating the bar. + */ +function PoolWindowCard({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + // The soonest reset that hands anything back; an untouched account resets to no effect. + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + return ( +
+
+ {pool.label} + + + {pool.remainingPercent}% + + left + {pool.pace ? : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} +
+ +
+ ); +} + +function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { + const color = barColor(pool.driver); + const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + return ( +
+

+ + {label} +

+ {pool.windows.map((window) => ( + + ))} +
+ ); +} + +/** + * Accounts pooled per provider: what is open across all of them, who resets + * next, and how much of the pool that hands back. Answers "can I keep going" + * before "on which account". + */ +export function UsageLimitsPooled({ + presentations, + now, +}: { + readonly presentations: Parameters[0]; + readonly now: number; +}) { + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const notices = collectLimitNotices(presentations); + return ( +
+ {pools.length === 0 ? ( +

+ No provider on the selected environments reports subscription limits. +

+ ) : null} + {pools.map((pool) => ( + + ))} + +
+ ); +} + +/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */ +function LimitNotices({ notices }: { readonly notices: readonly string[] }) { + if (notices.length === 0) return null; + return ( +
    + {notices.map((notice) => ( +
  • {notice}
  • + ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 944987388b06..e41843e6d9cc 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), - metric: "cost" as "cost" | "tokens", + metric: "cost" as "cost" | "tokens" | "limits", breakdown: "time" as "model" | "time", })); @@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useState: vi.fn((initial: unknown) => [ - typeof initial === "function" - ? { - days: 1, - window: { - sinceDay: "2026-08-10", - untilDay: "2026-08-11", - timeZone: "UTC", - resolution: "hour", - sinceTime: "2026-08-10T12:37:00.000Z", - untilTime: "2026-08-11T12:37:00.000Z", - }, - } - : initial === "cost" - ? testState.metric - : initial === "model" - ? testState.breakdown - : initial, + initial === readUsagePagePreferences + ? { metric: testState.metric, windowDays: 30 } + : typeof initial === "function" + ? { + days: 1, + window: { + sinceDay: "2026-08-10", + untilDay: "2026-08-11", + timeZone: "UTC", + resolution: "hour", + sinceTime: "2026-08-10T12:37:00.000Z", + untilTime: "2026-08-11T12:37:00.000Z", + }, + } + : initial === "cost" + ? testState.metric + : initial === "model" + ? testState.breakdown + : initial, vi.fn(), ]), }; @@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => { }); import { UsagePage } from "./UsagePage"; +import { readUsagePagePreferences } from "./usagePagePreferences"; const providerTotals = (codex: number, claude: number) => new Map([ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index e957002115ab..deb05f266b98 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, @@ -8,10 +9,9 @@ import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, - RefreshCwIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { isCompatibleUsageContractVersion, @@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; +import { + readUsagePagePreferences, + saveUsagePagePreferences, + type UsagePagePreferences, +} from "./usagePagePreferences"; type UsageMetric = UsageChartMetric | "limits"; const METRIC_OPTIONS = [ @@ -81,13 +86,24 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { + return WINDOW_OPTIONS.some((option) => option.days === value); +} + export function UsagePage() { + const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, - window: makeWindow(30), + days: preferences.windowDays, + window: makeWindow( + preferences.windowDays, + undefined, + preferences.windowDays === 1 ? "hour" : "day", + ), })); - const [metric, setMetric] = useState("cost"); + const metric = preferences.metric; const showingLimits = metric === "limits"; + const [isRefreshing, setIsRefreshing] = useState(false); + const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); @@ -132,32 +148,54 @@ export function UsagePage() { const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; const selectWindow = (days: number) => { + if (!isUsageWindowDays(days)) return; + const nextPreferences = { metric, windowDays: days }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectMetric = (nextMetric: UsageMetric) => { + const nextPreferences = { metric: nextMetric, windowDays }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); + }; const refreshWindow = () => { + if (refreshingRef.current) return; + if (showingLimits) { - for (const [environmentId, presentation] of presentations) { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - void refreshProviders({ environmentId, input: {} }); - } - } + refreshingRef.current = true; + setIsRefreshing(true); + void Promise.all( + Array.from(presentations, ([environmentId, presentation]) => { + if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; + if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { + return refreshProviders({ environmentId, input: {} }); + } + }), + ).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); return; } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setIsRefreshing(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); }; const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined @@ -195,7 +233,7 @@ export function UsagePage() { value={[metric]} onValueChange={(next) => { const value = next[0]; - if (isUsageMetric(value)) setMetric(value); + if (isUsageMetric(value)) selectMetric(value); }} > {METRIC_OPTIONS.map((option) => ( @@ -225,17 +263,19 @@ export function UsagePage() {
(key: string): { }; } -export function runInEnvironment( +function runInEnvironment( environmentId: EnvironmentIdType, effect: Effect.Effect, ): Effect.Effect< diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 6567726a4809..878f8c902f91 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -7,8 +7,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { + applyServerWelcomeEvent, + makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, - projectServerWelcome, resolveServerConfigValue, + resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, @@ -494,25 +496,227 @@ describe("server state projection", () => { expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); }); - it("retains welcome when a ready event follows in the same stream chunk", () => { + it("keeps a current welcome on ready and rejects a buffered welcome from the old session", () => { + const firstSession = session({} as WsRpcProtocolClient); + const secondSession = session({} as WsRpcProtocolClient); const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], cwd: "/repo", projectName: "repo", } as ServerLifecycleWelcomePayload; - const [afterWelcome] = projectServerWelcome(Option.none(), { + const initial = { + currentSession: firstSession, + welcomeSession: firstSession, + welcome: null, + }; + const afterWelcome = applyServerWelcomeEvent(initial, firstSession, { type: "welcome", payload: welcome, }); - const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, { type: "ready", payload: {}, }); + const afterSwitch = { ...afterReady, currentSession: secondSession }; + const afterBufferedOldWelcome = applyServerWelcomeEvent(afterSwitch, firstSession, { + type: "welcome", + payload: { ...welcome, cwd: "/stale" }, + }); - expect(Option.getOrThrow(afterReady)).toBe(welcome); - expect(emitted).toEqual([]); + expect(afterReady).toBe(afterWelcome); + expect(resolveServerWelcomeState(afterReady)).toBe(welcome); + expect(afterBufferedOldWelcome).toBe(afterSwitch); + expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull(); }); + it.effect("checks the authoritative session before accepting a buffered welcome", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(firstEvents)), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const staleWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/stale", + projectName: "stale", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + + // Model the point after the ref changed but before either subscriber + // processed its publication. + supervisorSession.value = Option.some(secondSession); + const handled = yield* SubscriptionRef.changes(state).pipe( + Stream.filter( + (value) => value.currentSession === secondSession || value.welcome === staleWelcome, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: staleWelcome }); + + const next = yield* Fiber.join(handled); + expect(next.currentSession).toBe(secondSession); + expect(resolveServerWelcomeState(next)).toBeNull(); + }), + ); + }), + ); + + it.effect("reads the authoritative session after waiting for the welcome state lock", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(Stream.drain), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const thirdSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + const changed = yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => value.currentSession !== firstSession), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + + yield* state.semaphore.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + supervisorSession.value = Option.some(thirdSession); + }), + ); + + expect((yield* Fiber.join(changed)).currentSession).toBe(thirdSession); + }), + ); + }), + ); + + it.effect("clears a welcome until the reconnected session sends its own", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const secondEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const firstWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/first", + projectName: "first", + } as ServerLifecycleWelcomePayload; + const secondWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/second", + projectName: "second", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const nextResolved = ( + predicate: (value: ServerLifecycleWelcomePayload | null) => boolean, + ) => + SubscriptionRef.changes(state).pipe( + Stream.map(resolveServerWelcomeState), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const first = yield* nextResolved((value) => value === firstWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: firstWelcome }); + expect(yield* Fiber.join(first)).toBe(firstWelcome); + + const cleared = yield* nextResolved((value) => value === null).pipe(Effect.forkChild); + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + expect(yield* Fiber.join(cleared)).toBeNull(); + expect(resolveServerWelcomeState(yield* SubscriptionRef.get(state))).toBeNull(); + + const second = yield* nextResolved((value) => value === secondWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(secondEvents, { type: "welcome", payload: secondWelcome }); + expect(yield* Fiber.join(second)).toBe(secondWelcome); + }), + ); + }), + ); + it("prefers an active session config over cache until a live event arrives", () => { const config = (source: string, serverVersion: string) => ({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ceb06ddfe495..1774f21f8f7e 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, + createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createRuntimeCommand, @@ -40,8 +41,10 @@ import { request, runStream, subscribe, + subscribeDynamicWithSession, type EnvironmentRpcInput, } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { applyServerConfigProjection, @@ -356,6 +359,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven export interface ServerConfigSubscriptionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; } export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( @@ -423,6 +427,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf yield* subscribe(WS_METHODS.subscribeServerConfig, { ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -453,7 +458,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf }, ); -export function serverConfigStateChanges( +function serverConfigStateChanges( environmentId: EnvironmentId, subscription: ServerConfigSubscriptionOptions, ) { @@ -476,21 +481,119 @@ export function serverConfigStateChanges( ); } -export function projectServerWelcome( - current: Option.Option, +export function applyServerWelcomeEvent( + current: EnvironmentServerWelcomeState, + session: RpcSession, event: { readonly type: "welcome" | "ready" | "legacyThreadMigration"; readonly payload: unknown; }, -): readonly [ - Option.Option, - ReadonlyArray, -] { - if (event.type !== "welcome") { - return [current, []]; - } - const welcome = event.payload as ServerLifecycleWelcomePayload; - return [Option.some(welcome), [welcome]]; +): EnvironmentServerWelcomeState { + return event.type === "welcome" && current.currentSession === session + ? { + ...current, + welcomeSession: session, + welcome: event.payload as ServerLifecycleWelcomePayload, + } + : current; +} + +export interface EnvironmentServerWelcomeState { + readonly currentSession: RpcSession | null; + readonly welcomeSession: RpcSession | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +export function resolveServerWelcomeState( + state: EnvironmentServerWelcomeState, +): ServerLifecycleWelcomePayload | null { + return state.currentSession === state.welcomeSession ? state.welcome : null; +} + +export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWelcomeState.make")( + function* () { + const supervisor = yield* EnvironmentSupervisor; + const initialSession = Option.getOrNull(yield* SubscriptionRef.get(supervisor.session)); + const state = yield* SubscriptionRef.make({ + currentSession: initialSession, + welcomeSession: null, + welcome: null, + }); + + const updateWithCurrentSession = Effect.fn( + "EnvironmentServerWelcomeState.updateWithCurrentSession", + )(function* ( + update: ( + current: EnvironmentServerWelcomeState, + currentSession: RpcSession | null, + ) => EnvironmentServerWelcomeState, + ) { + return yield* SubscriptionRef.modifyEffect(state, (current) => + SubscriptionRef.get(supervisor.session).pipe( + Effect.map( + (latestSession) => + [undefined, update(current, Option.getOrNull(latestSession))] as const, + ), + ), + ); + }); + + yield* SubscriptionRef.changes(supervisor.session).pipe( + Stream.runForEach(() => + updateWithCurrentSession((current, currentSession) => ({ + ...current, + currentSession, + })), + ), + Effect.forkScoped, + ); + + yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + Effect.fn("EnvironmentServerWelcomeState.makeSubscribeInput")(function* (session) { + yield* updateWithCurrentSession((current, currentSession) => + currentSession === session + ? { + ...current, + currentSession, + welcomeSession: session, + welcome: null, + } + : { ...current, currentSession }, + ); + return {}; + }), + ).pipe( + Stream.runForEach(([session, event]) => + updateWithCurrentSession((current, currentSession) => + applyServerWelcomeEvent( + { + ...current, + currentSession, + }, + session, + event, + ), + ), + ), + Effect.forkScoped, + ); + + return state; + }, +); + +function serverWelcomeStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeEnvironmentServerWelcomeState().pipe( + Effect.map((state) => + SubscriptionRef.changes(state).pipe(Stream.map(resolveServerWelcomeState)), + ), + ), + ), + ); } export function resolveServerConfigValue( @@ -521,6 +624,7 @@ export function createServerEnvironmentAtoms( readonly environmentThemes?: boolean; /** Whether this surface renders quota from configured usage-limit sources. */ readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; }, ) { const configScheduler = createAtomCommandScheduler(); @@ -536,6 +640,7 @@ export function createServerEnvironmentAtoms( serverConfigStateChanges(environmentId, { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }), ) .pipe( @@ -833,6 +938,27 @@ export function createServerEnvironmentAtoms( Atom.withLabel(`environment-data:server:providers:${environmentId}`), ), ); + const welcomeStateFamily = Atom.family((environmentId: EnvironmentId) => + runtime + .atom(serverWelcomeStateChanges(environmentId), { initialValue: null }) + .pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-data:server:welcome-state:${environmentId}`), + ), + ); + const welcomeFamily = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const result = get(welcomeStateFamily(environmentId)); + if (result._tag !== "Success") return result; + return result.value === null + ? AsyncResult.initial(result.waiting) + : AsyncResult.success(result.value, result); + }).pipe(Atom.withLabel(`environment-data:server:welcome:${environmentId}`)), + ); + const welcome = (target: { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; + }) => welcomeFamily(target.environmentId); return { configValueAtom, @@ -893,6 +1019,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostResources: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-resources", + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), + }), processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, @@ -921,14 +1053,7 @@ export function createServerEnvironmentAtoms( staleTimeMs: 5_000, }), configProjection, - welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { - label: "environment-data:server:welcome", - tag: WS_METHODS.subscribeServerLifecycle, - transform: (stream) => - stream.pipe( - Stream.mapAccum(Option.none, projectServerWelcome), - ), - }), + welcome, legacyThreadMigration: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:legacy-thread-migration", tag: WS_METHODS.subscribeServerLifecycle, diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 8c46a9f33579..712138aab4c9 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { + filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, @@ -11,6 +12,7 @@ import { const primaryId = EnvironmentId.make("env-primary"); const laptopId = EnvironmentId.make("env-laptop"); const boxId = EnvironmentId.make("env-box"); +const restartCapabilities = { threadRestartContinuation: true }; describe("supportsSharedSettingsSync", () => { it("accepts only connected servers that advertise the shared-settings capability", () => { @@ -40,17 +42,30 @@ describe("splitSharedServerPatch", () => { const { sharedPatch, localPatch } = splitSharedServerPatch({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false, + continueThreadsAfterServerUpdate: true, enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", + newWorktreesStartFromOrigin: true, + }); + expect(sharedPatch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: false, + continueThreadsAfterServerUpdate: true, + newWorktreesStartFromOrigin: true, + }); + expect(localPatch).toEqual({ + enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", }); - expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); - expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); }); }); describe("pickSharedServerSettings", () => { it("returns only the shared keys", () => { - expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ - "defaultThreadEnvMode", + expect( + Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), + ).toEqual([ + "continueThreadsAfterServerUpdate", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", @@ -59,9 +74,106 @@ describe("pickSharedServerSettings", () => { }); }); +describe("filterSharedServerPatch", () => { + it.each([true, false])("preserves supported restart preference %s", (enabled) => { + const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; + expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch); + }); + + it.each([undefined, {}, { threadRestartContinuation: false }])( + "omits only the unsupported restart preference with capabilities %j", + (capabilities) => { + expect( + filterSharedServerPatch( + { continueThreadsAfterServerUpdate: true, sidebarAutoSettleAfterDays: 7 }, + capabilities, + ), + ).toEqual({ sidebarAutoSettleAfterDays: 7 }); + expect(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, capabilities)).not.toHaveProperty( + "continueThreadsAfterServerUpdate", + ); + }, + ); +}); + describe("findSharedSettingsMismatches", () => { const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; + it.each([true, false])( + "detects remote restart continuation drift when the preference is %s", + (enabled) => { + const settings = { ...primarySettings, continueThreadsAfterServerUpdate: enabled }; + const remoteSettings = { ...settings, continueThreadsAfterServerUpdate: !enabled }; + const environment = { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + settings: remoteSettings, + capabilities: restartCapabilities, + }; + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: settings, + primaryCapabilities: restartCapabilities, + environments: [environment], + }), + ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: settings, + primaryCapabilities: restartCapabilities, + environments: [ + { + ...environment, + settings: Object.assign( + {}, + remoteSettings, + pickSharedServerSettings(settings, restartCapabilities), + ), + }, + ], + }), + ).toEqual([]); + }, + ); + + it.each([ + [undefined, restartCapabilities], + [restartCapabilities, undefined], + [undefined, undefined], + ])( + "ignores restart drift unless both servers support it (%j, %j)", + (primaryCapabilities, capabilities) => { + const environment = { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + capabilities, + settings: { ...primarySettings, continueThreadsAfterServerUpdate: true }, + }; + const input = { + primaryEnvironmentId: primaryId, + primarySettings, + primaryCapabilities, + environments: [environment], + }; + expect(findSharedSettingsMismatches(input)).toEqual([]); + expect( + findSharedSettingsMismatches({ + ...input, + environments: [ + { + ...environment, + settings: { ...environment.settings, sidebarAutoSettleAfterDays: 14 }, + }, + ], + }), + ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + }, + ); + it("lists sync-eligible environments whose shared settings differ", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, @@ -99,7 +211,12 @@ describe("findSharedSettingsMismatches", () => { environmentId: boxId, label: "Remote Box", syncEligible: true, - settings: { ...primarySettings, enableAgentBrowserAccess: false }, + settings: { + ...primarySettings, + enableAgentBrowserAccess: false, + defaultThreadEnvMode: + primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local", + }, }, ], }); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index f3236ef2035a..0fd691a64bcd 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -20,10 +20,10 @@ import * as Struct from "effect/Struct"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; /** Server keys that hold a user preference rather than machine config. */ -export const SHARED_SERVER_SETTING_KEYS = [ +const SHARED_SERVER_SETTING_KEYS = [ + "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", ] as const satisfies ReadonlyArray; @@ -52,15 +52,27 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { }; } -/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */ -export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch { - return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); +/** Omit restart recovery on servers that cannot persist its preference. */ +export function filterSharedServerPatch( + patch: ServerSettingsPatch, + capabilities: Pick | undefined, +): ServerSettingsPatch { + return capabilities?.threadRestartContinuation === true + ? patch + : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); +} + +/** The shared subset supported by one environment. */ +export function pickSharedServerSettings( + settings: ServerSettings, + capabilities?: Pick, +): ServerSettingsPatch { + return filterSharedServerPatch(Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), capabilities); } /** * Whether an environment can participate in shared-settings sync right now. - * Auto-settlement is the newest feature backed by a shared key, so a server - * advertising `threadAutoSettlement` can hold every shared key. + * Auto-settlement establishes baseline support; newer preferences are filtered separately. */ export function supportsSharedSettingsSync(environment: { readonly connection: { readonly phase: EnvironmentConnectionPhase }; @@ -81,12 +93,15 @@ export interface SharedSettingsEnvironment { readonly label: string; readonly syncEligible: boolean; readonly settings: ServerSettings | null; + readonly capabilities?: + | Pick + | undefined; } /** * Shared-settings sync targets whose values differ from the primary * environment's. Other environments are skipped: nothing can be read from or - * written to them, or their server cannot hold every shared key. With no + * written to them, or their server lacks baseline shared-settings support. With no * primary settings loaded there is nothing to compare against, so nothing is * reported. Callers must pass the real loaded settings, never a default * fallback, or "apply to all" would push defaults over real values. @@ -94,12 +109,18 @@ export interface SharedSettingsEnvironment { export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; readonly primarySettings: ServerSettings | null; + readonly primaryCapabilities?: + | Pick + | undefined; readonly environments: ReadonlyArray; }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { if (input.primaryEnvironmentId === null || input.primarySettings === null) { return []; } - const expected = pickSharedServerSettings(input.primarySettings); + const primarySettings = pickSharedServerSettings( + input.primarySettings, + input.primaryCapabilities, + ); return input.environments.flatMap((environment) => { if ( environment.environmentId === input.primaryEnvironmentId || @@ -108,7 +129,11 @@ export function findSharedSettingsMismatches(input: { ) { return []; } - const actual = pickSharedServerSettings(environment.settings); + const expected = filterSharedServerPatch(primarySettings, environment.capabilities); + const actual = filterSharedServerPatch( + pickSharedServerSettings(environment.settings, environment.capabilities), + input.primaryCapabilities, + ); return Equal.equals(actual, expected) ? [] : [{ environmentId: environment.environmentId, label: environment.label }]; diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 7f38cfad03ed..6437aa3bf7ff 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -306,7 +306,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return state; }); -export function shellStateChanges(environmentId: EnvironmentId) { +function shellStateChanges(environmentId: EnvironmentId) { return followStreamInEnvironment( environmentId, Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index e7f6965123b9..d366d7f0d4ee 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -5,10 +5,6 @@ import { foldSubagentActivities, formatSubagentModelLabel, formatSubagentTokenCount, - isAgentAttributedToolActivity, - isSubagentActivityKind, - isTimelineBypassActivity, - workflowCardMembers, } from "./subagentRuntime.ts"; let sequence = 0; @@ -557,64 +553,6 @@ describe("deriveAgentPanelModel", () => { }); }); -describe("workflowCardMembers", () => { - it("orders by urgency (failed, running, waiting) and reports overflow", () => { - const roster = fold([ - activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), - ...[..."abcdefghij"].map((letter, index) => - activity("task.progress", { - taskId: `wf-1:wf:${index}`, - title: `agent-${letter}`, - status: index === 3 ? "failed" : index < 3 ? "completed" : "running", - ...(index === 3 ? { error: "died" } : {}), - parentAgentId: "wf-1", - agentIndex: index, - phaseIndex: 0, - phaseTitle: "Work", - }), - ), - ]); - const model = deriveAgentPanelModel({ agents: roster }); - const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8); - expect(visible).toHaveLength(8); - expect(overflow).toBe(2); - expect(visible[0]!.status).toBe("failed"); - expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2); - }); -}); - -describe("timeline predicates", () => { - it("recognizes subagent activity kinds as fold input", () => { - for (const kind of [ - "task.started", - "task.progress", - "task.updated", - "task.completed", - "tool.progress", - ]) { - expect(isSubagentActivityKind(kind)).toBe(true); - } - expect(isSubagentActivityKind("tool.completed")).toBe(false); - }); - - it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => { - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe( - true, - ); - expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false); - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe( - false, - ); - }); - - it("timelineBypass rows never render in the parent chat", () => { - expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe( - true, - ); - expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false); - }); -}); - describe("formatSubagentTokenCount", () => { it("formats plain counters", () => { expect(formatSubagentTokenCount(950)).toBe("950"); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 848fb1cbbe6a..db9484ae4cf5 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -918,61 +918,6 @@ export function deriveAgentPanelModel({ }; } -/** - * Members ordered by urgency for the capped inline workflow card: running and - * failed first, then waiting, then most recently updated. - */ -export function workflowCardMembers( - group: AgentPanelWorkflowGroup, - limit: number, -): { readonly visible: ReadonlyArray; readonly overflow: number } { - const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; - const urgency = (agent: RuntimeSubagent): number => { - if (agent.status === "failed") return 0; - if (agent.status === "running") return 1; - if (agent.status === "waiting") return 2; - return 3; - }; - const ordered = all - .slice() - .sort((a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt)); - return { - visible: ordered.slice(0, limit), - overflow: Math.max(0, ordered.length - limit), - }; -} - -/** Kinds the timeline should not render as generic rows (fold input only). */ -export function isSubagentActivityKind(kind: string): boolean { - return ( - kind === "task.started" || - kind === "task.progress" || - kind === "task.updated" || - kind === "task.completed" || - kind === "tool.progress" - ); -} - -/** - * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in - * the Agents surface, not the parent chat. Unattributed rows must stay. - */ -export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - const payload = activity.payload as Record; - return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; -} - -/** Timeline-bypassing synthesized rows (Codex children, workflow members). */ -export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - return (activity.payload as Record).timelineBypass === true; -} - /** * Compact model chip text: strips vendor prefixes/date-or-context suffixes * ("claude-sonnet-5[1m]" → "sonnet-5[1m]", "claude-opus-4-20250514" → diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index 55cc4eef28f6..b1ef6500d414 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -96,7 +96,7 @@ export function nextTerminalAttachSeedState(): TerminalBufferState { }; } -export function terminalBufferStateFromSnapshot( +function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index f96197dc0a33..f8c280b295a6 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -210,7 +210,7 @@ export function pinOrderKeyBetween(before: string | null, after: string | null): drop lands next to keyless threads, so single-key insertion has nothing to anchor on). Two base-26 digits give 675 slots — far beyond any real pinned section — with monotonicity enforced as a belt-and-braces. */ -export function generateSpreadPinOrderKeys(count: number): string[] { +function generateSpreadPinOrderKeys(count: number): string[] { const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; const step = space / (count + 1); const keys: string[] = []; diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 43a52a06cc59..2ddb479aabd1 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -7,6 +7,8 @@ import { type OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -16,7 +18,10 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { RpcClientError } from "effect/unstable/rpc"; +import { Socket } from "effect/unstable/socket"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { EnvironmentRegistry } from "../connection/registry.ts"; @@ -25,8 +30,10 @@ import { PrimaryConnectionTarget, type NetworkStatus, type PreparedConnection, + type SupervisorConnectionState, } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { ConnectionWakeups, type ConnectionWakeup } from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; @@ -39,8 +46,10 @@ import { } from "./threadHistoryController.ts"; import { createEnvironmentThreadStateAtoms, + makeEnvironmentThreadState, ThreadSnapshotLoader, type EnvironmentThreadState, + type ThreadSnapshotLoadResult, } from "./threads.ts"; const TARGET = new PrimaryConnectionTarget({ @@ -56,12 +65,27 @@ const THREAD: OrchestrationV2ThreadProjection = { }; const SNAPSHOT: OrchestrationV2ThreadDetailSnapshot = { snapshotSequence: 7, projection: THREAD }; +const CONNECTED_STATE: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, +}; + const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: { readonly snapshot?: OrchestrationV2ThreadDetailSnapshot; + readonly connected?: boolean; + readonly httpNone?: boolean; + readonly initialLoad?: Effect.Effect; + readonly stream?: Stream.Stream; }) { + const clock = yield* Clock.Clock; + const wakeups = yield* Queue.unbounded(); const subscriptions = yield* Queue.unbounded<{ readonly afterSequence: number | undefined; - readonly events: Queue.Queue; + readonly events: Queue.Queue; readonly closed: Deferred.Deferred; }>(); const olderLoads = yield* Queue.unbounded<{ @@ -70,6 +94,21 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? readonly closed: Deferred.Deferred; }>(); const snapshot = options?.snapshot ?? SNAPSHOT; + const snapshotLoad: ThreadSnapshotLoadResult = options?.httpNone + ? { _tag: "unavailable" } + : { + _tag: "present", + snapshot, + ...(snapshot.historyCursor === undefined + ? {} + : { + history: { + historyCursor: snapshot.historyCursor, + hasMoreHistory: snapshot.hasMoreHistory ?? false, + latestLocalTurnOrdinal: snapshot.latestLocalTurnOrdinal ?? null, + }, + }), + }; let httpLoads = 0; let diskLoads = 0; let opened = 0; @@ -78,7 +117,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => Stream.unwrap( Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const closed = yield* Deferred.make(); yield* Effect.acquireRelease( Effect.sync(() => { @@ -91,7 +130,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }).pipe(Effect.andThen(Deferred.succeed(closed, undefined))), ); yield* Queue.offer(subscriptions, { afterSequence: input.afterSequence, events, closed }); - return Stream.fromQueue(events); + return options?.stream ?? Stream.fromQueue(events); }), ), } as unknown as WsRpcProtocolClient; @@ -106,10 +145,14 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? probe: Effect.void, closed: Effect.never, }; + const connectionState = yield* SubscriptionRef.make( + options?.connected ? CONNECTED_STATE : AVAILABLE_CONNECTION_STATE, + ); + const sessionRef = yield* SubscriptionRef.make(Option.some(session)); const supervisor = EnvironmentSupervisor.of({ target: TARGET, - state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), - session: yield* SubscriptionRef.make(Option.some(session)), + state: connectionState, + session: sessionRef, prepared: yield* SubscriptionRef.make>( Option.some({ environmentId: TARGET.environmentId, @@ -165,6 +208,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? Layer.mergeAll( Layer.succeed(ThreadHistoryController, historyController), Layer.succeed(HttpClient.HttpClient, historyHttpClient), + Layer.succeed(Clock.Clock, clock), + Layer.succeed(ConnectionWakeups, { changes: Stream.fromQueue(wakeups) }), Layer.succeed(EnvironmentRegistry, environmentRegistry), Layer.succeed( EnvironmentCacheStore, @@ -193,20 +238,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? load: () => Effect.sync(() => { httpLoads += 1; - return { - _tag: "present" as const, - snapshot, - ...(snapshot.historyCursor === undefined - ? {} - : { - history: { - historyCursor: snapshot.historyCursor, - hasMoreHistory: snapshot.hasMoreHistory ?? false, - latestLocalTurnOrdinal: snapshot.latestLocalTurnOrdinal ?? null, - }, - }), - }; - }), + }).pipe(Effect.andThen(options?.initialLoad ?? Effect.succeed(snapshotLoad))), }), ), ), @@ -222,6 +254,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? const registry = yield* makeRegistry; return { + runtime, + supervisor, registry, makeRegistry, rawAtoms: raw, @@ -231,6 +265,10 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? subscriptions, olderLoads, loadEarlier: () => historyController.loadEarlier(TARGET.environmentId, THREAD_ID), + connectionState, + session, + sessionRef, + wakeups, counts: () => ({ httpLoads, diskLoads, opened, active }), }; }); @@ -260,6 +298,300 @@ describe("createEnvironmentThreadStateAtoms", () => { vi.restoreAllMocks(); }); + it.effect("exposes snapshot loader defects before the RPC subscription starts", () => + Effect.gen(function* () { + const completed = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: true, + initialLoad: Effect.die( + new Error("SYNTHETIC_RAW_SNAPSHOT_DEFECT_SHOULD_NOT_REACH_THREAD_UI"), + ).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))), + }); + const unmount = h.registry.mount(h.stateAtom); + yield* Deferred.await(completed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.status).toBe("empty"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(Option.none()); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + unmount(); + }), + ); + + it.effect.each([ + { kind: "protocol", httpNone: true }, + { kind: "protocol", httpNone: false }, + { kind: "fatal", httpNone: true }, + { kind: "fatal", httpNone: false }, + ] as const)( + "retains a terminated $kind load diagnostic across connection updates (empty: $httpNone)", + ({ kind, httpNone }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + const error = new Error("SYNTHETIC_RAW_DEFECT_SHOULD_NOT_REACH_THREAD_UI"); + yield* Queue.failCause( + first.events, + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ), + ); + yield* Deferred.await(first.closed); + // The real finalizer has run; advancing the atom runtime's test clock + // also verifies that a defect does not enter the domain retry loop. + yield* TestClock.adjust("1 second"); + const failed = h.registry.get(h.stateAtom); + expect(failed.status).toBe(httpNone ? "empty" : "cached"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(httpNone ? Option.none() : Option.some(THREAD)); + expect(h.counts().opened).toBe(1); + expect(h.counts().active).toBe(0); + + // Session publication can precede connected, and a fatal child cannot + // restart just because its supervisor reconnects. + for (const connection of [ + AVAILABLE_CONNECTION_STATE, + { ...CONNECTED_STATE, phase: "connecting" as const }, + CONNECTED_STATE, + ]) { + yield* SubscriptionRef.set(h.connectionState, connection); + yield* TestClock.adjust("0 millis"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + } + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + if (kind === "fatal") { + yield* TestClock.adjust("1 second"); + expect(h.counts().opened).toBe(1); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + unmount(); + return; + } + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "snapshot", ...SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + expect(recovered.data).toEqual(Option.some(THREAD)); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retries a protocol failure on foreground without replacing the session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "incompatible snapshot", + cause: new Error("incompatible snapshot"), + }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("0 millis"); + expect(Option.isSome(h.registry.get(h.stateAtom).error)).toBe(true); + yield* Queue.offer(h.wakeups, "application-active"); + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + yield* Queue.offer(next.events, { kind: "snapshot", ...SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("keeps transport loss nonterminal and recovers with a replacement session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006, closeReason: "connection lost" }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + status: "synchronizing", + error: Option.none(), + data: Option.none(), + }); + expect(h.counts().opened).toBe(1); + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + const next = yield* Queue.take(h.subscriptions); + yield* Queue.offer(next.events, { kind: "snapshot", ...SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retains ordinary domain error reporting and same-session retries", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail(first.events, new Error("thread not found yet")); + yield* Deferred.await(first.closed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.error).toEqual(Option.some("thread not found yet")); + yield* TestClock.adjust("250 millis"); + const next = yield* Queue.take(h.subscriptions); + expect(h.counts().opened).toBe(2); + yield* Queue.offer(next.events, { kind: "snapshot", ...SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect.each([ + { kind: "protocol", deleted: false }, + { kind: "fatal", deleted: false }, + { kind: "domain", deleted: false }, + { kind: "protocol", deleted: true }, + ] as const)( + "keeps buffered outcomes after a $kind failure (deleted: $deleted)", + ({ kind, deleted }) => + Effect.gen(function* () { + const burst = yield* Deferred.make(); + const error = new Error( + kind === "domain" + ? "buffered thread failure" + : "SYNTHETIC_BUFFERED_DEFECT_SHOULD_NOT_REACH_THREAD_UI", + ); + const items: OrchestrationV2ThreadStreamItem[] = [ + { kind: "snapshot", ...SNAPSHOT }, + { kind: "synchronized" }, + { + kind: "event", + sequence: 8, + event: { + id: EventId.make("buffered-event"), + occurredAt: THREAD.thread.updatedAt, + threadId: THREAD_ID, + type: "thread.metadata-updated", + payload: { + ...THREAD.thread, + title: "Buffer drained", + }, + }, + }, + ]; + if (deleted) { + items.push({ + kind: "event", + sequence: 9, + event: { + id: EventId.make("buffered-deletion"), + occurredAt: THREAD.thread.updatedAt, + threadId: THREAD_ID, + type: "thread.deleted", + payload: { ...THREAD.thread, deletedAt: THREAD.thread.updatedAt }, + }, + }); + } + const failure = + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + kind === "domain" + ? error + : new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ); + const h = yield* makeHarness({ + connected: true, + httpNone: true, + stream: Stream.fromEffect(Deferred.await(burst)).pipe( + Stream.flatMap(() => Stream.fromIterable(items)), + Stream.concat(Stream.failCause(failure)), + ), + }); + yield* Effect.gen(function* () { + const state = yield* makeEnvironmentThreadState(THREAD_ID); + const initial = yield* Deferred.make(); + const drained = yield* Deferred.make(); + yield* SubscriptionRef.changes(state).pipe( + Stream.runForEach((value) => + Deferred.succeed(initial, undefined).pipe( + Effect.andThen( + ( + deleted + ? value.status === "deleted" + : Option.getOrNull(value.data)?.thread.title === "Buffer drained" + ) + ? Deferred.succeed(drained, undefined) + : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* Deferred.await(initial); + const subscription = yield* Queue.take(h.subscriptions); + yield* Deferred.succeed(burst, undefined); + yield* Deferred.await(subscription.closed); + yield* Deferred.await(drained); + const final = yield* SubscriptionRef.get(state); + if (deleted) { + expect(final.status).toBe("deleted"); + expect(final.data).toEqual(Option.none()); + expect(final.error).toEqual(Option.none()); + return; + } + expect(Option.getOrThrow(final.data).thread.title).toBe("Buffer drained"); + expect(final.error).toEqual( + Option.some(kind === "domain" ? error.message : "Could not synchronize the thread."), + ); + expect(final.status).toBe("cached"); + }).pipe( + Effect.provideService(EnvironmentSupervisor, h.supervisor), + Effect.provide(h.registry.get(h.runtime.layer)), + Effect.scoped, + ); + }), + ); + it.effect("shares one live stream and closes it after the last detail consumer leaves", () => Effect.gen(function* () { const h = yield* makeHarness(); @@ -308,6 +640,76 @@ describe("createEnvironmentThreadStateAtoms", () => { }), ); + it.effect.each([ + { replayed: false, statuses: ["live"] }, + { replayed: true, statuses: ["live", "synchronizing", "live"] }, + ])( + "keeps a warm resume live until it replays events (replayed: $replayed)", + ({ replayed, statuses }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + const observed: Array = []; + const stop = h.registry.subscribe(h.stateAtom, (state) => observed.push(state.status), { + immediate: true, + }); + const remount = h.registry.mount(h.stateAtom); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + if (replayed) { + yield* Queue.offer(next.events, { + kind: "snapshot", + snapshotSequence: 9, + projection: { ...THREAD, thread: { ...THREAD.thread, title: "Replayed" } }, + }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "synchronizing"); + } + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + expect(observed.filter((status, index) => observed[index - 1] !== status)).toEqual( + statuses, + ); + stop(); + remount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("downgrades a warm resume when the connection dropped while away", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + yield* SubscriptionRef.set(h.sessionRef, Option.none()); + yield* SubscriptionRef.set(h.connectionState, AVAILABLE_CONNECTION_STATE); + const remount = h.registry.mount(h.stateAtom); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "cached"); + expect(currentThread(h.registry, h.stateAtom)).toBe(THREAD); + expect(h.counts().opened).toBe(1); + + yield* SubscriptionRef.set(h.connectionState, CONNECTED_STATE); + yield* SubscriptionRef.set(h.sessionRef, Option.some(h.session)); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + remount(); + yield* Deferred.await(next.closed); + }), + ); + it.effect("keeps warm data when the raw atom family's weak entry is collected", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 20ba8a71ff51..2e20ead7fe3b 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -149,10 +149,18 @@ function matchesThreadSnapshot( ); } +// A retained "live" state stays live: the cursor resume that follows only +// replays what the thread missed, and on servers that send the completion +// marker the first replayed event moves the status to "synchronizing" on its +// own. Downgrading here would flash a sync label on every return to a +// recently viewed thread. function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, - status: value.status === "deleted" ? "deleted" : statusWithoutLiveData(value.data), + status: + value.status === "deleted" || (value.status === "live" && Option.isSome(value.data)) + ? value.status + : statusWithoutLiveData(value.data), error: Option.none(), history: { ...value.history, loading: false, error: null }, }; @@ -280,8 +288,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); - const setSynchronizing = SubscriptionRef.update(state, (current) => - current.status === "deleted" + const setConnecting = SubscriptionRef.update(state, (current) => + current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -290,7 +298,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ); const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" + current.status === "live" || current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -305,14 +313,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), })); }); - const setStreamError = (cause: Cause.Cause) => + const setStreamError = (message: string) => Ref.set(awaitingCompletion, false).pipe( Effect.andThen( SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - error: Option.some(formatThreadError(cause)), + error: Option.some(message), })), ), ); @@ -343,8 +351,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return { ...previous, data: Option.some(thread), - status: waiting ? ("synchronizing" as const) : ("live" as const), - error: Option.none(), + // Buffered values from a terminated attempt may still arrive after its + // diagnostic; only an actual retry may clear that error. + status: Option.isSome(previous.error) + ? ("cached" as const) + : waiting + ? ("synchronizing" as const) + : ("live" as const), + error: previous.error, history, }; }); @@ -392,7 +406,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.kind === "synchronized") { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.update(state, (current) => - Option.isSome(current.data) && current.status !== "deleted" + Option.isSome(current.data) && current.status !== "deleted" && Option.isNone(current.error) ? { ...current, status: "live" as const, error: Option.none() } : current, ); @@ -473,8 +487,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const updated: EnvironmentThreadState = { ...current, data: Option.some(next), - status: waiting ? "synchronizing" : "live", - error: Option.none(), + status: Option.isSome(current.error) ? "cached" : waiting ? "synchronizing" : "live", + error: current.error, history, }; return [{ _tag: "applied", projection: next, history: updated.history }, updated]; @@ -652,7 +666,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Stream.runForEach((connectionState) => { switch (connectionProjectionPhase(connectionState)) { case "synchronizing": - return setSynchronizing; + return setConnecting; case "disconnected": return setDisconnected; case "ready": @@ -668,7 +682,22 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), }); - yield* setSynchronizing; + // Only the first subscription after a warm live resume keeps the retained + // status. A replacement session or foreground resubscribe on the same scope + // may have missed events, so those show sync progress until confirmed. + const resumingLive = yield* Ref.make(initialState.status === "live"); + const markSynchronizing = Effect.gen(function* () { + if (yield* Ref.get(resumingLive)) return; + // Connection notifications do not establish that a terminated load restarted. + // Clear its diagnostic only when this subscription actually tries again. + yield* SubscriptionRef.update(state, (current) => + current.status === "deleted" + ? current + : { ...current, status: "synchronizing" as const, error: Option.none() }, + ); + }); + + yield* markSynchronizing; yield* Effect.forkScoped( subscribeDynamic( ORCHESTRATION_V2_WS_METHODS.subscribeThread, @@ -686,7 +715,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.orElseSucceed(() => false), ); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); - yield* setSynchronizing; + yield* markSynchronizing; + yield* Ref.set(resumingLive, false); if (Option.isNone(current.data)) { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( @@ -763,7 +793,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }; }), { - onExpectedFailure: setStreamError, + onDefect: () => setStreamError("Could not synchronize the thread."), + onExpectedFailure: (cause) => setStreamError(formatThreadError(cause)), retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, @@ -786,7 +817,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return state; }); -export function threadStateChanges( +function threadStateChanges( environmentId: EnvironmentIdType, threadId: ThreadIdType, resumeCache?: ThreadResumeCache, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts new file mode 100644 index 000000000000..29f029c9d863 --- /dev/null +++ b/packages/client-runtime/src/state/usage.test.ts @@ -0,0 +1,184 @@ +import { + EnvironmentId, + UsageDay, + USAGE_CONTRACT_VERSION, + type UsageSummary, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentPresentation } from "../connection/presentation.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import { refreshUsage } from "./usage.ts"; + +const input = { + sinceDay: UsageDay.make("2026-09-05"), + untilDay: UsageDay.make("2026-09-05"), + timeZone: "UTC", +}; +const pricing = { status: "fresh" as const, source: "test", fetchedAt: null, knownModels: 1 }; +const summary: UsageSummary = { + ...input, + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-09-05T12:00:00Z", + buckets: [], + sources: [], + pricing, + scanDurationMs: 1, +}; +const registries: AtomRegistry.AtomRegistry[] = []; +afterEach(() => { + for (const registry of registries.splice(0)) registry.dispose(); +}); + +function harness(ids = ["a"]) { + const registry = AtomRegistry.make(); + registries.push(registry); + const environments = ids.map((id) => { + const environmentId = EnvironmentId.make(id); + const rates = Promise.withResolvers< + AsyncResult.Success | AsyncResult.Failure + >(); + const scan = Promise.withResolvers(); + const scanStarted = Promise.withResolvers(); + const presentation = Atom.make({ + connection: { phase: "connected" }, + } as EnvironmentPresentation | null); + const query = Atom.make( + Effect.promise(() => { + scanStarted.resolve(); + return scan.promise; + }), + ); + return { environmentId, rates, scan, scanStarted, presentation, query }; + }); + function get(environmentId: EnvironmentId) { + const environment = environments.find((entry) => entry.environmentId === environmentId); + if (!environment) throw new Error(`Unknown environment: ${environmentId}`); + return environment; + } + const options = { + registry, + environmentIds: environments.map((entry) => entry.environmentId), + input, + server: { + usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => + get(environmentId).query, + refreshUsageRates: { + label: "test:rates", + run: ( + _registry: AtomRegistry.AtomRegistry, + { environmentId }: { environmentId: EnvironmentId }, + ) => get(environmentId).rates.promise, + }, + }, + presentations: { + presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, + }, + } satisfies Parameters[0]; + return { registry, environments, refresh: () => refreshUsage(options) }; +} + +describe("manual usage refresh", () => { + it.each(["success", "failure"])("waits for the rescan after a pricing %s", async (result) => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + expect(finished).toBe(false); + entry.rates.resolve( + result === "success" + ? AsyncResult.success(pricing) + : AsyncResult.fail(new Error("Pricing offline")), + ); + await entry.scanStarted.promise; + expect(finished).toBe(false); + entry.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when an environment disconnects during the rescan", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await entry.scanStarted.promise; + registry.set(entry.presentation, null); + await refreshing; + }); + + it("waits for healthy environments without waiting for a recovering environment", async () => { + const { registry, environments, refresh } = harness(["healthy", "recovering"]); + const [healthy, recovering] = environments; + registry.set(recovering!.presentation, null); + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + for (const entry of environments) entry.rates.resolve(AsyncResult.success(pricing)); + await healthy!.scanStarted.promise; + expect(finished).toBe(false); + healthy!.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when connected state has no usable RPC session", async () => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve( + AsyncResult.fail( + new EnvironmentRpcUnavailableError({ + environmentId: entry.environmentId, + message: "No session", + }), + ), + ); + await refreshing; + }); + + it("replaces a scan that started before pricing was refreshed", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let reads = 0; + const rescanned = Promise.withResolvers(); + const query = Atom.make( + Effect.promise(() => { + reads += 1; + if (reads > 1) { + rescanned.resolve(); + return Promise.resolve(summary); + } + return new Promise(() => {}); + }), + ); + entry.query = query; + const unmount = registry.mount(query); + expect(reads).toBe(1); + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await rescanned.promise; + await refreshing; + expect(reads).toBe(2); + unmount(); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts new file mode 100644 index 000000000000..10a565a0c24f --- /dev/null +++ b/packages/client-runtime/src/state/usage.ts @@ -0,0 +1,61 @@ +import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type { AtomRegistry } from "effect/unstable/reactivity"; + +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type { createEnvironmentPresentationAtoms } from "./presentation.ts"; +import { executeAtomQuery, runAtomCommand, squashAtomCommandFailure } from "./runtime.ts"; +import type { createServerEnvironmentAtoms } from "./server.ts"; + +const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); + +/** Refresh pricing, then await each selected environment's rescan while it remains connected. */ +export async function refreshUsage({ + registry, + server, + presentations, + environmentIds, + input, +}: { + registry: AtomRegistry.AtomRegistry; + server: Pick< + ReturnType, + "usageSummary" | "refreshUsageRates" + >; + presentations: Pick, "presentationAtom">; + environmentIds: readonly EnvironmentId[]; + input: UsageSummaryInput; +}): Promise { + await Promise.all( + environmentIds.map(async (environmentId) => { + const query = server.usageSummary({ environmentId, input }); + const presentation = presentations.presentationAtom(environmentId); + const controller = new AbortController(); + const abortWhenDisconnected = () => { + if (registry.get(presentation)?.connection.phase !== "connected") controller.abort(); + }; + const unsubscribe = registry.subscribe(presentation, abortWhenDisconnected); + abortWhenDisconnected(); + try { + const ratesResult = await runAtomCommand( + registry, + server.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ); + const sessionUnavailable = + ratesResult._tag === "Failure" && + isEnvironmentRpcUnavailable(squashAtomCommandFailure(ratesResult)); + // Invalidate even on failure so reconnects cannot reuse the old summary. + registry.refresh(query); + if (sessionUnavailable || controller.signal.aborted) return; + await executeAtomQuery(registry, query, { + reportFailure: false, + signal: controller.signal, + }); + } finally { + unsubscribe(); + } + }), + ); +} diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a932e7398d04..6c93e6204dc8 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -33,7 +33,7 @@ const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_IDLE_TTL_MS = 30_000; // Rows keep the last status they rendered, so the live stream only needs a // short grace period when virtualization or scrolling releases its consumer. -export const VCS_STATUS_IDLE_TTL_MS = 10_000; +const VCS_STATUS_IDLE_TTL_MS = 10_000; const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))), @@ -214,7 +214,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange return Stream.concat(cachedRefs, refreshedRefs); }); -export function cachedVcsRefsChanges( +function cachedVcsRefsChanges( environmentId: EnvironmentId, input: VcsListRefsInput, expectedRevision: number, @@ -345,4 +345,3 @@ export function createVcsEnvironmentAtoms( export * from "./gitActions.ts"; export * from "./vcsAction.ts"; export * from "./vcsRef.ts"; -export * from "./vcsStatus.ts"; diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 7823a28a75b2..b08c56853528 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -163,14 +163,14 @@ const decodeVcsActionTargetKey = Schema.decodeUnknownSync( Schema.Tuple([EnvironmentId, Schema.String]), ); -export const vcsActionStateAtom = Atom.family((key: string) => { +const vcsActionStateAtom = Atom.family((key: string) => { return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel(`vcs-action:${key}`), ); }); -export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( +const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel("vcs-action:null"), ); @@ -191,7 +191,7 @@ export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { } } -export function getVcsActionStateAtom(target: VcsActionTarget) { +function getVcsActionStateAtom(target: VcsActionTarget) { const key = getVcsActionTargetKey(target); return key === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); } @@ -217,7 +217,7 @@ export function beginVcsActionState( }; } -export function failVcsActionState( +function failVcsActionState( operation: VcsActionOperation, actionId: string, error: unknown, diff --git a/packages/client-runtime/src/state/vcsStatus.ts b/packages/client-runtime/src/state/vcsStatus.ts deleted file mode 100644 index 0a301fa86f3c..000000000000 --- a/packages/client-runtime/src/state/vcsStatus.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - -export interface VcsStatusTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} diff --git a/packages/client-runtime/src/voice-input/index.ts b/packages/client-runtime/src/voice-input/index.ts index c8c8da455ed1..2af9cf3e5ac4 100644 --- a/packages/client-runtime/src/voice-input/index.ts +++ b/packages/client-runtime/src/voice-input/index.ts @@ -1,7 +1,6 @@ export { VoiceInputController, VOICE_RECORDING_LIMIT_SECONDS, - resolveTranscriptCommit, voiceInputBlocksSubmission, voiceInputFreezesEditor, type VoiceDraftSnapshot, diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 0c5274136583..83cb94d6bdb2 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -360,7 +360,7 @@ export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationE ); } -export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { +function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { return ( entry.itemType === "file_search" || (entry.itemType === "web_search" && diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts new file mode 100644 index 000000000000..ffd90dd79db8 --- /dev/null +++ b/packages/contracts/src/agentSessions.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** Coding agent home directories the scanner knows how to read. */ +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export type AgentSessionSource = typeof AgentSessionSource.Type; + +/** File identity saved with an imported session so bounded retries can skip unchanged history. */ +export const AgentSessionImportSource = Schema.Struct({ + provider: AgentSessionSource, + providerInstanceId: ProviderInstanceId, + providerSessionId: TrimmedNonEmptyString, + filePath: TrimmedNonEmptyString, + size: NonNegativeInt, + mtimeMs: Schema.NullOr(Schema.Number), + device: Schema.Number, + inode: Schema.NullOr(Schema.Number), + birthtimeMs: Schema.NullOr(Schema.Number), +}); +export type AgentSessionImportSource = typeof AgentSessionImportSource.Type; + +/** Imported message ids retain their origin after event metadata is projected into SQLite. */ +export function isImportedAgentSessionMessageId(messageId: string): boolean { + return messageId.startsWith("import:"); +} + +/** + * Empty for now. Kept as a struct so future scan options (source filters, + * explicit roots) can be added without a new method. + */ +export const AgentSessionScanInput = Schema.Struct({}); +export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; + +/** + * A directory that at least one agent CLI has run in, suitable for import as a + * T3 Code project. `alreadyImported` marks candidates that already have an + * active project rooted at the same path. + */ +export const AgentSessionProjectCandidate = Schema.Struct({ + path: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + projectId: Schema.optional(ProjectId), + sources: Schema.Array(AgentSessionSource), + threadCount: NonNegativeInt, + lastActiveAt: Schema.NullOr(IsoDateTime), + alreadyImported: Schema.Boolean, +}); +export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; + +export const AgentSessionScanResult = Schema.Struct({ + candidates: Schema.Array(AgentSessionProjectCandidate), + scannedAt: IsoDateTime, + truncated: Schema.optional(Schema.Boolean), +}); +export type AgentSessionScanResult = typeof AgentSessionScanResult.Type; + +export const AgentSessionImportInput = Schema.Struct({ + projectId: ProjectId, + expectedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), +}); +export type AgentSessionImportInput = typeof AgentSessionImportInput.Type; + +export class AgentSessionImportProjectNotFoundError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectNotFoundError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' does not exist.`; + } +} + +export class AgentSessionImportProjectChangedError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectChangedError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' changed directories. Scan for projects again before importing history.`; + } +} + +export const AgentSessionImportResult = Schema.Struct({ + importedCount: NonNegativeInt, + skippedCount: NonNegativeInt, +}); +export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; + +export class AgentSessionScanError extends Schema.TaggedErrorClass()( + "AgentSessionScanError", + { + operation: Schema.Literals(["read-settings", "read-projects"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to scan agent sessions during ${this.operation}.`; + } +} diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 5777114dcbe4..39699ec30f68 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -52,12 +52,20 @@ export const AssetCreateUrlInput = Schema.Struct({ }); export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; +export const AssetImageDimensions = Schema.Struct({ + width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), + height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), +}); +export type AssetImageDimensions = typeof AssetImageDimensions.Type; + export const AssetCreateUrlResult = Schema.Struct({ relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), expiresAt: Schema.Number, sourcePath: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), ), + /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */ + imageDimensions: Schema.optional(AssetImageDimensions), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 2c7eb24821d3..e560bbadaed0 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; -export const BROWSER_IMPORT_SOURCE_IDS = [ +const BROWSER_IMPORT_SOURCE_IDS = [ "chrome", "edge", "brave", @@ -139,9 +139,7 @@ export const BrowserImportResult = Schema.Struct({ }); export type BrowserImportResult = typeof BrowserImportResult.Type; -export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< - Record -> = { +const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly> = { notInstalled: "Not installed on this machine.", needsKeychainApproval: "Needs Keychain access to read its cookies.", keychainItemMissing: diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index b7a7dd7c7307..4115b07d7aad 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -203,5 +203,3 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherEditorSpawnError, ]); export type ExternalLauncherError = typeof ExternalLauncherError.Type; - -export const isExternalLauncherError = Schema.is(ExternalLauncherError); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 17bcf4dfa0de..cb33c4571ebd 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -100,6 +100,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadSettlement: Schema.optionalKey(Schema.Boolean), /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), + /** Server persists the opt-in for continuing interrupted threads after restarts. */ + threadRestartContinuation: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index f223bde3b545..97593b41dfe8 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -426,13 +426,13 @@ export const AuthOtherClientSessionsRevokeResult = Schema.Struct({ }); export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type; -export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( +class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", { success: ExecutionEnvironmentDescriptor, }), ) {} -export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") +class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") .add( HttpApiEndpoint.get("session", "/api/auth/session", { headers: OptionalBearerHeaders, @@ -575,7 +575,7 @@ export class EnvironmentProjectsHttpApi extends HttpApiGroup.make("projects") ) {} /** Large, compressible pull-request payloads travel over HTTP rather than the RPC socket. */ -export class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullRequests").add( +class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullRequests").add( HttpApiEndpoint.post("diff", "/api/pull-requests/diff", { headers: OptionalBearerHeaders, payload: PullRequestDiffInput, @@ -590,7 +590,7 @@ export class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullReque }).middleware(EnvironmentAuthenticatedAuth), ) {} -export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") +class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", { headers: OptionalBearerHeaders, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 4b63b877923f..345adcc7849c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -201,11 +201,9 @@ const VcsStatusChangeRequest = Schema.Struct({ /** Optional for compatibility with older servers and providers. */ isDraft: Schema.optional(Schema.Boolean), /** - * Last provider-side activity (ISO). For a merged/closed change request - * this bounds when it reached that state, so clients can tell a PR that - * terminated during a thread's life from one that was already history - * when the thread was created. Optional for old servers and providers - * whose lookups do not report it. + * Last provider-side activity (ISO), including comments and metadata edits. + * This is not the time a change request closed or merged. Optional for old + * servers and providers whose lookups do not report it. */ updatedAt: Schema.optional(Schema.NullOr(Schema.String)), }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 092de0f48f26..edaa58190853 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ export * from "./background.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; +export * from "./localServerDiscovery.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./desktopAppActivation.ts"; @@ -39,6 +40,7 @@ export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./agentSessions.ts"; export * from "./assets.ts"; export * from "./review.ts"; export * from "./browserImport.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 120348ecda56..b8f3f47a1785 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -26,6 +26,7 @@ import type { } from "./review.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; +import type { LocalServerPairingResult, RunningLocalServer } from "./localServerDiscovery.ts"; import type { ProjectListEntriesInput, ProjectListEntriesResult, @@ -167,6 +168,8 @@ export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export type DesktopBackendMode = "managed" | "client-only"; +export type DesktopBackendModeSource = "settings" | "cli" | "existing-server"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", @@ -182,6 +185,26 @@ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other" export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopBackendModeSchema = Schema.Literals(["managed", "client-only"]); +export const DesktopBackendModeSourceSchema = Schema.Literals([ + "settings", + "cli", + "existing-server", +]); + +export interface DesktopBackendModeState { + effectiveMode: DesktopBackendMode; + configuredMode: DesktopBackendMode; + cliOverride: DesktopBackendMode | null; + source: DesktopBackendModeSource; +} + +export const DesktopBackendModeStateSchema = Schema.Struct({ + effectiveMode: DesktopBackendModeSchema, + configuredMode: DesktopBackendModeSchema, + cliOverride: Schema.NullOr(DesktopBackendModeSchema), + source: DesktopBackendModeSourceSchema, +}); export interface DesktopAppBranding { baseName: string; @@ -1067,11 +1090,15 @@ export interface DesktopBridge { * regardless of OS settings. */ getSystemLocale?: () => string | null; + getBackendModeState: () => DesktopBackendModeState; + setBackendMode: (mode: DesktopBackendMode) => Promise; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentBearerToken: () => Promise; + discoverLocalServers?: () => Promise; + pairLocalServer?: (environmentId: EnvironmentId) => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; getConnectionCatalog?: () => Promise; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index f1dc67077a5a..48bf228c99b7 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; -export const MAX_KEYBINDING_WHEN_LENGTH = 256; +const MAX_KEYBINDING_WHEN_LENGTH = 256; export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; @@ -34,7 +34,7 @@ export const MODEL_PICKER_JUMP_KEYBINDING_COMMANDS = [ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; -export const THREAD_KEYBINDING_COMMANDS = [ +const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", "thread.copyReference", @@ -44,7 +44,7 @@ export const THREAD_KEYBINDING_COMMANDS = [ ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; -export const MODEL_PICKER_KEYBINDING_COMMANDS = [ +const MODEL_PICKER_KEYBINDING_COMMANDS = [ "modelPicker.toggle", ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/contracts/src/localServerDiscovery.ts b/packages/contracts/src/localServerDiscovery.ts new file mode 100644 index 000000000000..b4f4a5196914 --- /dev/null +++ b/packages/contracts/src/localServerDiscovery.ts @@ -0,0 +1,34 @@ +import * as Schema from "effect/Schema"; + +import { EnvironmentId, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const LocalServerRuntimeVariant = Schema.Literals(["userdata", "dev"]); +export type LocalServerRuntimeVariant = typeof LocalServerRuntimeVariant.Type; + +export const RunningLocalServer = Schema.Struct({ + statePath: Schema.String.check(Schema.isMinLength(1)), + baseDir: Schema.String.check(Schema.isMinLength(1)), + variant: LocalServerRuntimeVariant, + pid: PositiveInt, + httpBaseUrl: TrimmedNonEmptyString, + startedAt: TrimmedNonEmptyString, + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, +}); +export type RunningLocalServer = typeof RunningLocalServer.Type; + +export const LocalServerPairCommandOutput = Schema.Struct({ + pairingUrl: TrimmedNonEmptyString, + token: TrimmedNonEmptyString, + expiresAt: TrimmedNonEmptyString, + origin: TrimmedNonEmptyString, + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, +}); +export type LocalServerPairCommandOutput = typeof LocalServerPairCommandOutput.Type; + +export const LocalServerPairingResult = Schema.Struct({ + pairingUrl: TrimmedNonEmptyString, + pairingExpiresAt: TrimmedNonEmptyString, +}); +export type LocalServerPairingResult = typeof LocalServerPairingResult.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index fed67e0262a9..19ae52150f57 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1136,6 +1136,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("rejects thread history imports without messages", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.history.import", + commandId: "command-empty-history", + threadId: "thread-1", + messages: [], + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2c2990a56520..1cbdffc2abb6 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -493,6 +493,7 @@ const ThreadCreateCommand = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, + historyImport: Schema.optional(Schema.Literal(true)), }); const ThreadDeleteCommand = Schema.Struct({ @@ -815,6 +816,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadHistoryImportCommand = Schema.Struct({ + type: Schema.Literal("thread.history.import"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array( + Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, + }), + ).check(Schema.isNonEmpty()), +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -866,6 +881,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadHistoryImportCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -1115,7 +1131,10 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ activity: OrchestrationThreadActivity, }); -export const OrchestrationEventMetadata = ApplicationEventMetadata; +export const OrchestrationEventMetadata = Schema.Struct({ + ...ApplicationEventMetadata.fields, + historyImport: Schema.optional(Schema.Boolean), +}); export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; const EventBaseFields = { diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 6d1664dda57c..510a2148325c 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -103,7 +103,7 @@ const RuntimeErrorClass = Schema.Literals([ ]); export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; -export const TOOL_LIFECYCLE_ITEM_TYPES = [ +const TOOL_LIFECYCLE_ITEM_TYPES = [ "command_execution", "file_change", "mcp_tool_call", diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0478b113d61d..554e68c1b200 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({ outcome: ProviderConsumeResetCreditOutcome, }); export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; + +/** A point-in-time view of one provider's limits, built for the /usage-limits panel. */ +export const UsageLimitsReport = Schema.Struct({ + createdAt: IsoDateTime, + accounts: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + driver: ProviderDriverKind, + label: TrimmedNonEmptyString, + plan: Schema.optional(TrimmedNonEmptyString), + email: Schema.optional(TrimmedNonEmptyString), + sourceLabel: Schema.optional(TrimmedNonEmptyString), + instanceId: Schema.optional(ProviderInstanceId), + displayName: Schema.optional(Schema.String), + accentColor: Schema.optional(Schema.String), + limits: ServerProviderUsageLimits, + }), + ), + notices: Schema.Array(Schema.String), +}); +export type UsageLimitsReport = typeof UsageLimitsReport.Type; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index f766578bb1a3..812489a5fb9f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -638,6 +638,8 @@ export const PullRequestSummary = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: IsoDateTime, }); export type PullRequestSummary = typeof PullRequestSummary.Type; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 37221262ebad..b5aae34d4c33 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -875,7 +875,7 @@ export const RelayHealthResponse = Schema.Struct({ }); export type RelayHealthResponse = typeof RelayHealthResponse.Type; -export const RelayHealthGroup = HttpApiGroup.make("health") +const RelayHealthGroup = HttpApiGroup.make("health") .add( HttpApiEndpoint.get("health", "/health", { success: RelayHealthResponse, @@ -884,7 +884,7 @@ export const RelayHealthGroup = HttpApiGroup.make("health") ) .annotate(OpenApi.Description, "Service health and readiness."); -export const RelayMetadataGroup = HttpApiGroup.make("metadata") +const RelayMetadataGroup = HttpApiGroup.make("metadata") .add( HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { success: RelayAuthorizationServerMetadata, @@ -946,7 +946,7 @@ export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( }, ).annotate(OpenApi.Summary, "Unregister a mobile device"); -export const RelayMobileGroup = HttpApiGroup.make("mobile") +const RelayMobileGroup = HttpApiGroup.make("mobile") .add( RelayRegisterDeviceEndpoint, RelayRegisterLiveActivityEndpoint, @@ -956,7 +956,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") .middleware(RelayDpopClientAuth); -export const RelayClientGroup = HttpApiGroup.make("client") +const RelayClientGroup = HttpApiGroup.make("client") .add( HttpApiEndpoint.get("listEnvironments", "/v1/environments", { headers: RelayBearerRequestHeaders, @@ -1025,7 +1025,7 @@ export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( "Bootstrap endpoint. Send the DPoP proof JWT in the dpop header and the Clerk token in subject_token. The returned access token is bound to the proof key.", ); -export const RelayTokenGroup = HttpApiGroup.make("token") +const RelayTokenGroup = HttpApiGroup.make("token") .add(RelayExchangeDpopAccessTokenEndpoint) .annotate(OpenApi.Description, "OAuth token exchange for DPoP-bound client access."); @@ -1056,7 +1056,7 @@ export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( }, ).annotate(OpenApi.Summary, "Check environment status"); -export const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") +const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") .add(RelayConnectEnvironmentEndpoint, RelayGetEnvironmentStatusEndpoint) .annotate(OpenApi.Description, "DPoP-authenticated client access to linked environments.") .middleware(RelayDpopClientAuth); diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 3ec1e4de3ef4..87b52993a56b 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; +/** Whole-host capacity, independent of T3's process diagnostics. */ +export const HostResourcesSnapshot = Schema.Struct({ + sampledAt: NonNegativeInt, + cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + cpuCount: NonNegativeInt, + availableMemoryBytes: NonNegativeInt, + totalMemoryBytes: NonNegativeInt, +}); +export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; + export const ResourceTelemetryIoSemantics = Schema.Literals([ "storage", "logical", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0ba697b1ff9d..e2032b7b9c8f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -28,6 +28,15 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { + AgentSessionImportInput, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionImportResult, + AgentSessionScanInput, + AgentSessionScanResult, + AgentSessionScanError, +} from "./agentSessions.ts"; import { AssetAccessError, AssetCreateUrlInput, @@ -210,6 +219,7 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { + HostResourcesSnapshot, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -263,6 +273,8 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + agentSessionsScan: "agentSessions.scan", + agentSessionsImport: "agentSessions.import", assetsCreateUrl: "assets.createUrl", assetsPersistChatAttachments: "assets.persistChatAttachments", attachmentsCreateUploadUrl: "attachments.createUploadUrl", @@ -336,6 +348,7 @@ export const WS_METHODS = { serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", + serverGetHostResources: "server.getHostResources", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -399,31 +412,31 @@ export const WS_METHODS = { subscribeResourceTelemetry: "subscribeResourceTelemetry", } as const; -export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { +const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { payload: ServerUpsertKeybindingInput, success: ServerUpsertKeybindingResult, error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); -export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { +const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { payload: ServerRemoveKeybindingInput, success: ServerRemoveKeybindingResult, error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); -export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { +const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { payload: Schema.Struct({}), success: Schema.Struct({}), error: EnvironmentAuthorizationError, }); -export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { +const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { +const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** * When supplied, only refresh this specific provider instance. When @@ -440,7 +453,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]), }); -export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { +const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), @@ -448,130 +461,130 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); -export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { +const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { payload: ProviderConsumeResetCreditInput, success: ProviderConsumeResetCreditResult, error: ProviderSetupRpcError, }); -export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { +const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { +const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { payload: ProviderAuthCompleteInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { +const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { payload: ProviderAuthCancelInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { +const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { +const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, stream: true, }); -export const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { +const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { +const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { payload: ProviderInstallCancelInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { +const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, stream: true, }); -export const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { +const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { +const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { payload: ServerSelfUpdateInput, success: ServerSelfUpdateResult, error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); -export const WsServerUpdateServerWithProgressRpc = Rpc.make( - WS_METHODS.serverUpdateServerWithProgress, - { - payload: ServerSelfUpdateInput, - success: ServerSelfUpdateProgressEvent, - error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), - stream: true, - }, -); +const WsServerUpdateServerWithProgressRpc = Rpc.make(WS_METHODS.serverUpdateServerWithProgress, { + payload: ServerSelfUpdateInput, + success: ServerSelfUpdateProgressEvent, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), + stream: true, +}); -export const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { +const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { payload: DesktopUpdateCommitInput, success: ServerSelfUpdateResult, error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); -export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { +const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { +const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { payload: Schema.Struct({ patch: ServerSettingsPatch }), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { +const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { payload: Schema.Struct({}), success: SourceControlDiscoveryResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { +const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { payload: Schema.Struct({}), success: ServerTraceDiagnosticsResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { +const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { payload: Schema.Struct({}), success: ServerProcessDiagnosticsResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetProcessResourceHistoryRpc = Rpc.make( - WS_METHODS.serverGetProcessResourceHistory, - { - payload: ServerProcessResourceHistoryInput, - success: ServerProcessResourceHistoryResult, - error: EnvironmentAuthorizationError, - }, -); +const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, { + payload: Schema.Struct({}), + success: HostResourcesSnapshot, + error: EnvironmentAuthorizationError, +}); -export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( +const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { + payload: ServerProcessResourceHistoryInput, + success: ServerProcessResourceHistoryResult, + error: EnvironmentAuthorizationError, +}); + +const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( WS_METHODS.serverGetResourceTelemetryHistory, { payload: ResourceTelemetryHistoryInput, @@ -580,13 +593,13 @@ export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( }, ); -export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { +const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { payload: Schema.Struct({}), success: ResourceTelemetryRetryResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { +const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { payload: UsageSummaryInput, success: UsageSummary, error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), @@ -596,42 +609,42 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm * Refetches the model rate table ahead of its daily TTL, so a model released * since the last fetch gets priced. The next usage summary uses the new table. */ -export const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { +const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { payload: Schema.Struct({}), success: UsagePricing, error: EnvironmentAuthorizationError, }); -export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { +const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, error: EnvironmentAuthorizationError, }); -export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { +const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, error: EnvironmentAuthorizationError, }); -export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { +const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { payload: Schema.Struct({}), success: RelayClientInstallProgressEventSchema, error: Schema.Union([RelayClientInstallFailedError, EnvironmentAuthorizationError]), stream: true, }); -export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { +const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { payload: ClientActivityReportInput, error: EnvironmentAuthorizationError, }); -export const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { +const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { payload: HostPowerSnapshot, error: EnvironmentAuthorizationError, }); -export const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { +const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { payload: Schema.Struct({}), success: BackgroundPolicySnapshot, error: EnvironmentAuthorizationError, @@ -643,7 +656,7 @@ const PullRequestRpcError = Schema.Union([ EnvironmentAuthorizationError, ]); -export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { +const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { payload: PullRequestListInput, success: PullRequestListResult, error: PullRequestRpcError, @@ -654,310 +667,312 @@ export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { * 40-60% of the listing read that answers everything else on the row, so the rows arrive first * and their stats a moment later. */ -export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, { +const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, { payload: PullRequestListStatsInput, success: PullRequestListStatsResult, error: PullRequestRpcError, }); -export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { +const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { payload: PullRequestRef, success: PullRequestSummary, error: PullRequestRpcError, }); -export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { +const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { payload: PullRequestRef, success: PullRequestDetail, error: PullRequestRpcError, }); -export const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { +const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { payload: PullRequestRef, success: PullRequestActivity, error: PullRequestRpcError, }); -export const WsPullRequestsThreadCommentsRpc = Rpc.make(WS_METHODS.pullRequestsThreadComments, { +const WsPullRequestsThreadCommentsRpc = Rpc.make(WS_METHODS.pullRequestsThreadComments, { payload: PullRequestThreadCommentsInput, success: PullRequestThreadCommentsResult, error: PullRequestRpcError, }); -export const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFileContents, { +const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFileContents, { payload: PullRequestDiffFileContentsInput, success: PullRequestDiffFileContentsResult, error: PullRequestRpcError, }); -export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { +const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { payload: PullRequestActionInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, { +const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, { payload: PullRequestUpdateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { +const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { payload: PullRequestCommentInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, { +const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, { payload: PullRequestCommentUpdateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, { +const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, { payload: PullRequestSubmitReviewInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsReplyToThreadRpc = Rpc.make(WS_METHODS.pullRequestsReplyToThread, { +const WsPullRequestsReplyToThreadRpc = Rpc.make(WS_METHODS.pullRequestsReplyToThread, { payload: PullRequestThreadReplyInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSetThreadResolutionRpc = Rpc.make( - WS_METHODS.pullRequestsSetThreadResolution, - { - payload: PullRequestThreadResolutionInput, - success: Schema.Void, - error: PullRequestRpcError, - }, -); +const WsPullRequestsSetThreadResolutionRpc = Rpc.make(WS_METHODS.pullRequestsSetThreadResolution, { + payload: PullRequestThreadResolutionInput, + success: Schema.Void, + error: PullRequestRpcError, +}); -export const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, { +const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, { payload: PullRequestReactionInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, { +const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, { payload: PullRequestInvalidateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSubscribeRefreshesRpc = Rpc.make( - WS_METHODS.pullRequestsSubscribeRefreshes, - { - payload: Schema.Struct({}), - success: NonNegativeInt, - error: EnvironmentAuthorizationError, - stream: true, - }, -); +const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(WS_METHODS.pullRequestsSubscribeRefreshes, { + payload: Schema.Struct({}), + success: NonNegativeInt, + error: EnvironmentAuthorizationError, + stream: true, +}); /** * Read on its own rather than as part of the detail: the people who may be asked are only wanted * once somebody opens the menu, and reading them with every change request would spend a request * per host on a list nobody looked at. */ -export const WsPullRequestsReviewerCandidatesRpc = Rpc.make( - WS_METHODS.pullRequestsReviewerCandidates, - { - payload: PullRequestRef, - success: PullRequestReviewerCandidateList, - error: PullRequestRpcError, - }, -); +const WsPullRequestsReviewerCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsReviewerCandidates, { + payload: PullRequestRef, + success: PullRequestReviewerCandidateList, + error: PullRequestRpcError, +}); -export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequestsRequestReviewers, { +const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequestsRequestReviewers, { payload: PullRequestReviewerRequestInput, success: Schema.Void, error: PullRequestRpcError, }); /** Read when the label menu opens, for the same reason the reviewer candidates are. */ -export const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { +const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { payload: PullRequestRef, success: PullRequestLabelCandidateList, error: PullRequestRpcError, }); -export const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { +const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { payload: PullRequestLabelChangeInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsSourceControlLookupRepositoryRpc = Rpc.make( - WS_METHODS.sourceControlLookupRepository, - { - payload: SourceControlRepositoryLookupInput, - success: SourceControlRepositoryInfo, - error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), - }, -); +const WsSourceControlLookupRepositoryRpc = Rpc.make(WS_METHODS.sourceControlLookupRepository, { + payload: SourceControlRepositoryLookupInput, + success: SourceControlRepositoryInfo, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); -export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { +const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); -export const WsSourceControlPublishRepositoryRpc = Rpc.make( - WS_METHODS.sourceControlPublishRepository, - { - payload: SourceControlPublishRepositoryInput, - success: SourceControlPublishRepositoryResult, - error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), - }, -); +const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, { + payload: SourceControlPublishRepositoryInput, + success: SourceControlPublishRepositoryResult, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); -export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { +const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { +const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { payload: ProjectSearchContentsInput, success: ProjectSearchContentsResult, error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), }); -export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { +const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { +const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), }); -export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { +const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); -export const WsProjectsMutateRpc = Rpc.make(WS_METHODS.projectsMutate, { +const WsProjectsMutateRpc = Rpc.make(WS_METHODS.projectsMutate, { payload: ProjectMutation, success: Project, error: Schema.Union([ProjectMutationError, EnvironmentAuthorizationError]), }); -export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { +const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), }); -export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { +const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { payload: FilesystemBrowseInput, success: FilesystemBrowseResult, error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); -export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { +const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { + payload: AgentSessionScanInput, + success: AgentSessionScanResult, + error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), +}); + +const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { + payload: AgentSessionImportInput, + success: AgentSessionImportResult, + error: Schema.Union([ + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionScanError, + EnvironmentAuthorizationError, + ]), +}); + +const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); -export const WsAssetsPersistChatAttachmentsRpc = Rpc.make(WS_METHODS.assetsPersistChatAttachments, { +const WsAssetsPersistChatAttachmentsRpc = Rpc.make(WS_METHODS.assetsPersistChatAttachments, { payload: PersistChatAttachmentsInput, success: PersistChatAttachmentsResult, error: Schema.Union([PersistChatAttachmentsError, EnvironmentAuthorizationError]), }); -export const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { +const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { payload: AttachmentCreateUploadUrlInput, success: AttachmentCreateUploadUrlResult, error: Schema.Union([AttachmentUploadSigningKeyError, EnvironmentAuthorizationError]), }); -export const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { +const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { payload: AttachmentDeleteInput, error: EnvironmentAuthorizationError, }); -export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { +const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { payload: ProviderUploadFeedbackInput, success: ProviderUploadFeedbackResult, error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]), }); -export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { +const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, }); -export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { +const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { payload: VcsPullInput, success: VcsPullResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { +const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { payload: VcsStatusInput, success: VcsStatusResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { +const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, }); -export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { +const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { payload: GitPullRequestRefInput, success: GitResolvePullRequestResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { +const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { payload: GitPreparePullRequestThreadInput, success: GitPreparePullRequestThreadResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { +const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { payload: VcsListRefsInput, success: VcsListRefsResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { +const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { +const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { payload: VcsRemoveWorktreeInput, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { +const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { payload: VcsCreateRefInput, success: VcsCreateRefResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { +const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { payload: VcsSwitchRefInput, success: VcsSwitchRefResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { +const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { payload: VcsInitInput, error: Schema.Union([VcsError, EnvironmentAuthorizationError]), }); @@ -967,148 +982,142 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { * Not the persisted T3 Review model. Future review sessions should use * review.open* + review.getSnapshot. */ -export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { +const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { payload: ReviewDiffPreviewInput, success: ReviewDiffPreviewResult, error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); -export const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, { +const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, { payload: ReviewDiffFileContentsInput, success: ReviewDiffFileContentsResult, error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); -export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { +const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { +const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { payload: TerminalAttachInput, success: TerminalAttachStreamEvent, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), stream: true, }); -export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { +const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { payload: TerminalWriteInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { +const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { payload: TerminalResizeInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { +const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { payload: TerminalClearInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { +const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { payload: TerminalRestartInput, success: TerminalSessionSnapshot, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { +const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { payload: TerminalCloseInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { +const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { payload: PreviewOpenInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { +const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { payload: PreviewNavigateInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { +const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { payload: PreviewResizeInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { +const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { payload: PreviewRefreshInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { +const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { payload: PreviewCloseInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { +const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { payload: PreviewListInput, success: PreviewListResult, error: EnvironmentAuthorizationError, }); -export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { +const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { +const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { payload: PreviewAutomationHost, success: PreviewAutomationStreamEvent, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), stream: true, }); -export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { +const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { payload: PreviewAutomationResponse, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), }); -export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { +const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { payload: PreviewAutomationHostFocus, error: EnvironmentAuthorizationError, }); -export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { +const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { payload: Schema.Struct({}), success: PreviewEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( - WS_METHODS.subscribeDiscoveredLocalServers, - { - payload: Schema.Struct({ - configuredUrls: Schema.optional(ConfiguredLocalServerUrls), - }), - success: DiscoveredLocalServerList, - error: EnvironmentAuthorizationError, - stream: true, - }, -); +const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscoveredLocalServers, { + payload: Schema.Struct({ + configuredUrls: Schema.optional(ConfiguredLocalServerUrls), + }), + success: DiscoveredLocalServerList, + error: EnvironmentAuthorizationError, + stream: true, +}); -export const WsOrchestrationV2DispatchCommandRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.dispatchCommand, - { - payload: OrchestrationV2RpcSchemas.dispatchCommand.input, - success: OrchestrationV2RpcSchemas.dispatchCommand.output, - error: Schema.Union([OrchestrationV2DispatchCommandError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationV2DispatchCommandRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, { + payload: OrchestrationV2RpcSchemas.dispatchCommand.input, + success: OrchestrationV2RpcSchemas.dispatchCommand.output, + error: Schema.Union([OrchestrationV2DispatchCommandError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationV2GetTurnDiffRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnDiff, { +const WsOrchestrationV2GetTurnDiffRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnDiff, { payload: OrchestrationV2RpcSchemas.getTurnDiff.input, success: OrchestrationV2RpcSchemas.getTurnDiff.output, error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]), }); -export const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make( +const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, { payload: OrchestrationV2RpcSchemas.getFullThreadDiff.input, @@ -1117,16 +1126,13 @@ export const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make( }, ); -export const WsOrchestrationV2SearchThreadsRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.searchThreads, - { - payload: OrchestrationSearchThreadsInput, - success: OrchestrationSearchThreadsResult, - error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationV2SearchThreadsRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThreads, { + payload: OrchestrationSearchThreadsInput, + success: OrchestrationSearchThreadsResult, + error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationV2GetArchivedShellSnapshotRpc = Rpc.make( +const WsOrchestrationV2GetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, { payload: OrchestrationV2RpcSchemas.getArchivedShellSnapshot.input, @@ -1135,7 +1141,7 @@ export const WsOrchestrationV2GetArchivedShellSnapshotRpc = Rpc.make( }, ); -export const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( +const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { payload: OrchestrationV2RpcSchemas.getThreadProjection.input, @@ -1144,7 +1150,7 @@ export const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( }, ); -export const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( +const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, { payload: OrchestrationV2RpcSchemas.getWorkflowScript.input, @@ -1153,13 +1159,13 @@ export const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( }, ); -export const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, { +const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, { payload: OrchestrationV2RpcSchemas.launchThread.input, success: OrchestrationV2RpcSchemas.launchThread.output, error: Schema.Union([OrchestrationV2ThreadLaunchError, EnvironmentAuthorizationError]), }); -export const WsOrchestrationV2SubscribeArchivedShellRpc = Rpc.make( +const WsOrchestrationV2SubscribeArchivedShellRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell, { payload: OrchestrationV2RpcSchemas.subscribeArchivedShell.input, @@ -1169,34 +1175,28 @@ export const WsOrchestrationV2SubscribeArchivedShellRpc = Rpc.make( }, ); -export const WsOrchestrationV2SubscribeShellRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.subscribeShell, - { - payload: OrchestrationV2RpcSchemas.subscribeShell.input, - success: OrchestrationV2RpcSchemas.subscribeShell.output, - error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), - stream: true, - }, -); +const WsOrchestrationV2SubscribeShellRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.subscribeShell, { + payload: OrchestrationV2RpcSchemas.subscribeShell.input, + success: OrchestrationV2RpcSchemas.subscribeShell.output, + error: Schema.Union([OrchestrationV2GetShellSnapshotError, EnvironmentAuthorizationError]), + stream: true, +}); -export const WsOrchestrationV2SubscribeThreadRpc = Rpc.make( - ORCHESTRATION_V2_WS_METHODS.subscribeThread, - { - payload: OrchestrationV2RpcSchemas.subscribeThread.input, - success: OrchestrationV2RpcSchemas.subscribeThread.output, - error: Schema.Union([OrchestrationV2GetThreadProjectionError, EnvironmentAuthorizationError]), - stream: true, - }, -); +const WsOrchestrationV2SubscribeThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.subscribeThread, { + payload: OrchestrationV2RpcSchemas.subscribeThread.input, + success: OrchestrationV2RpcSchemas.subscribeThread.output, + error: Schema.Union([OrchestrationV2GetThreadProjectionError, EnvironmentAuthorizationError]), + stream: true, +}); -export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { +const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { +const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { payload: Schema.Struct({}), success: TerminalMetadataStreamEvent, error: EnvironmentAuthorizationError, @@ -1215,72 +1215,78 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon environmentThemes: Schema.optional(Schema.Boolean), /** Whether this client understands `usageLimitSourcesUpdated` events. */ usageLimitSources: Schema.optional(Schema.Boolean), + /** + * Whether this client answers `/usage-limits` itself. The server injects + * that command into provider catalogs only for such clients; an older + * client would send it to the provider as an ordinary prompt. + */ + usageLimitsCommand: Schema.optional(Schema.Boolean), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), stream: true, }); -export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { +const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { payload: Schema.Struct({}), success: ServerLifecycleStreamEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsScheduledTasksListRpc = Rpc.make(WS_METHODS.scheduledTasksList, { +const WsScheduledTasksListRpc = Rpc.make(WS_METHODS.scheduledTasksList, { payload: ScheduledTaskListInput, success: ScheduledTaskListResult, error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }); /** Streams the full scheduled-task list: one snapshot on subscribe, then a fresh list after every change. */ -export const WsScheduledTasksSubscribeRpc = Rpc.make(WS_METHODS.scheduledTasksSubscribe, { +const WsScheduledTasksSubscribeRpc = Rpc.make(WS_METHODS.scheduledTasksSubscribe, { payload: ScheduledTaskListInput, success: ScheduledTaskListResult, error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), stream: true, }); -export const WsScheduledTasksUpsertRpc = Rpc.make(WS_METHODS.scheduledTasksUpsert, { +const WsScheduledTasksUpsertRpc = Rpc.make(WS_METHODS.scheduledTasksUpsert, { payload: ScheduledTaskUpsertInput, success: ScheduledTaskMutationResult, error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }); -export const WsScheduledTasksSetEnabledRpc = Rpc.make(WS_METHODS.scheduledTasksSetEnabled, { +const WsScheduledTasksSetEnabledRpc = Rpc.make(WS_METHODS.scheduledTasksSetEnabled, { payload: ScheduledTaskSetEnabledInput, success: ScheduledTaskMutationResult, error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }); -export const WsScheduledTasksDeleteRpc = Rpc.make(WS_METHODS.scheduledTasksDelete, { +const WsScheduledTasksDeleteRpc = Rpc.make(WS_METHODS.scheduledTasksDelete, { payload: ScheduledTaskDeleteInput, success: ScheduledTaskDeleteResult, error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }); -export const WsScheduledTasksRunNowRpc = Rpc.make(WS_METHODS.scheduledTasksRunNow, { +const WsScheduledTasksRunNowRpc = Rpc.make(WS_METHODS.scheduledTasksRunNow, { payload: ScheduledTaskRunNowInput, success: ScheduledTaskRunNowResult, error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }); -export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { +const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), success: AuthAccessStreamEvent, error: Schema.Union([AuthAccessStreamError, EnvironmentAuthorizationError]), stream: true, }); -export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { +const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { payload: Schema.Struct({}), success: BackgroundPolicySnapshot, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { +const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { payload: Schema.Struct({}), success: ResourceTelemetrySnapshot, error: EnvironmentAuthorizationError, @@ -1312,6 +1318,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, + WsServerGetHostResourcesRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, @@ -1361,6 +1368,8 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsMutateRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAgentSessionsScanRpc, + WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, WsAssetsPersistChatAttachmentsRpc, WsAttachmentsCreateUploadUrlRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 4f6e3b321ce1..765f5cfd610b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, + bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])), bootstrapProjectId: Schema.optional(ProjectId), bootstrapThreadId: Schema.optional(ThreadId), + bootstrapProjectCreated: Schema.optional(Schema.Boolean), + bootstrapThreadCreated: Schema.optional(Schema.Boolean), }); export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d4a5ff1fcb89..f4638b65e13e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,7 +7,6 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, - defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -145,6 +144,21 @@ describe("ClientSettings composer context strip", () => { }); }); +describe("ClientSettings load balancing", () => { + it("requires opt-in when settings are new or omit load balancing", () => { + expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); + expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false); + }); + + it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => { + const settings = decodeClientSettings({ loadBalancingEnabled }); + expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled); + expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe( + loadBalancingEnabled, + ); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); @@ -441,14 +455,6 @@ describe("provider enabled defaults", () => { expect(decoded.providers.opencode.enabled).toBe(false); }); - it("derives per-driver defaults from the settings schemas", () => { - expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); - expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); - expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); - // Unknown fork drivers stay enabled; their own build decides otherwise. - expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); - }); - it("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); @@ -469,6 +475,10 @@ describe("provider enabled defaults", () => { // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); + // Unknown fork drivers stay enabled. + expect( + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), + ).toBe(true); // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f3132bd0d029..704fbe99bb37 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,12 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { + ForwardCompatibleNullable, + ProjectId, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { @@ -12,6 +17,7 @@ import { ProviderOptionSelections, } from "./model.ts"; import { ModelSelection } from "./modelSelection.ts"; +import { ProjectScript } from "./project.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, @@ -31,11 +37,11 @@ import { export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; -export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; -export const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; +const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; @@ -51,7 +57,7 @@ export const SidebarProjectGroupingMode = Schema.Literals([ "separate", ]); export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; -export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; +const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; export const SidebarThreadPreviewCount = Schema.Int.check( @@ -61,7 +67,7 @@ export const SidebarThreadPreviewCount = Schema.Int.check( }), ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; -export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; export const SidebarAutoSettleAfterDays = Schema.Number.check( @@ -71,7 +77,7 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check( }), ); export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; -export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; +const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -81,7 +87,7 @@ export const GlassOpacity = Schema.Int.check( }), ); export type GlassOpacity = typeof GlassOpacity.Type; -export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; export const MIN_APPEARANCE_CONTRAST = 50; export const MAX_APPEARANCE_CONTRAST = 200; @@ -89,7 +95,7 @@ export const AppearanceContrast = Schema.Int.check( Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }), ); export type AppearanceContrast = typeof AppearanceContrast.Type; -export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; +const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; export const MIN_PANEL_ANIMATION_DURATION_MS = 0; export const MAX_PANEL_ANIMATION_DURATION_MS = 400; export const PanelAnimationDurationMs = Schema.Int.check( @@ -99,7 +105,7 @@ export const PanelAnimationDurationMs = Schema.Int.check( }), ); export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type; -export const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; +const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; /** * Font size preferences, in CSS pixels. The ranges are deliberately narrow: * the interface size scales every rem-based dimension in the app, so the @@ -135,7 +141,7 @@ export const TerminalFontSize = Schema.Int.check( Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), ); export type TerminalFontSize = typeof TerminalFontSize.Type; -export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; +const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; @@ -143,7 +149,7 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; -export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; +const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; const LegacyConfirmQuit = Schema.Boolean.pipe( Schema.decodeTo( @@ -199,7 +205,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; +export const LoadBalancingWeights = Schema.Record( + TrimmedNonEmptyString, + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), +); + export const ClientSettingsSchema = Schema.Struct({ + loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), ), @@ -289,6 +302,13 @@ export const ClientSettingsSchema = Schema.Struct({ persistComposerContextStrip: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + // When the first-run welcome wizard finished (or was skipped), as an ISO + // timestamp. `null` alone does not mean "show the wizard" — every install + // that predates this field decodes to `null` — so the gate also requires an + // empty workspace before it treats the client as a fresh install. + onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -419,7 +439,7 @@ export type ProviderSettingsOrder = readonl string >[]; -export function makeProviderSettingsSchema( +function makeProviderSettingsSchema( fields: Fields, options?: { readonly order?: ProviderSettingsOrder | undefined; @@ -884,6 +904,22 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + defaultProjectScripts: Schema.Array(ProjectScript).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1010,7 +1046,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays * the single source of truth. Unknown (fork) drivers default to enabled. */ -export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined @@ -1142,6 +1178,18 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + projectAgentBrowserAccessOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultAutoPull: Schema.optionalKey(Schema.Boolean), + defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), + projectScriptOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))), + ), + projectAutoPullOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( @@ -1203,6 +1251,8 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), + loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), @@ -1221,6 +1271,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), fontSizeCode: Schema.optionalKey(CodeFontSize), diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index be3d70aefadd..b013eea3bb6f 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -31,6 +31,8 @@ export const ChangeRequest = Schema.Struct({ state: ChangeRequestState, /** Present when the provider can tell that an open change request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed4923888..066253602a49 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -7,12 +7,18 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalError, TerminalOpenInput, + TerminalProviderEnvironmentError, TerminalResizeInput, TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, } from "./terminal.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const encodeTerminalError = Schema.encodeUnknownSync(TerminalError); +const decodeTerminalError = Schema.decodeUnknownSync(TerminalError); function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; @@ -27,6 +33,28 @@ function decodes(schema: S, input: unknown): boolean { } } +describe("TerminalProviderEnvironmentError", () => { + it("round-trips its required cause without exposing it in the message", () => { + const cause = { operation: "read-secret", detail: "secret backend unavailable" }; + const error = new TerminalProviderEnvironmentError({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + cause, + }); + const encoded = encodeTerminalError(error); + const decoded = decodeTerminalError(encoded); + + expect(decoded).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId: "codex_work", + cause, + }); + expect(decoded.message).toBe( + "Could not prepare the terminal environment for provider instance: codex_work", + ); + expect(decoded.message).not.toContain("secret backend unavailable"); + }); +}); + describe("TerminalOpenInput", () => { it("accepts valid open input", () => { expect( @@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => { T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }, + providerInstanceId: "codex_work", }); expect(parsed.env).toMatchObject({ T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }); expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); + expect(parsed.providerInstanceId).toBe("codex_work"); }); it("rejects invalid env keys", () => { @@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => { }), ).toBe(false); }); + + it("rejects invalid provider instance ids", () => { + for (const providerInstanceId of ["", "1invalid", "invalid id"]) { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project", + providerInstanceId, + }), + ).toBe(false); + } + }); }); describe("TerminalAttachInput", () => { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index fa5f18211695..36e3d339f521 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalOpenInput = Schema.Codec.Encoded; +export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), restartIfNotRunning: Schema.optional(Schema.Boolean), }); -export type TerminalAttachInput = Schema.Codec.Encoded; +export type TerminalAttachInput = typeof TerminalAttachInput.Type; export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({ cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalRestartInput = Schema.Codec.Encoded; +export type TerminalRestartInput = typeof TerminalRestartInput.Type; export const TerminalCloseInput = Schema.Struct({ ...TerminalThreadInput.fields, @@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass()( + "TerminalProviderInstanceNotFoundError", + { + providerInstanceId: ProviderInstanceId, + }, +) { + override get message() { + return `Provider instance is not available: ${this.providerInstanceId}`; + } +} + +export class TerminalProviderEnvironmentError extends Schema.TaggedErrorClass()( + "TerminalProviderEnvironmentError", + { + providerInstanceId: ProviderInstanceId, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`; + } +} + export class TerminalNotRunningError extends Schema.TaggedErrorClass()( "TerminalNotRunningError", { @@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalNotRunningError, TerminalWriteError, TerminalResizeError, diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index bff3491c3aa9..8e209fe01bd9 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -254,6 +254,7 @@ interface AcpCoreAgentRequestHandlers { const decodeCancelNotification = Schema.decodeUnknownEffect(AcpSchema.CancelNotification); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( stdio: Stdio.Stdio, options: AcpAgentOptions = {}, diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index d4495c9a363e..cb348d1e5456 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -592,11 +592,6 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }); }); -export const layer = ( - stdio: AcpProtocol.AcpStdio, - options: AcpClientOptions = {}, -): Layer.Layer => Layer.effect(AcpClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: AcpClientOptions = {}, diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index a3e6ffa65c2e..d79a5701ec85 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -3,7 +3,7 @@ import type * as SchemaIssue from "effect/SchemaIssue"; import * as AcpSchema from "./_generated/schema.gen.ts"; -export const AcpRequestOperation = Schema.Literals([ +const AcpRequestOperation = Schema.Literals([ "decode-extension-request-payload", "encode-extension-response", "handle-request", @@ -11,12 +11,12 @@ export const AcpRequestOperation = Schema.Literals([ "receive-response", "receive-streaming-response", ]); -export type AcpRequestOperation = typeof AcpRequestOperation.Type; +type AcpRequestOperation = typeof AcpRequestOperation.Type; export const AcpRequestId = Schema.Union([Schema.String, Schema.Number]); export type AcpRequestId = typeof AcpRequestId.Type; -export const AcpSchemaIssueKind = Schema.Literals([ +const AcpSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -29,9 +29,9 @@ export const AcpSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; +type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; -export interface AcpSchemaIssueDiagnostics { +interface AcpSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index 93d903e78729..5026645374eb 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -4,127 +4,127 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import * as AcpSchema from "./_generated/schema.gen.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; -export const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { +const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { payload: AcpSchema.InitializeRequest, success: AcpSchema.InitializeResponse, error: AcpSchema.Error, }); -export const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { +const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { payload: AcpSchema.AuthenticateRequest, success: AcpSchema.AuthenticateResponse, error: AcpSchema.Error, }); -export const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { +const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { payload: AcpSchema.LogoutRequest, success: AcpSchema.LogoutResponse, error: AcpSchema.Error, }); -export const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { +const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { payload: AcpSchema.NewSessionRequest, success: AcpSchema.NewSessionResponse, error: AcpSchema.Error, }); -export const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { +const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { payload: AcpSchema.LoadSessionRequest, success: AcpSchema.LoadSessionResponse, error: AcpSchema.Error, }); -export const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { +const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { payload: AcpSchema.ListSessionsRequest, success: AcpSchema.ListSessionsResponse, error: AcpSchema.Error, }); -export const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { +const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { payload: AcpSchema.ForkSessionRequest, success: AcpSchema.ForkSessionResponse, error: AcpSchema.Error, }); -export const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { +const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { payload: AcpSchema.ResumeSessionRequest, success: AcpSchema.ResumeSessionResponse, error: AcpSchema.Error, }); -export const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { +const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { payload: AcpSchema.CloseSessionRequest, success: AcpSchema.CloseSessionResponse, error: AcpSchema.Error, }); -export const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { +const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { payload: AcpSchema.PromptRequest, success: AcpSchema.PromptResponse, error: AcpSchema.Error, }); -export const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { +const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { payload: AcpSchema.SetSessionModelRequest, success: AcpSchema.SetSessionModelResponse, error: AcpSchema.Error, }); -export const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { +const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { payload: AcpSchema.SetSessionConfigOptionRequest, success: AcpSchema.SetSessionConfigOptionResponse, error: AcpSchema.Error, }); -export const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { +const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { payload: AcpSchema.ReadTextFileRequest, success: AcpSchema.ReadTextFileResponse, error: AcpSchema.Error, }); -export const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { +const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { payload: AcpSchema.WriteTextFileRequest, success: AcpSchema.WriteTextFileResponse, error: AcpSchema.Error, }); -export const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { +const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { payload: AcpSchema.RequestPermissionRequest, success: AcpSchema.RequestPermissionResponse, error: AcpSchema.Error, }); -export const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { +const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { payload: AcpSchema.ElicitationRequest, success: AcpSchema.ElicitationResponse, error: AcpSchema.Error, }); -export const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { +const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { payload: AcpSchema.CreateTerminalRequest, success: AcpSchema.CreateTerminalResponse, error: AcpSchema.Error, }); -export const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { +const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { payload: AcpSchema.TerminalOutputRequest, success: AcpSchema.TerminalOutputResponse, error: AcpSchema.Error, }); -export const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { +const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { payload: AcpSchema.ReleaseTerminalRequest, success: AcpSchema.ReleaseTerminalResponse, error: AcpSchema.Error, }); -export const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { +const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { payload: AcpSchema.WaitForTerminalExitRequest, success: AcpSchema.WaitForTerminalExitResponse, error: AcpSchema.Error, }); -export const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { +const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { payload: AcpSchema.KillTerminalRequest, success: AcpSchema.KillTerminalResponse, error: AcpSchema.Error, diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 34155348abfa..8bcb59467d3d 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -5,7 +5,7 @@ import * as CodexError from "../errors.ts"; export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); -export const JsonRpcError = Schema.Struct({ +const JsonRpcError = Schema.Struct({ code: Schema.Number, message: Schema.String, data: Schema.optional(Schema.Unknown), diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 0f5635cda6c2..f643762e19cb 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -161,7 +161,9 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make if (schema) { return decodeNotificationPayload(notification.method, schema, notification.params).pipe( Effect.flatMap((decoded) => - Effect.forEach(handlers, (handler) => handler(decoded), { discard: true }), + Effect.forEach(handlers, (handler) => handler(decoded), { + discard: true, + }), ), Effect.catch(() => Effect.void), ); @@ -260,11 +262,6 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make }); }); -export const layer = ( - stdio: Stdio.Stdio, - options: CodexAppServerClientOptions = {}, -): Layer.Layer => Layer.effect(CodexAppServerClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 3803b4d40659..f0bf470c251c 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -1,15 +1,15 @@ import * as Schema from "effect/Schema"; import type * as SchemaIssue from "effect/SchemaIssue"; -export const CodexAppServerRequestOperation = Schema.Literals([ +const CodexAppServerRequestOperation = Schema.Literals([ "decode-payload", "encode-payload", "handle-request", "receive-response", ]); -export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; +type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; -export const CodexAppServerSchemaIssueKind = Schema.Literals([ +const CodexAppServerSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -22,9 +22,9 @@ export const CodexAppServerSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; +type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; -export interface CodexAppServerSchemaIssueDiagnostics { +interface CodexAppServerSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; @@ -62,7 +62,7 @@ const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIs }; }; -export const CodexAppServerPayloadKind = Schema.Literals([ +const CodexAppServerPayloadKind = Schema.Literals([ "null", "array", "string", @@ -74,7 +74,7 @@ export const CodexAppServerPayloadKind = Schema.Literals([ "function", "undefined", ]); -export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; +type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { if (payload === null) return "null"; @@ -84,8 +84,7 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; -export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); -export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; +const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); export interface CodexAppServerRequestDiagnostics { readonly method?: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 69d7b8944430..543f95b6bce7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -211,6 +211,10 @@ "types": "./src/filePreview.ts", "import": "./src/filePreview.ts" }, + "./imageDimensions": { + "types": "./src/imageDimensions.ts", + "import": "./src/imageDimensions.ts" + }, "./video": { "types": "./src/video.ts", "import": "./src/video.ts" @@ -286,6 +290,14 @@ "./hostClassification": { "types": "./src/hostClassification.ts", "import": "./src/hostClassification.ts" + }, + "./dateTime": { + "types": "./src/dateTime.ts", + "import": "./src/dateTime.ts" + }, + "./serverRuntimeState": { + "types": "./src/serverRuntimeState.ts", + "import": "./src/serverRuntimeState.ts" } }, "scripts": { diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 4644576296bc..e3b653692880 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -63,6 +63,7 @@ export class NetService extends Context.Service()( "@t3tools/shared/Net/NetService", ) {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = () => { /** * Returns true when a TCP server can bind to {host, port}. diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 386f6c6e9cb0..ab4a4f5d4a1d 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -28,7 +28,7 @@ export interface AgentAwarenessState { readonly deepLink: string; } -export function buildAgentAwarenessDeepLink(input: { +function buildAgentAwarenessDeepLink(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; }): string { @@ -75,7 +75,10 @@ export function projectThreadAwarenessV2( ...(detail === undefined ? {} : { detail }), modelTitle: thread.modelSelection.model, updatedAt: DateTime.formatIso(thread.updatedAt), - deepLink: buildAgentAwarenessDeepLink({ environmentId, threadId: thread.id }), + deepLink: buildAgentAwarenessDeepLink({ + environmentId, + threadId: thread.id, + }), }; } diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 50c8cd7c2080..4b2763854457 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,20 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { serializeComposerFileLink, serializeComposerMentionPath } from "./composerTrigger.ts"; - -describe("serializeComposerMentionPath", () => { - it("keeps simple mention paths unquoted", () => { - expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts"); - }); - - it("quotes mention paths containing whitespace", () => { - expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"'); - }); - - it("escapes quoted mention path content", () => { - expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"'); - }); -}); +import { serializeComposerFileLink } from "./composerTrigger.ts"; describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index dcbdc784934b..6176d10d7908 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -8,15 +8,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/; - -export function serializeComposerMentionPath(path: string): string { - if (SIMPLE_MENTION_PATH_REGEX.test(path)) { - return path; - } - return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; -} - function composerFileLinkBasename(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; @@ -124,18 +115,6 @@ export function detectComposerTrigger( }; } -export function parseStandaloneComposerSlashCommand( - text: string, -): Exclude | null { - const match = /^\/(plan|default)\s*$/i.exec(text.trim()); - if (!match) { - return null; - } - const command = match[1]?.toLowerCase(); - if (command === "plan") return "plan"; - return "default"; -} - export function replaceTextRange( text: string, rangeStart: number, diff --git a/packages/shared/src/dateTime.test.ts b/packages/shared/src/dateTime.test.ts new file mode 100644 index 000000000000..562507de3ac2 --- /dev/null +++ b/packages/shared/src/dateTime.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { compareDateTimeStrings } from "./dateTime.ts"; + +describe("compareDateTimeStrings", () => { + it("compares valid date-time strings by absolute time", () => { + expect( + compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"), + ).toBe(0); + expect( + compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"), + ).toBeGreaterThan(0); + }); + + it.each([ + ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"], + ["2000-02-29T00:00:00.100Z", "2000-02-28T20:30:00.1-03:30"], + ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"], + ["+010000-01-01T00:00:00.000Z", "9999-12-31T23:00:00.000-01:00"], + ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00:00.0000Z", "2026-09-02T00:00:00.000Z"], + ["2024-02-29T24:00:00+05:30", "2024-03-01T00:00:00+05:30"], + ["2026-12-31T24:00:00-07:00", "2027-01-01T07:00:00Z"], + ["2026-09-01T12:00Z", "2026-09-01T12:00:00.000Z"], + ["2026-09-01T05:00-07:00", "2026-09-01T12:00:00Z"], + ["2026-09-01T24:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00+05:30", "2026-09-02T00:00:00+05:30"], + ])("preserves equal ISO instants %s and %s", (left, right) => { + expect(compareDateTimeStrings(left, right)).toBe(0); + }); + + it("sorts malformed values before valid values", () => { + expect(compareDateTimeStrings("invalid", "2026-09-01T12:00:00.000Z")).toBeLessThan(0); + expect(compareDateTimeStrings("2026-09-01T12:00:00.000Z", "invalid")).toBeGreaterThan(0); + }); + + it.each([ + "2014-02-30", + "2014-03-02", + "2014-03-02T00:00:00", + "2014-03-02T00:00:00.000", + "03/02/2014", + "March 2, 2014", + "Sun, 02 Mar 2014 00:00:00 GMT", + "2014-03-02T00:00:00.000Z\n", + "2014-02-30T00:00:00.000Z", + "1900-02-29T00:00:00.000-07:00", + "2024-04-31T00:00:00.000+05:30", + "2024-03-02T12:00:00.000+24:00", + "2026-09-01T24:01:00Z", + "2026-09-01T24:00:01Z", + "2026-09-01T24:00:00.0001Z", + "2026-09-01T24:01Z", + "2026-09-01T25:00Z", + ])("treats %s as malformed without native date guessing", (malformed) => { + const valid = "1970-01-01T00:00:00.000Z"; + expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0); + expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0); + expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, malformed)).toBe(0); + }); + + it("uses code-unit order for malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid-a", "invalid-B")).toBeGreaterThan(0); + expect(compareDateTimeStrings("invalid-B", "invalid-a")).toBeLessThan(0); + }); + + it("returns zero for equal malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid", "invalid")).toBe(0); + }); + + it("gives every permutation of mixed values the same order", () => { + const early = "2026-09-01T12:00:00.000+14:00"; + const late = "2026-09-01T00:00:00.000-12:00"; + const malformed = "2026-09-01T06:invalid"; + const expected = [malformed, early, late]; + + const permutations = [ + [early, late, malformed], + [early, malformed, late], + [late, early, malformed], + [late, malformed, early], + [malformed, early, late], + [malformed, late, early], + ]; + + for (const values of permutations) { + expect(values.toSorted(compareDateTimeStrings)).toEqual(expected); + } + }); +}); diff --git a/packages/shared/src/dateTime.ts b/packages/shared/src/dateTime.ts new file mode 100644 index 000000000000..544dd2d0d73b --- /dev/null +++ b/packages/shared/src/dateTime.ts @@ -0,0 +1,38 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const isZonedIsoDateTime = Schema.is( + Schema.String.check( + Schema.isPattern( + /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, + ), + Schema.isTrimmed(), + ), +); + +function parseTimestamp(value: string): number { + if (!isZonedIsoDateTime(value)) return Number.NaN; + + // Engines can normalize invalid calendar dates instead of rejecting them. + const datePart = value.slice(0, value.indexOf("T")); + const date = DateTime.make(`${datePart}T00:00:00.000Z`); + if (Option.isNone(date)) return Number.NaN; + const parts = DateTime.toPartsUtc(date.value); + if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { + return Number.NaN; + } + return Date.parse(value); +} + +/** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ +export function compareDateTimeStrings(left: string, right: string): number { + const leftTimestamp = parseTimestamp(left); + const rightTimestamp = parseTimestamp(right); + const leftIsValid = !Number.isNaN(leftTimestamp); + const rightIsValid = !Number.isNaN(rightTimestamp); + + if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; + if (leftIsValid) return leftTimestamp - rightTimestamp; + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index ce80a079b3fd..676f7811011e 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { - explicitFaviconUrl, - faviconUrlForOrigin, - faviconUrlForPage, - toolActivityFaviconUrl, -} from "./favicon.ts"; +import { faviconUrlForOrigin, toolActivityFaviconUrl } from "./favicon.ts"; describe("faviconUrlForOrigin", () => { it.each([ @@ -46,12 +41,12 @@ describe("faviconUrlForOrigin", () => { ); }); -describe("faviconUrlForPage", () => { +describe("toolActivityFaviconUrl", () => { it("uses the page origin instead of a third-party favicon service", () => { - expect(faviconUrlForPage("https://example.com/docs/page?q=1")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "https://example.com/docs/page?q=1" }, "light")).toBe( "https://example.com/favicon.ico", ); - expect(faviconUrlForPage("http://localhost:5173/app")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "http://localhost:5173/app" }, "light")).toBe( "http://localhost:5173/favicon.ico", ); }); @@ -86,7 +81,17 @@ describe("faviconUrlForPage", () => { }); it("accepts provider-supplied image URLs but rejects extension URLs", () => { - expect(explicitFaviconUrl("https://example.com/icon.png")).toBe("https://example.com/icon.png"); - expect(explicitFaviconUrl("chrome-extension://example/_favicon/")).toBeNull(); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "https://example.com/icon.png" }, + "light", + ), + ).toBe("https://example.com/icon.png"); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "chrome-extension://example/_favicon/" }, + "light", + ), + ).toBe("https://example.com/favicon.ico"); }); }); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index a3286b28e99a..2c4847115b90 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -5,7 +5,7 @@ import { isPublicFaviconHost } from "./hostClassification.ts"; * conventional favicon and let the image element fall back to a browser glyph. * Chrome-backed tools can pass their tab's explicit favicon URL separately. */ -export function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { +function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const pageUrl = new URL(rawUrl); @@ -40,7 +40,7 @@ function themedFaviconUrlForPage( } /** Accepts image URLs supplied by a trusted provider event. */ -export function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { +function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const url = new URL(rawUrl); diff --git a/packages/shared/src/httpReadiness.ts b/packages/shared/src/httpReadiness.ts index be1b3475ff3e..5aad9d488aae 100644 --- a/packages/shared/src/httpReadiness.ts +++ b/packages/shared/src/httpReadiness.ts @@ -5,7 +5,7 @@ import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -export const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; +const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; /** * Normalizes an arbitrary readiness probe failure into a plain, structured value diff --git a/packages/shared/src/imageDimensions.test.ts b/packages/shared/src/imageDimensions.test.ts new file mode 100644 index 000000000000..1b86bb97493d --- /dev/null +++ b/packages/shared/src/imageDimensions.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { readImageDimensions } from "./imageDimensions.ts"; + +function bytes(...parts: ReadonlyArray>): Uint8Array { + const out: number[] = []; + for (const part of parts) { + if (typeof part === "string") for (const c of part) out.push(c.charCodeAt(0)); + else if (typeof part === "number") out.push(part); + else out.push(...part); + } + return Uint8Array.from(out); +} + +const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; +const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff]; +const u16le = (n: number) => [n & 0xff, (n >>> 8) & 0xff]; + +describe("readImageDimensions", () => { + it("reads a PNG IHDR", () => { + const png = bytes( + [0x89], + "PNG", + [0x0d, 0x0a, 0x1a, 0x0a], + u32(13), + "IHDR", + u32(1600), + u32(900), + ); + expect(readImageDimensions(png)).toEqual({ width: 1600, height: 900 }); + }); + + it("reads a GIF logical screen", () => { + expect(readImageDimensions(bytes("GIF89a", u16le(320), u16le(240)))).toEqual({ + width: 320, + height: 240, + }); + }); + + it("reads a JPEG start-of-frame after an APP segment", () => { + const app1 = bytes([0xff, 0xe1], u16(2 + 6), "Exif\0\0"); + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(1400), u16(720)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({ + width: 720, + height: 1400, + }); + }); + + it("swaps the axes for a JPEG whose EXIF orientation rotates it 90 degrees", () => { + // Big-endian TIFF with one IFD0 entry: tag 0x0112 (orientation), SHORT, count 1, value 6. + const tiff = [ + 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + const exif = ["Exif\0\0", tiff] as const; + const app1 = bytes([0xff, 0xe1], u16(2 + 6 + tiff.length), ...exif); + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(3024), u16(4032)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({ + width: 3024, + height: 4032, + }); + // A later XMP APP1 segment must not clear the rotation. + const xmp = bytes([0xff, 0xe1], u16(2 + 29), "http://ns.adobe.com/xap/1.0/\0"); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...xmp], [...sof0]))).toEqual({ + width: 3024, + height: 4032, + }); + // Orientation 1 leaves the frame size alone. + const upright = [...tiff]; + upright[19] = 0x01; + const app1Upright = bytes([0xff, 0xe1], u16(2 + 6 + upright.length), "Exif\0\0", upright); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1Upright], [...sof0]))).toEqual({ + width: 4032, + height: 3024, + }); + }); + + it("steps over standalone TEM and restart markers", () => { + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(10), u16(20)); + expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0x01], [0xff, 0xd3], [...sof0]))).toEqual( + { width: 20, height: 10 }, + ); + }); + + it("does not mistake a Huffman table marker for a frame", () => { + const dht = bytes([0xff, 0xc4], u16(4), [0, 0]); + const sof2 = bytes([0xff, 0xc2], u16(17), [8], u16(10), u16(20)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...dht], [...sof2]))).toEqual({ + width: 20, + height: 10, + }); + }); + + it("reads each WebP container flavour", () => { + const riff = (chunk: string, body: ReadonlyArray) => + bytes("RIFF", u32(0), "WEBP", chunk, u32(body.length), body); + // VP8: frame tag (3), start code (3), then 14-bit width and height. + expect( + readImageDimensions(riff("VP8 ", [0, 0, 0, 0x9d, 0x01, 0x2a, ...u16le(800), ...u16le(600)])), + ).toEqual({ width: 800, height: 600 }); + // VP8L: signature 0x2f, then width-1 (14 bits) and height-1 (14 bits) packed LE. + const packed = (800 - 1) | ((600 - 1) << 14); + expect( + readImageDimensions( + riff("VP8L", [ + 0x2f, + packed & 0xff, + (packed >>> 8) & 0xff, + (packed >>> 16) & 0xff, + (packed >>> 24) & 0xff, + ]), + ), + ).toEqual({ width: 800, height: 600 }); + // VP8X: flags (4), then 24-bit width-1 and height-1. + expect( + readImageDimensions( + riff("VP8X", [ + 0, + 0, + 0, + 0, + 799 & 0xff, + (799 >> 8) & 0xff, + 0, + 599 & 0xff, + (599 >> 8) & 0xff, + 0, + ]), + ), + ).toEqual({ width: 800, height: 600 }); + }); + + it("returns null for unsupported, truncated, or zero-sized input", () => { + expect(readImageDimensions(bytes(""))).toBeNull(); + expect(readImageDimensions(bytes([0x89], "PNG"))).toBeNull(); + expect(readImageDimensions(bytes("GIF89a", u16le(0), u16le(240)))).toBeNull(); + expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0xd9]))).toBeNull(); + expect(readImageDimensions(new Uint8Array())).toBeNull(); + }); +}); diff --git a/packages/shared/src/imageDimensions.ts b/packages/shared/src/imageDimensions.ts new file mode 100644 index 000000000000..8eceb305b764 --- /dev/null +++ b/packages/shared/src/imageDimensions.ts @@ -0,0 +1,154 @@ +/** + * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP + * file so a client can reserve the exact box before the bytes arrive. Any + * other format, a truncated header, or a malformed file yields null; callers + * fall back to measuring after decode. + */ +export interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +/** + * Enough for every supported header. A JPEG's frame header can sit behind + * several 64 KiB metadata segments (EXIF, an ICC profile, XMP), so allow a + * few of them before giving up. + */ +export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024; + +export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null { + const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes); + return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null; +} + +const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + +function readPng(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 24) return null; + if ( + bytes[0] !== 0x89 || + bytes[1] !== 0x50 || + bytes[2] !== 0x4e || + bytes[3] !== 0x47 || + bytes[12] !== 0x49 || + bytes[13] !== 0x48 || + bytes[14] !== 0x44 || + bytes[15] !== 0x52 + ) { + return null; + } + const data = view(bytes); + return { width: data.getUint32(16), height: data.getUint32(20) }; +} + +function readGif(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 10) return null; + if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null; + const data = view(bytes); + return { width: data.getUint16(6, true), height: data.getUint16(8, true) }; +} + +function readWebp(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 16) return null; + if ( + bytes[0] !== 0x52 || + bytes[1] !== 0x49 || + bytes[2] !== 0x46 || + bytes[3] !== 0x46 || + bytes[8] !== 0x57 || + bytes[9] !== 0x45 || + bytes[10] !== 0x42 || + bytes[11] !== 0x50 + ) { + return null; + } + const data = view(bytes); + const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!); + switch (chunk) { + case "VP8 ": + if (bytes.length < 30) return null; + // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code. + return { + width: data.getUint16(26, true) & 0x3fff, + height: data.getUint16(28, true) & 0x3fff, + }; + case "VP8L": { + if (bytes.length < 25) return null; + // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the + // 32 bits after the signature byte. + const packed = data.getUint32(21, true); + return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 }; + } + case "VP8X": + if (bytes.length < 30) return null; + // Extended: 24-bit canvas dimensions minus one. + return { + width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1, + height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1, + }; + default: + return null; + } +} + +function readJpeg(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + const data = view(bytes); + let offset = 2; + let rotated = false; + while (offset + 9 <= bytes.length) { + if (bytes[offset] !== 0xff) return null; + const marker = bytes[offset + 1]!; + // Padding bytes between segments. + if (marker === 0xff) { + offset += 1; + continue; + } + // Start-of-frame markers carry the dimensions; skip the arithmetic-coding + // and Huffman-table markers that share the range. + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + const height = data.getUint16(offset + 5); + const width = data.getUint16(offset + 7); + return rotated ? { width: height, height: width } : { width, height }; + } + if (marker === 0xd9 || marker === 0xda) return null; + // TEM and the restart markers stand alone, with no length field. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + const length = data.getUint16(offset + 2); + // Viewers apply the EXIF orientation before display, so a phone photo + // stored on its side takes the swapped size on screen. + if (marker === 0xe1 && !rotated) { + rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length); + } + offset += 2 + length; + } + return null; +} + +/** Whether EXIF orientation 5-8 (a 90° rotation) applies. `start` is the APP1 payload. */ +function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean { + end = Math.min(end, bytes.length); + // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset. + if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") { + return false; + } + const tiff = start + 6; + const data = view(bytes); + const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false; + const ifd = tiff + data.getUint32(tiff + 4, littleEndian); + if (ifd + 2 > end) return false; + const entries = data.getUint16(ifd, littleEndian); + for (let i = 0; i < entries; i += 1) { + const entry = ifd + 2 + i * 12; + if (entry + 12 > end) return false; + if (data.getUint16(entry, littleEndian) === 0x0112) { + const orientation = data.getUint16(entry + 8, littleEndian); + return orientation >= 5 && orientation <= 8; + } + } + return false; +} diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 366cb7724047..ae2d5165d9ae 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -36,7 +36,7 @@ function getRawSelectionValueById( return selection?.value; } -export function getProviderOptionSelectionValue( +function getProviderOptionSelectionValue( selections: ReadonlyArray | null | undefined, id: string, ): string | boolean | undefined { @@ -337,11 +337,6 @@ export function readCustomModelEntries(value: unknown): CustomModelDefinition[] return entries; } -/** Slugs of a `customModels` setting, in stored order. */ -export function readCustomModelSlugs(value: unknown): string[] { - return readCustomModelEntries(value).map((entry) => entry.slug); -} - /** * Write a definition back to the compact stored shape: a bare slug when it * carries nothing custom, otherwise an entry with only the set fields. @@ -400,7 +395,7 @@ export function resolveSelectableModel( } /** Trim a string, returning null for empty/missing values. */ -export function trimOrNull(value: T | null | undefined): T | null { +function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; const trimmed = value.trim() as T; return trimmed || null; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 67057c548806..9692a05f7592 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -303,7 +303,7 @@ export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttri return truncated ?? attributes; } -export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { +function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { const status = span.status as Extract; const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index 7703421d5c29..dab35ad3e08c 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDuration, formatElapsed } from "./orchestrationTiming.ts"; +import { formatDuration } from "./orchestrationTiming.ts"; describe("formatDuration", () => { it.each([ @@ -29,9 +29,3 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); - -describe("formatElapsed", () => { - it("formats a long run across midnight", () => { - expect(formatElapsed("2026-09-03T22:00:00Z", "2026-09-04T04:59:50Z")).toBe("6h 59m 50s"); - }); -}); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index b4f55b1a7f1b..c378e552fe0d 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -28,17 +28,7 @@ export function formatDuration(durationMs: number): string { return parts.join(" "); } -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { - if (!endIso) return null; - const startedAt = Date.parse(startIso); - const endedAt = Date.parse(endIso); - if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { - return null; - } - return formatDuration(endedAt - startedAt); -} - -export function isLatestRunSettled( +function isLatestRunSettled( latestRun: LatestRunTiming | null, runtime: RuntimeActivityState | null, ): boolean { diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index fec4203c5334..14139216194e 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { isLoopbackHost, - isPreviewableUrl, newPreviewTabId, normalizePreviewUrl, PreviewUrlNormalizationError, @@ -27,24 +26,6 @@ describe("isLoopbackHost", () => { }); }); -describe("isPreviewableUrl", () => { - it.each([ - "http://localhost:5173", - "http://127.0.0.1:3000/path", - "http://0.0.0.0:8080", - "http://[::1]:5173", - ])("%s is previewable", (url) => { - expect(isPreviewableUrl(url)).toBe(true); - }); - - it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])( - "%s is not previewable", - (url) => { - expect(isPreviewableUrl(url)).toBe(false); - }, - ); -}); - describe("normalizePreviewUrl", () => { it("treats bare loopback hosts as http", () => { expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 926b30966e52..f0a781290b1c 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -36,17 +36,6 @@ export function isLoopbackHost(host: string): boolean { return false; } -/** True when a raw URL string looks like a loopback dev URL we can preview. */ -export function isPreviewableUrl(rawUrl: string): boolean { - try { - const parsed = new URL(rawUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; - return isLoopbackHost(parsed.hostname); - } catch { - return false; - } -} - export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( "PreviewUrlNormalizationError", { diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts index 3222e90d7be5..7a049376c50e 100644 --- a/packages/shared/src/previewViewport.test.ts +++ b/packages/shared/src/previewViewport.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - PREVIEW_VIEWPORT_PRESETS, - previewViewportLabel, - previewViewportPresetOrientation, - resolvePreviewViewport, -} from "./previewViewport.ts"; +import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "./previewViewport.ts"; describe("previewViewport", () => { it("resolves fill and exact freeform viewports", () => { @@ -59,12 +54,4 @@ describe("previewViewport", () => { "Nest Hub Max", ]); }); - - it("formats settings for compact UI", () => { - expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel"); - expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852"); - expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe( - "landscape", - ); - }); }); diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts index 1d70bca5dfbd..d1e066bee16d 100644 --- a/packages/shared/src/previewViewport.ts +++ b/packages/shared/src/previewViewport.ts @@ -173,14 +173,3 @@ export function resolvePreviewViewport( height: input.height, }; } - -export function previewViewportLabel(viewport: PreviewViewportSetting): string { - return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`; -} - -export function previewViewportPresetOrientation( - viewport: PreviewViewportSetting, -): "portrait" | "landscape" | null { - if (viewport._tag === "fill" || viewport.width === viewport.height) return null; - return viewport.width > viewport.height ? "landscape" : "portrait"; -} diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b63..b6fd9e56f3a6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,13 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconResourceKey( + environmentId: string, + workspaceRoot: string, + faviconPath?: string | null, +) { + return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); +} + export function getProjectFaviconCacheKey( environmentId: string, workspaceRoot: string, diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 199a55bf3cbf..4d98e36b4d70 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,4 +1,24 @@ -import type { ProjectScript } from "@t3tools/contracts"; +import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; + +/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ +export function resolveProjectScripts( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): readonly ProjectScript[] { + const override = settings.projectScriptOverrides[project.id]; + if (override === null) return settings.defaultProjectScripts; + return ( + override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts) + ); +} + +export function projectScriptsInheritDefaults( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): boolean { + const override = settings.projectScriptOverrides[project.id]; + return override === null || (override === undefined && project.scripts.length === 0); +} interface ProjectScriptRuntimeEnvInput { project: { diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index 3abff9b52109..4e1f28eefdaa 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -5,7 +5,6 @@ import { ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, clerkFrontendApiUrlFromPublishableKey, - isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; @@ -75,14 +74,4 @@ describe("Clerk relay auth", () => { }); expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); - - it("allows standard Clerk hosts and an exact configured custom hostname", () => { - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.dev", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.com", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("clerk.t3.codes", "clerk.t3.codes")).toBe(true); - expect(isAllowedClerkFrontendApiHostname("attacker.example", "clerk.t3.codes")).toBe(false); - expect(isAllowedClerkFrontendApiHostname("nested.clerk.t3.codes", "clerk.t3.codes")).toBe( - false, - ); - }); }); diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index a384db77d8ac..4c5d766c1480 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -81,17 +81,6 @@ export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: strin return parseClerkFrontendApi(publishableKey).hostname; } -export function isAllowedClerkFrontendApiHostname( - hostname: string, - configuredHostname: string | null, -): boolean { - return ( - hostname.endsWith(".clerk.accounts.dev") || - hostname.endsWith(".clerk.accounts.com") || - hostname === configuredHostname - ); -} - export function relayClerkTokenOptions(template: string) { return { template, diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 1d556ed6dc30..404d765ba74c 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -61,7 +61,10 @@ const makeSpawnerLayer = (commands: Array) => ChildProcessSpawner.make((command) => Effect.sync(() => { commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); - return makeHandle(); + // The pinned Windows executable rejects --version but accepts the version subcommand. + return makeHandle( + ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0, + ); }), ), ); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 0a56e45191c2..4743f12b19d5 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -20,7 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; -export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; +const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; export type RelayClientExecutableSource = "override" | "managed" | "path"; @@ -421,7 +421,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); } yield* report("validating"); - yield* runCommand(executablePath, ["--version"]).pipe( + yield* runCommand(executablePath, ["version"]).pipe( wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), ); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index e132b7084b74..076ff67b62df 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -199,12 +199,12 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { * strips trailing commas and JS-style comments before parsing. * Encoding produces strict JSON via `JSON.stringify`. */ -export const fromLenientJsonString = new SchemaTransformation.Transformation( +const fromLenientJsonString = new SchemaTransformation.Transformation( parseLenientJsonGetter, SchemaGetter.stringifyJson(), ); -export const prettyJsonString = SchemaGetter.parseJson().compose( +const prettyJsonString = SchemaGetter.parseJson().compose( SchemaGetter.stringifyJson({ space: 2 }), ); diff --git a/packages/shared/src/schemaYaml.ts b/packages/shared/src/schemaYaml.ts index 70e3c987ae00..55b5b97122ae 100644 --- a/packages/shared/src/schemaYaml.ts +++ b/packages/shared/src/schemaYaml.ts @@ -32,32 +32,8 @@ function formatYamlParseError(error: unknown): string { return `Invalid YAML (code=${error.code}${location}).`; } -/** - * Parses a YAML string into a value. - * - * **When to use** - * - * Use when you need a schema getter to parse a present encoded YAML string - * during decoding. - * - * **Details** - * - * Parse failures become `SchemaIssue.InvalidValue` values. - * - * **Example** (Parse YAML) - * - * ```ts - * import { parseYaml } from "@t3tools/shared/schemaYaml" - * - * const parse = parseYaml() - * // Getter - * ``` - * - * @see {@link stringifyYaml} for the inverse operation - */ -export function parseYaml( - options?: YamlParseOptions, -): SchemaGetter.Getter { +/** Parses YAML during decoding, reporting parse failures as InvalidValue issues. */ +function parseYaml(options?: YamlParseOptions): SchemaGetter.Getter { return SchemaGetter.transformOrFail((input: E) => Effect.try({ try: () => parseYamlString(input, options) as unknown, @@ -66,32 +42,8 @@ export function parseYaml( ); } -/** - * Stringifies a present value as YAML. - * - * **When to use** - * - * Use when you need a schema getter to serialize a present decoded value to - * YAML text during encoding. - * - * **Details** - * - * Stringify failures become `SchemaIssue.InvalidValue` values. - * - * **Example** (Stringify YAML) - * - * ```ts - * import { stringifyYaml } from "@t3tools/shared/schemaYaml" - * - * const stringify = stringifyYaml() - * // Getter - * ``` - * - * @see {@link parseYaml} for the inverse operation - */ -export function stringifyYaml( - options?: YamlStringifyOptions, -): SchemaGetter.Getter { +/** Serializes YAML during encoding, reporting stringify failures as InvalidValue issues. */ +function stringifyYaml(options?: YamlStringifyOptions): SchemaGetter.Getter { return SchemaGetter.transformOrFail((input: unknown) => Effect.try({ try: () => stringifyYamlValue(input, options), diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index 7e2ccce6e063..8ddf02ec8498 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - compareRankedSearchResults, insertRankedSearchResult, normalizeSearchQuery, scoreQueryMatch, @@ -90,6 +89,5 @@ describe("insertRankedSearchResult", () => { insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2); expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]); - expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0); }); }); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index b2fb2e223d3b..c8ec69e39703 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -135,7 +135,7 @@ export function scoreQueryMatch(input: { return null; } -export function compareRankedSearchResults( +function compareRankedSearchResults( left: RankedSearchResult, right: RankedSearchResult, ): number { diff --git a/packages/shared/src/serverRuntimeState.test.ts b/packages/shared/src/serverRuntimeState.test.ts new file mode 100644 index 000000000000..c93d0026bf16 --- /dev/null +++ b/packages/shared/src/serverRuntimeState.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveServerRuntimeStatePath } from "./serverRuntimeState.ts"; + +describe("deriveServerRuntimeStatePath", () => { + it("places runtime state under the selected state variant", () => { + expect( + deriveServerRuntimeStatePath({ + baseDir: "/home/user/.t3", + variant: "userdata", + joinPath: (...segments) => segments.join("/"), + }), + ).toBe("/home/user/.t3/userdata/server-runtime.json"); + expect( + deriveServerRuntimeStatePath({ + baseDir: "C:\\Users\\user\\.t3", + variant: "dev", + joinPath: (...segments) => segments.join("\\"), + }), + ).toBe("C:\\Users\\user\\.t3\\dev\\server-runtime.json"); + }); +}); diff --git a/packages/shared/src/serverRuntimeState.ts b/packages/shared/src/serverRuntimeState.ts new file mode 100644 index 000000000000..0c6f8f1f0b7b --- /dev/null +++ b/packages/shared/src/serverRuntimeState.ts @@ -0,0 +1,107 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export type ServerRuntimeStateVariant = "userdata" | "dev"; + +export const PersistedServerRuntimeState = Schema.Struct({ + version: Schema.Literal(1), + pid: Schema.Int, + host: Schema.optional(Schema.String), + port: Schema.Int, + origin: Schema.String, + devUrl: Schema.optional(Schema.String), + startedAt: Schema.String, +}); +export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; + +export class ServerRuntimeStateError extends Schema.TaggedErrorClass()( + "ServerRuntimeStateError", + { + operation: Schema.Literals(["persist", "read", "decode", "clear"]), + statePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} server runtime state at ${this.statePath}.`; + } +} + +const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( + Schema.fromJsonString(PersistedServerRuntimeState), +); + +export function deriveServerRuntimeStatePath(input: { + readonly baseDir: string; + readonly variant: ServerRuntimeStateVariant; + readonly joinPath: (first: string, ...segments: ReadonlyArray) => string; +}): string { + return input.joinPath(input.baseDir, input.variant, "server-runtime.json"); +} + +/** + * Signal 0 does not deliver a signal; it only reports whether the process + * exists. EPERM still means the process is alive but belongs to another user. + */ +export const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +export const readPersistedServerRuntimeState = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const raw = yield* fs.readFileString(path).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + new ServerRuntimeStateError({ + operation: "read", + statePath: path, + cause, + }), + ), + onSuccess: (contents) => Effect.succeed(Option.some(contents)), + }), + ); + if (Option.isNone(raw)) { + return Option.none(); + } + + const trimmed = raw.value.trim(); + if (trimmed.length === 0) { + return Option.none(); + } + + return yield* decodePersistedServerRuntimeState(trimmed).pipe( + Effect.map(Option.some), + Effect.mapError( + (cause) => + new ServerRuntimeStateError({ + operation: "decode", + statePath: path, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + ServerRuntimeStateError: (error) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + operation: error.operation, + statePath: error.statePath, + cause: error, + }), + Effect.as(Option.none()), + ), + }), + ); diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 1f847412b6c7..a5e428fcdaac 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, UsageLimitSourceId, @@ -9,45 +10,203 @@ import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; +import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, - extractPersistedServerObservabilitySettings, isModelSelectionProviderEnabled, - normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, + resolveProjectAgentBrowserAccess, + resolveProjectAutoPull, } from "./serverSettings.ts"; describe("serverSettings helpers", () => { - it("normalizes optional persisted strings", () => { - expect(normalizePersistedServerSettingString(undefined)).toBeUndefined(); - expect(normalizePersistedServerSettingString(" ")).toBeUndefined(); - expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe( - "http://localhost:4318/v1/traces", - ); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { + const project = { id: ProjectId.make("project-actions"), scripts: [] }; + const action = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [action], + }); + expect(resolveProjectScripts(defaults, project)).toEqual([action]); + expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); + const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; + expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts); + expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false); + const disabled = applyServerSettingsPatch(defaults, { + projectScriptOverrides: { [project.id]: [] }, + }); + expect(resolveProjectScripts(disabled, project)).toEqual([]); + expect(projectScriptsInheritDefaults(disabled, project)).toBe(false); + const changedDefault = applyServerSettingsPatch(disabled, { + defaultProjectScripts: [{ ...action, command: "npm run build" }], + }); + expect(resolveProjectScripts(changedDefault, project)).toEqual([]); + const reset = applyServerSettingsPatch(changedDefault, { + projectScriptOverrides: { [project.id]: null }, + }); + expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts); + expect(projectScriptsInheritDefaults(reset, existing)).toBe(true); + expect( + resolveProjectScripts( + applyServerSettingsPatch(reset, { defaultProjectScripts: [] }), + existing, + ), + ).toEqual([]); + }); + + it("preserves other projects' actions when overriding, clearing, or resetting one project", () => { + const firstProject = { id: ProjectId.make("first-project"), scripts: [] }; + const secondProject = { id: ProjectId.make("second-project"), scripts: [] }; + const defaultAction = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const firstAction = { ...defaultAction, command: "npm run lint" }; + const secondAction = { ...defaultAction, command: "npm run build" }; + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [defaultAction], + projectScriptOverrides: { [firstProject.id]: [firstAction] }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectScriptOverrides: { [secondProject.id]: [secondAction] }, + }); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]); + + const cleared = applyServerSettingsPatch(secondUpdate, { + projectScriptOverrides: { [firstProject.id]: [] }, + }); + expect(resolveProjectScripts(cleared, firstProject)).toEqual([]); + expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]); + + const reset = applyServerSettingsPatch(cleared, { + projectScriptOverrides: { [firstProject.id]: null }, + }); + expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([ + defaultAction, + ]); + expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + }); + + it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => { + const projectId = ProjectId.make("project-pull"); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true); + const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true }); + expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true); + const overridden = applyServerSettingsPatch(enabled, { + projectAutoPullOverrides: { [projectId]: false }, + }); + expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false); + const reset = applyServerSettingsPatch(overridden, { + projectAutoPullOverrides: { [projectId]: null }, + }); + expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true); + const disabled = applyServerSettingsPatch(reset, { + defaultAutoPull: false, + projectAutoPullOverrides: { [projectId]: true }, + }); + expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true); + expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false); + }); + + it("inherits browser access and restores inheritance when a project override is removed", () => { + const projectId = ProjectId.make("project-browser"); + const otherProjectId = ProjectId.make("other-project"); + const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectAgentBrowserAccessOverrides: { [projectId]: false }, + }); + expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false); + expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true); + const reset = applyServerSettingsPatch(overridden, { + projectAgentBrowserAccessOverrides: { [projectId]: null }, + }); + expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true); + const enabled = applyServerSettingsPatch(reset, { + enableAgentBrowserAccess: false, + projectAgentBrowserAccessOverrides: { [projectId]: true }, + }); + expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true); + expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false); }); - it("extracts persisted observability settings", () => { + it("preserves other projects' boolean overrides across separate updates and resets", () => { + const firstProjectId = ProjectId.make("first-project"); + const secondProjectId = ProjectId.make("second-project"); + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultAutoPull: true, + projectAutoPullOverrides: { [firstProjectId]: false }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: false }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectAutoPullOverrides: { [secondProjectId]: false }, + projectAgentBrowserAccessOverrides: { [secondProjectId]: false }, + }); + for (const projectId of [firstProjectId, secondProjectId]) { + expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false); + } + + const reset = applyServerSettingsPatch(secondUpdate, { + projectAutoPullOverrides: { [firstProjectId]: null }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: null }, + }); + expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true); + expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true); + expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false); + expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined(); + expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined(); + expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false); + }); + + it("replaces and clears conversation model defaults without retaining old options", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet"); + const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection }); + expect(updated.defaultModelSelection).toEqual(selection); expect( - extractPersistedServerObservabilitySettings({ - observability: { - otlpTracesUrl: " http://localhost:4318/v1/traces ", - otlpMetricsUrl: " http://localhost:4318/v1/metrics ", - }, - }), + applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection, + ).toBeNull(); + }); + + it("ignores missing and blank persisted observability URLs", () => { + expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + }); + expect( + parsePersistedServerObservabilitySettings( + JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }), + ), ).toEqual({ - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, }); }); - it("parses lenient persisted settings JSON", () => { + it("parses lenient persisted settings JSON and trims observability URLs", () => { expect( parsePersistedServerObservabilitySettings( JSON.stringify({ observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }), ), diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index dfd5b742e4e4..f969e4412c30 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -3,6 +3,7 @@ import { isProviderAvailable, resolveProviderInstanceEnabled, type ModelSelection, + type ProjectId, type ProviderDriverKind, type ServerProvider, ServerSettings, @@ -23,6 +24,27 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +export function resolveProjectAgentBrowserAccess( + settings: Pick, + projectId: ProjectId, +): boolean { + return ( + settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess + ); +} + +export function resolveProjectAutoPull( + settings: Pick, + projectId: ProjectId, + legacyAutoPull: boolean | undefined, +): boolean { + // Existing opt-ins stay enabled until explicitly overridden or reset. + return ( + settings.projectAutoPullOverrides[projectId] ?? + (legacyAutoPull === true || settings.defaultAutoPull) + ); +} + type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; const getLegacyProviderSettings = ( @@ -69,14 +91,14 @@ export interface PersistedServerObservabilitySettings { readonly otlpMetricsUrl: string | undefined; } -export function normalizePersistedServerSettingString( +function normalizePersistedServerSettingString( value: string | null | undefined, ): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } -export function extractPersistedServerObservabilitySettings(input: { +function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; @@ -151,6 +173,8 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, + projectAutoPullOverrides: projectAutoPullOverridesPatch, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -207,6 +231,36 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(projectAgentBrowserAccessOverridesPatch !== undefined + ? { + projectAgentBrowserAccessOverrides: mergeSettingsEntries( + current.projectAgentBrowserAccessOverrides, + projectAgentBrowserAccessOverridesPatch, + ), + } + : {}), + ...(projectAutoPullOverridesPatch !== undefined + ? { + projectAutoPullOverrides: mergeSettingsEntries( + current.projectAutoPullOverrides, + projectAutoPullOverridesPatch, + ), + } + : {}), + ...(patch.defaultModelSelection !== undefined + ? { defaultModelSelection: patch.defaultModelSelection } + : {}), + ...(patch.defaultProjectScripts !== undefined + ? { defaultProjectScripts: patch.defaultProjectScripts } + : {}), + ...(patch.projectScriptOverrides !== undefined + ? { + projectScriptOverrides: { + ...current.projectScriptOverrides, + ...patch.projectScriptOverrides, + }, + } + : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index c98c1c452d4b..621fe49b3087 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -8,7 +8,6 @@ import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it, vi } from "vite-plus/test"; import { - extractPathFromShellOutput, CommandAvailability, CommandResolutionCache, type CommandAvailabilityChecker, @@ -39,28 +38,6 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); -describe("extractPathFromShellOutput", () => { - it("extracts the path between capture markers", () => { - expect( - extractPathFromShellOutput( - "__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("ignores shell startup noise around the capture markers", () => { - expect( - extractPathFromShellOutput( - "Welcome to fish\n__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\nBye\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("returns null when the markers are missing", () => { - expect(extractPathFromShellOutput("/opt/homebrew/bin /usr/bin")).toBeNull(); - }); -}); - describe("readPathFromLoginShell", () => { it("uses a shell-agnostic printenv PATH probe", () => { const execFile = vi.fn< diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 7d7a7d7b4f41..07ac73f8c6a7 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -12,8 +12,6 @@ import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; import * as Context from "effect/Context"; -const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; -const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; @@ -179,18 +177,6 @@ export function listLoginShellCandidates( return candidates; } -export function extractPathFromShellOutput(output: string): string | null { - const startIndex = output.indexOf(PATH_CAPTURE_START); - if (startIndex === -1) return null; - - const valueStartIndex = startIndex + PATH_CAPTURE_START.length; - const endIndex = output.indexOf(PATH_CAPTURE_END, valueStartIndex); - if (endIndex === -1) return null; - - const pathValue = output.slice(valueStartIndex, endIndex).trim(); - return pathValue.length > 0 ? pathValue : null; -} - export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index df88de595a3f..93b7c41ad44f 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -92,23 +92,12 @@ export function resolveChangeRequestPresentation( } } -export function resolveChangeRequestPresentationForKind( +function resolveChangeRequestPresentationForKind( kind: SourceControlProviderKind, ): ChangeRequestPresentation { return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); } -export function formatChangeRequestAction( - verb: "View" | "Create", - presentation: ChangeRequestPresentation, -): string { - return `${verb} ${presentation.shortName}`; -} - -export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string { - return `create ${presentation.shortName}`; -} - export function getChangeRequestTerminology( provider: SourceControlProviderInfo | null | undefined, ): ChangeRequestTerminology { diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 83ac6906c61e..32fb4eaa9503 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -9,6 +9,13 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + isUsageLimitsCommand, + collectProviderUsageLimits, + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -16,6 +23,7 @@ import { limitsNotice, paceOf, providersWithLimits, + remainingPercent, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -304,3 +312,580 @@ describe("collectLimitSources", () => { ]); }); }); + +describe("pools", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: 7 * 24 * 60, + resetsAt: "2026-09-06T12:00:00.000Z", + } as const; + const claude = ProviderDriverKind.make("claudeAgent"); + const source = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + }; + const laptop = { entry: { target: { label: "Laptop" } } }; + + it("merges one account reported natively on two environments and by a hub into one entry", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "Same@example.com" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { + ...native, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + plan: "Claude Subscription", + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] }, + }, + ], + }, + ], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + key: "env-a:claude", + sourceLabel: null, + // Desktop's read is fresher, so its credits and its redeem are the ones on show. + redeem: { environmentId: "env-b", instanceId: "claude" }, + environments: [ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ], + }); + // The fresher native snapshot wins; the hub row is pre-filtered by email. + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); + }); + + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + resetCredits: { availableCount: 2 }, + }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.windows[0]?.usedPercent).toBe(55); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" }); + expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); + }); + + it("redeems on the environment whose snapshot supplied the credits on show", () => { + const stale = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [window], + resetCredits: { availableCount: 0 }, + }, + }); + const fresh = { + ...stale, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [window], + resetCredits: { availableCount: 2 }, + }, + }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" }); + }); + + it("names an environment once however many of its instances share the account", () => { + const shared = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + ]); + }); + + it("keys a hub account without an email by hub, so two environments on one hub share it", () => { + const seat = { + id: "claude-team-seat.json", + driver: claude, + usageLimits: { checkedAt, windows: [window] }, + }; + const hub = { ...source, accounts: [seat] }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]); + expect(accounts[0]?.displayName).toBe("claude-team-seat"); + }); + + it("pools windows by id across accounts and orders resets by when they land", () => { + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "a", + driver: claude, + usageLimits: { + checkedAt, + windows: [ + { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" }, + { ...weekly, usedPercent: 20 }, + ], + }, + }, + { + id: "b", + driver: claude, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + }, + }, + { + id: "c", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }, + { + id: "unsupported", + driver: claude, + usageLimits: { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" as const }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const pools = collectLimitPools(collectLimitAccounts(input), now); + expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([ + ["claudeAgent", 2], + ["codex", 1], + ]); + const [session, week] = pools[0]!.windows; + // A member with no reset has no clock, so it does not vote on pace. + const untimed = collectLimitPools( + collectLimitAccounts(input).map((account) => + account.key === "hub:b" + ? { + ...account, + limits: { + ...account.limits, + windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })), + }, + } + : account, + ), + now, + ); + // Only a votes: 80% used, 80% elapsed. + expect(untimed[0]?.windows[0]?.pace).toBe("on"); + // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used. + expect(session).toMatchObject({ + id: "five_hour", + remainingPercent: 40, + usedPercent: 60, + pace: "under", + }); + expect( + session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]), + ).toEqual([ + ["hub:a", 40], + ["hub:b", 20], + ]); + expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] }); + // Codex reports `primary` for both its five-hour and (on Go) monthly window. + const mixed = collectLimitPools( + [ + ...collectLimitAccounts(input), + { + key: "go", + driver: claude, + displayName: "Go", + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: null, + redeem: null, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: "2026-09-14T12:00:00.000Z", + }, + ], + }, + }, + ], + now, + ); + expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([ + ["session", 2], + ["weekly", 1], + ["monthly", 1], + ]); + // Segments read left to right as "who refills next", matching the reset list. + expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); + expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); + }); +}); + +describe("collectLimitNotices", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const claude = ProviderDriverKind.make("claudeAgent"); + const laptop = { entry: { target: { label: "Laptop" } } }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + accounts: [], + }; + + it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => { + const failed = provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + displayName: "Claude Max", + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + const apiKey = provider({ + instanceId: ProviderInstanceId.make("api"), + driver: claude, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }); + const silent = provider({ usageLimits: { checkedAt, windows: [] } }); + const one = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [failed, apiKey, silent], + usageLimitSources: [ + hub, + { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" }, + ], + }, + }, + ], + ]); + expect(collectLimitNotices(one)).toEqual([ + "Claude Max: Could not read limits.", + "codex: No limits reported.", + "hub: No accounts reported.", + "down: ECONNREFUSED", + ]); + + one.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [], usageLimitSources: [] }, + }); + expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); + }); +}); + +describe("/usage-limits", () => { + const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; + const selected = provider({ + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com" }, + }); + const sources = [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: limits.checkedAt, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "SAME@example.com", + usageLimits: limits, + }, + { id: "oss", driver: selected.driver, plan: "Codex OSS", usageLimits: limits }, + { id: "other-provider", driver: ProviderDriverKind.make("claude"), usageLimits: limits }, + ], + }, + ]; + + it("keeps accounts and custom instances separate, filtering by driver", () => { + const report = collectProviderUsageLimits( + selected.instanceId, + [ + selected, + provider({ + instanceId: ProviderInstanceId.make("codex-work"), + displayName: "Work", + usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, + }), + provider({ + driver: ProviderDriverKind.make("claude"), + instanceId: ProviderInstanceId.make("claude"), + usageLimits: limits, + }), + ], + sources, + now, + ); + expect(report?.createdAt).toBe("2026-09-03T12:00:00.000Z"); + expect(report?.accounts.map((account) => account.id)).toEqual([ + "codex", + "codex-work", + "hub:oss", + ]); + expect(report?.accounts[0]).toMatchObject({ + instanceId: selected.instanceId, + email: selected.auth.email, + }); + expect(report?.accounts[1]).toMatchObject({ + displayName: "Work", + limits: { resetCredits: { availableCount: 2 } }, + }); + expect(report?.accounts[2]).toMatchObject({ + label: "Accounts · oss", + sourceLabel: "CLI Proxy", + plan: "Codex OSS", + }); + expect(report?.notices).toEqual([]); + }); + + it("supports a source-only provider and keeps duplicates when the native probe failed", () => { + expect( + collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["hub:duplicate", "hub:oss"]); + const failed = provider({ usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }); + expect( + collectProviderUsageLimits(selected.instanceId, [failed], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["codex", "hub:duplicate", "hub:oss"]); + expect(collectProviderUsageLimits(selected.instanceId, [provider({})], [], now)).toBeNull(); + expect( + collectProviderUsageLimits( + selected.instanceId, + [provider({ enabled: false, usageLimits: limits })], + [], + now, + ), + ).toBeNull(); + }); + + it("surfaces source errors only for sources that carry the selected driver", () => { + const failing = { ...sources[0]!, error: "token expired" }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [failing], now)?.notices, + ).toEqual(["Accounts: token expired"]); + const claudeOnly = { ...failing, accounts: failing.accounts.slice(2) }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, + ).toEqual([]); + // A read failure clears the accounts, so the error must not depend on a match. + const unreadable = { ...failing, accounts: [] }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, + ).toEqual(["Accounts: token expired"]); + // A source-only provider still gets the report, carrying only the error. + const sourceOnly = collectProviderUsageLimits( + selected.instanceId, + [provider({})], + [unreadable], + now, + ); + expect(sourceOnly?.accounts).toEqual([]); + expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]); + }); + + it("advertises global and workspace commands only for providers present in Limits", () => { + const withWorkspace = provider({ + workspaceSnapshots: [ + { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] }, + ], + }); + const [supported] = withUsageLimitsCommands([withWorkspace], sources); + expect(supported?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]); + expect( + supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + // A provider's own command of the same name is left alone without coverage. + const ownCommand = provider({ + slashCommands: [{ name: "usage-limits", description: "Provider's own" }], + }); + expect(withUsageLimitsCommands([ownCommand], [])[0]?.slashCommands).toEqual([ + { name: "usage-limits", description: "Provider's own" }, + ]); + const unreadable = { ...sources[0]!, accounts: [], error: "token expired" }; + expect( + withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map( + (command) => command.name, + ), + ).toEqual(["usage-limits"]); + expect( + withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + }); +}); + +describe("sameUsageLimitCommandCoverage", () => { + const codexAccount = { + id: "a", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }, + }; + const base = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-09-03T11:00:00.000Z", + }; + it("ignores quota movement but not the drivers offered the command", () => { + const withCodex = [{ ...base, accounts: [codexAccount] }]; + const withCodexLater = [ + { + ...base, + accounts: [ + { + ...codexAccount, + usageLimits: { ...codexAccount.usageLimits, checkedAt: "2026-09-03T12:00:00.000Z" }, + }, + ], + }, + ]; + expect(sameUsageLimitCommandCoverage(withCodex, withCodexLater)).toBe(true); + expect(sameUsageLimitCommandCoverage(withCodex, [{ ...base, accounts: [] }])).toBe(false); + }); + it("treats a failed read as a change in coverage, in both directions", () => { + const empty = [{ ...base, accounts: [] }]; + const failed = [{ ...base, accounts: [], error: "token expired" }]; + expect(sameUsageLimitCommandCoverage(empty, failed)).toBe(false); + expect(sameUsageLimitCommandCoverage(failed, empty)).toBe(false); + expect( + sameUsageLimitCommandCoverage(failed, [{ ...base, accounts: [], error: "still down" }]), + ).toBe(true); + }); +}); + +describe("remainingPercent", () => { + it("inverts and clamps the reported usage", () => { + expect(remainingPercent(window)).toBe(60); + expect(remainingPercent({ ...window, usedPercent: 0 })).toBe(100); + expect(remainingPercent({ ...window, usedPercent: 100 })).toBe(0); + expect(remainingPercent({ ...window, usedPercent: 33.4 })).toBe(67); + }); +}); + +describe("isUsageLimitsCommand", () => { + it("recognizes only the standalone local action", () => { + expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); + expect(isUsageLimitsCommand("/usage-limits explain")).toBe(false); + expect(isUsageLimitsCommand("Explain /usage-limits")).toBe(false); + expect(isUsageLimitsCommand("/usage")).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 8341796e39c3..d3b295bf42fb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -7,6 +7,9 @@ */ import { type EnvironmentId, + type UsageLimitsReport, + type ProviderInstanceId, + type ServerProviderSlashCommand, isProviderAvailable, type ServerProvider, type ServerProviderUsageLimits, @@ -15,6 +18,8 @@ import { type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; @@ -53,7 +58,9 @@ export function collectLimitsGroups( EnvironmentId, { readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null; + readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; + } | null; } >, ): readonly LimitsGroup[] { @@ -142,12 +149,298 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) return normalizedEmail ? `${driver}:${normalizedEmail}` : null; } -/** The instance's configured name, else the driver's, else its raw kind. */ -export function providerLimitsLabel( - provider: ServerProvider, - driverLabel: (driver: ServerProvider["driver"]) => string | undefined, -): string { - return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); +/** + * One subscription account as the pooled views see it, whichever way it was + * reported. The same email signed in natively on two environments, or reported + * by a hub as well as natively, is one account: its quota is one bucket, so + * counting it twice would misstate what is left. + */ +export interface LimitAccount { + readonly key: string; + readonly driver: ServerProvider["driver"]; + /** The instance's configured name, which is not sensitive; null for hub accounts. */ + readonly displayName: string | null; + readonly email: string | undefined; + readonly plan: string | undefined; + readonly accentColor: string | undefined; + /** Environments the account is signed in on; empty when only a hub reports it. */ + readonly environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; + /** The hub that reported it, when no environment has it natively. */ + readonly sourceLabel: string | null; + /** Where a reset credit can be redeemed; only native instances can. */ + readonly redeem: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + } | null; + readonly limits: ServerProviderUsageLimits; +} + +/** + * Every account with usable windows across the connected environments, one + * entry per distinct account. Native instances win over hub reports, and the + * freshest snapshot wins when the same account is reported twice. + */ +export function collectLimitAccounts( + presentations: Parameters[0], +): readonly LimitAccount[] { + const accounts = new Map(); + const merge = (key: string, next: LimitAccount) => { + const previous = accounts.get(key); + if (!previous) { + accounts.set(key, next); + return; + } + const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); + // Two instances on one machine sharing an account still name it once. + const environments = [ + ...previous.environments, + ...next.environments.filter( + (candidate) => + !previous.environments.some((seen) => seen.environmentId === candidate.environmentId), + ), + ]; + const winner = fresher ? next : previous; + // Windows come from the freshest snapshot, wherever it was read. Reset + // credits only ever come from a native instance, and the redeem must go + // to the instance whose credits are on show, so the two travel together: + // the freshest native snapshot supplies both, or neither. + const native = [previous, next] + .filter((candidate) => candidate.redeem !== null) + .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; + accounts.set(key, { + ...previous, + displayName: previous.displayName ?? next.displayName, + plan: previous.plan ?? next.plan, + accentColor: previous.accentColor ?? next.accentColor, + environments, + // A hub only names the account when no environment has it natively. + sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), + redeem: native?.redeem ?? null, + limits: { + ...winner.limits, + ...(native?.limits.resetCredits + ? { resetCredits: native.limits.resetCredits } + : { resetCredits: undefined }), + }, + }); + }; + for (const [environmentId, presentation] of presentations) { + const label = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; + merge( + accountKey(provider.driver, provider.auth.email) ?? + `${environmentId}:${provider.instanceId}`, + { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, instanceId: provider.instanceId }, + limits: provider.usageLimits, + }, + ); + } + } + // Every hub account, including those a native instance also knows: the hub + // may hold a fresher read of the same subscription, and the merge above + // keeps the redeem target consistent with whichever snapshot wins. + const labelEnvironment = presentations.size > 1; + for (const presentation of presentations.values()) { + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + const sourceLabel = labelEnvironment + ? `${presentation.entry.target.label} · ${source.label}` + : source.label; + for (const account of source.accounts) { + if (limitsNotice(account.usageLimits) !== null) continue; + merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: null, + limits: account.usageLimits, + }); + } + } + } + return [...accounts.values()]; +} + +/** + * What the pooled views cannot draw as a bar: a hub that failed to read, a + * provider whose probe failed. Accounts that can never report (API keys) + * are left out; there is nothing for the user to act on. The environment + * is named only when more than one is connected. + */ +export function collectLimitNotices( + presentations: Parameters[0], +): readonly string[] { + const label = (environmentLabel: string, subject: string) => + presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; + const notices: string[] = []; + for (const presentation of presentations.values()) { + const environmentLabel = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + // An account that can never report (API key) is left out; one that + // failed, or reported nothing at all, is worth a line. + if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; + const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; + const name = provider.displayName?.trim() || String(provider.driver); + if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); + } + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + if (source.error) { + notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); + } else if (source.accounts.length === 0) { + notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); + } + } + } + return notices; +} + +export interface LimitPoolMember { + readonly account: LimitAccount; + readonly window: ServerProviderUsageWindow; +} + +/** + * One window id across every account that reports it: the pooled share left, + * pace against the clock, and the resets in the order they will land, each + * with the share of the pool it hands back. + */ +export interface LimitPoolWindow { + readonly id: string; + readonly kind: ServerProviderUsageWindow["kind"]; + readonly label: string; + readonly members: readonly LimitPoolMember[]; + readonly remainingPercent: number; + readonly usedPercent: number; + readonly pace: LimitPace | null; + readonly resets: ReadonlyArray<{ + readonly member: LimitPoolMember; + readonly at: number; + /** Points of the pool the reset restores: the member's used share over the member count. */ + readonly restoresPercent: number; + }>; +} + +export interface LimitPool { + readonly driver: ServerProvider["driver"]; + readonly accounts: readonly LimitAccount[]; + readonly windows: readonly LimitPoolWindow[]; +} + +const WINDOW_KIND_ORDER: Record = { + session: 0, + weekly: 1, + monthly: 2, + other: 3, +}; + +/** + * Accounts grouped by driver, each with its windows pooled by kind and id. + * Window ids are stable per provider, so a hub row and a native row for the + * same window land in the same pool; the kind is part of the key because + * Codex's `primary` is a position, not a duration (five hours on paid plans, + * a month on Free/Go), and a monthly allowance must not average into a + * five-hour pool. Pools order by kind, then first appearance. + * + * `accounts` is the table order: instances the user can act on (native, + * named) before hub-only accounts, each group alphabetical. Each window's + * `members` sort by reset instead, soonest first, so a bar reads left to + * right as "who refills next" and matches the reset list under it. + */ +export function collectLimitPools( + accounts: readonly LimitAccount[], + now: number, +): readonly LimitPool[] { + const byDriver = new Map(); + for (const account of accounts) { + const list = byDriver.get(account.driver); + if (list) list.push(account); + else byDriver.set(account.driver, [account]); + } + return [...byDriver].map(([driver, members]) => { + const sorted = [...members].sort( + (left, right) => + Number(left.redeem === null) - Number(right.redeem === null) || + accountSortName(left).localeCompare(accountSortName(right)), + ); + return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; + }); +} + +function accountSortName(account: LimitAccount): string { + return (account.displayName ?? account.email ?? account.key).toLowerCase(); +} + +function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { + const byKey = new Map(); + for (const account of accounts) { + for (const window of account.limits.windows) { + const key = `${window.kind}:${window.id}`; + const list = byKey.get(key); + if (list) list.push({ account, window }); + else byKey.set(key, [{ account, window }]); + } + } + const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { + const members = [...unordered].sort( + (left, right) => + (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - + (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), + ); + const first = members[0]!.window; + const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; + // Pace compares spend against the clock, so it is judged only over the + // members that have a clock; a window with no reset would otherwise + // count as spend with no time elapsed and skew the verdict. + const timed = members.flatMap((m) => { + const share = elapsedShare(m.window, now); + return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; + }); + const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; + const meanElapsed = + timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; + const resets = members + .flatMap((member) => { + const at = resetMillis(member.window); + return at === null + ? [] + : [ + { + member, + at, + restoresPercent: Math.round(member.window.usedPercent / members.length), + }, + ]; + }) + .sort((left, right) => left.at - right.at); + return { + id: first.id, + kind: first.kind, + label: first.label, + members, + usedPercent: Math.round(usedPercent), + remainingPercent: Math.round(100 - usedPercent), + pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), + resets, + }; + }); + return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]); } /** The one-line status under a provider heading when there are no bars to draw. */ @@ -161,7 +454,12 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } -export function resetMillis(window: ServerProviderUsageWindow): number | null { +/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ +export function remainingPercent(window: ServerProviderUsageWindow): number { + return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); +} + +function resetMillis(window: ServerProviderUsageWindow): number | null { if (window.resetsAt === undefined) return null; const at = Date.parse(window.resetsAt); return Number.isFinite(at) ? at : null; @@ -179,14 +477,17 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu export type LimitPace = "ahead" | "on" | "under"; /** - * Usage against the clock. The bar is the whole window, so the elapsed share - * is also where even spending would have put the fill; within five points of - * it counts as on pace. + * Usage against the clock. Spending evenly leaves the same share of quota as + * there is time left in the window; within five points of that counts as on + * pace, further ahead means the window may run dry first. */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); - if (elapsed === null) return null; - const gap = window.usedPercent - elapsed * 100; + return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); +} + +function paceOfShares(usedPercent: number, elapsed: number): LimitPace { + const gap = usedPercent - elapsed * 100; if (gap > 5) return "ahead"; if (gap < -5) return "under"; return "on"; @@ -209,3 +510,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): if (resetsAt === null) return null; return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; } + +/** Limit commands are served by T3 from the same snapshots as Usage → Limits. */ +export const USAGE_LIMITS_COMMAND = { + name: "usage-limits", + description: "Show this provider's usage limits", +} satisfies ServerProviderSlashCommand; + +/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ +export function isUsageLimitsCommand(prompt: string): boolean { + return prompt.trim().toLowerCase() === "/usage-limits"; +} + +/** + * Whether Limits has anything to say about this driver. A source that failed to + * read keeps no accounts, so its error counts for every driver rather than + * disappearing until the next successful refresh. + */ +export function hasProviderUsageLimits( + driver: ServerProvider["driver"], + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): boolean { + return ( + providersWithLimits(providers).some((provider) => provider.driver === driver) || + sources.some( + (source) => + source.accounts.some((account) => account.driver === driver) || + (source.error !== undefined && source.accounts.length === 0), + ) + ); +} + +/** + * The drivers a set of sources would offer the command to, where a source that + * failed to read counts for every driver. Two snapshots with the same coverage + * need no catalog republish, however much their quotas moved. + */ +export function sameUsageLimitCommandCoverage( + previous: UsageLimitSourceSnapshots, + next: UsageLimitSourceSnapshots, +): boolean { + const coverage = (sources: UsageLimitSourceSnapshots) => + new Set( + sources.flatMap((source) => + source.error !== undefined && source.accounts.length === 0 + ? ["*"] + : source.accounts.map((account) => String(account.driver)), + ), + ); + const before = coverage(previous); + const after = coverage(next); + return before.size === after.size && [...before].every((driver) => after.has(driver)); +} + +/** Advertise on workspace catalogs too, which replace the global command list. */ +export function withUsageLimitsCommands( + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): ServerProvider[] { + return providers.map((provider) => { + if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider; + const commands = (items: readonly ServerProviderSlashCommand[]) => [ + ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name), + USAGE_LIMITS_COMMAND, + ]; + return { + ...provider, + slashCommands: commands(provider.slashCommands), + ...(provider.workspaceSnapshots + ? { + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommands: commands(snapshot.slashCommands), + })), + } + : {}), + }; + }); +} + +/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */ +export function collectProviderUsageLimits( + instanceId: ProviderInstanceId, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): UsageLimitsReport | null { + const selected = providers.find((provider) => provider.instanceId === instanceId); + if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; + const native = providersWithLimits(providers).filter( + (provider) => provider.driver === selected.driver, + ); + const nativeAccounts = new Set( + native.flatMap((provider) => { + const key = accountKey(provider.driver, provider.auth.email); + return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable + ? [key] + : []; + }), + ); + const accounts: Array = []; + const notices: string[] = []; + for (const provider of native) { + if (!provider.usageLimits) continue; + accounts.push({ + id: provider.instanceId, + driver: provider.driver, + label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`, + ...(provider.auth.label ? { plan: provider.auth.label } : {}), + instanceId: provider.instanceId, + ...(provider.displayName ? { displayName: provider.displayName } : {}), + ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), + ...(provider.auth.email ? { email: provider.auth.email } : {}), + limits: provider.usageLimits, + }); + } + for (const source of sources) { + const matching = source.accounts.filter((account) => account.driver === selected.driver); + for (const account of matching) { + const key = accountKey(account.driver, account.email); + if (key && nativeAccounts.has(key)) continue; + accounts.push({ + id: `${source.id}:${account.id}`, + driver: account.driver, + label: `${source.label} · ${account.id}`, + sourceLabel: "CLI Proxy", + ...(account.plan ? { plan: account.plan } : {}), + ...(account.email ? { email: account.email } : {}), + limits: account.usageLimits, + }); + } + // A source that failed to read has no accounts left to match on, so its + // error is reported to every provider rather than silently dropped. + if (source.error && (matching.length > 0 || source.accounts.length === 0)) { + notices.push(`${source.label}: ${source.error}`); + } + } + return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; +} diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index ef78b2f24fec..fca086de3186 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -17,7 +17,7 @@ export interface SshPasswordRequest { readonly attempt: number; } -export interface SshAskpassFile { +interface SshAskpassFile { readonly path: string; readonly contents: string; readonly mode?: number; @@ -71,7 +71,7 @@ function joinSshAskpassPath( return platform === "win32" ? `${trimmed}\\${fileName}` : `${trimmed}/${fileName}`; } -export const ASKPASS_POSIX_SCRIPT = `#!/bin/sh +const ASKPASS_POSIX_SCRIPT = `#!/bin/sh # Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password # from the renderer's in-app prompt. We never expose a native dialog here - if # T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly. @@ -83,11 +83,11 @@ printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\\n' >&2 exit 1 `; -export const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r +const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*\r `; -export const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r +const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r # ssh with a cached password from the renderer's in-app prompt. We never expose\r # a native dialog here - if T3_SSH_AUTH_SECRET is missing, that's a caller bug\r # and we fail loudly.\r @@ -99,7 +99,7 @@ if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r exit 1\r `; -export const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( +const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -146,31 +146,29 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( }; }); -export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( - function* (input: { - readonly directory: string; - }): Effect.fn.Return { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const descriptor = yield* buildSshAskpassHelperDescriptor(input); - const platform = yield* HostProcessPlatform; - - yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); - - for (const file of descriptor.files) { - const existing = yield* fs.exists(file.path); - const current = existing ? yield* fs.readFileString(file.path) : null; - if (current !== file.contents) { - yield* fs.writeFileString(file.path, file.contents); - } - if (file.mode !== undefined && platform !== "win32") { - yield* fs.chmod(file.path, file.mode); - } +const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(function* (input: { + readonly directory: string; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const descriptor = yield* buildSshAskpassHelperDescriptor(input); + const platform = yield* HostProcessPlatform; + + yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); + + for (const file of descriptor.files) { + const existing = yield* fs.exists(file.path); + const current = existing ? yield* fs.readFileString(file.path) : null; + if (current !== file.contents) { + yield* fs.writeFileString(file.path, file.contents); + } + if (file.mode !== undefined && platform !== "win32") { + yield* fs.chmod(file.path, file.mode); } + } - return descriptor.launcherPath; - }, -); + return descriptor.launcherPath; +}); export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* ( input: SshChildEnvironmentOptions = {}, diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 10927b43089c..7a94670370b2 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -80,7 +80,7 @@ export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { .slice(0, 16); } -export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { +function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { const destination = target.alias.trim() || target.hostname.trim(); if (destination.length === 0) { throw new Error("SSH target is missing its alias/hostname."); diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index bb702515a31d..840f16170267 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -89,7 +89,7 @@ const expandGlob = Effect.fnUntraced(function* (pattern: string) { return matchedPaths.toSorted((left, right) => left.localeCompare(right)); }); -export const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( +const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( filePath: string, visited = new Set(), homeDir: string, diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 7dda675ee95c..d89ee5582c35 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -165,3 +165,143 @@ if (args.includes("--package")) { ); }, ); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner install diagnostics", + () => { + const decodeArguments = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(Schema.String)), + ); + const cases = (["npx", "npm"] as const).flatMap((packageManager) => + ( + [ + "etarget", + "network", + "empty-success", + "success", + "failed-with-path", + "existing-cli", + "node-override", + ] as const + ).map((mode) => ({ packageManager, mode })), + ); + + it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "installer-calls.jsonl"); + const packageSpec = "t3@0.0.39-nightly.20260905.1286"; + const args = ["serve", "a path with spaces"]; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString(callsPath, ""); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); +const mode = process.env.T3_TEST_MODE; +if (mode === "success" || mode === "failed-with-path") { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} +if (mode === "etarget" || mode === "failed-with-path") { + process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); + process.exitCode = 42; +} else if (mode === "network") { + process.stderr.write("npm error code ENETUNREACH\\n"); + process.exitCode = 43; +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); + + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", ...args], { + cwd: fixture, + extendEnv: false, + env: { + PATH: bin, + T3_TEST_MODE: mode, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + }, + stdin: Stream.make( + new TextEncoder().encode( + buildRemoteT3RunnerScript({ + packageSpec, + ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), + }), + ), + ), + }), + ); + const { stdout, stderr, exitCode } = yield* Effect.all( + { + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode, + }, + { concurrency: "unbounded" }, + ); + const installFailed = + mode === "etarget" || mode === "network" || mode === "failed-with-path"; + const missingExecutable = mode === "empty-success"; + assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); + if (installFailed || missingExecutable) { + assert.equal(stdout, ""); + } else { + assert.deepEqual(decodeArguments(stdout), args); + } + if (installFailed) { + const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; + assert.include(stderr, `npm error code ${npmError}\n`); + assert.include(stderr, `Remote host could not install ${packageSpec}.`); + assert.notInclude(stderr, "Remote host installed"); + assert.notInclude(stderr, "Install a C toolchain"); + } else if (missingExecutable) { + assert.include(stderr, `Remote host installed ${packageSpec}`); + assert.include(stderr, "npm produced no t3 executable"); + assert.include(stderr, "Install a C toolchain"); + } else { + assert.equal(stderr, ""); + } + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; + const calls = yield* fs.readFileString(callsPath); + if (usesInstaller) { + assert.deepEqual( + calls + .trim() + .split("\n") + .map((line) => decodeArguments(line)), + [expectedCall], + ); + } else { + assert.equal(calls, ""); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 04dbce65af60..a5bc55a7f778 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -49,7 +49,7 @@ import { SshReadinessError, } from "./errors.ts"; -export const DEFAULT_REMOTE_PORT = 3773; +const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; @@ -209,7 +209,7 @@ function buildRemoteNodeEngineCheckScript(): string { (${remoteNodeEngineCheckMain.toString()})();`; } -export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { +function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { const cleaned = stderr.trim(); return cleaned.length > 0 ? cleaned : fallbackMessage; } @@ -270,7 +270,7 @@ function tryPort(port) { })().catch(() => process.exit(1)); `; -export const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http"); +const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http"); const port = Number.parseInt(process.argv[2] ?? "", 10); const timeoutMs = Number.parseInt(process.argv[3] ?? "", 10); const probeTimeoutMs = Number.parseInt(process.argv[4] ?? "", 10); @@ -318,7 +318,7 @@ function probe() { })().catch(() => process.exit(1)); `; -export const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() { +const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() { if [ -d "$1" ]; then case ":$PATH:" in *":$1:"*) ;; @@ -411,7 +411,7 @@ ensure_remote_node_path() { } `; -export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh +const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu @@T3_NODE_ENV_SCRIPT@@ ensure_remote_node_path || true @@ -433,7 +433,10 @@ fi # never becomes ready. Resolve the CLI once up front so that install failure is # reported here, with npm's own output on stderr. require_installed_t3_cli() { - T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then + printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 + fi if [ -n "$T3_CLI_PATH" ]; then return 0 fi @@ -453,7 +456,7 @@ printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPE exit 1 `; -export const REMOTE_LAUNCH_SCRIPT = `set -eu +const REMOTE_LAUNCH_SCRIPT = `set -eu @@T3_NODE_ENV_SCRIPT@@ STATE_KEY="$1" STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" @@ -612,7 +615,7 @@ fi printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}" `; -export const REMOTE_PAIRING_SCRIPT = `set -eu +const REMOTE_PAIRING_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" DEFAULT_SERVER_HOME="$HOME/.t3" RUNNER_FILE="$STATE_DIR/run-t3.sh" @@ -625,7 +628,7 @@ PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME" "$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json `; -export const REMOTE_STOP_SCRIPT = `set -eu +const REMOTE_STOP_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" PID_FILE="$STATE_DIR/pid" PORT_FILE="$STATE_DIR/port" @@ -820,7 +823,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT }; }); -export const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( +const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( target: DesktopSshEnvironmentTarget, input?: SshAuthOptions, ): Effect.fn.Return< diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index d6db5e8bcc59..7f1cf41661fb 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -9,8 +9,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); -export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); -export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); +const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); +const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. @@ -47,7 +47,7 @@ const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< ]; /** Classifies stderr into a safe label, dropping the text itself. */ -export const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { +const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { if (stderr.trim().length === 0) { return undefined; } @@ -66,7 +66,7 @@ export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( +class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( "TailscaleCommandOutputError", { ...TailscaleCommandContext, @@ -137,7 +137,6 @@ const TailscaleStatusJson = Schema.Struct({ Self: Schema.optional(TailscaleStatusSelf), }); -export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index 3b558fe56552..5cf80feb3356 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -1,3 +1,79 @@ +diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +--- a/dist/components/VirtualizedFile.d.ts ++++ b/dist/components/VirtualizedFile.d.ts +@@ -42,7 +42,7 @@ declare class VirtualizedFile extends File { + private computeApproximateSize; + setVisibility(visible: boolean): void; + rerender(): void; +- applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; ++ applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; + protected renderPreparedFile({ + fileContainer, + file, +diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +--- a/dist/components/VirtualizedFile.js ++++ b/dist/components/VirtualizedFile.js +@@ -20,6 +20,7 @@ + cache = { + heights: /* @__PURE__ */ new Map(), + checkpoints: [], ++ codeWidth: void 0, + fileAnnotationHeight: 0 + }; + isVisible = false; +@@ -31,6 +32,8 @@ + super(options, workerManager, isContainerManaged); + this.virtualizer = virtualizer; + this.metrics = metrics; ++ const simpleVirtualizer = this.getSimpleVirtualizer(); ++ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); + } + setMetrics(metrics, force = false) { + if (!force && areObjectsEqual(this.metrics, metrics)) return; +@@ -70,10 +73,12 @@ + if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); + super.setThemeType(themeType); + } +- resetLayoutCache(recompute = false, resetRenderRange = true) { ++ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { + this.layoutDirty = true; +- this.cache.fileAnnotationHeight = 0; +- if (this.cache.heights.size > 0) this.cache.heights.clear(); ++ if (startLine === 0) this.cache.fileAnnotationHeight = 0; ++ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. ++ if (startLine === 0) this.cache.heights.clear(); ++ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); + if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; + if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; + if (recompute && this.isSimpleMode()) this.computeApproximateSize(); +@@ -91,6 +96,13 @@ + if (this.code == null) return hasHeightChange; + const content = this.code.children[1]; + if (!(content instanceof HTMLElement)) return hasHeightChange; ++ const codeWidth = this.code.getBoundingClientRect().width; ++ if (!(codeWidth > 0)) return hasHeightChange; ++ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { ++ this.resetLayoutCache(false, false); ++ hasHeightChange = true; ++ } ++ this.cache.codeWidth = codeWidth; + const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); + if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) { + const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; +@@ -287,11 +299,11 @@ + this.forceRenderOverride = true; + this.virtualizer.instanceChanged(this, false); + } +- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { + const previousRenderRange = this.renderRange; + super.applyDocumentChange(textDocument, newLineAnnotations); + this.getSimpleVirtualizer()?.markDOMDirty(); +- this.resetLayoutCache(this.isSimpleMode(), false); ++ this.resetLayoutCache(this.isSimpleMode(), false, startLine); + if (shouldUpdateBuffer && previousRenderRange !== void 0 && this.file !== void 0) { + const windowSpecs = this.virtualizer.getWindowSpecs(); + const renderRange = this.computeRenderRangeFromWindow(this.file, this.top ?? 0, windowSpecs); diff --git a/dist/editor/editor.js b/dist/editor/editor.js index ff78e2a..f9df318 100644 --- a/dist/editor/editor.js @@ -28,12 +104,18 @@ index ff78e2a..f9df318 100644 const gutterRow = resolveGutterTarget(e.composedPath()[0]); if (gutterRow?.dataset.lineType === "change-deletion") { const code = gutterRow.closest("[data-code]"); -@@ -1522,6 +1520,7 @@ var Editor = class { +@@ -1522,6 +1520,12 @@ var Editor = class { if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; } fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); + if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); - if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); +- if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); ++ if (didLineCountChange) { ++ const previousLineCount = change.lineCount - change.lineDelta; ++ // A wider or narrower line-number gutter can rewrap unchanged rows. ++ const layoutStartLine = String(previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; ++ fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); ++ } if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); if (newLineAnnotations !== void 0) { @@ -1788,6 +1787,7 @@ var Editor = class { @@ -44,6 +126,37 @@ index ff78e2a..f9df318 100644 try { this.#fileInstance?.setSelectedLines(range, { notify: false, +diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +--- a/dist/managers/ResizeManager.d.ts ++++ b/dist/managers/ResizeManager.d.ts +@@ -5,6 +5,8 @@ + columnVariables?: ResizeManagerColumnVariableMode; + } + declare class ResizeManager { ++ /** Schedule owner measurement after an observed code or gutter size change. */ ++ onResize?: () => void; + private static resizeObserver; + private static managersByElement; + private static getResizeObserver; +diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +--- a/dist/managers/ResizeManager.js ++++ b/dist/managers/ResizeManager.js +@@ -19,6 +19,7 @@ + for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); + } + observedNodes = /* @__PURE__ */ new Map(); ++ onResize; + setup(pre, { disableAnnotations, columnVariables = "apply" }) { + const annotationUpdates = /* @__PURE__ */ new Set(); + const applyColumnVariables = columnVariables === "apply"; +@@ -212,6 +213,7 @@ + this.applyAnnotationUpdates(annotationUpdates); + annotationUpdates.clear(); + this.applyColumnUpdates(codeUpdates); ++ if (codeUpdates.size > 0) this.onResize?.(); + codeUpdates.clear(); + } + applyAnnotationUpdates(annotationUpdates) { diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js index e9f62f5..af82a46 100644 --- a/dist/react/utils/useFileInstance.js @@ -63,6 +176,18 @@ index e9f62f5..af82a46 100644 diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js --- a/dist/renderers/FileRenderer.js +++ b/dist/renderers/FileRenderer.js +@@ -107,10 +107,10 @@ + result: massiveFile ? void 0 : cache?.result, + renderRange: void 0 + }; ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file); + } else if (this.highlighter == null) { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + this.initializeHighlighter(); + } + } @@ -163,6 +163,8 @@ if (this.renderCache == null) return; const { file, result } = this.renderCache; @@ -72,6 +197,22 @@ diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; for (const [line, tokens] of dirtyLines) { if (lineCache != null && line < lineCache.lines.length) { +@@ -268,6 +270,7 @@ + const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); + const newContent = !areFilesEqual(file, this.renderCache.file); + const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange); ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange)) { + this.renderCache.file = file; +@@ -278,7 +281,6 @@ + } + if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file); + } else { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + const hasThemes = this.highlighter != null && areThemesAttached(options.theme); + const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang); + const canHighlight = !forcePlainText && hasLangs; diff --git a/package.json b/package.json index ff61c90..1e170e5 100644 --- a/package.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50bca0371c64..2db0b03e7f90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,7 +92,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 - '@pierre/diffs@1.3.0-beta.10': c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e + '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -110,21 +110,18 @@ importers: .: devDependencies: - '@babel/plugin-transform-react-jsx': - specifier: 7.28.6 - version: 7.28.6(@babel/core@7.29.7) '@effect/tsgo': specifier: 'catalog:' version: 0.13.2 - '@oxlint/plugins': - specifier: ^1.63.0 - version: 1.68.0 '@types/node': specifier: 24.12.4 version: 24.12.4 '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260604.1 + knip: + specifier: 6.34.0 + version: 6.34.0 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -246,7 +243,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -596,7 +593,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1004,6 +1001,9 @@ importers: '@types/pngjs': specifier: 6.0.5 version: 6.0.5 + typescript: + specifier: 'catalog:' + version: 6.0.3 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -2208,12 +2208,18 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -3454,6 +3460,128 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.147.0': + resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.147.0': + resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.147.0': + resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.147.0': + resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/runtime@0.146.0': resolution: {integrity: sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3467,6 +3595,112 @@ packages: '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@oxfmt/binding-android-arm-eabi@0.64.0': resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7056,6 +7290,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -7118,6 +7355,11 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formatly@0.7.0: + resolution: {integrity: sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==} + engines: {node: '>=18.3.0'} + hasBin: true + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7199,6 +7441,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + get-tsconfig@5.0.0-beta.4: resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} engines: {node: '>=20.20.0'} @@ -7699,6 +7944,11 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + knip@6.34.0: + resolution: {integrity: sha512-bbHIrnGspYwe4EBPjjx+lvkUor0F2qfKQc5BPzPI4SOAImYA+k2ueVpIcF7d/W1LEVT7XoJUPt6zELeGZhXBgA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + kubernetes-types@1.30.0: resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} @@ -8571,6 +8821,13 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxc-parser@0.147.0: + resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxfmt@0.64.0: resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8636,6 +8893,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -8778,6 +9038,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -9573,6 +9837,10 @@ packages: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -9692,6 +9960,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -9924,6 +10196,10 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unbash@4.0.11: + resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} + engines: {node: '>=14'} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -10354,6 +10630,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -10701,10 +10981,10 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 js-yaml: 4.2.0 - picomatch: 4.0.4 + picomatch: 4.0.7 retext-smartypants: 6.2.0 shiki: 4.2.0 - smol-toml: 1.7.0 + smol-toml: 1.8.0 unified: 11.0.5 '@astrojs/language-server@2.16.10(prettier@3.8.3)(typescript@6.0.3)': @@ -12126,6 +12406,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -12136,6 +12422,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -12654,7 +12945,7 @@ snapshots: hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 lightningcss: 1.33.0 - picomatch: 4.0.4 + picomatch: 4.0.7 postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: @@ -13457,6 +13748,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@neon-rs/load@0.0.4': {} '@noble/curves@1.9.1': @@ -13574,6 +13872,63 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.147.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + optional: true + '@oxc-project/runtime@0.146.0': {} '@oxc-project/types@0.127.0': @@ -13583,6 +13938,69 @@ snapshots: '@oxc-project/types@0.146.0': {} + '@oxc-project/types@0.147.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@oxfmt/binding-android-arm-eabi@0.64.0': optional: true @@ -13741,7 +14159,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) @@ -13755,7 +14173,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) @@ -14011,7 +14429,7 @@ snapshots: '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) @@ -17198,10 +17616,18 @@ snapshots: dependencies: bser: 2.1.1 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-nodeshim@0.4.10: {} ffi-rs@1.3.2: @@ -17279,6 +17705,11 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formatly@0.7.0: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -17367,6 +17798,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.4: dependencies: resolve-pkg-maps: 1.0.0 @@ -17935,6 +18370,22 @@ snapshots: kleur@4.1.5: {} + knip@6.34.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.0 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.147.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.11 + yaml: 2.9.0 + zod: 4.4.3 + kubernetes-types@1.30.0: {} lan-network@0.2.1: {} @@ -19217,6 +19668,52 @@ snapshots: outvariant@1.4.3: optional: true + oxc-parser@0.147.0: + dependencies: + '@oxc-project/types': 0.147.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.147.0 + '@oxc-parser/binding-android-arm64': 0.147.0 + '@oxc-parser/binding-darwin-arm64': 0.147.0 + '@oxc-parser/binding-darwin-x64': 0.147.0 + '@oxc-parser/binding-freebsd-x64': 0.147.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.147.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.147.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.147.0 + '@oxc-parser/binding-linux-arm64-musl': 0.147.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.147.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-musl': 0.147.0 + '@oxc-parser/binding-openharmony-arm64': 0.147.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.147.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.147.0 + '@oxc-parser/binding-win32-x64-msvc': 0.147.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 @@ -19304,6 +19801,8 @@ snapshots: package-manager-detector@1.6.0: {} + package-manager-detector@1.8.0: {} + pako@1.0.11: {} parse-entities@4.0.2: @@ -19442,6 +19941,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} pkg-up@3.1.0: @@ -20537,6 +21038,8 @@ snapshots: smol-toml@1.7.0: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -20639,6 +21142,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} + strnum@2.3.0: {} structured-headers@0.4.1: {} @@ -20855,6 +21360,8 @@ snapshots: ultrahtml@1.6.0: {} + unbash@4.0.11: {} + uncrypto@0.1.3: {} undici-types@7.16.0: {} @@ -21276,6 +21783,8 @@ snapshots: vscode-uri@3.1.0: {} + walk-up-path@4.0.0: {} + walker@1.0.8: dependencies: makeerror: 1.0.12 diff --git a/scripts/knip-schemas.test.ts b/scripts/knip-schemas.test.ts new file mode 100644 index 000000000000..1184557d0e47 --- /dev/null +++ b/scripts/knip-schemas.test.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the real Knip CLI against a disposable on-disk project. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeProcess from "node:process"; +import { expect, it } from "vite-plus/test"; + +const require = NodeModule.createRequire(import.meta.url); +const cli = NodePath.join(NodePath.dirname(require.resolve("knip")), "cli.js"); +const preprocessor = NodePath.join(import.meta.dirname, "knip-schemas.ts"); + +it("allows types and schemas through the real Knip CLI without hiding runtime or file findings", () => { + // Keeping the disposable project here gives it the same Effect installation as the scripts. + const cwd = NodeFS.mkdtempSync(NodePath.join(import.meta.dirname, ".knip-test-")); + const write = (file: string, content: string) => + NodeFS.writeFileSync(NodePath.join(cwd, file), content); + const run = (filtered: boolean) => + NodeChildProcess.spawnSync( + NodeProcess.execPath, + [ + cli, + "--directory", + cwd, + "--config", + NodePath.join(cwd, "knip.json"), + "--no-config-hints", + "--include", + "files,dependencies,exports,nsExports,types,nsTypes,duplicates", + "--reporter", + "json", + ...(filtered ? ["--preprocessor", preprocessor] : []), + ], + { encoding: "utf8" }, + ); + try { + write( + "package.json", + JSON.stringify({ private: true, type: "module", dependencies: { effect: "*" } }), + ); + write( + "tsconfig.json", + JSON.stringify({ compilerOptions: { module: "NodeNext", strict: true } }), + ); + write( + "knip.json", + JSON.stringify({ + entry: ["entry.ts"], + project: ["*.ts"], + includeEntryExports: true, + include: [ + "exports", + "nsExports", + "types", + "nsTypes", + "duplicates", + "files", + "dependencies", + ], + rules: { types: "off", nsTypes: "off" }, + }), + ); + write( + "entry.ts", + ` + import * as S from "effect/Schema"; + import * as ns from "./namespace.ts"; + console.log(ns); + export { Remote as Reexported } from "./remote.ts"; + export const Text = S.String; + export const Alias = Text; + const Internal = S.Boolean; + export type Internal = typeof Internal.Type; + export const PublicAlias = Internal; + export default Text; + export const Record = S.Struct({ name: Text }).annotate({ title: "record" }); + export const Branded = S.String.pipe(S.brand("Name")); + export class Failure extends S.TaggedErrorClass()("Failure", { reason: Text }) {} + export class Person extends S.Class("Person")({ name: Text }) {} + export type UnusedType = { name: string }; + export interface UnusedInterface { name: string } + `, + ); + write( + "remote.ts", + ` + import { Schema as S } from "effect"; + export const Remote = S.Struct({ id: S.Number }); + throw new Error("The preprocessor must never evaluate application modules"); + `, + ); + write( + "namespace.ts", + ` + import * as S from "effect/Schema"; + export const Unused = S.Number; + export type UnusedType = string; + `, + ); + const baseline = run(false); + expect(baseline.status, baseline.stderr).toBe(1); + expect(baseline.stdout).toContain('"Text"'); + const allowed = run(true); + expect(allowed.status, allowed.stderr + allowed.stdout).toBe(0); + expect(JSON.parse(allowed.stdout).issues).toEqual([]); + + NodeFS.appendFileSync( + NodePath.join(cwd, "entry.ts"), + ` + export const decode = S.decodeUnknownSync(Text); + export const makeSchema = () => S.String; + export const LooksLikeSchema = { ast: "not a schema" }; + export const ordinary = 123; + export const duplicate = ordinary; + `, + ); + NodeFS.appendFileSync(NodePath.join(cwd, "namespace.ts"), `export const helper = () => 1;`); + write("unused.ts", `export const orphan = 1;`); + write( + "package.json", + JSON.stringify({ + private: true, + type: "module", + dependencies: { effect: "*", "unused-knip-fixture-dependency": "*" }, + }), + ); + const rejected = run(true); + expect(rejected.status, rejected.stderr).toBe(1); + const issues = JSON.parse(rejected.stdout).issues; + const entry = issues.find((issue: { file: string }) => issue.file === "entry.ts"); + expect(entry.exports.map((issue: { name: string }) => issue.name).sort()).toEqual([ + "LooksLikeSchema", + "decode", + "duplicate", + "makeSchema", + "ordinary", + ]); + expect(entry.duplicates).toHaveLength(1); + expect( + issues.find((issue: { file: string }) => issue.file === "namespace.ts").nsExports, + ).toEqual([expect.objectContaining({ name: "helper" })]); + expect(issues.find((issue: { file: string }) => issue.file === "unused.ts").files).toHaveLength( + 1, + ); + expect( + issues.find((issue: { file: string }) => issue.file === "package.json").dependencies, + ).toEqual([expect.objectContaining({ name: "unused-knip-fixture-dependency" })]); + } finally { + NodeFS.rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/scripts/knip-schemas.ts b/scripts/knip-schemas.ts new file mode 100644 index 000000000000..fafa7ccd678a --- /dev/null +++ b/scripts/knip-schemas.ts @@ -0,0 +1,96 @@ +// @effect-diagnostics nodeBuiltinImport:off - Knip and the TypeScript compiler host use synchronous Node paths. +import * as NodePath from "node:path"; +import type { Preprocessor } from "knip"; +import ts from "typescript"; + +// Effect 4 schemas carry this marker, including aliases and Schema.Class constructors. +// Checking the type avoids evaluating application modules or exempting schema factories/decoders. +const schemaTypeId = "~effect/Schema/Schema"; + +const preprocess: Preprocessor = (options) => { + const categories = ["exports", "nsExports", "duplicates"] as const; + const projects = new Map>(); + for (const category of categories) { + for (const [filePath, issues] of Object.entries(options.issues[category])) { + if (Object.keys(issues).length === 0) continue; + const configPath = ts.findConfigFile( + NodePath.dirname(NodePath.resolve(options.cwd, filePath)), + ts.sys.fileExists, + ); + const files = projects.get(configPath) ?? new Set(); + files.add(filePath); + projects.set(configPath, files); + } + } + + for (const [configPath, files] of projects) { + const config = configPath + ? ts.getParsedCommandLineOfConfigFile( + configPath, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + }, + }, + ) + : undefined; + if (config?.errors.length) { + throw new Error( + config.errors + .map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")) + .join("\n"), + ); + } + const program = ts.createProgram( + [...files].map((file) => NodePath.resolve(options.cwd, file)), + { + module: ts.ModuleKind.NodeNext, + allowJs: true, + ...config?.options, + noEmit: true, + }, + ); + const checker = program.getTypeChecker(); + for (const filePath of files) { + const source = program.getSourceFile(NodePath.resolve(options.cwd, filePath)); + const module = source && checker.getSymbolAtLocation(source); + if (!source || !module) continue; + const schemas = new Set( + checker.getExportsOfModule(module).flatMap((symbol) => { + const exported = + symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + // Duplicate exports can name a private value with a public type of the same name. + const target = + exported.flags & ts.SymbolFlags.Value + ? exported + : checker.resolveName(symbol.name, source, ts.SymbolFlags.Value, false); + if (!target) return []; + const type = checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration ?? source); + const marker = type.getProperty(schemaTypeId); + if (!marker) return []; + const markerType = checker.getTypeOfSymbolAtLocation(marker, source); + return markerType.isStringLiteral() && markerType.value === schemaTypeId + ? [symbol.name] + : []; + }), + ); + for (const category of categories) { + const issues = options.issues[category][filePath]; + if (!issues) continue; + for (const [key, issue] of Object.entries(issues)) { + const symbols = issue.symbols ?? [{ symbol: issue.symbol }]; + if (symbols.length > 0 && symbols.every(({ symbol }) => schemas.has(symbol))) { + delete issues[key]; + options.counters[category]--; + } + } + if (Object.keys(issues).length === 0) delete options.issues[category][filePath]; + } + } + } + return options; +}; + +export default preprocess; diff --git a/scripts/package.json b/scripts/package.json index 042d69a8d9f9..0d080008ead3 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@types/pngjs": "6.0.5", + "typescript": "catalog:", "vite-plus": "catalog:" } }