diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml new file mode 100644 index 000000000000..6d1264aa7b7a --- /dev/null +++ b/.github/workflows/desktop-macos-preview.yml @@ -0,0 +1,165 @@ +name: Desktop macOS Preview + +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + name: Build macOS Apple Silicon preview + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'preview:mac') && + (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: false + + - name: Install desktop dependencies + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor + key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - id: version + name: Set preview version and public configuration + shell: bash + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + base_version="$(node -p "require('./apps/desktop/package.json').version")" + preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + node scripts/update-release-package-versions.ts "$preview_version" + cp .env.example .env + + echo "version=$preview_version" >> "$GITHUB_OUTPUT" + + - id: build + name: Build unsigned macOS DMG + shell: bash + env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + PREVIEW_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + vp run dist:desktop:artifact \ + --platform mac \ + --target dmg \ + --arch arm64 \ + --build-version "$PREVIEW_VERSION" \ + --verbose + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" + + - id: upload + name: Upload macOS DMG + uses: actions/upload-artifact@v7 + with: + path: release/*.dmg + if-no-files-found: error + archive: false + overwrite: true + retention-days: 7 + + - name: Comment download link + uses: actions/github-script@v8 + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + DMG_NAME: ${{ steps.build.outputs.dmg_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PREVIEW_VERSION: ${{ steps.version.outputs.version }} + with: + script: | + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + if (pullRequest.head.sha !== process.env.HEAD_SHA) { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.ARTIFACT_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "Unsigned build. Clear quarantine before opening:", + "```sh", + `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, + "```", + "", + "The download requires GitHub access and expires after 7 days.", + ].join("\n"); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0097cd87d496..70d5d2746798 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" + workflow_dispatch: inputs: channel: @@ -25,6 +26,17 @@ on: default: false type: boolean +# Serialize nightlies (scheduled and manual) so overlapping runs cannot build +# the same commit twice or publish out of order. Stable tag releases get their +# 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. +concurrency: + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + cancel-in-progress: false + queue: max + permissions: contents: read id-token: none @@ -202,8 +214,14 @@ jobs: relay_public_config: name: Resolve T3 Connect public config - needs: preflight - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # 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 + # nor success, so success() would be wrong here). + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: ubuntu-24.04 timeout-minutes: 5 environment: @@ -225,7 +243,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -298,15 +316,19 @@ jobs: # machine. node-pty is N-API, so one binary works across all WSL Node versions. build_wsl_node_pty: name: Build WSL node-pty (linux-x64) - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # Same gating as relay_public_config: only the commit SHA is needed, so this + # runs alongside preflight. See the condition comment there. + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -761,9 +783,8 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - name: Build web package - run: vp run --filter @t3tools/web build - + # The t3 build task depends on @t3tools/web#build, so the web client is + # built (once) as part of this step. - name: Build CLI package run: vp run --filter t3 build diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index c2c091b205cf..c2f2c57c1cf2 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -70,6 +70,13 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - Do not treat a screenshot as proof of keyboard, overflow, scrollbar, responsive, or runtime-theme behavior. Pair visual evidence with source, computed-style, emitted-CSS, or interaction checks as appropriate. - Be alert to shared primitive color indirection. When a primitive routes icon color through a CSS variable, ensure migrated contextual icons retain their intended tone, including pressed and disabled states. +## Environment routing in shared renderers + +- A shared renderer that performs an environment-scoped action — a server RPC such as opening or revealing a file, an environment-gated capability check, or an OS-derived label — must resolve its target environment from explicit scope: the bound thread's `environmentId`, or an `environmentId` prop threaded from the owning surface. Never let it silently fall back to the globally active environment. Multi-environment surfaces (pull request panels, review annotations, cross-environment listings) can render content from environment B while environment A is active; a silent fallback sends B's paths to A's server and presents A's platform wording. +- When a call site cannot supply an explicit environment scope, suppress the environment-scoped actions at that call site rather than guessing. A hidden menu item is correct; an item that targets the wrong server is a concrete finding. +- Capability gating, action dispatch, and user-facing labels must all read from the same environment's server config that the action will execute against. Flag a renderer whose label derives from one environment while its RPC targets another. +- Flag new call sites of shared markdown, chip, or menu renderers that trigger environment actions without passing explicit scope, and flag new environment-action props whose default reintroduces an active-environment fallback. + ## Change discipline - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a34a55f16acf..c88a69239577 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.33", + "version": "0.0.34", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index e2f611d94e73..f2f8a41725ad 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -303,6 +303,19 @@ describe("sortThreadsForListV2", () => { ]); expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); }); + + it("surfaces an un-settled thread at the top via its re-entry stamp", () => { + const sorted = sortThreadsForListV2([ + { + id: "old-unsettled", + createdAt: "2026-06-01T08:00:00.000Z", + unsettledAt: "2026-06-01T13:00:00.000Z", + }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); + }); }); describe("buildThreadListV2Items", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 77cc1ce8f2fd..8ca3dec68f5b 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -16,7 +16,10 @@ import { groupSortedThreadsByRecency, shouldShowRecencySectionHeaders, } from "@t3tools/client-runtime/state/thread-recency-groups"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; +import { + activeThreadAnchorTimestampMs, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; import { threadMatchesAttributeQuery } from "@t3tools/shared/threadAttributeSearch"; @@ -196,19 +199,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray( - threads: readonly T[], -): T[] { +export function sortThreadsForListV2< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + }, +>(threads: readonly T[]): T[] { // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 // change-by-copy array methods. return [...threads].sort( (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || left.id.localeCompare(right.id), ); } diff --git a/apps/server/package.json b/apps/server/package.json index cfd4b90e84d7..58d7134790c6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.33", + "version": "0.0.34", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0e3a180085ca..1e60f06cfc5f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -269,15 +269,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const settledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; assert.deepEqual(settledRows, [ - { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, ]); yield* eventStore.append({ @@ -301,14 +303,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const unsettledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; - assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); + // The un-settle stamps the active-list re-entry time so clients can + // surface the thread at the top of the list. + assert.deepEqual(unsettledRows, [ + { + settledOverride: "active", + settledAt: null, + unsettledAt: "2026-01-01T00:00:02.000Z", + }, + ]); }), ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6dc7ae67d9c2..5865fbff5c65 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -719,6 +719,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -777,6 +778,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: "settled", settledAt: event.payload.settledAt, + unsettledAt: null, updatedAt: event.payload.updatedAt, }); // Idempotent re-settles must not double-count freeze interest. @@ -798,6 +800,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, + // Re-entry stamp for active-list ordering. A thread already pinned + // active keeps its stamp: the activity reset that clears the pin + // is not a re-entry and must not reorder the list. + unsettledAt: + existingRow.value.settledOverride === "active" + ? existingRow.value.unsettledAt + : event.payload.updatedAt, updatedAt: event.payload.updatedAt, }); if (wasSettled) { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index ce1562647b3b..e52c5d58710a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -330,6 +330,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", @@ -458,6 +459,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 6becab78f7fd..dfcc62d3896d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -1134,6 +1137,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -2028,6 +2032,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2298,6 +2303,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2439,6 +2445,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2594,6 +2601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2913,6 +2921,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, @@ -3100,6 +3109,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, diff --git a/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts b/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts index 9906aa87bf77..0d62ee2d6ebb 100644 --- a/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts +++ b/apps/server/src/orchestration/Layers/WorktreeLifecycle.test.ts @@ -226,6 +226,7 @@ const makeThreadRow = (input: { archivedAt: input.archivedAt ?? null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, pinnedAt: null, snoozedAt: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 8ef5d3fee3c8..29b0b2ca1aec 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -5,6 +5,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + type OrchestrationEvent, type OrchestrationSession, type OrchestrationThread, } from "@t3tools/contracts"; @@ -13,7 +14,8 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { decideOrchestrationCommand } from "./decider.ts"; -import { fromWireReadModel, type CommandReadModel } from "./commandReadModel.ts"; +import { findThreadById, fromWireReadModel, type CommandReadModel } from "./commandReadModel.ts"; +import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; @@ -430,6 +432,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); + // Command-to-projection: an accepted un-settle must land as the re-entry + // stamp clients sort by (max of createdAt and unsettledAt, see + // activeThreadAnchorTimestampMs in client-runtime), so the thread surfaces + // above threads created after it. The projector tests feed events directly; + // this one proves the decider actually emits what they consume. + it.effect("an accepted un-settle re-anchors the thread for the active list", () => + Effect.gen(function* () { + const readModel = makeReadModel("settled"); + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-anchor"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + const unsettled = events[0]!; + expect(unsettled.type).toBe("thread.unsettled"); + + const projected = yield* projectEvent(readModel, { + ...unsettled, + sequence: readModel.snapshotSequence + 1, + } as OrchestrationEvent); + const thread = findThreadById(projected, ThreadId.make("thread-1"))!; + expect(thread.settledOverride).toBe("active"); + // The stamp is the decider's accept time: every thread created before + // the un-settle anchors below it. + expect(thread.unsettledAt).toBe(unsettled.occurredAt); + if (unsettled.type === "thread.unsettled") { + expect(thread.unsettledAt).toBe(unsettled.payload.updatedAt); + } + }), + ); + it.effect("prepends activity unsets for turn starts and live session updates", () => Effect.gen(function* () { const turnResult = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts index c6b04fd16cf5..0ef95553a16a 100644 --- a/apps/server/src/orchestration/projector.settled.test.ts +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -63,31 +63,68 @@ it.effect("projects settled lifecycle events", () => ); expect(findThreadById(settled, ThreadId.make("thread-1"))?.settledOverride).toBe("settled"); expect(findThreadById(settled, ThreadId.make("thread-1"))?.settledAt).toBe(now); + expect(findThreadById(settled, ThreadId.make("thread-1"))?.unsettledAt).toBeNull(); + const unsettleAt = "2026-01-02T00:00:00.000Z"; const userUnsettled = yield* projectEvent( settled, makeEvent({ sequence: 3, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: unsettleAt }, }), ); expect(findThreadById(userUnsettled, ThreadId.make("thread-1"))?.settledOverride).toBe( "active", ); expect(findThreadById(userUnsettled, ThreadId.make("thread-1"))?.settledAt).toBeNull(); + expect(findThreadById(userUnsettled, ThreadId.make("thread-1"))?.unsettledAt).toBe(unsettleAt); + // Clearing the keep-active pin on activity is not a re-entry: the thread + // is already in the active list, so the stamp must not move it. + const activityAt = "2026-01-03T00:00:00.000Z"; const activityUnsettled = yield* projectEvent( userUnsettled, makeEvent({ sequence: 4, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: activityAt }, }), ); expect( findThreadById(activityUnsettled, ThreadId.make("thread-1"))?.settledOverride, ).toBeNull(); expect(findThreadById(activityUnsettled, ThreadId.make("thread-1"))?.settledAt).toBeNull(); + expect(findThreadById(activityUnsettled, ThreadId.make("thread-1"))?.unsettledAt).toBe( + unsettleAt, + ); + + const resettledAt = "2026-01-04T00:00:00.000Z"; + const resettled = yield* projectEvent( + activityUnsettled, + makeEvent({ + sequence: 5, + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: resettledAt, + updatedAt: resettledAt, + }, + }), + ); + expect(findThreadById(resettled, ThreadId.make("thread-1"))?.unsettledAt).toBeNull(); + + // Waking a settled thread on activity IS a re-entry and stamps. + const wakeAt = "2026-01-05T00:00:00.000Z"; + const woke = yield* projectEvent( + resettled, + makeEvent({ + sequence: 6, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: wakeAt }, + }), + ); + expect(findThreadById(woke, ThreadId.make("thread-1"))?.settledOverride).toBeNull(); + expect(findThreadById(woke, ThreadId.make("thread-1"))?.unsettledAt).toBe(wakeAt); }), ); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 4618fa12897c..2e5cf58fa7fe 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -104,6 +104,7 @@ describe("orchestration projector", () => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0a8bfc49a6ea..c0ff315fe93c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -323,6 +323,7 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -390,6 +391,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { settledOverride: "settled", settledAt: payload.settledAt, + unsettledAt: null, updatedAt: payload.updatedAt, }), })), @@ -397,14 +399,24 @@ export function projectEvent( case "thread.unsettled": return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - settledOverride: payload.reason === "user" ? "active" : null, - settledAt: null, - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + const existing = findThreadById(nextBase, payload.threadId); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + // Re-entry stamp for active-list ordering. A thread already + // pinned active keeps its stamp: the activity reset that clears + // the pin is not a re-entry and must not reorder the list. + unsettledAt: + existing?.settledOverride === "active" + ? (existing.unsettledAt ?? null) + : payload.updatedAt, + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.snoozed": diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a5cd0a55e6cd..269e0cdc31e6 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -94,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: "settled", settledAt: "2026-03-25T00:00:00.000Z", + unsettledAt: null, snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", @@ -186,6 +188,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ...row, settledOverride: "active", settledAt: null, + unsettledAt: "2026-03-26T00:00:00.000Z", snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -196,6 +199,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const updated = Option.getOrNull(repersisted); assert.strictEqual(updated?.settledOverride, "active"); assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.unsettledAt, "2026-03-26T00:00:00.000Z"); assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); assert.strictEqual(updated?.pinnedAt, null); @@ -223,6 +227,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { updatedAt: "2026-03-24T00:00:00.000Z", settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -313,6 +318,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 06110dbef46f..827dd331be90 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -54,6 +54,7 @@ function toProjectionThread(row: ProjectionThreadDbRow): ProjectionThread { archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -96,6 +97,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at, settled_override, settled_at, + unsettled_at, snoozed_until, snoozed_at, pinned_at, @@ -126,6 +128,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.archivedAt}, ${row.settledOverride}, ${row.settledAt}, + ${row.unsettledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, @@ -156,6 +159,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at = excluded.archived_at, settled_override = excluded.settled_override, settled_at = excluded.settled_at, + unsettled_at = excluded.unsettled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, @@ -199,6 +203,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -238,6 +243,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", diff --git a/apps/server/src/persistence/MigrationNamespaces.test.ts b/apps/server/src/persistence/MigrationNamespaces.test.ts index bb977aacbfbd..0b1678717656 100644 --- a/apps/server/src/persistence/MigrationNamespaces.test.ts +++ b/apps/server/src/persistence/MigrationNamespaces.test.ts @@ -8,10 +8,10 @@ describe("migration namespaces", () => { it("keeps upstream and fork manifests in independent ledgers", () => { assert.notEqual(upstreamMigrationTable, forkMigrationTable); assert.deepStrictEqual(migrationManifest.slice(-4), [ - [39, "ProjectionProjectsDefaultThreadEnvMode"], [40, "ProjectionProjectFaviconPath"], [41, "AuthSessionClientConnection"], [42, "ProjectionThreadLinkedPullRequest"], + [43, "ProjectionThreadsUnsettledAt"], ]); assert.deepStrictEqual(forkMigrationManifest, [ [1, "ProjectionQueuedMessages"], diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9a7553a0898e..112f4b15eae9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -58,6 +58,7 @@ import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMo import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; +import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -112,6 +113,7 @@ export const migrationEntries = [ [40, "ProjectionProjectFaviconPath", Migration0040], [41, "AuthSessionClientConnection", Migration0041], [42, "ProjectionThreadLinkedPullRequest", Migration0042], + [43, "ProjectionThreadsUnsettledAt", Migration0043], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts index cee0658f14d3..be179cea9895 100644 --- a/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts +++ b/apps/server/src/persistence/Migrations/039_040_RepairRestackedProjectionThreads.test.ts @@ -87,10 +87,10 @@ layer("b18 desktop migration namespace repair", (it) => { readonly name: string; }>`SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id`; assert.deepStrictEqual(upstreamMigrations.slice(-4), [ - { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, { migration_id: 41, name: "AuthSessionClientConnection" }, { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, + { migration_id: 43, name: "ProjectionThreadsUnsettledAt" }, ]); const forkMigrations = yield* sql<{ diff --git a/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts new file mode 100644 index 000000000000..981d3c78f3a6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "unsettled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN unsettled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts index 956eaeed901e..700b14807fd7 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceRepaired.test.ts @@ -39,10 +39,10 @@ layer("fork migration namespace for a repaired database", (it) => { SELECT migration_id, name FROM ${sql(legacyMigrationBackupTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-4), [ - { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, { migration_id: 41, name: "AuthSessionClientConnection" }, { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, + { migration_id: 43, name: "ProjectionThreadsUnsettledAt" }, ]); assert.deepStrictEqual(fork, [ { migration_id: 1, name: "ProjectionQueuedMessages" }, diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts index d64012b5fef7..9b74bfc6027c 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceSmart.test.ts @@ -72,10 +72,10 @@ layer("smart migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-4), [ - { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, { migration_id: 41, name: "AuthSessionClientConnection" }, { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, + { migration_id: 43, name: "ProjectionThreadsUnsettledAt" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts index 7db388c06232..e89af7a56ea0 100644 --- a/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts +++ b/apps/server/src/persistence/Migrations/ForkMigrationNamespaceT3vm.test.ts @@ -29,12 +29,12 @@ layer("t3vm migration namespace repair", (it) => { SELECT migration_id, name FROM ${sql(upstreamMigrationTable)} ORDER BY migration_id `; assert.deepStrictEqual(upstream.slice(-6), [ - { migration_id: 37, name: "ProjectionTurnsKeysetIndex" }, { migration_id: 38, name: "ProjectionThreadsPinOrderKey" }, { migration_id: 39, name: "ProjectionProjectsDefaultThreadEnvMode" }, { migration_id: 40, name: "ProjectionProjectFaviconPath" }, { migration_id: 41, name: "AuthSessionClientConnection" }, { migration_id: 42, name: "ProjectionThreadLinkedPullRequest" }, + { migration_id: 43, name: "ProjectionThreadsUnsettledAt" }, ]); const fork = yield* sql` SELECT migration_id, name FROM ${sql(forkMigrationTable)} ORDER BY migration_id diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 545ed1e87f94..a446669cec01 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -43,6 +43,7 @@ export const ProjectionThread = Schema.Struct({ archivedAt: Schema.NullOr(IsoDateTime), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(IsoDateTime), + unsettledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1ab6166e92a1..a72b42b60b75 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -function makeMockDetachedHandle(onUnref: () => void = () => undefined) { +interface MockSpawnResult { + readonly exitCode?: number; + readonly stdout?: string; + /** Never deliver an exit code, like a child wedged on a broken desktop session. */ + readonly stall?: boolean; +} + +function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: input.stall + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.sync(() => { - onUnref(); + input.onUnref?.(); return Effect.void; }), stdin: Sink.drain, - stdout: Stream.empty, + stdout: + input.stdout === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(input.stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -40,6 +57,7 @@ const testLayer = (input: { readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; + readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined; }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -50,7 +68,10 @@ const testLayer = (input: { throw new Error("Expected a standard command"); } input.onSpawn?.(command); - return makeMockDetachedHandle(input.onUnref); + return makeMockDetachedHandle({ + ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), + ...input.spawnResult?.(command), + }); }), ), ); @@ -132,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("reveals a file in Finder with open -R on macOS", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const openPath = path.join(binDir, "open"); + yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(openPath, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "open"); + assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a file in File Explorer through PowerShell on Windows", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows + // separators, which on the posix test filesystem is one file name. + const systemRoot = path.join(binDir, "system-root"); + const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); + yield* fileSystem.writeFileString(powerShellPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "C:\\workspace with spaces\\media\\author's clip.mp4", + reveal: true, + }); + return yield* launcher.resolveFileManagerRevealKind(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(kind, "file-explorer"); + assert.ok(spawned); + assert.equal(spawned.command, powerShellPath); + assert.deepEqual(spawned.args.slice(0, -1), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + // explorer.exe expects `/select,""` with only the path quoted; + // PowerShell 5.1's Start-Process passes the argument string verbatim. + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", + ); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Real-chain smoke check for the Explorer selection contract: runs the exact +// PowerShell source the reveal launch encodes, against a stub that records +// the raw argument tail it receives, and asserts a spaced path arrives as the +// single `/select,""` switch. Mock argv assertions cannot prove this — +// only Windows' own PowerShell -> CreateProcess quoting chain can, so the +// test runs only where that chain exists. +// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. +it.skipIf(process.platform !== "win32")( + "delivers the raw /select switch for spaced paths through real PowerShell", + { timeout: 60_000 }, + async () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); + try { + const recorderPath = NodePath.join(tempDir, "recorder.cmd"); + const outputPath = NodePath.join(tempDir, "argv.txt"); + NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); + + const target = "C:\\workspace with spaces\\media\\author's clip.mp4"; + const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(recorderPath, target); + const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + NodeChildProcess.execFileSync( + powerShellPath, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(source, "utf16le").toString("base64"), + ], + { timeout: 30_000 }, + ); + + // Start-Process returns before the recorder runs; wait for its output. + // The waits run outside the Effect runtime on purpose: the test + // exercises the real Windows process chain in real time. + // @effect-diagnostics-next-line globalTimers:off + const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); + // @effect-diagnostics-next-line globalDate:off + const deadline = Date.now() + 20_000; + // @effect-diagnostics-next-line globalDate:off + while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { + await sleep(100); + } + await sleep(200); + const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); + assert.equal(recorded, `/select,"${target}"`); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }, +); + +it.effect("does not advertise reveal on Windows when PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { + PATH: binDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + SYSTEMROOT: path.join(binDir, "missing-system-root"), + }, + }), + ), + ); + + // Plain "open in file manager" still works through explorer; only the + // reveal capability, which launches PowerShell, must stay hidden. + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a WSL file in Windows File Explorer through its UNC path", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { kind, editors }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + // The reveal routes through interop PowerShell so Explorer receives its + // raw `/select,""` switch even for spaced paths. + assert.equal(spawned.command, "powershell.exe"); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise reveal from WSL when interop PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const explorerPath = path.join(binDir, "explorer.exe"); + yield* fileSystem.writeFileString(explorerPath, ""); + yield* fileSystem.chmod(explorerPath, 0o755); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// When interop PowerShell is missing the capability advertises the Linux +// "files" kind (or nothing), so the reveal must open the Linux file manager +// the label promised even though plain open still prefers File Explorer. +it.effect("reveals through the Linux file manager when WSL lacks interop PowerShell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const revealKind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return revealKind; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false) +// while WSLg still provides a working Linux file manager; the host must keep +// the Linux open/reveal path instead of losing the editor entirely. +it.effect("falls back to the Linux file manager when WSL lacks the Explorer bridge", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { editors, kind }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.equal(result.kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "falls back to opening the containing directory for WSL paths Explorer cannot select", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: '/home/t3/work "quoted"/clip.mp4', + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + // Explorer's raw switch cannot express a double quote, so the launch + // opens the parent directory instead of misparsing a /select argument. + assert.ok(spawned); + assert.equal(spawned.command, "explorer.exe"); + assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals by opening the containing directory on Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager without a graphical session", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("advertises a Linux file manager when a directory handler is installed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let probe: ChildProcess.StandardCommand | undefined; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + probe = command; + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), true); + assert.ok(probe); + assert.equal(probe.command, "xdg-mime"); + assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// `xdg-open` with a display variable but no `inode/directory` handler exits +// nonzero after the launch has already detached: without this gate the server +// advertises a reveal that is a silent no-op. +it.effect("does not advertise a Linux file manager without a directory handler", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when the handler query fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// The handler probe carries its own timeout because the editor scan's outer +// timeout in server.getConfig degrades to an EMPTY editor list: a wedged +// xdg-mime must cost only the file manager, never the other editors. Runs on +// the live clock so the probe's real timeout fires. +it.live("a stalled handler probe drops only the file manager", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime", "code"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when xdg-mime is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("discovers editors through the service API", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ec928f26fc3..96e6470311f4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -15,6 +15,7 @@ import { ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, type EditorId, + type FileManagerRevealKind, type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -29,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({ SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), container: Config.string("container").pipe(Config.option), + DISPLAY: Config.string("DISPLAY").pipe(Config.option), + WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), }).pipe(Config.map(compactEnv)); const CommandLookupEnvConfig = Config.all({ @@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -function shouldUseWindowsBrowserFromWsl( +// File reveals from WSL resolve PowerShell through the interop PATH rather +// than the fixed /mnt/c mount: the automount root is configurable, and a +// PATH-resolved command keeps the advertised capability aligned with the +// availability check `launchEditor` performs before spawning. +const WSL_POWERSHELL_COMMAND = "powershell.exe"; + +function shouldUseWindowsHostFromWsl( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, ): boolean { @@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { +function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { + return [env.DISPLAY, env.WAYLAND_DISPLAY].some( + (value) => value !== undefined && value.trim().length > 0, + ); +} + +function fileManagerCommandForPlatform( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | undefined { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: - return "xdg-open"; + if (shouldUseWindowsHostFromWsl(platform, env)) { + return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; + } + return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; } } +// A graphical session variable plus an executable `xdg-open` does not prove +// that opening a directory does anything: without an `inode/directory` MIME +// handler, `xdg-open` exits nonzero after the launcher has already detached, +// so the client would see a silent no-op. Require the handler before +// advertising the file manager on Linux. +// +// The probe carries its own timeout well inside the scan timeout +// `server.getConfig` applies to editor discovery: that outer timeout degrades +// to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop +// session) must cost only the file manager, not every discovered editor. +const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; + +const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( + function* ( + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable("xdg-mime", { env }))) { + return false; + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .spawn( + ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.flatMap((handle) => + Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { + concurrency: "unbounded", + }), + ), + Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), + Effect.scoped, + Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), + Effect.orElseSucceed(() => false), + ); + }, +); + +const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( + function* ( + command: string, + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable(command, { env }))) { + return false; + } + return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); + }, +); + +// The file-manager command a launch can actually run, not just the platform +// preference. WSL hosts prefer the Windows Explorer bridge, but interop can +// exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a +// distro name while WSLg still provides a working Linux file manager, so they +// keep the `xdg-open` fallback instead of losing the editor entirely. +const resolveUsableFileManagerCommand = Effect.fn( + "externalLauncher.resolveUsableFileManagerCommand", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + string | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + const command = fileManagerCommandForPlatform(platform, env); + if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { + return command; + } + if ( + shouldUseWindowsHostFromWsl(platform, env) && + hasGraphicalLinuxSession(env) && + (yield* isUsableFileManagerCommand("xdg-open", env)) + ) { + return "xdg-open"; + } + return undefined; +}); + +// Reveal on Windows and WSL runs through PowerShell (see +// resolveFileManagerRevealLaunch), not the `explorer` command that gates the +// file-manager editor itself, so the capability must probe the executables the +// reveal actually spawns. Callers gate on file-manager availability first; +// the Linux "files" kind relies on that gate for the directory-handler probe, +// while the WSL fallback re-probes because its availability may have come +// from the Explorer bridge instead. +const fileManagerRevealKindForPlatform = Effect.fn( + "externalLauncher.fileManagerRevealKindForPlatform", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + FileManagerRevealKind | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") return "finder"; + if (platform === "win32") { + return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) + ? "file-explorer" + : undefined; + } + if (shouldUseWindowsHostFromWsl(platform, env)) { + if ( + env.WSL_DISTRO_NAME?.trim() && + (yield* isCommandAvailable("explorer.exe", { env })) && + (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) + ) { + return "file-explorer"; + } + return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) + ? "files" + : undefined; + } + return hasGraphicalLinuxSession(env) ? "files" : undefined; +}); + +function resolveWslFileManagerPath(target: string, distroName: string): string { + const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); + return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; +} + function buildBrowserLaunch( target: string, platform: NodeJS.Platform, @@ -251,7 +407,7 @@ function buildBrowserLaunch( return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); } - if (shouldUseWindowsBrowserFromWsl(platform, env)) { + if (shouldUseWindowsHostFromWsl(platform, env)) { return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); } @@ -265,13 +421,16 @@ function buildBrowserLaunch( const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +): Effect.fn.Return< + ReadonlyArray, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (yield* isCommandAvailable(command, { env })) { + if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { available.push(editor.id); } continue; @@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env); }); +const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( + function* () { + const platform = yield* HostProcessPlatform; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; + return yield* fileManagerRevealKindForPlatform(platform, env); + }, +); + // Editor discovery walks PATH for every known editor and runs for every // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the @@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; + /** + * Reveal kind for the host, or undefined when the executable a reveal + * actually spawns is unavailable. Only meaningful when + * `resolveAvailableEditors` includes "file-manager": on Linux that + * availability check also carries the directory-handler probe this + * capability relies on. + */ + readonly resolveFileManagerRevealKind: () => Effect.Effect; /** Launch a URL target in the default browser. */ readonly launchBrowser: (target: string) => Effect.Effect; /** @@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service< const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, -): Effect.fn.Return { +): Effect.fn.Return< + EditorLaunch, + ExternalLauncherError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const command = yield* resolveUsableFileManagerCommand(platform, env); + if (command === undefined) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); + } + + if (input.reveal === true) { + return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); + } + return { editor: editorDef.id, target: input.cwd, - command: fileManagerCommandForPlatform(platform), - args: [input.cwd], + command, + args: + command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined + ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] + : [input.cwd], }; }); +/** + * PowerShell source that launches File Explorer with its raw selection + * switch. Explorer's contract is the single argument `/select,""` with + * only the path quoted; Node's default spawn quoting wraps the whole argument + * when the path has spaces and Explorer misparses it, silently opening a + * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1 + * reaches the child's command line verbatim, preserving the raw switch. + * + * Exported so the Windows smoke test can drive the identical source through a + * real PowerShell against a recording stub instead of Explorer. + */ +export function buildFileExplorerRevealPowerShellSource( + explorerCommand: string, + target: string, +): string { + return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; +} + +function fileExplorerRevealLaunch( + target: string, + explorerTarget: string, + powershellCommand: string, +): EditorLaunch { + return { + editor: "file-manager", + target, + command: powershellCommand, + args: [ + ...POWERSHELL_ARGUMENTS_PREFIX, + encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), + ], + }; +} + +const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* ( + target: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + // The command resolveUsableFileManagerCommand picked; a WSL host that fell + // back to the Linux file manager must reveal through it as well. + command: string, +): Effect.fn.Return< + EditorLaunch, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") { + return { editor: "file-manager", target, command: "open", args: ["-R", target] }; + } + + if (platform === "win32") { + return fileExplorerRevealLaunch(target, target, resolvePowerShellPath(env)); + } + + if ( + command === "explorer.exe" && + shouldUseWindowsHostFromWsl(platform, env) && + env.WSL_DISTRO_NAME !== undefined + ) { + const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); + if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { + // Explorer's raw switch cannot express a double quote, and unlike + // Windows paths a WSL path may legally contain one: open the containing + // directory in File Explorer instead, matching the advertised + // "file-explorer" kind. + if (explorerTarget.includes('"')) { + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); + } + // Without interop PowerShell the capability advertised the Linux "files" + // kind when it advertised anything at all, so the reveal must open the + // Linux file manager the label promised, not File Explorer. + if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { + const path = yield* Path.Path; + return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] }; + } + // Nothing was advertised here; open the parent in File Explorer as the + // best remaining effort for a stale client. + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + + // Linux file managers have no portable "select this file" flag, so open + // the containing directory instead. + const path = yield* Path.Path; + return { editor: "file-manager", target, command, args: [path.dirname(target)] }; +}); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -476,7 +767,9 @@ export const make = Effect.gen(function* () { if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { return entry.value.editors; } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); yield* Ref.set( editorDiscoveryCache, Option.some({ @@ -489,18 +782,18 @@ export const make = Effect.gen(function* () { return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, + resolveFileManagerRevealKind: () => + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => provideCommandResolutionServices( - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ), - ), + Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess), + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ad52af7511da..c03201d6885d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -37,6 +37,7 @@ import { } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -89,6 +90,7 @@ export type ClaudeDriverEnv = | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig @@ -128,6 +130,7 @@ export const ClaudeDriver: ProviderDriver = { const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const direnvEnvironment = yield* DirenvEnvironment; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -167,13 +170,24 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - const checkProvider = checkClaudeProviderStatus( - effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), - processEnv, - cwd, - ).pipe( - Effect.map(stampIdentity), + // Kick the TTL-gated manifest refresh in the background and classify + // with the in-memory manifest, so a slow or hung fetch never delays the + // provider check. A refresh that lands mid-probe applies on the next one. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + cwd, + ), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -186,7 +200,12 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingClaudeProvider(settings.provider), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 0b716eb10a05..9c513c8652b1 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -40,6 +40,7 @@ import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; @@ -80,6 +81,7 @@ export type CodexDriverEnv = | DirenvEnvironment | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig @@ -122,6 +124,7 @@ export const CodexDriver: ProviderDriver = { const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const direnvEnvironment = yield* DirenvEnvironment; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -170,8 +173,19 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( - Effect.map(stampIdentity), + // Kick the TTL-gated manifest refresh in the background and classify + // with the in-memory manifest, so a slow or hung fetch never delays the + // provider check. A refresh that lands mid-probe applies on the next one. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); @@ -181,7 +195,12 @@ export const CodexDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingCodexProvider(settings.provider), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 040e63b80229..7e5fa2611f0f 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -9,27 +9,11 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, - isLegacyClaudeModel, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); -it("keeps only the Claude 5 family out of legacy models", () => { - assert.deepStrictEqual( - ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ - model, - isLegacyClaudeModel(model), - ]), - [ - ["claude-fable-5", false], - ["claude-opus-5", false], - ["claude-sonnet-5", false], - ["claude-opus-4-8", true], - ], - ); -}); - it("isolates Claude capability probes without dropping workspace setting sources", () => { const abortController = new AbortController(); const options = buildClaudeCapabilitiesProbeQueryOptions({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 51854288ea1f..6d01046b75d2 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -59,12 +59,6 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); - -export function isLegacyClaudeModel(model: string): boolean { - return !CURRENT_CLAUDE_MODELS.has(model); -} - const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ { slug: "claude-fable-5", @@ -334,9 +328,9 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ Number(left.slug === DEFAULT_MODEL_BY_PROVIDER[PROVIDER]), ); -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => - isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, -); +// Legacy classification happens at the driver boundary via `applyModelManifest`, +// so the catalog itself carries no `isLegacy` flags. +const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 7469818dcefd..2aeebdb2ccd8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,31 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { - applyPreferredCodexDefaultModel, - isLegacyCodexModel, - mapCodexModelCapabilities, -} from "./CodexProvider.ts"; - -it("keeps current Codex models out of legacy models", () => { - assert.deepStrictEqual( - [ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", - "gpt-5.4", - ].map((model) => [model, isLegacyCodexModel(model)]), - [ - ["gpt-5.6-luna", false], - ["gpt-5.6-terra", false], - ["gpt-5.6-sol", false], - ["gpt-daybreak-blue-latest", false], - ["gpt-daybreak-red-latest", false], - ["gpt-5.4", true], - ], - ); -}); +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 93730046dc49..52a8fdd25dc7 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,17 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; -const CURRENT_CODEX_MODELS = new Set([ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", -]); - -export function isLegacyCodexModel(model: string): boolean { - return !CURRENT_CODEX_MODELS.has(model); -} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -201,7 +190,6 @@ function parseCodexModelListResponse( name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), - ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 7fdbf512c0a1..d4447111f897 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -41,6 +41,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -150,6 +152,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(DirenvEnvironment.layerNoop), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -318,6 +321,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots one instance of every shipped driver from a single config map", () => @@ -370,7 +374,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* makeProviderInstanceRegistry({ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 90704ba9b690..9e10ee13fa3d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -36,6 +36,7 @@ import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from ". import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as DirenvEnvironment from "../DirenvEnvironment.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -1491,6 +1492,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -1582,6 +1584,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -1692,6 +1695,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -1751,6 +1755,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( @@ -1836,6 +1841,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts new file mode 100644 index 000000000000..fdcfa9335424 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -0,0 +1,185 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + BUNDLED_MODEL_MANIFEST, + classifyModels, + isLegacyModel, + make, + type ModelManifestData, +} from "./ModelManifest.ts"; + +const CODEX = ProviderDriverKind.make("codex"); +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const CURSOR = ProviderDriverKind.make("cursor"); + +describe("isLegacyModel (bundled manifest)", () => { + it("keeps current Codex models out of legacy models", () => { + assert.deepStrictEqual( + [ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest", + "gpt-5.4", + ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]), + [ + ["gpt-5.6-luna", false], + ["gpt-5.6-terra", false], + ["gpt-5.6-sol", false], + ["gpt-daybreak-blue-latest", false], + ["gpt-daybreak-red-latest", false], + ["gpt-5.4", true], + ], + ); + }); + + it("keeps only the Claude 5 family out of legacy models", () => { + assert.deepStrictEqual( + ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ + model, + isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), + ]), + [ + ["claude-fable-5", false], + ["claude-opus-5", false], + ["claude-sonnet-5", false], + ["claude-opus-4-8", true], + ], + ); + }); + + it("leaves driver kinds without a manifest entry unflagged", () => { + assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); + }); +}); + +const model = (overrides: Partial): ServerProviderModel => ({ + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: null, + ...overrides, +}); + +describe("classifyModels", () => { + it("flags non-current models, clears stale flags, and skips custom models", () => { + const models = [ + model({ slug: "gpt-5.6-sol" }), + // Stale flag from a previous classification pass must be cleared. + model({ slug: "gpt-5.6-luna", isLegacy: true }), + model({ slug: "gpt-5.4" }), + // Custom models are user-defined and never reclassified. + model({ slug: "my-own-model", isCustom: true }), + ]; + assert.deepStrictEqual( + classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ + entry.slug, + entry.isLegacy ?? false, + ]), + [ + ["gpt-5.6-sol", false], + ["gpt-5.6-luna", false], + ["gpt-5.4", true], + ["my-own-model", false], + ], + ); + }); +}); + +const REMOTE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: { + codex: ["gpt-5.4"], + claudeAgent: ["claude-fable-5"], + }, +}; + +const httpClientLayer = (handler: () => Response) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler()))), + ); + +const serviceLayers = (input: { + readonly prefix: string; + readonly response: () => Response; + readonly settings?: Parameters[0]; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings ?? {})), + Layer.provideMerge(httpClientLayer(input.response)), + ); + +describe("ModelManifest service", () => { + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => + Effect.gen(function* () { + const service = yield* make; + const refreshed = yield* service.refresh; + assert.deepStrictEqual(refreshed, REMOTE_MANIFEST); + assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol")); + assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4")); + + // A fresh service instance sees the disk cache without another fetch: + // its HTTP layer is still stubbed, but `current` never fetches at all. + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-fetch-test", + response: () => Response.json(REMOTE_MANIFEST), + }), + ), + ), + ); + + it.live("keeps the bundled manifest when the remote payload is malformed", () => + Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-malformed-test", + response: () => Response.json({ version: 999, nonsense: true }), + }), + ), + ), + ); + + it.live("does not fetch when provider update checks are disabled", () => + Effect.gen(function* () { + let fetchCount = 0; + const service = yield* make.pipe( + Effect.provide( + httpClientLayer(() => { + fetchCount += 1; + return Response.json(REMOTE_MANIFEST); + }), + ), + ); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 0); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-optout-test", + response: () => Response.json(REMOTE_MANIFEST), + settings: { enableProviderUpdateChecks: false }, + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts new file mode 100644 index 000000000000..cb9494992287 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.ts @@ -0,0 +1,221 @@ +/** + * ModelManifest — decides which provider models are current and which belong + * in the model picker's legacy section. + * + * The classification data (current slugs per driver kind) lives in + * `model-manifest.json` next to this file. The bundled copy ships with every + * release. At runtime the service refreshes it from the same file on `main` + * via raw.githubusercontent.com, so a new model can leave the legacy section + * with a commit to `main` instead of a release. Preference order is remote, + * then the on-disk copy of the last successful fetch, then the bundle. A + * failed fetch never fails a provider check. + * + * Drivers apply the manifest to snapshot drafts with `applyModelManifest` + * before publishing, so every path that produces models (pending, probe, + * error fallbacks) is classified the same way. + */ +import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +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 Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import type { ServerProviderDraft } from "./providerSnapshot.ts"; + +const MODEL_MANIFEST_URL = + "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; + +/** How long a fetched manifest stays fresh before the next probe re-fetches. */ +const MANIFEST_TTL_MS = 60 * 60 * 1000; + +/** Minimum gap between fetch attempts after a failure, so an offline server + * does not pay a network timeout on every provider check. */ +const MANIFEST_RETRY_MS = 5 * 60 * 1000; + +const FETCH_TIMEOUT_MS = 10_000; + +/** + * `version` gates breaking schema changes: a build only accepts remote + * manifests whose version it understands, and keeps its bundled copy + * otherwise. `currentModels` is keyed by driver kind; kinds absent from the + * map have no legacy concept and their models are left unflagged. + */ +const ModelManifestSchema = Schema.Struct({ + version: Schema.Literal(1), + currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), +}); +export type ModelManifestData = typeof ModelManifestSchema.Type; + +const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); + +export const BUNDLED_MODEL_MANIFEST: ModelManifestData = + Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); + +/** On-disk shape of the last successfully fetched manifest. */ +const ManifestCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + manifest: ModelManifestSchema, +}); +const decodeManifestCache = Schema.decodeUnknownEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); +const encodeManifestCache = Schema.encodeEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); + +/** True when the manifest classifies `slug` as legacy for `driverKind`. */ +export function isLegacyModel( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, + slug: string, +): boolean { + const currentModels = manifest.currentModels[driverKind]; + if (!currentModels) return false; + return !currentModels.includes(slug); +} + +/** + * Reclassifies every built-in model on a snapshot draft against the manifest. + * Custom models are user-defined and never reclassified. + */ +export function applyModelManifest( + draft: ServerProviderDraft, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ServerProviderDraft { + return { ...draft, models: classifyModels(draft.models, manifest, driverKind) }; +} + +/** Model-level half of `applyModelManifest`, exported for focused tests. */ +export function classifyModels( + models: ReadonlyArray, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ReadonlyArray { + return models.map((model) => { + if (model.isCustom) return model; + if (isLegacyModel(manifest, driverKind, model.slug)) { + return model.isLegacy ? model : { ...model, isLegacy: true }; + } + if (!model.isLegacy) return model; + const { isLegacy: _isLegacy, ...rest } = model; + return rest; + }); +} + +export class ModelManifest extends Context.Service< + ModelManifest, + { + /** Manifest already in memory (disk cache or bundle); never fetches. + * Snapshot classification reads this, so it never waits on the network. */ + readonly current: Effect.Effect; + /** Manifest after a TTL-gated remote refresh; never fails. */ + readonly refresh: Effect.Effect; + /** Forks `refresh` into the service's own scope. Drivers call this from + * provider checks: the fetch is process-shared state, so it must survive + * the teardown of whichever instance happened to trigger it. */ + readonly refreshInBackground: Effect.Effect; + } +>()("t3/provider/ModelManifest") {} + +/** Constant service for tests and callers that only need the bundled data. */ +export const BundledOnlyModelManifest: ModelManifest["Service"] = { + current: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, +}; + +export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + const serviceScope = yield* Effect.scope; + + const cachePath = path.join(config.stateDir, "model-manifest.json"); + let manifest = BUNDLED_MODEL_MANIFEST; + let fetchedAtMs: number | null = null; + let lastAttemptMs: number | null = null; + const refreshSemaphore = yield* Semaphore.make(1); + + // `Effect.cached` makes concurrent first readers await the same disk load + // rather than racing a "loaded" flag. Only `refreshed` takes the fetch + // semaphore; `current` must never wait behind an in-flight network refresh. + const ensureDiskCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( + Effect.flatMap((raw) => decodeManifestCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk === null) return; + // The disk copy is the last-seen remote manifest, so it outranks the + // bundle even when stale: it is refreshed on the next successful fetch. + manifest = fromDisk.manifest; + fetchedAtMs = fromDisk.fetchedAtMs; + }), + ); + + const refresh = Effect.fn("ModelManifest.refresh")(function* () { + yield* ensureDiskCacheLoaded; + const now = yield* Clock.currentTimeMillis; + // A timestamp in the future means the wall clock moved backwards (the + // disk cache crosses restarts, so monotonic time cannot cover it). Treat + // it as expired: the refetch rewrites both timestamps and self-heals. + const isWithin = (sinceMs: number | null, windowMs: number) => + sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; + if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + + // The same switch that gates provider CLI update checks. It stops network + // fetches only: a manifest already cached on disk from an earlier fetch + // stays in effect, since the setting is about phoning home, not about + // discarding data the server already holds. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings !== null && !settings.enableProviderUpdateChecks) return manifest; + + lastAttemptMs = now; + const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap((json) => decodeManifest(json)), + Effect.timeout(FETCH_TIMEOUT_MS), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) return manifest; + + manifest = fetched; + fetchedAtMs = now; + yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + return manifest; + }); + + const guardedRefresh = refreshSemaphore.withPermits(1)(refresh()); + + return ModelManifest.of({ + current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), + refresh: guardedRefresh, + refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(ModelManifest, make); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json new file mode 100644 index 000000000000..7022ce226170 --- /dev/null +++ b/apps/server/src/provider/model-manifest.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "currentModels": { + "codex": [ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest" + ], + "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] + } +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2889102d94db..d449e8ee0891 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -108,7 +108,11 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { isCommandReadinessExemptPath, makeRoutesLayer } from "./server.ts"; import * as IdentityService from "./identity/IdentityService.ts"; -import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; +import { + isThreadDetailEvent, + resolveAvailableEditorsForConfig, + resolveFileManagerRevealKindForConfig, +} from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GrokTranscriptResync from "./externalSessions/GrokTranscriptResync.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -697,6 +701,7 @@ const buildAppUnderTest = (options?: { Layer.mergeAll( Layer.mock(ExternalLauncher.ExternalLauncher)({ resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), ...options?.layers?.externalLauncher, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -4186,10 +4191,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); assert.equal(response.shellResumeCompletionMarker, true); + assert.isUndefined(response.shellRevealInFileManager); + assert.isUndefined(response.shellRevealInFileManagerKind); assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("advertises the usable file manager and its reveal label", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.deepEqual(response.availableEditors, ["file-manager"]); + assert.equal(response.shellRevealInFileManager, true); + assert.equal(response.shellRevealInFileManagerKind, "file-explorer"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); @@ -4207,6 +4240,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); + it.effect("does not block server config when file manager reveal discovery never resolves", () => + Effect.gen(function* () { + const discoveryInterrupted = yield* Deferred.make(); + const responseFiber = yield* resolveFileManagerRevealKindForConfig( + Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), + ), + ).pipe(Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(5)); + + const revealKind = yield* Fiber.join(responseFiber); + yield* Deferred.await(discoveryInterrupted); + assert.isUndefined(revealKind); + }), + ); + it.effect( "rejects websocket rpc handshake when a session token is only provided via query string", () => @@ -4805,6 +4855,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const path = yield* Path.Path; const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -4858,7 +4909,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); + assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index baf8e9d5404c..e6e65f78a63b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,6 +30,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -493,7 +494,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, DirenvEnvironment.layerLive)), + // `ModelManifest.layer` is the legacy-model classification data, refreshed + // from the repo's `model-manifest.json` on `main` and applied by the + // Codex/Claude drivers. + Layer.provideMerge( + Layer.mergeAll(ProviderEventLoggers.layer, DirenvEnvironment.layerLive, ModelManifest.layer), + ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3d3e10ec6e62..99a03e11b0db 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1987,6 +1987,111 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("publishes a branch tracking its base under its own name, not the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "dev"]); + yield* git(cwd, ["push", "-u", "origin", "dev"]); + const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/x", + upstreamBranch: "origin/feature/x", + setUpstream: true, + }); + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "feature/x"]), "Add feature"); + assert.equal(yield* git(remote, ["rev-parse", "dev"]), devSha); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/x", + ); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + }), + ); + + it.effect("keeps a recorded merge base when publishing a tracked branch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]); + yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/y", + upstreamBranch: "origin/feature/y", + setUpstream: true, + }); + assert.equal( + yield* driver.readConfigValue(cwd, "branch.feature/y.gh-merge-base"), + "release/v2", + ); + }), + ); + + it.effect("still pushes a git-mangled tracking alias to its upstream head", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "my-org/upstream", remote]); + yield* git(cwd, ["push", "my-org/upstream", "main:effect-atom"]); + yield* git(cwd, ["fetch", "my-org/upstream"]); + // `checkout --track my-org/upstream/effect-atom` cannot name the local + // branch `effect-atom`, so git keeps `upstream/effect-atom`. Its + // upstream is still its published head. + yield* git(cwd, ["checkout", "--track", "my-org/upstream/effect-atom"]); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + "upstream/effect-atom", + ); + yield* writeTextFile(cwd, "alias.txt", "alias\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add alias update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "upstream/effect-atom", + upstreamBranch: "my-org/upstream/effect-atom", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "effect-atom"]), + "Add alias update", + ); + }), + ); + it.effect("pushes to the requested remote instead of the primary remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 28c1979bf564..f7c400e08eef 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2064,6 +2064,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { + // A branch tracking a differently named ref was cut from it, the way + // `git checkout -b feature origin/dev` and our own worktree flow leave + // it. That upstream is the branch's base, not its publish target, and + // pushing HEAD onto it would write feature commits to a shared branch + // (bare `git push` refuses this under push.default=simple). The one + // same-repo tracking setup that legitimately differs is a git-mangled + // alias such as local `upstream/effect-atom` for my-org/upstream's + // `effect-atom`: the branch name ends in the upstream head while the + // upstream ref ends in the branch name. + const isAliasOfUpstreamHead = + branch === currentUpstream.branchName || + (branch.endsWith(`/${currentUpstream.branchName}`) && + currentUpstream.upstreamRef.endsWith(`/${branch}`)); + if (!isAliasOfUpstreamHead) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const remoteName = publishRemoteName ?? currentUpstream.remoteName; + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + // `-u` retargets the upstream to the published branch, so keep the + // base recorded first; base resolution reads gh-merge-base before the + // upstream ref. + const configuredMergeBase = yield* runGitStdout( + "GitVcsDriver.pushCurrentBranch.readMergeBase", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (configuredMergeBase.length === 0) { + yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ + "config", + `branch.${branch}.gh-merge-base`, + currentUpstream.branchName, + ]); + } + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushOwnBranch", + cwd, + ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${remoteName}/${publishBranch}`, + setUpstream: true, + }; + } + yield* runGit( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 81b82c62a0cf..a6b06dc81bef 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -23,6 +23,8 @@ import { CommandId, type DiscoveredLocalServerList, EventId, + type EditorId, + type FileManagerRevealKind, type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, @@ -153,16 +155,25 @@ import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5); +const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); -export const resolveAvailableEditorsForConfig = ( - discovery: Effect.Effect, E, R>, +const resolveDiscoveryForConfig = ( + discovery: Effect.Effect, + onTimeout: () => A, ) => discovery.pipe( - Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT), - Effect.map(Option.getOrElse(() => [])), + Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT), + Effect.map(Option.getOrElse(onTimeout)), ); +export const resolveAvailableEditorsForConfig = ( + discovery: Effect.Effect, E, R>, +) => resolveDiscoveryForConfig(discovery, () => []); + +export const resolveFileManagerRevealKindForConfig = ( + discovery: Effect.Effect, +) => resolveDiscoveryForConfig(discovery, () => undefined); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1341,6 +1352,14 @@ const makeWsRpcLayer = ( ); 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, @@ -1350,9 +1369,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ), + 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( @@ -1370,6 +1387,12 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, }; diff --git a/apps/web/package.json b/apps/web/package.json index a5d6d19f54ee..2a5f31e14e9f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.33", + "version": "0.0.34", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 1edf37e9b84a..2ca9ad4ae311 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -12,10 +12,15 @@ vi.mock("../state/session", async (importOriginal) => ({ })); vi.mock("../state/entities", () => ({ readThreadShell: () => null, - useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), useProjects: () => [], })); -vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); vi.mock("~/lib/openPullRequestLink", () => ({ findProjectForChangeRequest: () => undefined, matchesLinkedPullRequestUrl: () => false, @@ -23,7 +28,124 @@ vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn(), })); -import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown"; +import ChatMarkdown, { + canUseMarkdownFileShellActions, + hasMarkdownFilePrimaryAction, + orderedListGutterStyle, + shouldUseMarkdownFileBrowserPrimaryAction, +} from "./ChatMarkdown"; + +describe("canUseMarkdownFileShellActions", () => { + const environmentId = EnvironmentId.make("environment-1"); + + it("allows editor and file manager actions for local environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); + }); + + it("hides shell actions until the environment mode is resolved", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); + }); + + it("hides editor and file manager actions for remote environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); + expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); + }); + + it("hides shell actions when no environment owns the markdown", () => { + expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); + }); +}); + +describe("hasMarkdownFilePrimaryAction", () => { + it("keeps the chip interactive when an editor, browser, or panel can open it", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: true, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: true, + }), + ).toBe(true); + }); + + it("removes the link affordance when no primary action can open the file", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(false); + }); +}); + +describe("ChatMarkdown file option chips", () => { + it("keeps the fallback button text selectable", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(" { + it("uses the browser when it is the only available primary action", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + }); + + it("preserves the normal editor and panel defaults for HTML files", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(false); + }); + + it("continues to open PDF files in the browser by default", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.pdf", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(true); + }); +}); describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -67,10 +189,13 @@ describe("orderedListGutterStyle", () => { }); describe("ChatMarkdown Windows file links", () => { + const environmentId = EnvironmentId.make("env-windows"); + it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; @@ -157,6 +172,35 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; } +export function canUseMarkdownFileShellActions( + environmentId: EnvironmentId | null, + remoteOpenMode: RemoteOpenMode, + isRemoteOpenResolved: boolean, +): boolean { + return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; +} + +export function hasMarkdownFilePrimaryAction(input: { + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; +} + +export function shouldUseMarkdownFileBrowserPrimaryAction(input: { + iconPath: string; + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return ( + input.canOpenInBrowser && + (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || + (!input.canOpenInEditor && !input.canOpenInPanel)) + ); +} + const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; @@ -918,14 +962,19 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; - onOpen: (targetPath: string) => Promise>; + onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onReveal?: (() => Promise>) | undefined; + /** Platform-specific menu label ("Reveal in Finder", ...); required for the + reveal item to show. */ + revealLabel?: string | undefined; className?: string | undefined; } -const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; +const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; +const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70`; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); @@ -1272,10 +1321,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + openInEditorMenuLabel, onOpenInBrowser, + onReveal, + revealLabel, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { + if (!onOpen) { + return; + } void (async () => { try { const result = await onOpen(targetPath); @@ -1356,6 +1411,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpenInBrowser, targetPath]); + const handleRevealInFileManager = useCallback(() => { + if (!onReveal) { + return; + } + void (async () => { + try { + const result = await onReveal(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [onReveal, targetPath]); + const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -1395,25 +1488,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [targetPath], ); - const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - + const showFileContextMenu = useCallback( + async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; try { const clicked = await api.contextMenu.show( [ - { id: "open", label: "Open in editor" }, + ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), + ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, - { x: event.clientX, y: event.clientY }, + position, ); if (clicked === "open") { @@ -1424,6 +1515,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "reveal") { + handleRevealInFileManager(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1438,34 +1533,100 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + onOpen, + onReveal, + openInEditorMenuLabel, + revealLabel, + targetPath, + ], ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const position = + event.clientX === 0 && event.clientY === 0 + ? (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })() + : { x: event.clientX, y: event.clientY }; + void showFileContextMenu(position); + }, + [showFileContextMenu], + ); + + const canOpenInEditor = onOpen !== undefined; + const canOpenInBrowser = onOpenInBrowser !== undefined; + const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const hasPrimaryAction = hasMarkdownFilePrimaryAction({ + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath, + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + return ( { - event.preventDefault(); - event.stopPropagation(); - if (shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (onOpenInBrowser && shouldOpenMarkdownFileLinkInBrowserByDefault(iconPath)) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - + hasPrimaryAction ? ( + { + event.preventDefault(); + event.stopPropagation(); + if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (useBrowserPrimaryAction) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + + ) } /> { + if (environmentId === null) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), + ), + ); + } + return openInEditor({ + environmentId, + input: { cwd: filePath, editor: "file-manager", reveal: true }, + }); + }, + [environmentId, openInEditor], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { @@ -1679,6 +1877,26 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + const findWorkspaceBasenameMatch = useCallback( + async (workspaceRelativePath: string) => { + if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + return null; + } + const result = await searchProjectEntries({ + environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + return result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + }, + [cwd, environmentId, searchProjectEntries], + ); // A bare filename resolves to the workspace root, which is rarely where the // file is, so ask the index before opening. const openFileInPanel = useCallback( @@ -1694,24 +1912,23 @@ function ChatMarkdown({ return; } void (async () => { - const result = await searchProjectEntries({ - environmentId: threadRef.environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - const match = - result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; + const match = await findWorkspaceBasenameMatch(workspaceRelativePath); if (!isLatestLookup()) return; openAt(match ?? workspaceRelativePath); })(); }, - [cwd, searchProjectEntries, threadRef], + [cwd, findWorkspaceBasenameMatch, threadRef], + ); + const revealMarkdownFileInFileManager = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; + const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; + return revealFileInFileManager(filePath); + }, + [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that @@ -1747,8 +1964,15 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} - onOpen={openInPreferredEditor} + {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} onOpenInPanel={openFileInPanel} + openInEditorMenuLabel={preferredEditorMenuLabel} + onReveal={ + canUseShellActions && revealInFileManagerLabel !== undefined + ? () => revealMarkdownFileInFileManager(fileLinkMeta) + : undefined + } + revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -2038,6 +2262,7 @@ function ChatMarkdown({ }, }; }, [ + canUseShellActions, cwd, diffThemeName, fileLinkParentSuffixByPath, @@ -2050,8 +2275,11 @@ function ChatMarkdown({ openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, + preferredEditorMenuLabel, resolveThreadPullRequest, resolvedTheme, + revealMarkdownFileInFileManager, + revealInFileManagerLabel, skills, text, threadRef, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index e7b042a62c95..37a0f27a0ba3 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -25,10 +25,15 @@ vi.mock("../state/session", async (importOriginal) => ({ })); vi.mock("../state/entities", () => ({ readThreadShell: () => null, - useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), useProjects: () => [], })); -vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); vi.mock("~/lib/openPullRequestLink", () => ({ findProjectForChangeRequest: () => undefined, matchesLinkedPullRequestUrl: () => false, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index de7e7b6ff690..2b25d9878ede 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1228,6 +1228,33 @@ describe("sortThreadsForSidebar", () => { expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); }); + + it("surfaces an un-settled thread at the top via its re-entry stamp", () => { + const sorted = sortThreadsForSidebar([ + { + id: "old-unsettled", + createdAt: "2026-03-09T08:00:00.000Z", + unsettledAt: "2026-03-09T13:00:00.000Z", + }, + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); + }); + + it("ignores a re-entry stamp older than the thread's creation", () => { + const sorted = sortThreadsForSidebar([ + { + id: "stale-stamp", + createdAt: "2026-03-09T10:00:00.000Z", + unsettledAt: "2026-03-09T09:00:00.000Z", + }, + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "stale-stamp"]); + }); }); describe("pinOrderKeyBetween", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index fa53339d46f2..96437bb9e5b2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -12,6 +12,7 @@ import { import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { + activeThreadAnchorTimestampMs, getThreadSortTimestamp, sortThreads, toSortableTimestamp, @@ -1013,16 +1014,23 @@ export function firstValidTimestamp( return null; } -// Sidebar sort: static creation order, newest thread on top. Activity NEVER -// reorders the list — a row holds its position from open until settled, so -// the screen only moves at lifecycle transitions. Status (including pending -// approval) is carried by each card's edge strip, not by position. +// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders +// the list — a row holds its position between lifecycle transitions, so the +// screen only moves when a thread enters or leaves the active list. The +// anchor is creation time until an un-settle re-anchors it (see +// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the +// top instead of sinking back to its creation-order slot. Status (including +// pending approval) is carried by each card's edge strip, not by position. export function sortThreadsForSidebar< - T extends { readonly id: string; readonly createdAt: string }, + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + }, >(threads: readonly T[]): T[] { return [...threads].toSorted( (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || left.id.localeCompare(right.id), ); } diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 31c6a0a891eb..7f07e163e56e 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -26,6 +26,7 @@ import { import { memo, useCallback, useEffect, useId, useMemo, useState, type FormEvent } from "react"; import { readLegacyPreferredEditor } from "../../editorPreferences"; +import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { mergeOpenWithOptions, @@ -234,7 +235,7 @@ function OptionIcon({ function optionLabel(option: OpenWithOption, platform: string): string { if (option.type === "custom") return option.entry.name; if (option.id === "file-manager") { - return isMacPlatform(platform) ? "Finder" : isWindowsPlatform(platform) ? "Explorer" : "Files"; + return editorLabelForPlatform("file-manager", platform); } return builtinPresentationById.get(option.id)?.label ?? option.id; } diff --git a/apps/web/src/components/preview/fileExplorerLabel.test.ts b/apps/web/src/components/preview/fileExplorerLabel.test.ts index 0b39a8d9ef99..3848c503c237 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.test.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { revealInFileExplorerLabel } from "./fileExplorerLabel"; +import { + revealInFileExplorerLabel, + revealInFileExplorerLabelForKind, + revealInFileExplorerLabelForOs, +} from "./fileExplorerLabel"; describe("revealInFileExplorerLabel", () => { it.each([ @@ -11,3 +15,24 @@ describe("revealInFileExplorerLabel", () => { expect(revealInFileExplorerLabel(platform)).toBe(expected); }); }); + +describe("revealInFileExplorerLabelForOs", () => { + it.each([ + ["darwin", "Reveal in Finder"], + ["windows", "Reveal in File Explorer"], + ["linux", "Reveal in Files"], + ["unknown", "Reveal in Files"], + ] as const)("maps %s to %s", (os, expected) => { + expect(revealInFileExplorerLabelForOs(os)).toBe(expected); + }); +}); + +describe("revealInFileExplorerLabelForKind", () => { + it.each([ + ["finder", "Reveal in Finder"], + ["file-explorer", "Reveal in File Explorer"], + ["files", "Reveal in Files"], + ] as const)("maps %s to %s", (kind, expected) => { + expect(revealInFileExplorerLabelForKind(kind)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/preview/fileExplorerLabel.ts b/apps/web/src/components/preview/fileExplorerLabel.ts index fd0785dfadf7..5229782721d0 100644 --- a/apps/web/src/components/preview/fileExplorerLabel.ts +++ b/apps/web/src/components/preview/fileExplorerLabel.ts @@ -1,6 +1,23 @@ +import type { ExecutionEnvironmentPlatformOs, FileManagerRevealKind } from "@t3tools/contracts"; + export function revealInFileExplorerLabel(platform: string): string { const normalized = platform.toLowerCase(); if (normalized.includes("mac")) return "Reveal in Finder"; if (normalized.includes("win")) return "Reveal in File Explorer"; return "Reveal in Files"; } + +/** Same wording keyed by an environment's reported OS rather than a + navigator platform string, for actions that reveal on the server machine. */ +export function revealInFileExplorerLabelForOs(os: ExecutionEnvironmentPlatformOs): string { + if (os === "darwin") return "Reveal in Finder"; + if (os === "windows") return "Reveal in File Explorer"; + return "Reveal in Files"; +} + +/** Server-selected wording, including Windows File Explorer reached from WSL. */ +export function revealInFileExplorerLabelForKind(kind: FileManagerRevealKind): string { + if (kind === "finder") return "Reveal in Finder"; + if (kind === "file-explorer") return "Reveal in File Explorer"; + return "Reveal in Files"; +} diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 46aa44dc1289..3e8cdcd6b5d0 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,4 +1,5 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; @@ -19,10 +20,12 @@ import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; export function PullRequestMarkdown({ text, cwd, + environmentId, className, }: { text: string; cwd: string; + environmentId: EnvironmentId; className?: string; }) { const segments = splitPullRequestBody(text); @@ -30,7 +33,14 @@ export function PullRequestMarkdown({
{segments.map((segment) => { if (segment.kind === "markdown") { - return ; + return ( + + ); } const isVideo = segment.media === "video"; const Icon = isVideo ? PlayIcon : PaperclipIcon; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index f0145c059c0d..d5d2ee0a4757 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; @@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; export function PullRequestMarkdownEditor({ value, cwd, + environmentId, placeholder, label, saving, @@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({ }: { readonly value: string; readonly cwd: string; + readonly environmentId: EnvironmentId; readonly placeholder?: string | undefined; readonly label: string; readonly saving: boolean; @@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({ {empty ? (

Nothing to preview.

) : ( - + )}
) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index c2e95ee41e12..90a1926d1fad 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -273,6 +273,7 @@ export function ReviewThreadCard({ className="mt-1" value={comment.body} cwd={workspaceRoot} + environmentId={environmentId} label="Edit comment" saving={savingEdit} onSave={(body) => void saveEdit(comment.id, body)} @@ -284,6 +285,7 @@ export function ReviewThreadCard({ className="min-w-0 flex-1 text-sm" text={comment.body} cwd={workspaceRoot} + environmentId={environmentId} /> {canEditComment(comment) ? (