diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e0ef427bd1d36..e97365e2f3ec5 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -5,6 +5,14 @@ inputs: description: Read-only credential for the private maintainer task cache required: false default: "" + install: + description: Set to "false" to set up Vite+ only, with no pnpm store or install + required: false + default: "true" + install-args: + description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. + required: false + default: "" runs: using: composite steps: @@ -19,10 +27,11 @@ runs: echo "node=$(jq -r '.engines.node' package.json)" >> "$GITHUB_OUTPUT" echo "pnpm=$(jq -r '.packageManager' package.json)" >> "$GITHUB_OUTPUT" + # Pinned because release-desktop.yml signs builds with this action in the job. - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.vite-plus key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} @@ -38,38 +47,73 @@ runs: echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" "$HOME/.vite-plus/bin/vp" env use "${{ steps.vp.outputs.node }}" + # vp gives each Node.js download attempt 10 minutes by default, so one + # stalled stream holds the job that long. It retries 3 times and resumes + # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + env: + VP_DOWNLOAD_TIMEOUT: "120" with: node-version-file: package.json cache: false run-install: false - - name: Resolve pnpm store path + # A shared key lets whichever install saves first, often a small filtered + # one, become every job's store. Each set of install args gets its own. + - name: Resolve pnpm store id: pnpm_store + if: inputs.install == 'true' shell: bash + env: + INSTALL_ARGS: ${{ inputs.install-args }} run: | - store_path="$(vp pm cache dir)" - echo "path=$store_path" >> "$GITHUB_OUTPUT" + echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + if [[ -z "$INSTALL_ARGS" ]]; then + echo "namespace=full" >> "$GITHUB_OUTPUT" + else + echo "namespace=filtered-$(printf '%s' "$INSTALL_ARGS" | cksum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + fi - # setup-vp's shared cache key ignores install filters. Keep full installs in a - # separate namespace, including fallback restores. + # Filtered installs prefer the current full store, which holds every package, + # over an older store of their own. # The pnpm store stays a cache tarball on the runner's own disk. A store on a # Blacksmith sticky disk mounts in under a second, but pnpm then copies every # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - - name: Cache full pnpm store - uses: actions/cache@v6 + - name: Restore pnpm store + id: pnpm_cache + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.pnpm_store.outputs.path }} - key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} restore-keys: | + pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- - name: Install dependencies + if: inputs.install == 'true' shell: bash - run: vp install + env: + INSTALL_ARGS: ${{ inputs.install-args }} + # Unquoted so each filter is its own argument. + run: vp install $INSTALL_ARGS + + # A filtered install that started from the current full store adds nothing + # worth a second copy of it. + - name: Save pnpm store + if: >- + inputs.install == 'true' && + steps.pnpm_cache.outputs.cache-hit != 'true' && + (steps.pnpm_store.outputs.namespace == 'full' || + steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.path }} + key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} - name: Enable shared task cache if: >- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca294dd4a701a..e4c45728c2342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,7 +354,9 @@ jobs: id-token: write name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 10 + # Shards take 2-5 minutes. When the shared task cache throttles reads, each + # failed fetch stalls vp for 15-90s, which pushed shards past 10 minutes. + timeout-minutes: 20 strategy: fail-fast: false matrix: diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index ff68c19caab5a..5db7a86eb98c9 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -80,6 +80,7 @@ jobs: CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} - name: Publish relay deploy commit status uses: actions/github-script@v8 diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml index 53a71110cef1f..fdce22c68a59e 100644 --- a/.github/workflows/relay-preview.yml +++ b/.github/workflows/relay-preview.yml @@ -79,3 +79,5 @@ jobs: PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + # Unset by default, so previews keep the T3 Chat link off. + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index bff1b125be44b..3383bb0e80bb8 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -147,13 +147,6 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -163,10 +156,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - name: Cache resource monitor id: resource_monitor_cache @@ -259,6 +254,24 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + # tsdown downloads the target Node with one fetch and no retry, and + # nodejs.org drops that download often enough to fail releases. Seed + # tsdown's cache with a retried, checksum-verified copy instead. + - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: | + set -euo pipefail + version="$(node -p 'require("./package.json").engines.node')" + name="node-v$version-win-${{ inputs.arch }}" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$version/win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" "$dest" + curl -fsSL --retry 5 --retry-all-errors -o "$dir/$name.zip" "https://nodejs.org/dist/v$version/$name.zip" + curl -fsSL --retry 5 --retry-all-errors -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$version/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" + # Setup Vite+ selects the exact engines.node version from package.json. # Native builds embed that executable; cross-target builds download the # same version for their target. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33076d208f3a4..e8f300242f054 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -420,14 +420,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... + install-args: --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without # planning or applying. Select the backend explicitly to avoid loading @@ -522,13 +518,6 @@ jobs: !/.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 - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -538,10 +527,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install bundle dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -781,14 +772,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -977,15 +964,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... + install-args: --filter=t3... --filter=@t3tools/scripts... - name: Build the npm launcher run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages @@ -1041,15 +1023,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -1145,11 +1122,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Alias channel shell: bash @@ -1216,14 +1191,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/marketing... + install-args: --filter=@t3tools/marketing... - id: deploy name: Build staged deployment @@ -1287,11 +1258,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Promote marketing site shell: bash @@ -1346,15 +1315,10 @@ jobs: echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings @@ -1411,14 +1375,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Read published release notes if: needs.preflight.outputs.is_prerelease == 'true' diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 340cb23136441..094033192e088 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary. +import { pipe } from "effect/Function"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; @@ -151,8 +152,10 @@ export async function startDesktopAppControlServer(input: { } if (!isDesktopAppActivationRequest(parsed)) { - finish( - invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + pipe( + requestIdFromUnknown(parsed), + (requestId) => invalidResponse(requestId, "The desktop app request is invalid."), + finish, ); return; } diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 9f76003c19c8d..ac4c6e61378e3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -63,7 +63,7 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, + boringSidebarEnabled: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 76daee09c2aeb..e31d347084209 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1,5 +1,6 @@ // @effect-diagnostics globalTimers:off -- Capture timeouts and Electron overlay animation timers run at native callback boundaries outside Effect fibers. +import { pipe } from "effect/Function"; import { DEFAULT_CLIENT_SETTINGS, DesktopPendingSnapShot, @@ -1288,10 +1289,10 @@ export const make = Effect.gen(function* () { if (isModifierPairShortcut(shortcut)) { registered = yield* Effect.tryPromise(() => startPairShortcutProcess(snapShotShortcutModifierPair(shortcut), onCurrentShortcut, () => { - void runPromise( - setShortcutFailure( - snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), - ), + void pipe( + snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), + setShortcutFailure, + runPromise, ).catch(() => undefined); }), ).pipe( diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 5798c1adb060d..225496bb7a407 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -17,6 +17,7 @@ import { type NavigationAction, type NavigationState, } from "@react-navigation/native"; +import { pipe } from "effect/Function"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -52,7 +53,11 @@ import { OverlayPortalHost } from "./components/ui/OverlayPortal"; import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state"; -import { pairingFormLinkAction, startDevAutoConnect } from "./features/connection/devAutoConnect"; +import { + pairingFormLinkAction, + startDevAutoConnect, + withoutPairingTarget, +} from "./features/connection/devAutoConnect"; import { resolvePendingNewTaskPromptLink, takeNewTaskPromptLink, @@ -104,17 +109,15 @@ const linkingConfig = { } as const; function getAppStateFromPath(path: string, options: Parameters[1]) { - return takeScheduledTaskNewLink( - startDevAutoConnect( - takeNewTaskPromptLink( - takeThreadPromptLink( - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), - ), - ), - ), - ), + return pipe( + getStateFromPath(path, options), + (state) => restoreDraftLinkState(state, path), + (state) => restoreSettingsLinkState(state, settingsLinkPage()), + takeThreadPromptLink, + takeNewTaskPromptLink, + withoutPairingTarget, + startDevAutoConnect, + takeScheduledTaskNewLink, ); } diff --git a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx index df0e8c165929e..b3922c4a95eda 100644 --- a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx +++ b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx @@ -10,7 +10,6 @@ import { } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import Constants from "expo-constants"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { Linking, View } from "react-native"; @@ -28,8 +27,8 @@ export function useConnectionDiagnosis( environmentId: EnvironmentId, presentation: EnvironmentPresentation | null, ) { - const state = Option.getOrElse( - AsyncResult.value(useAtomValue(environmentCatalog.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + useAtomValue(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); if (presentation === null || !presentation.entry.enabled) return { diagnosis: null, state }; diff --git a/apps/mobile/src/features/connection/devAutoConnect.test.ts b/apps/mobile/src/features/connection/devAutoConnect.test.ts index 760e68ac598c1..f4261a4eacd09 100644 --- a/apps/mobile/src/features/connection/devAutoConnect.test.ts +++ b/apps/mobile/src/features/connection/devAutoConnect.test.ts @@ -89,7 +89,7 @@ it("pairs from a nested settings link once, reports failure, and closes the form const state = settingsLink({ pairingUrl: ` ${PAIRING_URL} `, autoConnect: "1" }); expect(startDevAutoConnect(state)).toBe(state); - expect(harness.pair).toHaveBeenCalledExactlyOnceWith(PAIRING_URL, undefined); + expect(harness.pair).toHaveBeenCalledExactlyOnceWith(PAIRING_URL); expect(appAtomRegistry.get(devAutoConnectAttemptAtom)).toEqual({ pairingUrl: PAIRING_URL, status: "connecting", diff --git a/apps/mobile/src/features/connection/devAutoConnect.ts b/apps/mobile/src/features/connection/devAutoConnect.ts index 213504d8dfd55..67f3ab1eee942 100644 --- a/apps/mobile/src/features/connection/devAutoConnect.ts +++ b/apps/mobile/src/features/connection/devAutoConnect.ts @@ -1,9 +1,8 @@ import type { NavigationState, PartialState } from "@react-navigation/native"; import { CommonActions, StackActions } from "@react-navigation/native"; -import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; -import { findLinkedRoute } from "../../lib/appLinking"; +import { findLinkedRoute, updateLinkedRoute } from "../../lib/appLinking"; import { rootNavigationRef, whenRootNavigationReady } from "../../lib/rootNavigation"; import { appAtomRegistry } from "../../state/atom-registry"; import { leaveSettingsPage } from "../settings/settings-navigation"; @@ -44,10 +43,10 @@ function isAutoConnectFlag(value: unknown): boolean { return value === "1" || value === "true"; } -async function connect(pairingUrl: string, expectedEnvironmentId: EnvironmentId | undefined) { +async function connect(pairingUrl: string) { const attempt: DevAutoConnectAttempt = { pairingUrl, status: "connecting" }; appAtomRegistry.set(devAutoConnectAttemptAtom, attempt); - const outcome = await pairEnvironment(pairingUrl, expectedEnvironmentId); + const outcome = await pairEnvironment(pairingUrl); // A later link replaced or cleared this attempt; its result belongs to no open form. if (appAtomRegistry.get(devAutoConnectAttemptAtom) !== attempt) return; if (!outcome.ok) { @@ -71,6 +70,20 @@ async function connect(pairingUrl: string, expectedEnvironmentId: EnvironmentId }); } +/** + * `routeFor` makes a pairing an "Add route" to a saved environment, which skips the guard against + * a pairing taking over that environment's id. Only in-app navigation may set it, never a link. + */ +export function withoutPairingTarget( + state: PartialState | undefined, +): PartialState | undefined { + return updateLinkedRoute(state, PAIRING_FORM_ROUTES, (route) => { + if (route.params === undefined || !("routeFor" in route.params)) return route; + const { routeFor: _routeFor, ...params } = route.params as { readonly routeFor?: unknown }; + return { ...route, params }; + }); +} + /** * `connections/new?pairingUrl=…&autoConnect=1` (or `settings/environment-new?…`) pairs from the * link itself, so a remount of the form never pairs twice. Production builds ignore the flag so @@ -85,11 +98,10 @@ export function startDevAutoConnect( const params = (route.params ?? {}) as { readonly autoConnect?: unknown; readonly pairingUrl?: unknown; - readonly routeFor?: EnvironmentId; }; const pairingUrl = typeof params.pairingUrl === "string" ? params.pairingUrl.trim() : ""; if (isAutoConnectFlag(params.autoConnect) && pairingUrl.length > 0) { - void connect(pairingUrl, params.routeFor); + void connect(pairingUrl); } else { // Only links put a pairing URL on the form, so a form opened by any other link starts // clean instead of showing an earlier auto-connect's failure for the same URL. diff --git a/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts b/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts index 46f6f941806a7..7467c73fbe403 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts @@ -2,7 +2,6 @@ import { usePullRequestConnected } from "./usePullRequestConnected"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { PullRequestDetail } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { use, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; @@ -50,8 +49,9 @@ export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullReq input: { ...target.input, files: [{ path, viewed }] }, }); // The read that is current once the write lands still predates it; a newer one carries it. - const readAtWrite = Option.getOrNull( - AsyncResult.value(registry.get(composerPullRequests.filesViewed(target))), + const readAtWrite = AsyncResult.getOrElse( + registry.get(composerPullRequests.filesViewed(target)), + () => null, ); setState((current) => { if (current.key !== scopeKey || current.presses[path]?.version !== version) return current; diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index a87e07b4dbff8..5bc3cf730b520 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -3,7 +3,6 @@ import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runt import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import { use, useMemo, useState } from "react"; import { environmentCatalog } from "../../connection/catalog"; @@ -32,7 +31,7 @@ type Read = (atom: Atom.Atom) => A; /** The head the list query holds now, cached or answered. */ function currentHead(read: Read, target: ListTarget) { - return Option.getOrNull(AsyncResult.value(read(composerPullRequests.list(target)))); + return AsyncResult.getOrElse(read(composerPullRequests.list(target)), () => null); } function currentPages(read: Read, target: ListTarget): PullRequestListPages | null { diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 632fb08c2e7b3..012d00737d1df 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -56,7 +56,7 @@ export function EnvironmentRoutesSection({ const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); - const session = Option.getOrNull(AsyncResult.value(sessionResult)); + const session = AsyncResult.getOrElse(sessionResult, () => null); const [permissionsOpen, setPermissionsOpen] = useState(false); const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); diff --git a/apps/mobile/src/features/settings/environmentUpdateCheck.ts b/apps/mobile/src/features/settings/environmentUpdateCheck.ts index b75bc06a3949a..1bbe0c63cae5d 100644 --- a/apps/mobile/src/features/settings/environmentUpdateCheck.ts +++ b/apps/mobile/src/features/settings/environmentUpdateCheck.ts @@ -40,7 +40,7 @@ export const environmentReleaseCheckAtom = Atom.family((environmentId: Environme export function releaseCheckValue( result: AsyncResult.AsyncResult, ): EnvironmentReleaseCheck | null { - return Option.getOrNull(AsyncResult.value(result)); + return AsyncResult.getOrElse(result, () => null); } export function releaseCheckError( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 2f6cc0d292a7f..a2a2450864eee 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -20,7 +20,6 @@ import { AppText as Text } from "../../components/ui/AppText"; import { TerminalContextSheet } from "./TerminalContextSheet"; import { hasNativeTerminalSurface, supportsNativeTerminalOutput } from "./nativeTerminalModule"; import * as Clipboard from "expo-clipboard"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { @@ -340,7 +339,7 @@ function readLiveTerminalSessions( ): ReadonlyArray { if (!readEnvironmentScope(environmentId, AuthTerminalReadScope)) return []; const result = appAtomRegistry.get(terminalEnvironment.metadata({ environmentId, input: null })); - const summaries = Option.getOrElse(AsyncResult.value(result), () => []); + const summaries = AsyncResult.getOrElse(result, () => []); return summaries .filter((summary) => summary.threadId === threadId) .map((summary) => ({ diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 2bbb168ba5b32..ac96063001836 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { useAtomValue } from "@effect/atom-react"; import { type LinkableMachine, @@ -125,7 +126,7 @@ function PendingLinkRequest(props: { setDeclining(false), ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; @@ -311,7 +312,7 @@ function LinkRequestSheet(props: { setPairingCode(""); props.onClose(); } else if (!isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index 0112257fec713..4ab7a18beffec 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { SECRET_REQUEST_DEFAULT_PLACEHOLDER, secretRequestAnswerInput, @@ -91,7 +92,7 @@ function PendingSecretRequestForm(props: { return; } if (!isAtomCommandInterrupted(result)) { - setError(secretRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), secretRequestFailureMessage, setError); } }; diff --git a/apps/mobile/src/features/triage/useStartTriage.ts b/apps/mobile/src/features/triage/useStartTriage.ts index 059e852337d22..eefb775bcd177 100644 --- a/apps/mobile/src/features/triage/useStartTriage.ts +++ b/apps/mobile/src/features/triage/useStartTriage.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -67,11 +68,11 @@ export function useStartTriage() { const scratch = await openScratch({ environmentId, input: {} }); if (scratch._tag === "Failure") { if (!isAtomCommandInterrupted(scratch)) { - fail( - errorMessage( - squashAtomCommandFailure(scratch), - "This machine cannot start threads without a project.", - ), + pipe( + squashAtomCommandFailure(scratch), + (failure) => + errorMessage(failure, "This machine cannot start threads without a project."), + fail, ); } return; @@ -82,7 +83,11 @@ export function useStartTriage() { }); if (prepared._tag === "Failure") { if (!isAtomCommandInterrupted(prepared)) { - fail(errorMessage(squashAtomCommandFailure(prepared), "The server could not prepare it.")); + pipe( + squashAtomCommandFailure(prepared), + (failure) => errorMessage(failure, "The server could not prepare it."), + fail, + ); } return; } diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 4f3a77daf5d90..067e3aea2ecf7 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -23,7 +23,7 @@ interface ContextChipPresentation { * surface. See `ContextChip.tsx`. */ const CONTEXT_CHIP_PRESENTATIONS = { - image: { accent: "#d55665", icon: "image" }, + image: { accent: "#0090cd", icon: "image" }, video: { accent: "#d06217", icon: "film" }, file: { accent: "#0090cd", icon: null }, mention: { accent: "#0096af", icon: null }, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 03ea87da79672..9cdb3b6f8d815 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2191,6 +2191,12 @@ export function buildThreadFeed( const item = row.item; if (turnItemIsWorkspacePreparation(item)) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; + if ( + item.type === "user_message" && + item.inputIntent === "queued_turn" && + item.status === "pending" + ) + continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; if ( item.type === "run_interrupt_request" || diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 65fd42b6ab986..c473969884e47 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -26,7 +26,6 @@ import { threadLinkRefs, type ThreadLink, } from "@t3tools/shared/threadLinks"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -118,7 +117,7 @@ const threadProjectChipIconAtom = Atom.family((key: string) => { ); if (!icon) return null; if (icon.kind === "favicon") return icon.uri; - return Option.getOrNull(AsyncResult.value(get(projectGlyphImageAtom(projectGlyphKey(icon))))); + return AsyncResult.getOrElse(get(projectGlyphImageAtom(projectGlyphKey(icon))), () => null); }).pipe(Atom.withLabel(`mobile-thread-project-chip-icon:${key}`)); }); diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts index b8736d6d3c36e..22a3a9a26bb69 100644 --- a/apps/mobile/src/state/mediaActions.ts +++ b/apps/mobile/src/state/mediaActions.ts @@ -13,7 +13,6 @@ import { type SessionGrantInput, } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { useEffect, useRef, useState } from "react"; @@ -32,7 +31,7 @@ function allowsHostMedia(session: SessionGrantInput | null) { function canReadHostMedia(environmentId: EnvironmentId | null): boolean { if (environmentId === null) return true; const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); - return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); + return result._tag !== "Failure" && allowsHostMedia(AsyncResult.getOrElse(result, () => null)); } export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { diff --git a/apps/mobile/src/state/projectClones.ts b/apps/mobile/src/state/projectClones.ts index 321cff9db2202..fc8176a8aff92 100644 --- a/apps/mobile/src/state/projectClones.ts +++ b/apps/mobile/src/state/projectClones.ts @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { serverEnvironment } from "./server"; @@ -22,7 +21,7 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); // On subscription failure, let server dispatch guards decide rather than blocking Start until reconnect. if (result._tag === "Failure") return EMPTY_CLONES; - return Option.getOrElse(AsyncResult.value(result), () => "pending" as const); + return AsyncResult.getOrElse(result, () => "pending" as const); }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), ); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index f923314b4cb72..75e60887546d3 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -318,7 +318,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { ); const results = useAtomValue(pagesAtom); const values = results.flatMap((result) => { - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); return value === null ? [] : [value]; }); const refs = new Map(); diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index b5d5b6a6a1a66..8c3a75fb3bf45 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -1,6 +1,5 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( @@ -31,7 +30,7 @@ export function useEnvironmentQuery( const result = useAtomValue(selectedAtom); const refresh = useAtomRefresh(selectedAtom); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), // Data may be structurally shared; only a settled success proves a new response. successResult: result._tag === "Success" && !result.waiting ? result : null, failure: result._tag === "Failure" ? Cause.squash(result.cause) : null, diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 010325dfe722a..c7fbecab5805d 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -21,7 +21,7 @@ export function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, ): boolean { - const session = Option.getOrNull(AsyncResult.value(result)); + const session = AsyncResult.getOrElse(result, () => null); return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); } diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 28cb2f88c0d61..4cb0f92b4ee83 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -10,7 +10,6 @@ import { createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect } from "react"; @@ -94,8 +93,8 @@ export function useEnvironmentThread( ? environmentThreads.stateAtom(environmentId, threadId) : EMPTY_THREAD_STATE_ATOM, ); - const state = Option.getOrElse( - AsyncResult.value(result), + const state = AsyncResult.getOrElse( + result, () => EMPTY_ENVIRONMENT_THREAD_STATE, ) as EnvironmentThreadState; return state; diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index b0931e175c558..9851b841c88c5 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -9,7 +9,6 @@ import { import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useCallback, useMemo } from "react"; @@ -39,7 +38,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); const access = resolveUsageAccess({ connectionPhase: presentation.connection.phase, - session: Option.getOrNull(AsyncResult.value(sessionResult)), + session: AsyncResult.getOrElse(sessionResult, () => null), hasSessionError: sessionResult._tag === "Failure", }); if (!access.canReadDiagnostics) { @@ -54,7 +53,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => continue; } const result = get(serverEnvironment.usageSummary({ environmentId, input })); - const summary = Option.getOrNull(AsyncResult.value(result)); + const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, label: presentation.entry.target.label, diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 8c049cd4b6180..791383be9e7a3 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1517,9 +1517,7 @@ export function subscribeComposerDraftModelReconciliation(input: { if (!thread || !draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection)) continue; // Status and selection must come from the same emission; derived atoms can update apart. - const shell = Option.getOrNull( - AsyncResult.value(get(input.shellStateAtom(ref.environmentId))), - ); + const shell = AsyncResult.getOrElse(get(input.shellStateAtom(ref.environmentId)), () => null); if (shell?.status !== "live") continue; const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( (candidate) => candidate.id === ref.threadId, diff --git a/apps/server/scripts/fetch-expo-schema.ts b/apps/server/scripts/fetch-expo-schema.ts index 9cf7c3920379f..cc41f4ffe7f98 100644 --- a/apps/server/scripts/fetch-expo-schema.ts +++ b/apps/server/scripts/fetch-expo-schema.ts @@ -4,6 +4,7 @@ * without a credential. eas-cli generates its own client from the same * endpoint. Run `vp run generate` afterwards to regenerate the operations. */ +import { pipe } from "effect/Function"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -36,8 +37,10 @@ const program = Effect.gen(function* () { Effect.flatMap(HttpClientResponse.schemaBodyJson(IntrospectionResponse)), ); // Sorted so a refresh diffs by what Expo changed, not by its declaration order. - const sdl = printSchema( - lexicographicSortSchema(buildClientSchema(response.data as unknown as IntrospectionQuery)), + const sdl = pipe( + buildClientSchema(response.data as unknown as IntrospectionQuery), + lexicographicSortSchema, + printSchema, ); const target = path.join(import.meta.dirname, "..", "schema", "expo.graphql"); yield* fs.makeDirectory(path.dirname(target), { recursive: true }); diff --git a/apps/server/scripts/record-pi-rpc-replay-fixture.ts b/apps/server/scripts/record-pi-rpc-replay-fixture.ts index 6e5d59cd735c7..8451074a426fc 100644 --- a/apps/server/scripts/record-pi-rpc-replay-fixture.ts +++ b/apps/server/scripts/record-pi-rpc-replay-fixture.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ProviderReplayEntry, ProviderReplayTranscript } from "@t3tools/contracts"; import * as Console from "effect/Console"; @@ -99,8 +100,12 @@ function normalizeEntries(input: { .replaceAll(input.workspace, "") .replaceAll(home.length > 1 ? home : "\0", "~") .replace(ISO_TIMESTAMP_PATTERN, (iso) => - DateTime.formatIso( - DateTime.makeUnsafe(shiftTime(DateTime.toEpochMillis(DateTime.makeUnsafe(iso)))), + pipe( + DateTime.makeUnsafe(iso), + DateTime.toEpochMillis, + shiftTime, + DateTime.makeUnsafe, + DateTime.formatIso, ), ) .replace(UUID_PATTERN, (uuid) => { diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 2f0366d4d2d22..31acd7bdb60bf 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -5,22 +5,26 @@ import { CommandId, EventId, GuestShareId, + type GuestStreamEvent, MessageId, type OrchestrationV2TurnItem, ProjectId, ProviderDriverKind, ProviderInstanceId, + RunId, ThreadId, TurnItemId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -842,3 +846,270 @@ it.effect("serves the shared thread's files to its guest, never another thread's assert.isDefined(shareId); }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + +it.effect("serves an image on the host only when an agent reply the guest sees shows it", () => + Effect.gen(function* () { + const { orchestrator, guestShares } = yield* shareThread; + const runId = yield* ownerRun; + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const shot = `${directory}/my shot.png`; + const fileUrl = `${directory}/file-url.png`; + const page = `${directory}/page.png`; + const fromUser = `${directory}/from-user.png`; + const unnamed = `${directory}/unnamed.png`; + const later = `${directory}/later.png`; + for (const path of [shot, fileUrl, fromUser, unnamed, later]) { + yield* fileSystem.writeFile(path, PNG); + } + yield* fileSystem.writeFileString(page, ""); + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const run = (yield* orchestrator.getThreadRecords(threadId, ["runs"])).runs[0]; + const item = ( + id: string, + ordinal: number, + fields: + | { readonly type: "assistant_message"; readonly text: string } + | { readonly type: "user_message"; readonly text: string }, + target: { readonly threadId: ThreadId; readonly runId: RunId } = { threadId, runId }, + ) => ({ + id: EventId.make(`event:${id}`), + type: "turn-item.updated" as const, + threadId: target.threadId, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(id), + threadId: target.threadId, + runId: target.runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + title: null, + status: "completed" as const, + startedAt: now, + completedAt: now, + updatedAt: now, + messageId: MessageId.make(id), + ...(fields.type === "assistant_message" + ? { ...fields, streaming: false } + : { + ...fields, + createdBy: "user" as const, + creationSource: "web" as const, + inputIntent: "turn_start" as const, + attachments: [], + }), + }, + }); + yield* eventSink.write({ + events: [ + item("item:agent-images", 1, { + type: "assistant_message", + text: `Done: ![Shot](<${shot}>) ![Other](file://${encodeURI(fileUrl)}) ![Page](${page})`, + }), + // A guest's sent message is a user message; it must not open files on the host. + item("item:guest-image", 2, { type: "user_message", text: `![Mine](${fromUser})` }), + { + id: EventId.make("event:run-done"), + type: "run.updated" as const, + threadId, + runId, + providerInstanceId: instanceId, + occurredAt: now, + payload: { ...run!, status: "completed" as const, completedAt: now }, + }, + ], + }); + // The guest watches a fork, which shows these replies as inherited history. + const forkId = ThreadId.make("thread:guest-share-fork"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork"), + sourceThreadId: threadId, + targetThreadId: forkId, + sourcePoint: { type: "run", runId }, + createdBy: "user", + creationSource: "web", + }); + const { url } = yield* guestShares.create({ threadId: forkId }); + const { token: session } = yield* guestShares.redeem({ + credential: url.slice(url.indexOf("#") + 1), + name: "Sam", + }); + + // The guest's page opens, and its stream sends the replies the fork inherits. + const sent = yield* Queue.unbounded(); + yield* Stream.runForEach(yield* guestShares.stream(session), (event) => + Queue.offer(sent, event), + ).pipe(Effect.forkScoped); + const sentItem = Effect.fn(function* (id: string) { + for (;;) { + const event = yield* Queue.take(sent); + if ( + event.type === "thread" && + event.item.kind === "event" && + event.item.event.type === "turn-item.updated" && + event.item.event.payload.id === id + ) { + return; + } + } + }); + + // The agent shows another screenshot live, then enough rows to push the first reply out of + // the window, before the page asks for any image. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("fork-message"), + threadId: forkId, + messageId: MessageId.make("message:fork"), + text: "One more", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const forkRun = (yield* orchestrator.getThreadRecords(forkId, ["runs"])).runs.at(-1)!; + const inFork = { threadId: forkId, runId: forkRun.id }; + yield* eventSink.write({ + events: [ + item( + "item:later-image", + 1, + { type: "assistant_message", text: `![Later](${later})` }, + inFork, + ), + ...Array.from({ length: 600 }, (_, index) => + item( + `item:filler-${index}`, + index + 2, + { type: "assistant_message", text: "Working." }, + inFork, + ), + ), + ], + }); + yield* sentItem("item:later-image"); + + // The page still shows every reply it was sent, so each image loads. + for (const path of [shot, fileUrl, later]) { + assert.deepStrictEqual(yield* guestShares.media(session, path), { + path, + mimeType: "image/png", + }); + } + for (const path of [page, fromUser, unnamed, `${directory}/missing.png`]) { + assert.equal((yield* Effect.flip(guestShares.media(session, path))).reason, "unavailable"); + } + + // A reply edited to drop its image stops allowing it. + yield* eventSink.write({ + events: [ + { + ...item("item:later-image", 1, { type: "assistant_message", text: "Gone." }, inFork), + id: EventId.make("event:later-image-edited"), + }, + ], + }); + yield* sentItem("item:later-image"); + assert.equal((yield* Effect.flip(guestShares.media(session, later))).reason, "unavailable"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), +); + +it.effect("keeps a connected guest's images while other shares come and go", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const shot = `${directory}/shot.png`; + yield* fileSystem.writeFile(shot, PNG); + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + /** A shared thread whose agent reply shows the screenshot, and its guest's session. */ + const sharedWithImage = Effect.fn(function* (id: ThreadId) { + const { orchestrator, guestShares, credential } = yield* shareThreadFor(id); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`message:${id}`), + threadId: id, + messageId: MessageId.make(`message:${id}`), + text: "Show me", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const runId = (yield* orchestrator.getThreadRecords(id, ["runs"])).runs[0]!.id; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`event:reply:${id}`), + type: "turn-item.updated", + threadId: id, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:reply:${id}`), + threadId: id, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + title: null, + status: "completed", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "assistant_message", + messageId: MessageId.make(`item:reply:${id}`), + text: `![Shot](${shot})`, + streaming: false, + }, + }, + ], + }); + const { token } = yield* guestShares.redeem({ credential, name: "Sam" }); + return { guestShares, token }; + }); + + // This guest's page stays open while more guests than the record keeps open and close theirs. + const { guestShares, token } = yield* sharedWithImage(threadId); + const opened = yield* Deferred.make(); + yield* Stream.runForEach(yield* guestShares.stream(token), () => + Deferred.succeed(opened, undefined), + ).pipe(Effect.forkScoped); + yield* Deferred.await(opened); + const closedTokens: Array = []; + for (let index = 0; index < 65; index++) { + const other = yield* sharedWithImage(ThreadId.make(`thread:other-${index}`)); + const events = yield* guestShares.stream(other.token); + // The first is built but never read, as for a HEAD request; the rest open and close. + if (index > 0) yield* Stream.runDrain(Stream.take(events, 2)); + closedTokens.push(other.token); + } + assert.equal((yield* guestShares.media(token, shot)).mimeType, "image/png"); + // Records of guests with no open page were dropped to make room. + for (const closed of closedTokens.slice(0, 2)) { + assert.equal((yield* Effect.flip(guestShares.media(closed, shot))).reason, "unavailable"); + } + + // A new link on the thread starts empty: its guest gets nothing the last guest was sent. + const { url } = yield* guestShares.create({ threadId }); + const next = yield* guestShares.redeem({ + credential: url.slice(url.indexOf("#") + 1), + name: "Kim", + }); + assert.equal((yield* Effect.flip(guestShares.media(next.token, shot))).reason, "unavailable"); + yield* Stream.runDrain(Stream.take(yield* guestShares.stream(next.token), 2)); + assert.equal((yield* guestShares.media(next.token, shot)).mimeType, "image/png"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), +); diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 1f65217e45212..0346068bc546f 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -15,9 +15,11 @@ import { type OrchestrationV2CommandOutputFrame, type OrchestrationV2DomainEvent, type OrchestrationV2GetTurnItemResult, + type OrchestrationV2TurnItem, ThreadId, TurnItemId, } from "@t3tools/contracts"; +import { chatMarkdownImageSources } from "@t3tools/shared/markdownPipeline"; import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -63,7 +65,10 @@ import { guestShareView, guestThreadModel } from "./guestView.ts"; import { classifyGuestUploads, GUEST_UPLOAD_HEADER_BYTES, + guestHostImageType, type GuestUpload, + matchesImageSignature, + repliesShowImage, } from "./guestAttachments.ts"; const SIGNING_SECRET_NAME = "guest-share-signing-key"; @@ -71,6 +76,8 @@ const SHARE_ID_BYTES = 16; const INVITE_SIGNATURE_BYTES = 16; const SNAPSHOT_ROW_LIMIT = 600; const SNAPSHOT_USER_TURN_LIMIT = 20; +// Shares with no open guest stream whose sent image replies are still kept. +const SHARED_THREAD_REPLIES_LIMIT = 64; // How soon an open read notices that its link stopped working, such as Connect no longer serving // the host. Closing or replacing the link ends the main stream at once. const ACCESS_RECHECK_INTERVAL = Duration.seconds(30); @@ -190,9 +197,20 @@ export class GuestShareService extends Context.Service< { readonly path: string; readonly attachment: ChatAttachment }, GuestShareAccessError >; + /** An image on the host that an agent reply the guest sees shows, and its type. */ + readonly media: ( + session: string, + path: string, + ) => Effect.Effect<{ readonly path: string; readonly mimeType: string }, GuestShareAccessError>; } >()("t3/guest/GuestShareService") {} +/** A reply a guest's stream sent, with its image sources once a media request parsed them. */ +interface SentReply { + readonly text: string; + sources?: ReadonlyArray; +} + /** The thread record an event carries, for the share and the view. */ const threadOf = (event: OrchestrationV2DomainEvent): OrchestrationV2AppThread | null => { switch (event.type) { @@ -464,6 +482,40 @@ const make = Effect.gen(function* () { Effect.ignore, ); + // The image replies each share's guest stream has sent, in its opening snapshot or live, so an + // image loads while the guest's page can show its reply, inherited ones included. Keyed by + // share, so a new link on the thread starts empty. A share with an open stream keeps its + // record; past the limit, closed ones are dropped. + const sentImageReplies = new Map< + GuestShareId, + { openStreams: number; readonly replies: Map } + >(); + const sentRecord = (shareId: GuestShareId) => { + let record = sentImageReplies.get(shareId); + if (record === undefined) { + record = { openStreams: 0, replies: new Map() }; + sentImageReplies.set(shareId, record); + for (const [id, other] of sentImageReplies) { + if (sentImageReplies.size <= SHARED_THREAD_REPLIES_LIMIT) break; + if (other.openStreams === 0 && id !== shareId) sentImageReplies.delete(id); + } + } + return record; + }; + const rememberSent = (shareId: GuestShareId, items: Iterable) => { + for (const item of items) { + if (item.type !== "assistant_message") continue; + const key = `${item.threadId}\u0000${item.id}`; + // Only replies with image syntax can show an image. One edited to drop its images stops + // allowing them. + if (item.text.includes("![") || / => { if (item.kind !== "event") return []; if (endsShare(item.event)) return [ended]; + if (item.event.type === "turn-item.updated") rememberSent(share.id, [item.event.payload]); const updated = threadOf(item.event); const view = updated === null ? null : viewOf(updated); return view === null @@ -528,17 +581,15 @@ const make = Effect.gen(function* () { const untilExpiry = Effect.sleep( Math.max(0, Date.parse(share.expiresAt) - DateTime.toEpochMillis(yield* DateTime.now)), ); + const openingSnapshot = buildBoundedThreadStreamSnapshot({ + snapshotSequence: snapshot.snapshotSequence, + projection: snapshot.projection, + }); const opening: ReadonlyArray = [ { type: "view", view: initialView }, - { - type: "thread", - item: buildBoundedThreadStreamSnapshot({ - snapshotSequence: snapshot.snapshotSequence, - projection: snapshot.projection, - }), - }, + { type: "thread", item: openingSnapshot }, ]; - return Stream.fromIterable(opening).pipe( + const events = Stream.fromIterable(opening).pipe( Stream.concat( Stream.merge(live, Stream.fromEffect(Effect.as(untilExpiry, ended)), { haltStrategy: "either", @@ -557,6 +608,25 @@ const make = Effect.gen(function* () { ), ), ); + // The record is opened and counted when the stream runs, paired with its release, so a + // response whose body never runs (HEAD) touches nothing. + return Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => { + const record = sentRecord(share.id); + record.openStreams += 1; + rememberSent( + share.id, + openingSnapshot.projection.visibleTurnItems.map((row) => row.item), + ); + return record; + }), + (record) => + Effect.sync(() => { + record.openStreams -= 1; + }), + ).pipe(Effect.as(events)), + ); }); const unavailable = new GuestShareAccessError({ reason: "unavailable" }); @@ -630,6 +700,33 @@ const make = Effect.gen(function* () { return exists ? { path, attachment: found } : yield* unavailable; }); + const media = Effect.fn("GuestShareService.media")(function* (session: string, path: string) { + const { share } = yield* authorize(session, "session"); + const mimeType = guestHostImageType(path); + if (mimeType === null) return yield* unavailable; + const sent = sentImageReplies.get(share.id)?.replies.values() ?? []; + // Each sent reply is parsed once, on the first request that needs it. + if ( + !repliesShowImage( + path, + [...sent], + (reply) => (reply.sources ??= chatMarkdownImageSources(reply.text)), + ) + ) { + return yield* unavailable; + } + // The bytes must be the image the name says, as for a guest's own upload. + const header = yield* fileSystem.open(path, { flag: "r" }).pipe( + Effect.flatMap((file) => file.readAlloc(GUEST_UPLOAD_HEADER_BYTES)), + Effect.scoped, + Effect.mapError(() => unavailable), + ); + if (Option.isNone(header) || !matchesImageSignature(mimeType, header.value)) { + return yield* unavailable; + } + return { path, mimeType }; + }); + return GuestShareService.of({ create, redeem, @@ -640,6 +737,7 @@ const make = Effect.gen(function* () { item, commandOutput, attachment, + media, }); }); diff --git a/apps/server/src/guest/guestAttachments.test.ts b/apps/server/src/guest/guestAttachments.test.ts new file mode 100644 index 0000000000000..c65dd1364c2b0 --- /dev/null +++ b/apps/server/src/guest/guestAttachments.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { guestHostImageType, repliesShowImage } from "./guestAttachments.ts"; + +describe("guest host images", () => { + it.each([ + ["![a](/w/a.png)", "/w/a.png", "image/png"], + ["![a]()", "/w/my shot.jpg", "image/jpeg"], + ["![a](/w/shot(1).png)", "/w/shot(1).png", "image/png"], + ["![a](/w/shot\\_1.png) ![b](/w/a&b.png)", "/w/shot_1.png", "image/png"], + ["![a](/w/shot\\_1.png) ![b](/w/a&b.png)", "/w/a&b.png", "image/png"], + ["![a](file:///w/caf%c3%a9.png)", "/w/café.png", "image/png"], + ["![a](file:///w/%73hot.png) ![b](/w/shot2.png)", "/w/shot.png", "image/png"], + ["![a](file:///w/%73hot.png) ![b](/w/shot2.png)", "/w/shot2.png", "image/png"], + ["![a][r]\n\n[r]: /w/ref.png", "/w/ref.png", "image/png"], + ["![a](file:///w/url%20form.png)", "/w/url form.png", "image/png"], + ["![a](C:\\w\\win.png)", "C:\\w\\win.png", "image/png"], + ["![a][r]\n\n[r]: C:\\w\\ref.png", "C:/w/ref.png", "image/png"], + // Shown as a link, in code, or not at all: the guest sees no image, so none loads. + ["[a](/w/link.png)", "/w/link.png", null], + ["```\n![a](/w/code.png)\n```", "/w/code.png", null], + ["`![a](/w/span.png)`", "/w/span.png", null], + ["", "/w/comment.png", null], + ["![a](/w/a.png)", "/a.png", null], + ["![a](//host/s/x.png)", "//host/s/x.png", null], + ["![a](\\\\host\\s\\x.png)", "\\\\host\\s\\x.png", null], + ["![a](/w/a.svg)", "/w/a.svg", null], + ["![a](/w/a.constructor)", "/w/a.constructor", null], + ])("%j serves %j as %j", (text, path, type) => { + const served = guestHostImageType(path) !== null && repliesShowImage(path, [{ text }]); + expect(served ? guestHostImageType(path) : null).toBe(type); + }); +}); diff --git a/apps/server/src/guest/guestAttachments.ts b/apps/server/src/guest/guestAttachments.ts index ab7bd1a78b8e5..5118899620d64 100644 --- a/apps/server/src/guest/guestAttachments.ts +++ b/apps/server/src/guest/guestAttachments.ts @@ -3,6 +3,9 @@ import { GUEST_MESSAGE_MAX_ATTACHMENTS, isProviderSendTurnSupportedImageMimeType, } from "@t3tools/contracts"; +import { markdownDestinationHostPath } from "@t3tools/shared/markdownLinks"; +import { chatMarkdownImageSources } from "@t3tools/shared/markdownPipeline"; +import { isAbsolutePath, isNetworkPath } from "@t3tools/shared/path"; /** A file from a guest's message request, saved by the multipart parser to a temporary path. */ export interface GuestUpload { @@ -46,6 +49,44 @@ const IMAGE_SIGNATURES: Readonly boolean> startsWith(header, ascii("RIFF")) && startsWith(header, ascii("WEBP"), 8), }; +export const matchesImageSignature = (mimeType: string, header: Uint8Array) => + IMAGE_SIGNATURES[mimeType]?.(header) === true; + +const IMAGE_TYPE_BY_EXTENSION: ReadonlyMap = new Map([ + ["png", "image/png"], + ["jpg", "image/jpeg"], + ["jpeg", "image/jpeg"], + ["gif", "image/gif"], + ["webp", "image/webp"], +]); + +/** + * The type of a host image a guest may ask for by path, or null. A network path never qualifies: + * it would make the host reach out to another machine. + */ +export function guestHostImageType(path: string): string | null { + if (!isAbsolutePath(path) || isNetworkPath(path)) return null; + return IMAGE_TYPE_BY_EXTENSION.get(path.split(".").at(-1)!.toLowerCase()) ?? null; +} + +/** + * Whether an agent reply shows the image at `path`, so the guest already sees it. `imageSources` + * lets the caller reuse a reply's parse across requests. + */ +export function repliesShowImage( + path: string, + replies: ReadonlyArray, + imageSources: (reply: Reply) => ReadonlyArray = (reply) => + chatMarkdownImageSources(reply.text), +) { + // Parsing is the slow part; a reply with no image syntax shows no image. + return replies.some( + (reply) => + (reply.text.includes("![") || / markdownDestinationHostPath(source) === path), + ); +} + /** * Control characters and separators would break the name in headers and on disk, and direction * overrides would let `invoice\u202Efdp.exe` show as a PDF. @@ -76,11 +117,9 @@ export function classifyGuestUploads( upload.contentType.split(";", 1)[0]!.trim().toLowerCase() || "application/octet-stream"; if (upload.sizeBytes === 0) return { ok: false, reason: "unsupported_file" }; if (mimeType.startsWith("image/")) { - const signatureMatches = IMAGE_SIGNATURES[mimeType]; if ( !isProviderSendTurnSupportedImageMimeType(mimeType) || - signatureMatches === undefined || - !signatureMatches(upload.header) + !matchesImageSignature(mimeType, upload.header) ) { return { ok: false, reason: "unsupported_file" }; } diff --git a/apps/server/src/guest/http.ts b/apps/server/src/guest/http.ts index 7c8356119deb5..bf368ac884d92 100644 --- a/apps/server/src/guest/http.ts +++ b/apps/server/src/guest/http.ts @@ -2,6 +2,7 @@ import { GUEST_ATTACHMENT_ROUTE, GUEST_COMMAND_OUTPUT_ROUTE, GUEST_ITEM_ROUTE, + GUEST_MEDIA_ROUTE, GUEST_RENDER_ROUTE, GUEST_ROUTE_PREFIX, GUEST_SHARE_PARAM, @@ -34,6 +35,12 @@ import { GuestShareAccessError, GuestShareService } from "./GuestShareService.ts // Cloudflare closes idle streams after about 100 seconds. const HEARTBEAT_INTERVAL = Duration.seconds(20); const NO_STORE = { "cache-control": "no-store" }; +// A file never runs as a page on this origin, whatever its bytes. +const FILE_HEADERS = { + ...NO_STORE, + "x-content-type-options": "nosniff", + "content-security-policy": "default-src 'none'; sandbox", +}; const decodeRedeem = Schema.decodeUnknownEffect(GuestRedeemRequest); const decodeSubmit = Schema.decodeUnknownEffect(GuestSubmitRequest); @@ -315,9 +322,7 @@ const attachmentRoute = HttpRouter.add( Effect.flatMap(({ path, attachment }) => HttpServerResponse.file(path, { headers: { - ...NO_STORE, - "x-content-type-options": "nosniff", - "content-security-policy": "default-src 'none'; sandbox", + ...FILE_HEADERS, ...(attachment.type === "image" && isProviderSendTurnSupportedImageMimeType(attachment.mimeType) ? { "content-type": attachment.mimeType } @@ -334,6 +339,25 @@ const attachmentRoute = HttpRouter.add( }), ); +// Only an image an agent reply shows loads; anything else on the host is refused. +const mediaRoute = HttpRouter.add( + "GET", + GUEST_MEDIA_ROUTE, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const path = new URLSearchParams(request.url.split("?", 2)[1] ?? "").get("path"); + if (!path) return yield* badRequest; + const guestShares = yield* GuestShareService; + return yield* guestShares.media(yield* sessionToken, path).pipe( + Effect.flatMap(({ path, mimeType }) => + HttpServerResponse.file(path, { headers: { ...FILE_HEADERS, "content-type": mimeType } }), + ), + Effect.catchTags({ GuestShareAccessError: errorResponse }), + Effect.orElseSucceed(() => HttpServerResponse.empty({ status: 500, headers: NO_STORE })), + ); + }), +); + export const layer = Layer.mergeAll( redeemRoute, sessionRoute, @@ -343,4 +367,5 @@ export const layer = Layer.mergeAll( itemRoute, commandOutputRoute, attachmentRoute, + mediaRoute, ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index af763b73f7055..8f9737a3efc18 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -16,7 +16,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { cast } from "effect/Function"; +import { cast, pipe } from "effect/Function"; import { HttpClient, HttpClientResponse, @@ -181,8 +181,11 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( if (bytes === null) { return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 }); } - const body = new TextEncoder().encode( - injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes))), + const body = pipe( + new TextDecoder().decode(bytes), + injectMcpAppThemeBootstrap, + injectMcpAppCookieBootstrap, + (html) => new TextEncoder().encode(html), ); headers["Content-Length"] = String(body.byteLength); return method === "HEAD" diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 92eb5b1047ff8..f05ace7ff750d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -111,7 +111,25 @@ const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); describe("acpToolCallDiffPatch", () => { - it("builds a patch per file from ACP v1 oldText/newText, with /dev/null for a new file", () => { + it("builds per-file patches from ACP hunks and recorded old/new text", () => { + for (const operation of ["add", "delete"]) { + const text = operation === "add" ? "@@ -0,0 +1 @@\n+content\n" : "@@ -1 +0,0 @@\n-content\n"; + assert.equal( + acpToolCallDiffPatch([ + { + type: "diff", + changes: [{ operation, path: "/repo/a.ts" }], + patch: { format: "git_patch", text }, + }, + ]), + [ + 'diff --git "a//repo/a.ts" "b//repo/a.ts"', + operation === "add" ? "--- /dev/null" : '--- "a//repo/a.ts"', + operation === "delete" ? "+++ /dev/null" : '+++ "b//repo/a.ts"', + text, + ].join("\n"), + ); + } assert.equal( acpToolCallDiffPatch([ { type: "diff", path: "/repo/new.txt", oldText: null, newText: "hello\n" }, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 36702689be9e8..58cd4a0c0c17b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as DocumentText from "../../provider/DocumentText.ts"; import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; @@ -1448,8 +1449,11 @@ function redactCodexProtocolValue(value: unknown): unknown { return value; } try { - return JSON.stringify( - redactCodexProtocolValue(boundProviderEventForLogging(JSON.parse(trimmed) as unknown)), + return pipe( + JSON.parse(trimmed) as unknown, + boundProviderEventForLogging, + redactCodexProtocolValue, + JSON.stringify, ); } catch { return value; @@ -4304,7 +4308,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("account/rateLimits/updated", (payload) => Effect.gen(function* () { - yield* Ref.update(rateLimitSnapshot, (previous) => + const previous = yield* Ref.getAndUpdate(rateLimitSnapshot, (previous) => mergeCodexRateLimits(previous, payload.rateLimits), ); const resetAt = codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)); @@ -4324,7 +4328,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( turnItem: updated, }); } - const update = codexRateLimitsToUpdate(payload.rateLimits); + const update = codexRateLimitsToUpdate(payload.rateLimits, previous); if (update && adapterOptions.onUsageLimits) { const checkedAt = DateTime.formatIso(yield* DateTime.now); yield* adapterOptions.onUsageLimits({ ...update, checkedAt }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index fa447ef165531..3f76bd6185eb7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1920,6 +1920,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), providerThreadId: queuedProviderThread.id, inputIntent: "queued_turn", + status: "completed", startedAt: now, completedAt: now, updatedAt: now, @@ -5749,6 +5750,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerThreadId: queuedProviderThread.id, providerTurnId: null, inputIntent: "queued_turn", + status: "pending", + startedAt: null, + completedAt: null, updatedAt: now, }, }); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 7481dcaa41b2c..d8ac4769e140f 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -339,6 +339,13 @@ it.effect.each( if (delivery !== null) { yield* Deferred.succeed(rejectSteer, undefined); yield* Fiber.join(delivery); + const requeued = yield* orchestrator.getThreadProjection(threadId); + const item = requeued.turnItems.find( + (item) => item.type === "user_message" && item.messageId === messageId, + ); + assert.equal(item?.status, "pending"); + assert.isNull(item?.startedAt); + assert.isNull(item?.completedAt); } if (timing === "before dispatch") yield* dispatchSteer; yield* worker.drain(); @@ -391,6 +398,14 @@ it.effect.each( ]); assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); const final = yield* orchestrator.getThreadProjection(threadId); + if (!mailbox) { + assert.equal( + final.turnItems.find( + (item) => item.type === "user_message" && item.messageId === messageId, + )?.status, + "completed", + ); + } assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); assert.equal( final.messages.find((message) => message.id === messageId)?.runId, @@ -669,6 +684,78 @@ it.effect("sends a selected held message once and preserves the remaining queue ), ); +it.effect("force sends multiple queued messages and can force send a requeued message again", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "multiple-promoted-messages", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const queue = (text: string, commandId: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(commandId), + threadId, + messageId: MessageId.make(`message:${text}`), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + for (const text of ["one", "two", "remaining"]) yield* queue(text, `queue-${text}`); + const promote = Effect.fn("test.promote")(function* (text: string, commandId: string) { + const projection = yield* orchestrator.getThreadProjection(threadId); + const queued = projection.runs.find( + (run) => + run.status === "queued" && run.userMessageId === MessageId.make(`message:${text}`), + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make(commandId), + threadId, + queuedRunId: queued.id, + targetRunId: started[0]!.runId, + }); + }); + yield* promote("one", "promote-one"); + yield* promote("two", "promote-two"); + yield* worker.drain(); + assert.deepEqual(steered, ["one", "two"]); + + // The completion-race fallback redispatches the same message into the queue. + yield* queue("one", "requeue-one"); + const requeued = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + requeued.turnItems.find( + (item) => item.type === "user_message" && item.messageId === "message:one", + )?.status, + "pending", + ); + yield* promote("one", "promote-one-again"); + yield* worker.drain(); + assert.deepEqual(steered, ["one", "two", "one"]); + assert.lengthOf(started, 1); + const final = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + final.runs.filter((run) => run.status === "queued").map((run) => run.userMessageId), + [MessageId.make("message:remaining")], + ); + for (const text of ["one", "two"]) { + const items = final.turnItems.filter( + (item) => item.type === "user_message" && item.messageId === `message:${text}`, + ); + assert.lengthOf(items, 1); + assert.equal(items[0]?.status, "completed"); + } + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("keeps a message queued when send now races with a running turn", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index baf1ea0a3cb77..d449aeb933dbf 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -275,6 +275,15 @@ describe("orchestration V2 wire projection", () => { expect(projected).not.toHaveProperty("newStr"); expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 }); expect(item.diffStr).toBe("+new code"); + expect(projectTurnItemForDetail(item)).toMatchObject({ diffStr: "+new code" }); + expect(projectTurnItemForDetail(item)).not.toHaveProperty("oldStr"); + expect(projectTurnItemForDetail(item)).not.toHaveProperty("newStr"); + const { diffStr: _diff, ...contentOnly } = item; + expect(projectTurnItemForDetail(contentOnly)).toMatchObject({ + oldStr: "old code", + newStr: "new code", + }); + expect(projectTurnItemForWire(contentOnly)).not.toHaveProperty("newStr"); const failed = projectTurnItemForWire({ ...item, status: "failed", diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index a84f8cd466859..6b66f1b91f204 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -200,8 +200,20 @@ export function projectTurnItemForDetail(item: OrchestrationV2TurnItem): Orchest result: item.result === null ? null : (truncateDetail(item.result, MAX_ON_DEMAND_BYTES) ?? null), }; - case "handoff": case "file_change": + return { + ...projectTurnItemForWire(item), + ...(item.diffStr === undefined + ? {} + : { diffStr: truncateDetail(item.diffStr, MAX_ON_DEMAND_BYTES) }), + ...(!item.diffStr?.trim() && item.oldStr !== undefined + ? { oldStr: truncateDetail(item.oldStr, MAX_ON_DEMAND_BYTES) } + : {}), + ...(!item.diffStr?.trim() && item.newStr !== undefined + ? { newStr: truncateDetail(item.newStr, MAX_ON_DEMAND_BYTES) } + : {}), + }; + case "handoff": return projectTurnItemForWire(item); default: return item; diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 1051f72814390..3e51b66a0e5ee 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -736,78 +736,69 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* ), ) .pipe(Effect.map(Hex.encode), Effect.orDie); - const access = lock.withPermit( - withSessionLock( - Effect.uninterruptible( - Effect.gen(function* () { - const saved = yield* read; - if (Option.isNone(saved)) - return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); - let record = saved.value; - if ( - options.expectedProfileKey !== undefined && - (yield* profileKey(record)) !== options.expectedProfileKey - ) - return yield* failure("access", "The ChatGPT connection changed. Start a new session."); - if (!record.scopes.includes(REQUIRED_SCOPE)) - return yield* failure( - "sharing", - "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.", - ); - const now = yield* Clock.currentTimeMillis; - if (record.expiresAt - now > 60_000) return record; - if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) { - if (record.expiresAt > now) return record; - return yield* failure( - "refresh", - "ChatGPT cannot renew this connection yet. Try again shortly.", - ); - } - if (!record.refreshToken) { - yield* remove; - return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again."); - } - const tokens = yield* exchange( - new URLSearchParams({ - grant_type: "refresh_token", - client_id: record.clientId, - refresh_token: record.refreshToken, - resource, - }), - ).pipe( - Effect.interruptible, - Effect.timeout("20 seconds"), - Effect.mapError((error) => - error._tag === "ProviderSetupError" && error.operation === "client" - ? error - : failure( - "refresh", - "Could not renew the ChatGPT connection. Retry, or sign in again.", - ), - ), - ); - if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") { - return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again."); - } - record = { - ...record, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, - earliestRefreshAt: earliest(tokens.earliest_refresh_at), - scopes: tokens.scope.split(/\s+/).filter(Boolean), - }; - yield* save(record); - if (!record.scopes.includes(REQUIRED_SCOPE)) - return yield* failure( - "sharing", - "ChatGPT token sharing is no longer enabled. Sign in again.", - ); - return record; - }), + const access = Effect.gen(function* () { + const saved = yield* read; + if (Option.isNone(saved)) + return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); + let record = saved.value; + if ( + options.expectedProfileKey !== undefined && + (yield* profileKey(record)) !== options.expectedProfileKey + ) + return yield* failure("access", "The ChatGPT connection changed. Start a new session."); + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.", + ); + const now = yield* Clock.currentTimeMillis; + if (record.expiresAt - now > 60_000) return record; + if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) { + if (record.expiresAt > now) return record; + return yield* failure( + "refresh", + "ChatGPT cannot renew this connection yet. Try again shortly.", + ); + } + if (!record.refreshToken) { + yield* remove; + return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again."); + } + const tokens = yield* exchange( + new URLSearchParams({ + grant_type: "refresh_token", + client_id: record.clientId, + refresh_token: record.refreshToken, + resource, + }), + ).pipe( + Effect.interruptible, + Effect.timeout("20 seconds"), + Effect.mapError((error) => + error._tag === "ProviderSetupError" && error.operation === "client" + ? error + : failure("refresh", "Could not renew the ChatGPT connection. Retry, or sign in again."), ), - ), - ); + ); + if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") { + return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again."); + } + record = { + ...record, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, + earliestRefreshAt: earliest(tokens.earliest_refresh_at), + scopes: tokens.scope.split(/\s+/).filter(Boolean), + }; + yield* save(record); + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "ChatGPT token sharing is no longer enabled. Sign in again.", + ); + return record; + }).pipe(Effect.uninterruptible, withSessionLock, lock.withPermit); const logout = Effect.gen(function* () { const credentials = Option.getOrUndefined(yield* read); let confirmed = !credentials?.refreshToken; diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 8458315f64de8..2b2d5501881b4 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -801,11 +802,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, - models: applyConfiguredCodexServiceTier( - applyPreferredCodexDefaultModel( - appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), - ), - snapshot.serviceTier, + models: pipe( + appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), + applyPreferredCodexDefaultModel, + (models) => applyConfiguredCodexServiceTier(models, snapshot.serviceTier), ), skills: snapshot.skills, slashCommands: [ diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 7d73f1221cb4b..d3035d5ae9b5f 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -1,5 +1,6 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime, which the cache fingerprint needs. import * as NodeFSP from "node:fs/promises"; +import { pipe } from "effect/Function"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -244,7 +245,9 @@ const readDatabase = Effect.fn("readDatabase")(function* ( for (const row of yield* sql.unsafe<{ readonly data: unknown }>( "SELECT data FROM trajectory_metadata_blob", )) { - trajectoryTimestamp ??= yield* decode(() => timestamp(nested(fields(blob(row.data)), 2))); + trajectoryTimestamp ??= yield* decode(() => + pipe(blob(row.data), fields, (message) => nested(message, 2), timestamp), + ); } } const steps = tables.has("steps") diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 934412bd3bb41..9c863f54c0867 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -128,6 +128,29 @@ describe("codexRateLimitsToLimits", () => { }); describe("codexRateLimitsToUpdate", () => { + it("does not republish cached windows over a newer account probe", () => { + const cached = { + primary: { usedPercent: 0, resetsAt: 1_784_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 20, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, + }; + expect(codexRateLimitsToUpdate(cached)?.windows).toHaveLength(2); + expect(codexRateLimitsToUpdate(structuredClone(cached), cached)).toBeUndefined(); + + const changed = { ...cached, secondary: { ...cached.secondary, usedPercent: 21 } }; + expect(codexRateLimitsToUpdate(changed, cached)?.windows.map((window) => window.id)).toEqual([ + "secondary", + ]); + expect(codexRateLimitsToUpdate({ primary: cached.primary }, cached)).toBeUndefined(); + expect( + codexRateLimitsToUpdate({ primary: { ...cached.primary, resetsAt: 1_784_001_000 } }, cached) + ?.windows, + ).toHaveLength(1); + expect( + codexRateLimitsToUpdate({ primary: { ...cached.primary, usedPercent: 76 } }, cached) + ?.windows[0]?.usedPercent, + ).toBe(76); + }); + it("carries only the windows the notification names", () => { expect( codexRateLimitsToUpdate({ diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index f8718cdda03d9..d4f9c621119df 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -135,8 +135,20 @@ export function codexRateLimitsToLimits(input: { export function codexRateLimitsToUpdate( snapshot: CodexRateLimitSnapshot, + previous?: CodexRateLimitSnapshot, ): ProviderUsageLimitsUpdate | undefined { - const windows = codexRateLimitsToWindows(snapshot); + const previousWindows = previous ? codexRateLimitsToWindows(previous) : []; + // Token ticks repeat session-cached limits that may predate an account probe or reset. + const windows = codexRateLimitsToWindows(snapshot).filter((window) => { + const existing = previousWindows.find((candidate) => candidate.id === window.id); + return ( + !existing || + existing.kind !== window.kind || + existing.usedPercent !== window.usedPercent || + existing.resetsAt !== window.resetsAt || + existing.windowDurationMins !== window.windowDurationMins + ); + }); return windows.length > 0 ? { windows } : undefined; } diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 444c130c1ee11..b42932fcc6668 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-27T23:47:46Z", + "updatedAt": "2026-10-28T08:10:39Z", "compatibility": [ { "driver": "codex", @@ -150,7 +150,7 @@ }, "claudeAgent": { "defaults": { - "chat": "claude-fable-5-1" + "chat": "claude-opus-5-5" }, "profiles": { "fable-5": { diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index 7c8e961dbd2eb..9e3a7c033c220 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -1,6 +1,7 @@ // Answers stay in the secret store behind one-use refs; orchestration records only request status. // Consuming tools keep values out of transcripts, projections, clients and model context. // A `file` request instead moves its value to an owner-only file for the agent's own shell. +import { pipe } from "effect/Function"; import { CommandId, SecretRef, @@ -143,7 +144,7 @@ const make = Effect.gen(function* () { // Clear failed saves so the pending card can be answered again. Effect.tapError(() => input.answer.type === "save" - ? removeLogged(storeName(refFor(salt, input.threadId, item.id))) + ? pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged) : Effect.void, ), ); @@ -159,7 +160,7 @@ const make = Effect.gen(function* () { ); const card = recorded.turnItems.find((candidate) => candidate.id === item.id); if (card?.type === "secret_request" && card.secretStatus !== "saved") { - yield* removeLogged(storeName(refFor(salt, input.threadId, item.id))); + yield* pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged); return yield* new SecretRequestError({ reason: "agent_stopped" }); } }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e35aaf1bb93cd..bea3b8a46097a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -889,11 +889,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs projection: dedupeBodies ? dedupeSnapshotBodies(projection) : projection, }; })(); - return withLiveDedupe( - Stream.concat( - Stream.concat(rpcInitialItems([snapshotItem]), completionMarker), - eventStreamFrom(snapshotSequence), - ), + return rpcInitialItems([snapshotItem]).pipe( + Stream.concat(completionMarker), + Stream.concat(eventStreamFrom(snapshotSequence)), + withLiveDedupe, ); }); @@ -962,11 +961,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs if (plan.mode === "snapshot") { return yield* snapshotThenLive(); } - return withLiveDedupe( - Stream.concat( - Stream.concat(rpcInitialItems(replay), completionMarker), - eventStreamFrom(highWater), - ), + return rpcInitialItems(replay).pipe( + Stream.concat(completionMarker), + Stream.concat(eventStreamFrom(highWater)), + withLiveDedupe, ); } diff --git a/apps/web/package.json b/apps/web/package.json index de75da24e380b..729d44f8ea6cd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,7 +27,6 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource-variable/inter": "^5.3.0", - "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 60a60525f0350..7d1042e612629 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -5,7 +5,6 @@ import type { EnvironmentId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -91,5 +90,5 @@ export function usePreviewWebviewConfig( profileId?: string, ): DesktopPreviewWebviewConfig | null { const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId))); - return Option.getOrNull(AsyncResult.value(result)); + return AsyncResult.getOrElse(result, () => null); } diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 16fec79749b68..f562373463e1b 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,10 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + createManagedRelayT3ChatLinkRequest, deregisterManagedRelayEnvironment, ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, + unlinkManagedRelayT3Chat, } from "@t3tools/client-runtime/relay"; import { createAtomCommandScheduler, @@ -13,6 +15,7 @@ import { import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, + RelayT3ChatLinkResponse, } from "@t3tools/contracts/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -50,6 +53,50 @@ export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( }, ); +const accountSerial = { + mode: "serial", + key: (input: { readonly accountId: string }) => input.accountId, +} as const; + +export const createManagedRelayT3ChatLinkRequestCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "web:managed-relay:create-t3chat-link-request", + scheduler: managedRelayMutationScheduler, + concurrency: accountSerial, + execute: (input: { readonly accountId: string }, registry) => + createManagedRelayT3ChatLinkRequest(registry, input), + }, +); + +export const unlinkManagedRelayT3ChatCommand = createRuntimeCommand(managedRelayAtomRuntime, { + label: "web:managed-relay:unlink-t3chat", + scheduler: managedRelayMutationScheduler, + concurrency: accountSerial, + execute: (input: { readonly accountId: string }, registry) => + unlinkManagedRelayT3Chat(registry, input), +}); + +const EMPTY_T3CHAT_LINK_ATOM = Atom.make( + AsyncResult.success({ available: false, link: null }), +).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:t3chat-link:null")); + +export function useManagedRelayT3ChatLink() { + const session = useAtomValue(managedRelaySessionAtom); + const accountId = session?.accountId ?? null; + const atom = accountId + ? managedRelayQueryManager.t3ChatLinkAtom(accountId) + : EMPTY_T3CHAT_LINK_ATOM; + const snapshot = readManagedRelaySnapshotState(useAtomValue(atom)); + const refresh = () => { + if (accountId) { + managedRelayQueryManager.refreshT3ChatLink(appAtomRegistry, accountId); + } + }; + + return { ...snapshot, accountId, refresh }; +} + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 9672f2a00366e..867008fbc3bef 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -8,7 +8,6 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { HttpClient } from "effect/http"; @@ -90,7 +89,7 @@ export function usePrimaryCloudLinkState() { } return { - data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)), + data: result._tag === "Failure" ? null : AsyncResult.getOrElse(result, () => null), error, isPending: result.waiting, refresh, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 20abcf1e57ade..6c8837e63f40a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -31,14 +31,13 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, usePanelNavigationSuppression, } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; -import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; import { isAppModalPage } from "./sidebar/mainAppLocation"; @@ -403,7 +402,6 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads"); - const legacySidebarEnabled = useLegacySidebarEnabled(); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, pendingKey: null, @@ -470,11 +468,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }} style={windowControlsInsetStyle} > - {legacySidebarEnabled ? ( - - ) : ( - - )} + {children} diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index d67803a6399ce..345bde84976c3 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -10,6 +10,7 @@ import "../index.css"; import { filesystemEnvironment } from "../state/filesystem"; import { useRightPanelStore } from "../rightPanelStore"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; +import { HostImageUrlContext } from "./chat/hostImageUrl"; const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ workspaceSearch: vi.fn(), @@ -122,6 +123,22 @@ it("shows unavailable audio when a workspace embed has no thread", async () => { expect(screen.container.querySelector("audio")).toBeNull(); }); +it("loads a host image through the page's own route when there is no environment", async () => { + setup(); + const pixel = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + const hostImageUrl = vi.fn((_path: string) => pixel); + await using screen = await render( + + + , + ); + await expect.element(screen.getByAltText("Shot")).toHaveAttribute("src", pixel); + expect(new Set(hostImageUrl.mock.calls.map(([path]) => path))).toEqual( + new Set(["/work/my shot.png"]), + ); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2ae3e8d9f971a..32f15bcf542d7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -65,6 +65,7 @@ import { import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; import { InlineAudioPreview } from "./files/InlineAudioPreview"; +import { HostImageUrlContext } from "./chat/hostImageUrl"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -1483,6 +1484,24 @@ const CHAT_MARKDOWN_COMPONENTS = { /> ); } + const hostImageUrl = use(HostImageUrlContext); + if (imageSource._tag === "WorkspaceFile" && kind === "image" && hostImageUrl !== null) { + const hostSrc = hostImageUrl(imageSource.path); + return ( + + ); + } if (imageSource._tag === "WorkspaceFile" && threadRef) { return ( >( + () => new Set(), + ); + const onHeldRequestIdsChange = useStableCallback((requestIds: ReadonlySet) => { + setHeldApprovalRequestIds((current) => (shallow(current, requestIds) ? current : requestIds)); + }); const activeProposedPlan = (() => { if (!latestRunSettled) { return null; @@ -2316,6 +2319,7 @@ export default function ChatView(props: ChatViewProps) { createBrowserSurface, createBrowserSurfaceStable, moveRightPanelSurfaceStable, + onOpenToolDiffStable, onOpenTurnDiffStable, openChangesFromThreadPanelStable, openFileSurfaceStable, @@ -3245,6 +3249,11 @@ export default function ChatView(props: ChatViewProps) { className="relative flex min-h-0 flex-1 flex-col bg-background" > selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); + const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null; + const isGitSelection = diffSelection.kind === "branch" || diffSelection.kind === "unstaged"; + const toolCallItems = useQuietThreadVisibleTurnItems(toolCallSelection ? routeThreadRef : null); + const toolCallDetail = useTurnItemDetail( + toolCallSelection && routeThreadRef + ? { + ...resolveToolCallDiffTarget(toolCallSelection, toolCallItems), + environmentId: routeThreadRef.environmentId, + } + : null, + ); + const toolCallItem = toolCallDetail.data?.item; const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadRef); const orderedTurnDiffSummaries = turnDiffSummaries.toSorted((left, right) => { @@ -283,24 +302,31 @@ export default function DiffPanel({ orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn?.checkpointTurnCount; const latestTurn = orderedTurnDiffSummaries[0]; - const selectedScopeLabel = - selectedRunId === null + const selectedScopeLabel = toolCallSelection + ? "Tool call" + : selectedRunId === null ? selectedGitScope === "unstaged" ? "Uncommitted" : "Changes" : selectedTurn?.runId === latestTurn?.runId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; - const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope; + const reviewSectionId = toolCallSelection + ? `tool-call:${toolCallSelection.threadId}:${toolCallSelection.itemId}` + : selectedTurn + ? `turn:${selectedTurn.runId}` + : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; - const reviewSectionTitle = selectedTurn - ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes"; + const reviewSectionTitle = toolCallSelection + ? "Tool call" + : selectedTurn + ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + : selectedGitScope === "unstaged" + ? "Uncommitted" + : "Changes"; const selectedCheckpointRange = typeof selectedCheckpointTurnCount === "number" ? { @@ -320,7 +346,7 @@ export default function DiffPanel({ { enabled: isGitRepo && selectedTurn !== undefined }, ); const branchDiffPreviewInput = - canReadFiles && selectedRunId === null && activeThread && activeCwd + canReadFiles && isGitSelection && activeThread && activeCwd ? { cwd: activeCwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), @@ -328,7 +354,7 @@ export default function DiffPanel({ } : undefined; const canRefreshGitDiff = - isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null; + isGitRepo && isGitSelection && activeThread != null && activeCwd != null; // Read under the workspace mutation id, so each mutation refetches the diff; the panel stays // mounted while hidden, so a mutation made then is already reflected when it shows. const branchDiffPreview = useEnvironmentQuery( @@ -355,25 +381,52 @@ export default function DiffPanel({ const preview = branchDiffPreview.data; const gitDiff = selectedGitSource?.diff; - const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; - const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true; - const selectedPatchQuery = selectedTurn ? activeCheckpointDiff : branchDiffPreview; + const toolCallPreview = + toolCallSelection && toolCallItem?.type === "file_change" ? toolCallDiff(toolCallItem) : null; + const selectedPatch = toolCallSelection + ? toolCallPreview?.patch + : selectedTurn + ? activeCheckpointDiff.data?.diff + : gitDiff; + const isSelectedPatchTruncated = + toolCallPreview?.truncated === true || + (isGitSelection && selectedGitSource?.truncated === true); + const selectedPatchQuery = toolCallSelection + ? toolCallDetail + : selectedTurn + ? activeCheckpointDiff + : branchDiffPreview; const isLoadingSelectedPatch = selectedPatchQuery.isPending && selectedPatchQuery.data === null; - const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error; + const selectedPatchError = toolCallSelection + ? toolCallDetail.error + : selectedTurn + ? activeCheckpointDiff.error + : branchDiffPreview.error; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const lazySource = - !selectedTurn && selectedGitSource?.truncated && selectedGitSource.files + isGitSelection && selectedGitSource?.truncated && selectedGitSource.files ? selectedGitSource : null; const renderablePatch = getRenderablePatch( lazySource ? undefined : selectedPatch, `diff-panel:${resolvedTheme}`, - { compactPartialHunkOffsets: selectedRunId === null }, + { + compactPartialHunkOffsets: isGitSelection, + ...(toolCallSelection ? { ignoreWhitespace: diffIgnoreWhitespace } : {}), + }, ); + const recordedToolCallFileCount = + toolCallSelection && toolCallItem?.type === "file_change" + ? (toolCallItem.changes?.length ?? 0) + : 0; + const omittedToolCallFiles = + renderablePatch?.kind === "files" + ? Math.max(0, recordedToolCallFileCount - renderablePatch.files.length) + : 0; const fileStats = new Map(lazySource?.files?.map((file) => [file.path, file])); const sourceLineStat = - !selectedTurn && selectedGitSource?.files + isGitSelection && selectedGitSource?.files ? selectedGitSource.files.reduce( (total, file) => ({ additions: total.additions + file.additions, @@ -383,7 +436,7 @@ export default function DiffPanel({ ) : null; const currentLoadDiffFiles = - selectedRunId === null && activeThread && preview && selectedGitSource && canReadFiles + isGitSelection && activeThread && preview && selectedGitSource && canReadFiles ? createGitDiffFileContentsLoader(getDiffFileContents, { environmentId: activeThread.environmentId, cwd: preview.cwd, @@ -400,10 +453,7 @@ export default function DiffPanel({ return currentLoadDiffFiles(fileDiff); }); const localBranchRefs = useEnvironmentQuery( - selectedRunId === null && - selectedGitScope === "branch" && - activeThread && - branchDiffPreview.data?.cwd + isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { @@ -417,10 +467,7 @@ export default function DiffPanel({ : null, ); const remoteBranchRefs = useEnvironmentQuery( - selectedRunId === null && - selectedGitScope === "branch" && - activeThread && - branchDiffPreview.data?.cwd + isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { @@ -630,8 +677,9 @@ export default function DiffPanel({ useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.runId}` : ""; - const selectedScopeValue = - selectedRunId === null + const selectedScopeValue = toolCallSelection + ? "" + : selectedRunId === null ? selectedGitScope : selectedTurn?.runId === latestTurn?.runId ? "latest" @@ -698,7 +746,7 @@ export default function DiffPanel({ - {selectedRunId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( + {isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
- {codeViewFiles.length > 0 || (!selectedTurn && selectedGitSource?.files?.length) ? ( + {codeViewFiles.length > 0 || (isGitSelection && selectedGitSource?.files?.length) ? ( Select a thread to inspect turn diffs.
- ) : !isGitRepo ? ( + ) : !isGitRepo && !toolCallSelection ? (
Turn diffs are unavailable because this project is not a git repository.
@@ -981,7 +1029,7 @@ export default function DiffPanel({
No completed turns yet.
- ) : selectedRunId === null && !canReadFiles ? ( + ) : isGitSelection && !canReadFiles ? ( fileAccess.isPending ? ( ) : ( @@ -995,7 +1043,13 @@ export default function DiffPanel({ {isSelectedPatchTruncated && !lazySource && (

This preview exceeds the size limit. Changes shown are incomplete. - {selectedGitSource?.files ? " Totals include all changes." : ""} + {isGitSelection && selectedGitSource?.files ? " Totals include all changes." : ""} +

+ )} + {omittedToolCallFiles > 0 && ( +

+ This tool call changed {recordedToolCallFileCount} files. The recorded patch omits{" "} + {omittedToolCallFiles} of them.

)} {selectedPatchError && !renderablePatch && ( @@ -1003,15 +1057,36 @@ export default function DiffPanel({

{selectedPatchError}

)} - {!renderablePatch && !lazySource ? ( + {!renderablePatch && toolCallPreview && toolCallPreview.content.length > 0 ? ( +
+

+ The provider recorded file content without a diff. +

+ {toolCallPreview.content.map(({ label, text }) => ( +
+

{label}

+
+                      {text === "" ? "Empty content." : text}
+                    
+
+ ))} +
+ ) : !renderablePatch && !lazySource ? ( isLoadingSelectedPatch ? ( ) : ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx deleted file mode 100644 index 27ea62b61511a..0000000000000 --- a/apps/web/src/components/LegacySidebar.tsx +++ /dev/null @@ -1,3743 +0,0 @@ -import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; -import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; -import { Spinner } from "~/components/ui/spinner"; -import { - ArchiveIcon, - ArrowUpDownIcon, - ChevronRightIcon, - FolderPlusIcon, - Globe2Icon, - SearchIcon, - SquarePenIcon, - TriangleAlertIcon, -} from "lucide-react"; -import { - ChangeRequestStatusIcon, - prStatusIndicator, - PrStatusTooltipContent, - ThreadTerminalStatusButton, - ThreadStatusLabel, - ThreadWorktreeIndicator, - useLinkedThreadPullRequest, -} from "./ThreadStatusIndicators"; -import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { ProjectFavicon } from "./ProjectFavicon"; -import { useAtomValue } from "@effect/atom-react"; -import { autoAnimate } from "@formkit/auto-animate"; -import React, { - createContext, - useEffect, - useEffectEvent, - memo, - useContext, - useRef, - useState, -} from "react"; -import { cn } from "~/lib/utils"; -import { useShallow } from "zustand/react/shallow"; -import { - DndContext, - type DragCancelEvent, - type CollisionDetection, - PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; -import { - AuthOrchestrationOperateScope, - AuthPreviewOperateScope, - type ContextMenuItem, - type EnvironmentId, - ProjectId, - type ScopedThreadRef, - type ResolvedKeybindingsConfig, - type SidebarProjectGroupingMode, - resolveEnvironmentMachineKind, - ThreadId, -} from "@t3tools/contracts"; -import { - parseScopedThreadKey, - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import { - isAtomCommandInterrupted, - settlePromise, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; -import { useNavigate, useRouter } from "@tanstack/react-router"; -import { - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - MIN_SIDEBAR_THREAD_PREVIEW_COUNT, - type SidebarProjectSortOrder, - type SidebarThreadPreviewCount, - type SidebarThreadSortOrder, -} from "@t3tools/contracts/settings"; -import { isDesktopLocalConnectionTarget, isWslConnectionTarget } from "../connection/desktopLocal"; -import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; -import { isElectron } from "../env"; -import { useStableCallback } from "../hooks/useStableCallback"; -import { useTerminalFocus } from "../hooks/useTerminalFocus"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform } from "../lib/utils"; -import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; -import { - readThreadShell, - useProjects, - useDelegatedUnderParent, - useThreadShells, - useThreadShellsForProjectRefs, - waitForProjectRemoval, -} from "../state/entities"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; -import { useAtomCommand } from "../state/use-atom-command"; -import { - ORCHESTRATION_OPERATE_DENIED_MESSAGE, - useOrchestrationCommand, -} from "../state/use-orchestration-command"; -import { previewEnvironment } from "../state/preview"; -import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; -import { - legacyProjectCwdPreferenceKey, - resolveProjectExpanded, - useUiStateStore, -} from "../uiStateStore"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHintsForModifiers, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, -} from "../keybindings"; -import { isModelPickerOpen } from "../modelPickerVisibility"; -import { useShortcutModifierState } from "../shortcutModifierState"; -import { ensureLocalApi, readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useDesktopUpdateState } from "../state/desktopUpdate"; - -import { useThreadActions } from "../hooks/useThreadActions"; -import { projectEnvironment } from "../state/projects"; -import { threadEnvironment, useEnvironmentThread } from "../state/threads"; -import { readUnloadedThreadCount } from "../state/olderSettledShelf"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { copyThreadReference } from "../lib/copyThreadReference"; -import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import { RelativeTime } from "./RelativeTime"; -import { Kbd } from "./ui/kbd"; -import { - getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, - isDesktopUpdateButtonDisabled, - resolveDesktopUpdateButtonAction, - shouldShowArm64IntelBuildWarning, - shouldToastDesktopUpdateActionResult, -} from "./desktopUpdate.logic"; -import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "./ui/number-field"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { - SidebarContent, - SidebarGroup, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - useSidebar, -} from "./ui/sidebar"; -import { - getThreadKeysToDeselectAfterDelete, - useThreadSelectionStore, -} from "../threadSelectionStore"; -import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; -import { - archiveSelectedThreadEntries, - buildMultiSelectThreadContextMenuItems, - deleteSelectedThreadEntries, - filterSidebarV2VisibleThreads, - getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, - isContextMenuPointerDown, - isSidebarNestedLinkClick, - isTrailingDoubleClick, - resolveProjectStatusIndicator, - resolveThreadLastVisitedAt, - resolveThreadStatusPill, - orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, - useSidebarRowSubscriptionLease, - setFileDragActive, - useThreadJumpHintsVisible, - ThreadStatusPill, -} from "./Sidebar.logic"; -import { sortThreads } from "@t3tools/client-runtime/state/thread-sort"; -import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useIsMobile } from "~/hooks/useMediaQuery"; -import { CommandDialogTrigger } from "./ui/command"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; -import { openGuestShareDialog } from "./chat/guestShareDialogState"; -import { - derivePhysicalProjectKey, - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "@t3tools/client-runtime/state/project-grouping"; -import type { SidebarThreadSummary } from "../types"; -import { - buildPhysicalToLogicalProjectKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; -import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; -import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -import { type SidebarRouteReveal, useActiveRowReveal } from "./sidebar/routeReveal"; -const SIDEBAR_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", - manual: "Manual", -}; -const SIDEBAR_THREAD_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", -}; -const SIDEBAR_LIST_ANIMATION_OPTIONS = { - duration: 180, - easing: "ease-out", -} as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); -const PROJECT_GROUPING_MODE_LABELS: Record = { - repository: "Group by repository", - repository_path: "Group by repository path", - separate: "Keep separate", -}; -const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; - -function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { - useEnvironmentThread(threadRef.environmentId, threadRef.threadId); - return null; -} - -function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { - return Math.min( - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), - ) as SidebarThreadPreviewCount; -} - -function formatProjectMemberActionLabel( - member: SidebarProjectGroupMember, - groupedProjectCount: number, -): string { - if (groupedProjectCount <= 1) { - return member.title; - } - - return member.environmentLabel - ? `${member.environmentLabel}: ${member.workspaceRoot}` - : member.workspaceRoot; -} - -function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { - return [ - project.projectKey, - ...project.memberProjects.map((member) => member.physicalProjectKey), - ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), - ]; -} - -function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { - switch (mode) { - case "repository": - return "Projects from the same repository share one sidebar row."; - case "repository_path": - return "Projects group only when both the repository and repo-relative path match."; - case "separate": - return "Every project path gets its own sidebar row."; - } -} - -function buildThreadJumpLabelMap(input: { - keybindings: ResolvedKeybindingsConfig; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; -}): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } - - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; -} - -interface SidebarThreadRowProps { - navigationParent?: string | undefined; - thread: SidebarThreadSummary; - orderedProjectThreadKeys: readonly string[]; - isActive: boolean; - openPullRequestsInRightPanel: boolean; - jumpLabel: string | null; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => Promise; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: ( - event: React.MouseEvent, - prUrl: string, - threadRef?: ScopedThreadRef, - ) => boolean; - onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; -} - -function checkTaskPermission(environmentId: EnvironmentId): boolean { - if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return true; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Action unavailable", - description: ORCHESTRATION_OPERATE_DENIED_MESSAGE, - }), - ); - return false; -} - -// The active row reports itself, so a route change scrolls to it once it mounts. -const RevealActiveRowContext = createContext<(row: HTMLElement | null, threadKey: string) => void>( - () => {}, -); - -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { - orderedProjectThreadKeys, - isActive, - openPullRequestsInRightPanel, - jumpLabel, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - onFileDropThreads, - thread, - } = props; - const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); - const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope); - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const [isFileDragOver, setIsFileDragOver] = useState(false); - const revealActiveRow = useContext(RevealActiveRowContext); - const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); - const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const isMobile = useIsMobile(); - const discoveredPorts = useThreadDiscoveredPorts({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); - const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = environment?.label ?? null; - const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); - const isDesktopLocalThread = - environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) - : null; - const isThreadRunning = !threadRuntimeCanArchive(thread.runtime); - const threadStatus = resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt, - }, - }); - const linkedPullRequestStatus = useLinkedThreadPullRequest( - thread.environmentId, - thread.linkedPullRequest, - leaseLiveStatus, - thread.pullRequests, - thread.branchPullRequest, - ); - const pr = linkedPullRequestStatus?.pr ?? null; - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); - // Hooks must precede this ref's plain uses for the compiler to cache it. - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const fileDropHandlers = makeWorkspaceFileDropHandlers({ - setDragActive: (active) => setFileDragActive(setIsFileDragOver, active), - addFiles: (files) => { - onFileDropThreads(threadRef, files); - }, - addFolders: () => {}, - }); - const isHighlighted = isActive || isSelected; - const handleOpenDiscoveredPort = (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port || !canOperatePreview) return; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(threadRef); - void (async () => { - const result = await openDiscoveredPort({ threadRef, port, openPreview }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open preview", - description: error instanceof Error ? error.message : "The preview could not be opened.", - }), - ); - })(); - }; - const currentLinkedPr = supportsMultiplePullRequests - ? resolveThreadCurrentPullRequestLink(thread.pullRequests) - : null; - const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - const isConfirmingArchive = - canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : canOperateThread && !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - const clearConfirmingArchive = () => { - setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleMouseLeave = () => { - clearConfirmingArchive(); - }; - const handleBlurCapture = (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - clearConfirmingArchive(); - }); - }; - const handleRowClick = (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }; - const handleRowDoubleClick = (event: React.MouseEvent) => { - if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return; - if (renamingThreadKey === threadKey) return; - // Mobile navigation closes the sidebar on the first tap, so rename stays in the context menu. - if (isMobile) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if ((event.target as HTMLElement).closest("button, a")) return; - event.preventDefault(); - startThreadRename(threadKey, thread.title); - }; - const handleRowKeyDown = (event: React.KeyboardEvent) => { - if (event.target !== event.currentTarget) return; - if ( - event.key === "F2" && - renamingThreadKey !== threadKey && - readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope) - ) { - event.preventDefault(); - startThreadRename(threadKey, thread.title); - return; - } - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }; - const handleRowContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - const hasSelection = useThreadSelectionStore.getState().hasSelection(); - if (hasSelection && isSelected) { - void (async () => { - const result = await settlePromise(() => - handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - return; - } - - if (hasSelection) { - clearSelection(); - } - void (async () => { - const result = await settlePromise(() => - handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }; - const handlePrClick = (event: React.MouseEvent) => { - const url = prStatus?.url ?? currentLinkedPr?.url; - if (!url) return; - const openedInRightPanel = openPrLink( - event, - url, - openPullRequestsInRightPanel ? threadRef : undefined, - ); - if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { - navigateToThread(threadRef); - } - }; - const handleRenameInputRef = (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); - } - }; - const handleRenameInputChange = (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }; - const handleRenameInputKeyDown = (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }; - const handleRenameInputBlur = () => { - if (!renamingCommittedRef.current) { - void commitRename(threadRef, renamingTitle, thread.title); - } - }; - // Stop dblclick propagation so word selection cannot restart rename and discard edits. - const handleRenameInputClick = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - const handleConfirmArchiveRef = (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }; - const stopPropagationOnPointerDown = (event: React.PointerEvent) => { - event.stopPropagation(); - }; - const handleConfirmArchiveClick = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - clearConfirmingArchive(); - void attemptArchiveThread(threadRef); - }; - const handleStartArchiveConfirmation = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (!checkTaskPermission(thread.environmentId)) return; - setConfirmingArchiveThreadKey(threadKey); - requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(threadKey)?.focus(); - }); - }; - const handleArchiveImmediateClick = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - void attemptArchiveThread(threadRef); - }; - - return ( - -
revealActiveRow(node, threadKey) : undefined} - role="treeitem" - tabIndex={-1} - data-sidebar-row={threadKey} - data-sidebar-parent={props.navigationParent} - aria-current={isActive ? "page" : undefined} - data-active={isActive} - data-slot="sidebar-menu-sub-button" - data-sidebar="menu-sub-button" - data-size="sm" - data-testid={`thread-row-${thread.id}`} - className={cn( - "relative isolate flex h-8 w-full min-w-0 cursor-pointer select-none items-center gap-2 overflow-hidden rounded-md px-2 text-left text-xs outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring group-data-[collapsible=icon]:hidden [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground", - isActive - ? "bg-sidebar-row-active font-medium text-sidebar-foreground hover:bg-sidebar-row-active" - : isSelected - ? "bg-sidebar-row-selected text-sidebar-foreground hover:bg-sidebar-row-active" - : "text-sidebar-muted-foreground/80 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", - isFileDragOver && "ring-1 ring-inset ring-primary/70", - )} - onClick={handleRowClick} - onDoubleClick={handleRowDoubleClick} - onKeyDown={handleRowKeyDown} - onContextMenu={handleRowContextMenu} - > -
- {prStatus && pr && ( - - event.stopPropagation()} - onClick={handlePrClick} - > - - - } - /> - - - - - )} - {!pr && currentLinkedPr ? ( - event.stopPropagation()} - onClick={handlePrClick} - className="text-muted-foreground" - aria-label={`PR #${currentLinkedPr.number}, status pending`} - > - - - ) : null} - {threadStatus && } - {canOperateThread && renamingThreadKey === threadKey ? ( - - ) : ( - - - {thread.title} - - } - /> - {thread.title} - - )} -
-
- {canOperatePreview && discoveredPorts.length > 0 && ( - - - } - > - - - - Open localhost:{discoveredPorts[0]?.port} - {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} - - - )} - - -
- {isConfirmingArchive ? ( - - ) : canOperateThread && !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
- -
- ) : ( - - - -
- } - /> - Archive - - ) - ) : null} - - - {isRemoteThread && !isDesktopLocalThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - - } - > - {jumpLabel} - - {jumpLabel} - - ) : ( - - - - )} - - -
-
- - - ); -}); - -interface SidebarProjectThreadListProps { - projectKey: string; - projectExpanded: boolean; - hasOverflowingThreads: boolean; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; - showEmptyThreadState: boolean; - shouldShowThreadPanel: boolean; - isThreadListExpanded: boolean; - activeRouteThreadKey: string | null; - openPullRequestsInRightPanel: boolean; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => Promise; - onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: ( - event: React.MouseEvent, - prUrl: string, - threadRef?: ScopedThreadRef, - ) => boolean; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; -} - -const SidebarProjectThreadList = memo(function SidebarProjectThreadList( - props: SidebarProjectThreadListProps, -) { - const { - projectKey, - projectExpanded, - hasOverflowingThreads, - hiddenThreadStatus, - orderedProjectThreadKeys, - renderedThreads, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - activeRouteThreadKey, - openPullRequestsInRightPanel, - threadJumpLabelByKey, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - attachThreadListAutoAnimateRef, - handleThreadClick, - navigateToThread, - onFileDropThreads, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - expandThreadListForProject, - collapseThreadListForProject, - } = props; - const showMoreButtonRender = - - } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - - - - - - { - if (!open) { - closeProjectRenameDialog(); - } - }} - > - - - Rename project - - {projectRenameTarget - ? `Update the title for ${projectRenameTarget.workspaceRoot}.` - : "Update the project title."} - - - -
- Project title - setProjectRenameTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void submitProjectRename(); - } - }} - /> -
- {projectRenameTarget?.environmentLabel ? ( -

- Environment: {projectRenameTarget.environmentLabel} -

- ) : null} - {!canRenameProject ? ( -

- This connection cannot change projects. -

- ) : null} -
- - - - -
-
- - { - if (!open) { - closeProjectGroupingDialog(); - } - }} - > - - - Project grouping - - {projectGroupingTarget - ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` - : "Choose how this project should be grouped in the sidebar."} - - - -
- Grouping rule - -
-

- {projectGroupingSelection === "inherit" - ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) - : projectGroupingModeDescription(projectGroupingSelection)} -

-
- - - - -
-
- - ); -}); - -const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { - return ( - - - - ); -}); - -function LocalSecondaryStatus() { - const { environments } = useEnvironments(); - // Poll desktop-local backends because the bridge has no change event. - const secondaries = useDesktopLocalBootstraps(); - - const localEnvByUrl = (() => { - const map = new Map(); - for (const environment of environments) { - if ( - isDesktopLocalConnectionTarget(environment.entry.target) && - environment.displayUrl !== null - ) { - map.set(environment.displayUrl, { - phase: environment.connection.phase, - error: environment.connection.error, - }); - } - } - return map; - })(); - - const connecting: string[] = []; - const failed: Array<{ label: string; error: string | null }> = []; - for (const bootstrap of secondaries) { - const env = - bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; - if (env?.phase === "connected") { - continue; - } - if (env?.phase === "error") { - failed.push({ label: bootstrap.label, error: env.error }); - continue; - } - connecting.push(bootstrap.label); - } - - if (connecting.length === 0 && failed.length === 0) { - return null; - } - - return ( - - {connecting.length > 0 ? ( - - - Connecting {connecting.join(", ")} - - ) : null} - {failed.length > 0 ? ( - - - Couldn't connect {failed.map((entry) => entry.label).join(", ")} - - {failed - .map((entry) => entry.error) - .filter(Boolean) - .join("; ") || "The backend didn't respond."} - - - ) : null} - - ); -} - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; - -function ProjectSortMenu({ - projectSortOrder, - threadSortOrder, - threadPreviewCount, - onProjectSortOrderChange, - onThreadSortOrderChange, - onThreadPreviewCountChange, -}: { - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; - onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; -}) { - const handleThreadPreviewCountChange = (nextValue: number | null) => { - if (nextValue === null) { - return; - } - - const clampedValue = clampSidebarThreadPreviewCount(nextValue); - if (clampedValue !== threadPreviewCount) { - onThreadPreviewCountChange(clampedValue); - } - }; - - return ( - - - } - /> - } - > - - - Sidebar options - - - -
- Sort projects -
- { - onProjectSortOrderChange(value as SidebarProjectSortOrder); - }} - > - {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( - ([value, label]) => ( - - {label} - - ), - )} - -
- -
- Sort threads -
- { - onThreadSortOrderChange(value as SidebarThreadSortOrder); - }} - > - {( - Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> - ).map(([value, label]) => ( - - {label} - - ))} - -
- -
- Visible threads -
-
- - - - { - event.stopPropagation(); - }} - /> - - - -
-
-
-
- ); -} - -function SortableProjectItem({ - projectId, - disabled = false, - children, -}: { - projectId: string; - disabled?: boolean; - children: (handleProps: SortableProjectHandleProps) => React.ReactNode; -}) { - const { - attributes, - listeners, - setActivatorNodeRef, - setNodeRef, - transform, - transition, - isDragging, - isOver, - } = useSortable({ id: projectId, disabled }); - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef })} -
  • - ); -} - -interface SidebarProjectsContentProps { - showArm64IntelBuildWarning: boolean; - arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; - desktopUpdateButtonDisabled: boolean; - desktopUpdateActionPending: boolean; - handleDesktopUpdateButtonClick: () => void; - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - openAddProject: () => void; - isManualProjectSorting: boolean; - projectDnDSensors: ReturnType; - projectCollisionDetection: CollisionDetection; - handleProjectDragStart: (event: DragStartEvent) => void; - handleProjectDragEnd: (event: DragEndEvent) => void; - handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - markThreadUnread: ReturnType["markThreadUnread"]; - sortedProjects: readonly SidebarProjectSnapshot[]; - sortedProjectKeys: string[]; - expandedThreadListsByProject: ReadonlySet; - activeRouteProjectKey: string | null; - routeThreadKey: string | null; - openPullRequestsInRightPanel: boolean; - newThreadShortcutLabel: string | null; - commandPaletteShortcutLabel: string | null; - threadJumpLabelByKey: ReadonlyMap; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; - projectsLength: number; -} - -const SidebarProjectsContent = memo(function SidebarProjectsContent( - props: SidebarProjectsContentProps, -) { - const { - showArm64IntelBuildWarning, - arm64IntelBuildWarningDescription, - desktopUpdateButtonAction, - desktopUpdateButtonDisabled, - desktopUpdateActionPending, - handleDesktopUpdateButtonClick, - projectSortOrder, - threadSortOrder, - threadPreviewCount, - openAddProject, - isManualProjectSorting, - projectDnDSensors, - projectCollisionDetection, - handleProjectDragStart, - handleProjectDragEnd, - handleProjectDragCancel, - handleNewThread, - archiveThread, - deleteThread, - markThreadUnread, - sortedProjects, - sortedProjectKeys, - expandedThreadListsByProject, - activeRouteProjectKey, - routeThreadKey, - openPullRequestsInRightPanel, - newThreadShortcutLabel, - commandPaletteShortcutLabel, - threadJumpLabelByKey, - attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - attachProjectListAutoAnimateRef, - projectsLength, - } = props; - - const attachTreeRef = (node: HTMLUListElement | null) => { - attachProjectListAutoAnimateRef(node); - const dispose = attachSidebarTree(node); - return () => { - dispose?.(); - attachProjectListAutoAnimateRef(null); - }; - }; - const handleProjectSortOrderChange = (sortOrder: SidebarProjectSortOrder) => { - updateClientSettings({ sidebarProjectSortOrder: sortOrder }); - }; - const handleThreadSortOrderChange = (sortOrder: SidebarThreadSortOrder) => { - updateClientSettings({ sidebarThreadSortOrder: sortOrder }); - }; - const handleThreadPreviewCountChange = (count: SidebarThreadPreviewCount) => { - updateClientSettings({ sidebarThreadPreviewCount: count }); - }; - - return ( - - - - } - > - - Search - {commandPaletteShortcutLabel ? {commandPaletteShortcutLabel} : null} - - - - - } - > - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - - -
    - Projects -
    - - - - } - > - - - Add project - -
    -
    - - {isManualProjectSorting ? ( - - - - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} - - - - ) : ( - - {sortedProjects.map((project) => ( - - ))} - - )} - - {projectsLength === 0 && ( -
    No projects yet
    - )} -
    -
    - ); -}); - -const PROJECT_POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 6 } }; -const PROJECT_DND_MODIFIERS = [restrictToVerticalAxis, restrictToFirstScrollableAncestor]; - -const projectCollisionDetection: CollisionDetection = (args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - return closestCorners(args); -}; - -function openAddProjectCommandPalette() { - openCommandPalette({ open: "add-project" }); -} - -// Separate hooks cache real inputs; later sidebar hooks would invalidate every row's props. -function useLegacySidebarProjects() { - const projects = useProjects(); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const environmentLabelById = new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ); - const desktopLocalEnvironmentIds = new Set( - environments - .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) - .map((environment) => environment.environmentId), - ); - const wslEnvironmentIds = new Set( - environments - .filter((environment) => isWslConnectionTarget(environment.entry.target)) - .map((environment) => environment.environmentId), - ); - const orderedProjects = orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }); - const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - }); - const projectPhysicalKeyByScopedRef = new Map( - orderedProjects.map((project) => [ - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - derivePhysicalProjectKey(project), - ]), - ); - const sidebarProjects: SidebarProjectSnapshot[] = buildSidebarProjectSnapshots({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), - isWslEnvironment: (environmentId) => wslEnvironmentIds.has(environmentId), - }); - return { - projectsLength: projects.length, - orderedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjects, - }; -} - -// Re-sort on thread changes without rebuilding project snapshots used as row keys. -function useSortedLegacySidebarProjects(input: { - sidebarProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - projectPhysicalKeyByScopedRef: ReadonlyMap; - sidebarProjectSortOrder: SidebarProjectSortOrder; -}) { - const { - sidebarProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjectSortOrder, - } = input; - const sidebarThreads = useThreadShells(); - const delegatedUnderParent = useDelegatedUnderParent(); - const sidebarProjectByKey = new Map( - sidebarProjects.map((project) => [project.projectKey, project] as const), - ); - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = filterSidebarV2VisibleThreads( - sidebarThreads, - null, - delegatedUnderParent, - ).map((thread) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return { - ...thread, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - }; - }); - const sortedProjects = sortProjectsForSidebar( - sortableProjects, - sortableThreads, - sidebarProjectSortOrder, - ).flatMap((project) => { - const resolvedProject = sidebarProjectByKey.get(project.id); - return resolvedProject ? [resolvedProject] : []; - }); - return { - sortedProjects, - sortedProjectKeys: sortedProjects.map((project) => project.projectKey), - }; -} - -function useLegacySidebarThreads(input: { - sortedProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - projectPhysicalKeyByScopedRef: ReadonlyMap; - routeThreadKey: string | null; - sidebarThreadSortOrder: SidebarThreadSortOrder; - sidebarThreadPreviewCount: number; - expandedThreadListsByProject: ReadonlySet; -}) { - const { - sortedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - routeThreadKey, - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - } = input; - const sidebarThreads = useThreadShells(); - const delegatedUnderParent = useDelegatedUnderParent(); - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const sidebarThreadByKey = new Map( - sidebarThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ); - const logicalProjectKeyForThread = (thread: SidebarThreadSummary) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }; - const activeRouteThread = routeThreadKey ? sidebarThreadByKey.get(routeThreadKey) : undefined; - const activeRouteProjectKey = activeRouteThread - ? logicalProjectKeyForThread(activeRouteThread) - : null; - const threadsByProjectKey = new Map(); - for (const thread of sidebarThreads) { - const logicalKey = logicalProjectKeyForThread(thread); - const existing = threadsByProjectKey.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - threadsByProjectKey.set(logicalKey, [thread]); - } - } - const visibleSidebarThreadKeys = sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - filterSidebarV2VisibleThreads( - threadsByProjectKey.get(project.projectKey) ?? [], - null, - delegatedUnderParent, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = resolveProjectExpanded( - projectExpandedById, - projectExpansionPreferenceKeys(project), - ); - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - if (pinnedCollapsedThread) { - return [ - scopedThreadKey( - scopeThreadRef(pinnedCollapsedThread.environmentId, pinnedCollapsedThread.id), - ), - ]; - } - return projectThreads.flatMap((thread, index) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - return index < previewThreads.length || threadKey === activeThreadKey ? [threadKey] : []; - }); - }); - const threadJumpCommandByKey = new Map< - string, - NonNullable> - >(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - break; - } - threadJumpCommandByKey.set(threadKey, jumpCommand); - } - const prewarmedSidebarThreadRefs = getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys).flatMap( - (threadKey) => { - const ref = parseScopedThreadKey(threadKey); - return ref ? [ref] : []; - }, - ); - return { - sidebarThreadByKey, - activeRouteProjectKey, - visibleSidebarThreadKeys, - threadJumpCommandByKey, - threadJumpThreadKeys: [...threadJumpCommandByKey.keys()], - prewarmedSidebarThreadRefs, - }; -} - -function clearThreadSelectionOnOutsideMouseDown(event: globalThis.MouseEvent) { - const selection = useThreadSelectionStore.getState(); - if (!selection.hasSelection()) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (shouldClearThreadSelectionOnMouseDown(target)) selection.clearSelection(); -} - -export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); - const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); - const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread, markThreadUnread } = useThreadActions(); - const { isMobile, setOpenMobile } = useSidebar(); - const routeTarget = useThreadRouteTarget(); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeTerminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); - const dragInProgressRef = useRef(false); - const suppressProjectClickAfterDragRef = useRef(false); - const suppressProjectClickForContextMenuRef = useRef(false); - const desktopUpdateState = useDesktopUpdateState(); - const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const platform = navigator.platform; - const shortcutModifiers = useShortcutModifierState(); - const terminalFocused = useTerminalFocus(); - const { - projectsLength, - orderedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjects, - } = useLegacySidebarProjects(); - const { sortedProjects, sortedProjectKeys } = useSortedLegacySidebarProjects({ - sidebarProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjectSortOrder, - }); - const { - sidebarThreadByKey, - activeRouteProjectKey, - visibleSidebarThreadKeys, - threadJumpCommandByKey, - threadJumpThreadKeys, - prewarmedSidebarThreadRefs, - } = useLegacySidebarThreads({ - sortedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - routeThreadKey, - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - }); - const revealActiveRow = useActiveRowReveal(routeReveal, routeThreadKey); - const newThreadShortcutLabelOptions = { - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }; - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - - const navigateToThread = (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - if (isMobile) { - setOpenMobile(false); - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }; - - const projectDnDSensors = useSensors(useSensor(PointerSensor, PROJECT_POINTER_SENSOR_OPTIONS)); - - const handleProjectDragEnd = useStableCallback((event: DragEndEvent) => { - if (sidebarProjectSortOrder !== "manual") { - dragInProgressRef.current = false; - return; - } - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); - if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjects.map( - (member) => member.physicalProjectKey, - ); - const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); - reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); - }); - - const handleProjectDragStart = useStableCallback((_event: DragStartEvent) => { - if (sidebarProjectSortOrder !== "manual") { - return; - } - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }); - - const handleProjectDragCancel = (_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }; - - const animatedProjectListsRef = useRef(new WeakSet()); - const attachProjectListAutoAnimateRef = (node: HTMLElement | null) => { - if (!node || animatedProjectListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedProjectListsRef.current.add(node); - }; - - const animatedThreadListsRef = useRef(new WeakSet()); - const attachThreadListAutoAnimateRef = (node: HTMLElement | null) => { - if (!node || animatedThreadListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedThreadListsRef.current.add(node); - }; - - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - // Build before modifier-dependent context to give the compiler a separate cache used by every row. - const threadJumpLabelByKey = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: routeTerminalOpen, - threadJumpCommandByKey, - }); - const sidebarShortcutContext = { - terminalFocus: terminalFocused, - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }; - const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( - shortcutModifiers, - keybindings, - { - platform, - context: sidebarShortcutContext, - }, - ); - const showThreadJumpHints = useThreadJumpHintsVisible(shouldShowThreadJumpHintsNow); - const visibleThreadJumpLabelByKey = showThreadJumpHints - ? threadJumpLabelByKey - : EMPTY_THREAD_JUMP_LABELS; - const orderedSidebarThreadKeys = visibleSidebarThreadKeys; - - const onWindowKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => { - const shortcutContext = { - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }; - - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for thread navigation shortcuts for the sidebar's lifetime - useEffect(() => { - const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => onWindowKeyDown(event); - window.addEventListener("keydown", handleWindowKeyDown); - return () => { - window.removeEventListener("keydown", handleWindowKeyDown); - }; - }, []); - // A window listener whose lifetime is the sidebar's: no element owns outside clicks. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window mousedown listener that clears thread selection on outside clicks for the sidebar's lifetime - useEffect(() => { - window.addEventListener("mousedown", clearThreadSelectionOnOutsideMouseDown); - return () => window.removeEventListener("mousedown", clearThreadSelectionOnOutsideMouseDown); - }, []); - - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); - const desktopUpdateButtonAction = desktopUpdateState - ? resolveDesktopUpdateButtonAction(desktopUpdateState) - : "none"; - const showArm64IntelBuildWarning = - isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); - const arm64IntelBuildWarningDescription = - desktopUpdateState && showArm64IntelBuildWarning - ? getArm64IntelBuildWarningDescription(desktopUpdateState) - : null; - const commandPaletteShortcutLabel = isMobile - ? null - : shortcutLabelForCommand(keybindings, "commandPalette.toggle", newThreadShortcutLabelOptions); - const handleDesktopUpdateButtonClick = async () => { - const bridge = window.desktopBridge; - if (!bridge || !desktopUpdateState) return; - if ( - desktopUpdateButtonDisabled || - desktopUpdateButtonAction === "none" || - desktopUpdateActionPending - ) { - return; - } - - setDesktopUpdateActionPending(true); - - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setDesktopUpdateActionPending(false)); - return; - } - - if (desktopUpdateButtonAction === "install") { - let confirmed = false; - try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), - ); - } catch (error) { - setDesktopUpdateActionPending(false); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not confirm update", - description: error instanceof Error ? error.message : "Update confirmation failed.", - }), - ); - return; - } - if (!confirmed) { - setDesktopUpdateActionPending(false); - return; - } - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setDesktopUpdateActionPending(false)); - } - }; - - const expandThreadListForProject = (projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectKey)) return current; - const next = new Set(current); - next.add(projectKey); - return next; - }); - }; - - const collapseThreadListForProject = (projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectKey)) return current; - const next = new Set(current); - next.delete(projectKey); - return next; - }); - }; - - return ( - - {prewarmedSidebarThreadRefs.map((threadRef) => ( - - ))} - - - - - - ); -} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 54c8d7a577fe6..e22169b79b094 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -32,7 +32,11 @@ import { type SidebarListMarker, type SidebarSection, resolveSidebarDropVerb, + groupSidebarThreadsByProject, + resolveSidebarFolderDot, + sidebarFolderExpansionKeys, } from "./Sidebar.logic"; +import { resolveProjectExpanded } from "../uiStateStore"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; @@ -1355,3 +1359,97 @@ describe("resolveSidebarRouteReveal", () => { ).toEqual({ pendingKey: hiddenKey, widensScope: false }); }); }); + +describe("sidebarFolderExpansionKeys", () => { + it("honors a collapse the legacy sidebar saved under a member's cwd", () => { + const keys = sidebarFolderExpansionKeys({ + projectKey: "github.com/pingdotgg/t3code", + memberProjects: [{ physicalProjectKey: "local:/code/t3code", workspaceRoot: "/code/t3code" }], + }); + const cwdKey = keys.at(-1)!; + + expect(resolveProjectExpanded({}, keys)).toBe(true); + expect(resolveProjectExpanded({ [cwdKey]: false }, keys)).toBe(false); + expect(resolveProjectExpanded({ [keys[0]!]: true, [cwdKey]: false }, keys)).toBe(true); + }); +}); + +describe("groupSidebarThreadsByProject", () => { + it("puts each thread in its project's folder and keeps empty projects", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const t3code = ProjectId.make("t3code"); + const t3codeRemote = ProjectId.make("t3code-remote"); + const fleet = ProjectId.make("fleet"); + const thread = (id: string, environmentId: EnvironmentId, projectId: ProjectId) => ({ + id, + environmentId, + projectId, + }); + const groups = [ + { + projectKey: "t3code", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: t3code }, + { environmentId: remoteEnvironmentId, projectId: t3codeRemote }, + ], + }, + { + projectKey: "fleet", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: fleet }], + }, + ]; + + const folders = groupSidebarThreadsByProject(groups, [ + thread("a", localEnvironmentId, t3code), + thread("orphan", localEnvironmentId, ProjectId.make("deleted")), + thread("b", remoteEnvironmentId, t3codeRemote), + ]); + + expect( + folders.map((folder) => ({ + projectKey: folder.group.projectKey, + threads: folder.threads.map((entry) => entry.id), + })), + ).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + { projectKey: "fleet", threads: [] }, + ]); + }); +}); + +describe("resolveSidebarFolderDot", () => { + it("flags the most urgent hidden thread and stays quiet otherwise", () => { + const idle = { + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default" as const, + latestRun: makeLatestRun(), + lastVisitedAt: "2026-03-09T10:06:00.000Z", + runtime: null, + }; + const runtime = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }; + const working = { ...idle, runtime }; + const unread = { ...idle, lastVisitedAt: "2026-03-09T10:04:00.000Z" }; + const limited = { + ...idle, + runtime: { ...runtime, status: "failed" as const, lastErrorClass: "usage_limit" as const }, + }; + + expect(resolveSidebarFolderDot([idle])).toBeNull(); + // A waiting thread stays quiet even with an unread completion, like its row. + expect( + resolveSidebarFolderDot([{ ...unread, runtime: { ...runtime, status: "idle" as const } }]), + ).toBeNull(); + expect(resolveSidebarFolderDot([idle, working])).toBe("working"); + expect(resolveSidebarFolderDot([working, unread])).toBe("unread"); + expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index da60dc6f6ce8f..4a14127d238f6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -34,6 +34,7 @@ import { isLatestRunSettled } from "../session-logic"; import { observeIntersection } from "../lib/observeIntersection"; import { deadlineAfter, deadlineStore } from "@t3tools/client-runtime/clock"; import { useDeadlinePassed } from "../hooks/useDeadline"; +import { legacyProjectCwdPreferenceKey } from "../uiStateStore"; export function shouldNavigateAfterThreadPark(input: { readonly threadKey: string; @@ -51,7 +52,6 @@ export function shouldNavigateAfterThreadPark(input: { ); } -const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; export function resolveSidebarRowAccessibility(input: { @@ -67,7 +67,6 @@ export function resolveSidebarRowAccessibility(input: { } // Each prewarm retains growing thread detail and a live subscription, multiplying heap and server load. -const SIDEBAR_THREAD_PREWARM_LIMIT = 3; const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): { @@ -547,21 +546,6 @@ export async function archiveSelectedThreadEntries< return { archivedThreadKeys, mutationFailure: null, followupFailures }; } -export function buildMultiSelectThreadContextMenuItems(input: { - count: number; - hasRunningThread: boolean; -}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { - return [ - { id: "mark-unread", label: `Mark unread (${input.count})` }, - { - id: "archive", - label: `Archive (${input.count})`, - disabled: input.hasRunningThread, - }, - { id: "delete", label: `Delete (${input.count})`, destructive: true }, - ]; -} - export function isSidebarSubagentThread(thread: Pick): boolean { return thread.lineage.relationshipToParent === "subagent"; } @@ -727,11 +711,6 @@ function hasUnseenFailure(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } -export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { - if (target === null) return true; - return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); -} - // Suppress trailing double-click navigation; keyboard and synthetic clicks use detail 0. export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; @@ -800,13 +779,6 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } -export function getSidebarThreadIdsToPrewarm( - visibleThreadIds: readonly TThreadId[], - limit = SIDEBAR_THREAD_PREWARM_LIMIT, -): TThreadId[] { - return visibleThreadIds.slice(0, Math.max(0, limit)); -} - export function resolveAdjacentThreadId(input: { threadIds: readonly T[]; currentThreadId: T | null; @@ -834,15 +806,6 @@ export function resolveAdjacentThreadId(input: { return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } -export function isContextMenuPointerDown(input: { - button: number; - ctrlKey: boolean; - isMac: boolean; -}): boolean { - if (input.button === 2) return true; - return input.isMac && input.button === 0 && input.ctrlKey; -} - export type SidebarThreadStatus = | "approval" | "input" @@ -914,6 +877,92 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } +/** + * The dot on a closed boring-sidebar folder: the most urgent state among its + * hidden threads. Waiting threads stay quiet, like their rows. + */ +export function resolveSidebarFolderDot( + threads: ReadonlyArray, +): "attention" | "failed" | "unread" | "working" | null { + const statuses = new Set(threads.map(resolveSidebarThreadStatus)); + if ( + statuses.has("input") || + statuses.has("approval") || + statuses.has("request") || + statuses.has("limited") + ) { + return "attention"; + } + if (statuses.has("failed")) return "failed"; + // Rows show Done only for ready threads, so the dot follows the same rule. + const hasUnread = threads.some( + (thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread), + ); + if (hasUnread) return "unread"; + if (statuses.has("working")) return "working"; + return null; +} + +/** + * Keys the old per-project sidebar saved folder open state under, most + * specific first, for `resolveProjectExpanded`. Boring folders read and write + * the same keys, so a legacy user's closed folders stay closed after regrouping. + */ +export function sidebarFolderExpansionKeys(group: { + readonly projectKey: string; + readonly memberProjects: ReadonlyArray<{ + readonly physicalProjectKey: string; + readonly workspaceRoot: string; + }>; +}): string[] { + return [ + group.projectKey, + ...group.memberProjects.map((member) => member.physicalProjectKey), + ...group.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), + ]; +} + +/** + * Boring sidebar folders. Folders follow `groups` order and include empty + * projects so each one can start a thread. Threads keep their input order. + * A thread whose project is in no group has no folder and is left out. + */ +export function groupSidebarThreadsByProject< + TGroup extends { + readonly memberProjectRefs: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectId: string; + }>; + }, + TThread extends Pick, +>( + groups: readonly TGroup[], + threads: readonly TThread[], +): Array<{ group: TGroup; threads: TThread[] }> { + const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); + const folderByProject = new Map(); + for (const folder of folders) { + for (const ref of folder.group.memberProjectRefs) { + folderByProject.set(`${ref.environmentId}:${ref.projectId}`, folder); + } + } + for (const thread of threads) { + folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); + } + return folders; +} + +export type SidebarV2TopStatusKind = + | "approval" + | "done" + | "failed" + | "limited" + | "input" + | "request" + | "waiting" + | "woke" + | "working"; + export function firstValidTimestampMs( ...candidates: ReadonlyArray ): number { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d364a5886624d..67d77c08bd375 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -110,7 +110,7 @@ import { getSidebarProjectSettingsKey, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { useUiStateStore } from "../uiStateStore"; +import { resolveProjectExpanded, useUiStateStore } from "../uiStateStore"; import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, @@ -122,7 +122,7 @@ import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useBoringSidebarEnabled, useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -169,6 +169,8 @@ import { applySidebarThreadDrop, filterSidebarV2VisibleThreads, botShelfName, + groupSidebarThreadsByProject, + sidebarFolderExpansionKeys, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, @@ -249,6 +251,10 @@ import { type SidebarSweepAction, } from "./sidebar/SidebarThreadRow"; import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { + SidebarProjectFolderHeader, + sidebarFolderRowId, +} from "./sidebar/SidebarProjectFolderHeader"; import { SidebarDragBoundary, SidebarSectionHeader, @@ -395,6 +401,8 @@ function useSidebarThreads(input: { readonly threads: readonly EnvironmentThreadShell[]; readonly delegatedUnderParent: ReadonlySet; readonly routeRowThreadKey: string | null; + // Set in boring mode: the project groups to fold active threads into, in order. + readonly folderGroups: readonly SidebarProjectSnapshot[] | null; }) { const { projectScopeKey, @@ -404,8 +412,11 @@ function useSidebarThreads(input: { threads, delegatedUnderParent, routeRowThreadKey, + folderGroups, } = input; - const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); + // Folders already separate the work, so boring mode has no Working shelf. + const workingShelfEnabled = + useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null; const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -433,6 +444,9 @@ function useSidebarThreads(input: { false, Schema.Boolean, ); + // Shared with the old per-project sidebar, so folders keep their open state. + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const [settledTail, setSettledTail] = useState({ scopeKey: projectScopeKey, count: SETTLED_TAIL_INITIAL_COUNT, @@ -497,9 +511,31 @@ function useSidebarThreads(input: { const visibleBotThreads = botsShelfExpanded ? botThreads : routeThreadOnly(botThreads, routeRowThreadKey); + const projectFolders = + folderGroups === null + ? null + : groupSidebarThreadsByProject(folderGroups, activeThreads).map((folder) => { + const collapsed = !resolveProjectExpanded( + projectExpandedById, + sidebarFolderExpansionKeys(folder.group), + ); + return { + group: folder.group, + collapsed, + // A closed folder still shows the open thread, and hints at the rest. + threads: collapsed + ? routeThreadOnly(folder.threads, routeRowThreadKey) + : folder.threads, + hiddenThreads: collapsed + ? folder.threads.filter((thread) => threadShellKey(thread) !== routeRowThreadKey) + : EMPTY_THREADS, + }; + }); const orderedThreads = [ ...pinnedThreads, - ...activeThreads, + ...(projectFolders === null + ? activeThreads + : projectFolders.flatMap((folder) => folder.threads)), ...visibleWorkingThreads, ...visibleBotThreads, ...visibleSnoozedThreads, @@ -545,6 +581,11 @@ function useSidebarThreads(input: { toggleWorkingShelf: () => setWorkingShelfExpanded((value) => !value), botsShelfExpanded, toggleBotsShelf: () => setBotsShelfExpanded((value) => !value), + projectFolders, + toggleProjectFolder: (group: SidebarProjectSnapshot) => { + const keys = sidebarFolderExpansionKeys(group); + setProjectExpanded(keys, !resolveProjectExpanded(projectExpandedById, keys)); + }, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -940,6 +981,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); }, }); + const boringSidebarEnabled = useBoringSidebarEnabled(); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -1174,6 +1216,40 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve openProjectSettings(projectGroup); }, ); + const startThreadInProjectFolder = useStableCallback((group: SidebarProjectSnapshot) => { + if (isMobile) setOpenMobile(false); + void handleNewThread(scopeProjectRef(group.environmentId, group.id)); + }); + const handleProjectFolderContextMenu = useStableCallback( + (group: SidebarProjectSnapshot, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + void (async () => { + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "new-thread", label: "New thread" }, + { id: "copy-path", label: "Copy path" }, + { id: "project-settings", label: "Project settings", icon: "settings" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "new-thread": + startThreadInProjectFolder(group); + return; + case "copy-path": + copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); + return; + case "project-settings": + openProjectSettings(group); + return; + } + })(); + }, + ); const { workingShelfEnabled, @@ -1200,6 +1276,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve toggleWorkingShelf, botsShelfExpanded, toggleBotsShelf, + projectFolders, + toggleProjectFolder, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -1221,6 +1299,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threads, delegatedUnderParent, routeRowThreadKey, + folderGroups: boringSidebarEnabled + ? scopedProjectGroups.length === 0 + ? projectGroups + : scopedProjectGroups + : null, }); // Ranked across environments as the palette ranks them, so one @@ -1781,6 +1864,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, }); }; + const hasProjectFolders = projectFolders !== null && projectFolders.length > 0; const sidebarListItemsFresh = ((): readonly SidebarListItem[] => { const rowsOf = ( list: readonly EnvironmentThreadShell[], @@ -1790,14 +1874,16 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); + // Boring mode keeps empty project folders, so each can start a thread. if ( + !hasProjectFolders && pinnedThreads.length + activeThreads.length + workingThreads.length + botThreads.length + snoozedThreads.length + settledCount === - 0 + 0 ) { return []; } @@ -1851,6 +1937,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve // Only order, section, or draft changes need layout reads and motion. const listMotionLayoutKey = [ sidebarListItemsKey, + projectFolders + ?.map((folder) => `${folder.group.projectKey}:${folder.threads.length}`) + .join("\n") ?? "", visibleDraftSessionCount, routeDraftIdForRows ?? "", ].join("\0"); @@ -2780,7 +2869,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if ( + boringSidebarEnabled || + shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) + ) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -2980,7 +3072,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={projectGroups.length > 1} + showNewThreadInProjectHint={!boringSidebarEnabled && projectGroups.length > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { @@ -3097,6 +3189,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const renderThreadRow = ( thread: EnvironmentThreadShell, section: SidebarSection, + // The tree row of the project folder this row sits in. + folderRow?: string, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -3106,13 +3200,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve section === "pinned" || section === "working" || section === "bots"; - const rowVariant = isCard ? "card" : "slim"; + const rowVariant = folderRow ? "folder" : isCard ? "card" : "slim"; return ( , + ); + for (const thread of folder.threads) { + items.push( + renderThreadRow( + thread, + "active", + sidebarFolderRowId(folder.group.projectKey), + ), + ); + } + } + continue; + } switch (item.marker) { case "pinned-header": items.push( @@ -3394,6 +3517,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : null} {!isSearchingThreads && + !hasProjectFolders && visibleDraftSessionCount === 0 && pinnedThreads.length + activeThreads.length + diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 45eb01782c763..0af0e063bdea3 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { scopeProjectRef, scopedThreadKey, @@ -325,8 +326,10 @@ export function ThreadPullRequestsMiniList({ pullRequests: ReadonlyArray; onOpenPullRequest?: (event: MouseEvent, url: string) => void; }) { - const lines = pullRequestListLines( - resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests)), + const lines = pipe( + visibleThreadPullRequests(pullRequests), + resolveThreadPullRequestChains, + pullRequestListLines, ); if (lines.length === 0) return null; return ( @@ -440,7 +443,7 @@ export function ChangeRequestStatusIcon({ return ; } -export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { +function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { return ( {status.tooltipLead} @@ -600,7 +603,7 @@ export function ThreadWorktreeIndicator({ ); } -export function ThreadStatusLabel({ +function ThreadStatusLabel({ status, compact = false, }: { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index da6b05b52b871..57a3a8aa41909 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -20,7 +20,6 @@ import type { ModelSelection, ProjectId, PreviewAnnotationPayload, - ProviderApprovalDecision, ThreadContextRecord, EnvironmentContextRecord, ProviderInteractionMode, @@ -187,7 +186,6 @@ import { ComposerCommandMenu, composerSuggestionOptionId, } from "./ComposerCommandMenu"; -import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { ComposerAttachButton, ComposerAttachmentFileList, @@ -198,7 +196,6 @@ import { } from "./ComposerAttachmentStrip"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerFooterPrimaryActions } from "./ComposerFooterControls"; -import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; @@ -239,7 +236,7 @@ const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidErro const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; import { toastManager } from "../ui/toast"; -import { PlayIcon, ShieldIcon } from "lucide-react"; +import { PlayIcon } from "lucide-react"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; @@ -402,6 +399,7 @@ export interface ChatComposerProps { activePendingApproval: PendingApproval | null; pendingApprovals: PendingApproval[]; + onHeldRequestIdsChange: (requestIds: ReadonlySet) => void; pendingUserInputs: PendingUserInput[]; activePendingProgress: { questionIndex: number; @@ -482,10 +480,6 @@ export interface ChatComposerProps { onResume: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; - onRespondToApproval: ( - requestId: RuntimeRequestId, - decision: ProviderApprovalDecision, - ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void; onAdvanceActivePendingUserInput: () => void; onDismissActivePendingUserInput: (requestId: RuntimeRequestId) => void; @@ -550,6 +544,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentUnavailable, activePendingApproval: incomingPendingApproval, pendingApprovals, + onHeldRequestIdsChange, pendingUserInputs: incomingPendingUserInputs, activePendingProgress: incomingPendingProgress, activePendingResolvedAnswers, @@ -601,7 +596,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onResume, onInterrupt, onImplementPlanInNewThread, - onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, onDismissActivePendingUserInput, @@ -633,6 +627,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...pendingApprovals.map((request) => request.requestId), ...incomingPendingUserInputs.map((request) => request.requestId), ]); + // A layout effect so the timeline never paints a held approval as answerable. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes held requests to the timeline without lifting per-keystroke typing state + useLayoutEffect(() => { + onHeldRequestIdsChange(heldRequestIds); + }, [heldRequestIds, onHeldRequestIdsChange]); const activePendingApproval = incomingPendingApproval && !heldRequestIds.has(incomingPendingApproval.requestId) ? incomingPendingApproval @@ -1200,7 +1199,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isChoiceOnlyPendingQuestion = activePendingProgress?.activeQuestion?.allowCustomAnswer === false; const showComposerTopDrawer = - isComposerApprovalState || pendingUserInputs.length > 0 || (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile; @@ -2320,8 +2318,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) importContextRecords, expandMobileComposer: expandMobileComposerStable, }); - const hasBlockingComposerTopDrawer = - activePendingApproval !== null || pendingUserInputs.length > 0; + const hasBlockingComposerTopDrawer = pendingUserInputs.length > 0; const hasImageAttachmentAttention = standaloneComposerImages.some((image) => { const upload = uploadsByImageId[image.id]; const failedInCurrentEnvironment = @@ -2580,7 +2577,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getModelDisabledReason={getModelDisabledReason} /> ); - // An open approval or question owns the space above the composer. Only a machine problem, + // An open question owns the space above the composer. Only a machine problem, // which is why an answer could not go through, stays beside it. const shelfItems = hasBlockingComposerTopDrawer ? props.bannerItems.filter( @@ -3444,39 +3441,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {showComposerTopDrawer ? ( - - {activePendingApproval ? ( - - - - - - - - - - - - ) : pendingUserInputs.length > 0 ? ( + + {pendingUserInputs.length > 0 ? ( ; export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ + presentation = "composer", requestId, isResponding, canRespond, @@ -47,23 +49,42 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro return ( <> {primaryOptions.map((option) => { - const button = ( - + + {option.label} + + ); + const button = + presentation === "inline" ? ( + + {content} + + ) : ( + + ); return option.warning ? ( @@ -76,20 +97,26 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro {moreOptions.length > 0 ? ( } + disabled={disabled || isResponding || !canRespond} + render={ + presentation === "inline" ? ( + + ) : ( + ) : ( diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index 9d9327368a5ad..728c8fe61efa2 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { useAtomValue } from "@effect/atom-react"; import { type LinkableMachine, @@ -106,7 +107,7 @@ function PendingLinkRequest(props: { setDeclining(false), ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; @@ -311,7 +312,7 @@ function LinkRequestDialog(props: { // The card switches to its answered row once the item updates. close(); } else if (!isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index bde0d35c90e55..2c7aa332756cb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,11 +1,14 @@ import { AudioPreview } from "../files/AudioPreview"; import { onTestFinished } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; import { stubGlobal } from "../../../test/render"; import { EnvironmentId, MessageId, RunAttemptId, RunId, + RuntimeRequestId, + TurnItemId, ScheduledTaskId, ThreadId, } from "@t3tools/contracts"; @@ -18,7 +21,10 @@ import "~/index.css"; import { act, fakeTimers, render } from "../../../test/render"; import { MessagesTimeline } from "./MessagesTimeline"; import { makeStreamingTimelineFixture } from "../../test-fixtures"; -import { deriveTimelineEntriesFromVisibleTurnItems } from "../../session-logic"; +import { + type PendingApproval, + deriveTimelineEntriesFromVisibleTurnItems, +} from "../../session-logic"; import { remarkThoughtPreview } from "./TimelineWorkEntryRow.logic"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { collectThreadFindRanges } from "./threadFindHighlights"; @@ -990,3 +996,172 @@ it("renders automatic compaction as one live divider that finishes with its reas expect(container.querySelectorAll('[role="separator"]')).toHaveLength(1); expect(container.textContent).not.toContain("Compacting before"); }); + +it("keeps concurrent approvals actionable outside collapsed work and restores settled rows", async () => { + setup(); + const fixture = makeStreamingTimelineFixture(); + const source = fixture.visibleTurnItems[4]!; + if (source.item.type !== "dynamic_tool") throw new Error("Expected fixture tool"); + const sourceItem = source.item; + const commandRequest = RuntimeRequestId.make("command-approval"); + const appRequest = RuntimeRequestId.make("app-approval"); + const approvals: PendingApproval[] = [ + { + requestId: commandRequest, + requestKind: "command", + createdAt: fixture.time(6), + detail: "printf '" + "long-command ".repeat(80) + "'", + reason: "Needs permission to run the command", + fullAccessOverride: "ask_rule", + responseCapability: "live", + }, + { + requestId: appRequest, + requestKind: "permission", + createdAt: fixture.time(6), + detail: "Open the current tab", + appName: "Safari", + options: [ + { decision: "decline", label: "Deny" }, + { decision: "accept", label: "Allow once" }, + { + decision: "acceptAlways", + label: "Always allow Safari", + warning: "Applies to future requests", + }, + ], + responseCapability: "live", + }, + ]; + const entries = (completed = false) => + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { ...source, item: { ...sourceItem, status: completed ? "completed" : sourceItem.status } }, + ...approvals.map((approval, index) => ({ + ...source, + position: source.position + index + 1, + sourceItemId: TurnItemId.make(`approval-${index}`), + item: { + ...sourceItem, + id: TurnItemId.make(`approval-${index}`), + type: "approval_request" as const, + status: completed ? ("completed" as const) : ("waiting" as const), + requestId: approval.requestId, + requestKind: approval.requestKind, + prompt: approval.detail, + }, + })), + ], + optimisticMessages: [], + }); + const props = baseProps(); + const onRespondToApproval = vi.fn(async () => {}); + const ui = (overrides: Partial = {}) => ( + + ); + await using screen = await renderElement(ui()); + await expect.element(screen.getByRole("group", { name: "Command approval" })).toBeVisible(); + await expect + .element(screen.getByRole("group", { name: "App permission approval" })) + .toBeVisible(); + await expect.element(screen.getByText(approvals[0]!.detail!, { exact: true })).toBeVisible(); + await expect.element(screen.getByText("Needs permission to run the command")).toBeVisible(); + await vi.waitFor(() => { + expect( + getComputedStyle(screen.getByLabelText("Command", { exact: true }).element()).userSelect, + ).toBe("text"); + expect( + getComputedStyle(screen.getByText("Needs permission to run the command").element()) + .userSelect, + ).toBe("text"); + }); + await expect.element(screen.getByText("Safari", { exact: true })).toBeVisible(); + await click(screen.button("Why am I being asked this?")); + await expect.element(page.getByText(/an ask rule in the agent's own settings/)).toBeVisible(); + await click(screen.button("Why am I being asked this?")); + await click(screen.buttons("More approval options")[1]!); + await expect.element(page.getByRole("menuitem", { name: "Always allow Safari" })).toBeVisible(); + await page.getByRole("menuitem", { name: "Always allow Safari" }).click(); + expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "acceptAlways"); + await click(screen.buttons("More approval options")[0]!); + await expect.element(page.getByRole("menuitem", { name: "Cancel", exact: true })).toBeVisible(); + await page.getByRole("menuitem", { name: "Always allow this session" }).click(); + expect(onRespondToApproval).toHaveBeenLastCalledWith(commandRequest, "acceptForSession"); + await click(screen.button("Decline")); + expect(onRespondToApproval).toHaveBeenLastCalledWith(commandRequest, "decline"); + await click(screen.button("Allow once")); + expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "accept"); + for (const timelineEntries of [entries(), []]) { + await screen.rerender( + ui({ timelineEntries, heldApprovalRequestIds: new Set([commandRequest]) }), + ); + await expect + .element(screen.getByRole("group", { name: "Command approval" })) + .not.toBeInTheDocument(); + await vi.waitFor(() => expect(screen.buttons("Approve")).toHaveLength(0)); + await expect + .element(screen.getByRole("button", { name: "Allow once", exact: true })) + .toBeVisible(); + await screen.rerender(ui({ timelineEntries })); + await expect.element(screen.getByRole("group", { name: "Command approval" })).toBeVisible(); + await vi.waitFor(() => expect(screen.buttons("Approve")).toHaveLength(1)); + } + await screen.rerender(ui({ timelineEntries: [], isWorking: false })); + await expect.element(screen.getByRole("group", { name: "Command approval" })).toBeVisible(); + await click(screen.button("Approve")); + expect(onRespondToApproval).toHaveBeenLastCalledWith(commandRequest, "accept"); + await click(screen.button("Deny")); + expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "decline"); + await screen.rerender( + ui({ + timelineEntries: [], + isWorking: false, + pendingApprovals: [{ ...approvals[0]!, detail: "" }], + }), + ); + await expect + .element(screen.getByLabelText("Command", { exact: true })) + .toHaveTextContent("Command approval"); + await screen.rerender(ui({ canOperateThread: false })); + await expect.element(screen.getByRole("button", { name: "Approve", exact: true })).toBeDisabled(); + await vi.waitFor(() => + expect( + screen.buttons("More approval options").every((button) => button.hasAttribute("disabled")), + ).toBe(true), + ); + await screen.rerender(ui({ respondingRequestIds: [commandRequest] })); + await expect.element(screen.getByRole("button", { name: "Approve", exact: true })).toBeDisabled(); + await expect + .element(screen.getByRole("button", { name: "Allow once", exact: true })) + .toBeEnabled(); + await screen.rerender( + ui({ pendingApprovals: [{ ...approvals[0]!, responseCapability: "not_resumable" }] }), + ); + await expect + .element(screen.getByText("Provider process is gone. Interrupt or restart the run to respond.")) + .toBeVisible(); + await vi.waitFor(() => expect(screen.buttons("Approve")).toHaveLength(0)); + await screen.rerender( + ui({ pendingApprovals: [], timelineEntries: entries(true), isWorking: false }), + ); + await expect + .element(screen.getByRole("group", { name: "Command approval" })) + .not.toBeInTheDocument(); + await vi.waitFor(() => expect(screen.text()).not.toContain("Waiting for approval")); + await screen.getByRole("button", { expanded: false }).click(); + await expect + .element(screen.getByRole("button", { name: approvals[0]!.detail!, exact: true })) + .toBeVisible(); + await expect + .element(screen.getByRole("button", { name: "Open the current tab", exact: true })) + .toBeVisible(); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index a3ed14223d827..aa082a268667d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -241,6 +241,37 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "event")).toBe(false); expect(workRows[0]).not.toMatchObject({ expanded: true }); expect(workRows[0]).not.toMatchObject({ isExpandedToolGroup: true }); + const pendingRows = deriveMessagesTimelineRows({ + timelineEntries: entries, + pendingApprovalRequestIds: new Set([RuntimeRequestId.make("approval-request")]), + isWorking: status === "waiting", + runningRunId: fixture.runId, + activeTurnStartedAt: fixture.time(0), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(pendingRows.filter((row) => row.kind === "work")).toContainEqual( + expect.objectContaining({ + groupedEntries: [expect.objectContaining({ itemType: "approval_request" })], + isExpandedToolGroup: false, + }), + ); + expect( + pendingRows + .filter((row) => row.kind === "work-live") + .flatMap((row) => row.groupedEntries) + .some((entry) => entry.itemType === "approval_request"), + ).toBe(false); + const approvalOnlyRows = deriveMessagesTimelineRows({ + timelineEntries: entries.slice(1, 2), + pendingApprovalRequestIds: new Set([RuntimeRequestId.make("approval-request")]), + isWorking: status === "waiting", + runningRunId: fixture.runId, + activeTurnStartedAt: fixture.time(0), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(approvalOnlyRows.some((row) => row.kind === "thinking")).toBe(false); }, ); @@ -2611,6 +2642,15 @@ describe("v2 run and attempt history", () => { completedAt: null, }; const timelineEntries = [ + { + id: "context-handoff-entry", + kind: "event" as const, + createdAt: "2026-01-01T00:00:00Z", + attempt: supersededAttempt, + projectedItem: { + item: { type: "handoff", runId }, + } as never, + }, { id: "initial-user-entry", kind: "message" as const, @@ -2713,6 +2753,7 @@ describe("v2 run and attempt history", () => { const collapsedRows = deriveMessagesTimelineRows(common); expect(collapsedRows.map((row) => row.id)).toEqual([ + "context-handoff-entry", "initial-user-entry", `attempt-fold:${supersededAttemptId}`, "superseded-thread-created-entry", @@ -2731,6 +2772,7 @@ describe("v2 run and attempt history", () => { expandedAttemptIds: new Set([supersededAttemptId]), }); expect(expandedRows.map((row) => row.id)).toEqual([ + "context-handoff-entry", "initial-user-entry", `attempt-fold:${supersededAttemptId}`, "superseded-assistant-entry", @@ -3027,6 +3069,7 @@ describe("streaming v2 row projection", () => { const previous = deriveMessagesTimelineRowsWithState({ ...initial.input, availableRevertTurnCounts: new Set([0]), + pendingApprovalRequestIds: new Set(), }); Object.freeze(previous.rows); for (const row of previous.rows) Object.freeze(row); @@ -3044,6 +3087,7 @@ describe("streaming v2 row projection", () => { latestRun: { ...initial.input.latestRun }, turnDiffSummaries: initial.input.turnDiffSummaries.map((summary) => ({ ...summary })), availableRevertTurnCounts: new Set([0]), + pendingApprovalRequestIds: new Set(), }; const next = deriveMessagesTimelineRowsWithState(input, previous); expect(next.rows).toEqual( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index fe38e16bb78ca..ac9db2f7dd794 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -55,6 +55,7 @@ import { type WorktreeSetupSnapshot, type OrchestrationV2ProjectedTurnItem, type RunAttemptId, + type RuntimeRequestId, RunId, type ThreadId, type TurnItemId, @@ -769,6 +770,7 @@ function deriveSupersededAttemptFolds( completeAttemptIds.has(entry.attempt.id) || unfoldedRunIds.has(entry.attempt.runId) || (entry.kind === "message" && entry.message.role === "user") || + (entry.kind === "event" && entry.projectedItem.item.type === "handoff") || entry.kind === "html-render" || entry.kind === "mcp-app" || timelineEntryIsPersistentResourceCard(entry) || @@ -1345,6 +1347,7 @@ function deriveTimelineTurnFolds( export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; + pendingApprovalRequestIds?: ReadonlySet; latestRun?: TimelineLatestRun | null; runningRunId?: RunId | null; expandedRunIds?: ReadonlySet; @@ -1361,6 +1364,13 @@ export function deriveMessagesTimelineRows(input: { worktreeSetup?: WorktreeSetupSnapshot | null; }): MessagesTimelineRow[] { const timelineEntries = timelineRowEntries(input.timelineEntries); + const isPendingApproval = (entry: WorkLogEntry) => { + const item = entry.projectedItem?.item; + return ( + item?.type === "approval_request" && + input.pendingApprovalRequestIds?.has(item.requestId) === true + ); + }; const turnDiffSummaryByAssistantMessageId = new Map(); for (const summary of input.turnDiffSummaries) { if (summary.assistantMessageId) { @@ -1423,6 +1433,14 @@ export function deriveMessagesTimelineRows(input: { } } } + if (input.pendingApprovalRequestIds?.size) { + for (const entry of timelineEntries) { + if (entry.kind === "work" && isPendingApproval(entry.entry)) { + collapsedEntryIds.delete(entry.id); + collapsedSupersededEntryIds.delete(entry.id); + } + } + } const runIdIsActiveResponse = (runId: RunId | null | undefined) => runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId); const workEntryIsInActiveRun = (entry: WorkLogEntry) => @@ -1446,6 +1464,7 @@ export function deriveMessagesTimelineRows(input: { entry.entry.itemType === "system_notice" || entry.entry.itemType === "notification" || entry.entry.questionAnswer !== undefined || + isPendingApproval(entry.entry) || !runIdIsActiveResponse(entry.entry.runId) || entry.entry.sourceActivityKind === "context-compaction" || collapsedEntryIds.has(entry.id) || @@ -1475,7 +1494,8 @@ export function deriveMessagesTimelineRows(input: { const latestToolKeepsActivityLive = latestRunningToolEntry !== undefined || (latestVisibleToolEntry !== undefined && - workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); + workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry) && + !input.pendingApprovalRequestIds?.size); const latestToolFailed = latestRunningToolEntry === undefined && latestVisibleToolEntry !== undefined && @@ -1717,6 +1737,7 @@ export function deriveMessagesTimelineRows(input: { if (timelineEntry.kind === "work") { if ( + isPendingApproval(timelineEntry.entry) || (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) || (timelineEntry.entry.itemType === "error" && timelineEntry.entry.toolLifecycleStatus === "failed") || @@ -1748,6 +1769,7 @@ export function deriveMessagesTimelineRows(input: { nextEntry.entry.itemType === "system_notice" || nextEntry.entry.itemType === "notification" || nextEntry.entry.questionAnswer !== undefined || + isPendingApproval(nextEntry.entry) || activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || collapsedSupersededEntryIds.has(nextEntry.id) || @@ -2023,7 +2045,7 @@ export function deriveMessagesTimelineRows(input: { !setupOwnsWorkingSlot && !hasActiveCompaction && !compactionOnlyRunFinished && - (!hasActivityRow || latestToolFailed) + (latestToolFailed || (!hasActivityRow && !input.pendingApprovalRequestIds?.size)) ) { const failedGroupAnchor = latestToolFailed ? activeWorkAnchor : undefined; if (failedGroupAnchor) { @@ -2173,6 +2195,7 @@ function replaceStreamingMessageRows( expandedRunIds: previousExpandedRuns, expandedAttemptIds: previousExpandedAttempts, expandedWorkGroupIds: previousExpandedGroups, + pendingApprovalRequestIds: previousPendingApprovalRequestIds, availableRevertTurnCounts: previousAvailableRevertTurnCounts, ...previousContext } = previous.input; @@ -2183,6 +2206,7 @@ function replaceStreamingMessageRows( expandedRunIds, expandedAttemptIds, expandedWorkGroupIds, + pendingApprovalRequestIds, availableRevertTurnCounts, ...context } = input; @@ -2194,6 +2218,7 @@ function replaceStreamingMessageRows( !shallow(previousExpandedRuns, expandedRunIds) || !shallow(previousExpandedAttempts, expandedAttemptIds) || !shallow(previousExpandedGroups, expandedWorkGroupIds) || + !shallow(previousPendingApprovalRequestIds, pendingApprovalRequestIds) || !shallow(previousAvailableRevertTurnCounts, availableRevertTurnCounts) || !sameCheckpointSummaries(previousCheckpoints, turnDiffSummaries) ) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f5cc45730c3cd..c7a38de8fbed6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,8 +1,10 @@ +import type { ToolCallDiffTarget } from "../../diffPanelStore"; import { AudioPreviewUnmountContext } from "../files/AudioPreview"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; import { MarkdownFindContext } from "./markdownFindContext"; import { useChatCanvas } from "./ChatCanvasContext"; import { WorkLogBlock } from "./WorkLog"; +import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow"; import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { deriveTimelineMinimapItems } from "./timelineMinimapItems"; import { @@ -22,6 +24,9 @@ import { observeResize } from "~/lib/observeResize"; import { useStableCallback } from "~/hooks/useStableCallback"; const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; +const EMPTY_PENDING_APPROVALS: ReadonlyArray = []; +const EMPTY_RESPONDING_REQUEST_IDS: TimelineRowSharedState["respondingRequestIds"] = []; + const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; @@ -41,7 +46,7 @@ import { type LegendListRef, type MaintainScrollAtEndOptions, } from "@legendapp/list/react"; -import { type TimelineEntry } from "../../session-logic"; +import { type PendingApproval, type TimelineEntry } from "../../session-logic"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; import { ArrowRightIcon, BotIcon, CircleAlertIcon, Minimize2Icon } from "lucide-react"; @@ -197,6 +202,11 @@ export interface MessagesTimelineHistoryControls { } interface MessagesTimelineProps { + pendingApprovals?: ReadonlyArray; + heldApprovalRequestIds?: ReadonlySet | undefined; + respondingRequestIds?: TimelineRowSharedState["respondingRequestIds"]; + canOperateThread?: boolean; + onRespondToApproval?: TimelineRowSharedState["onRespondToApproval"]; citationRequest?: AssistantCitationRequest | null; citationHistory?: CitedHistory; onCiteAssistantText?: ( @@ -232,6 +242,7 @@ interface MessagesTimelineProps { displayThreadKey?: string; /** Off where another region already speaks the thread's status, like a subagent's bar. */ announceTurnStatus?: boolean; + onOpenToolDiff?: (target: ToolCallDiffTarget) => void; onOpenTurnDiff?: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; @@ -304,6 +315,11 @@ export function MessagesTimeline(props: MessagesTimelineProps) { } const ConversationTimeline = memo(function ConversationTimeline({ + pendingApprovals = EMPTY_PENDING_APPROVALS, + heldApprovalRequestIds, + respondingRequestIds = EMPTY_RESPONDING_REQUEST_IDS, + canOperateThread = false, + onRespondToApproval, citationRequest = null, citationHistory = CITED_HISTORY_LOADING, onCiteAssistantText, @@ -336,6 +352,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ routeThreadKey, displayThreadKey, announceTurnStatus = true, + onOpenToolDiff, onOpenTurnDiff, onOpenThread, renderHtmlRender, @@ -650,8 +667,15 @@ const ConversationTimeline = memo(function ConversationTimeline({ ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) : paintedExpandedAttemptIds; + const pendingApprovalsByRequestId = new Map( + pendingApprovals + .filter((approval) => !heldApprovalRequestIds?.has(approval.requestId)) + .map((approval) => [approval.requestId, approval]), + ); + const pendingApprovalRequestIds = new Set(pendingApprovalsByRequestId.keys()); const rawRows = useIncrementalTimelineRows(`${listIdentityKey}\0${workspaceRoot ?? ""}`, { timelineEntries, + pendingApprovalRequestIds, latestRun, runningRunId, expandedRunIds: visibleExpandedRunIds, @@ -667,6 +691,17 @@ const ConversationTimeline = memo(function ConversationTimeline({ worktreeSetup, }); const rows = useStableRows(rawRows, listIdentityKey); + const unmatchedPendingApprovals = new Map(pendingApprovalsByRequestId); + if (unmatchedPendingApprovals.size > 0) { + for (const row of rows) { + const entries = + row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; + for (const entry of entries) { + const item = entry.projectedItem?.item; + if (item?.type === "approval_request") unmatchedPendingApprovals.delete(item.requestId); + } + } + } // Run status/timestamps change per stream event; keep them out of shared row context. const runs = useStableHandoffRuns(runsProp); const minimapItems = deriveTimelineMinimapItems(rows); @@ -679,6 +714,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined; const showsEmptyState = rows.length === 0 && + unmatchedPendingApprovals.size === 0 && !isWorking && parentThreadLink === null && historyControls === undefined && @@ -720,6 +756,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ listRef, rememberedPosition, restoreReady, + viewportRef, ]); const [timelineViewportElement, setTimelineViewportElement] = useState( @@ -805,15 +842,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ size: true, shouldRestorePosition: shouldRestoreVisibleContentPosition, }; - const timelineListFooter = ( - - {footer} - - ); - const measureContentOverflow = () => timelineContentOverflowsViewport(listRef.current?.getState?.(), { composerInset: contentInsetEndAdjustment, @@ -1003,6 +1031,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; const sharedState: TimelineRowSharedState = { + pendingApprovals: pendingApprovalsByRequestId, + respondingRequestIds, + canOperateThread, + onRespondToApproval, citationRequest: readyCitationRequest, listRef, timestampFormat, @@ -1024,6 +1056,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onAppFullscreenChange, onFileDownload, openPullRequest, + onOpenToolDiff, onOpenTurnDiff, onOpenThread, renderHtmlRender, @@ -1230,6 +1263,30 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); const listExtraData = { identity: listIdentityKey, rowCount: rows.length, sharedState }; + const timelineListFooter = ( + + {footer} + {unmatchedPendingApprovals.size > 0 ? ( + +
    +
    + + {[...unmatchedPendingApprovals.values()].map((approval) => ( + + ))} + +
    +
    +
    + ) : null} +
    + ); const forwardMinimapWheel = (event: WheelEvent) => { const scrollNode = listRef.current?.getScrollableNode(); diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 11d22b1354e10..6750298402942 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,7 +1,7 @@ import { PanelBottomIcon, PanelRightIcon, SquareMenuIcon } from "lucide-react"; import { Maximize2, Minimize2 } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; -import { memo, type ReactElement, type ReactNode } from "react"; +import { memo, type ReactNode } from "react"; import { cn } from "~/lib/utils"; @@ -53,6 +53,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ ); - const threadPanelTooltip = (trigger: ReactElement) => ( + const threadPanelControl = ( - + }> + {threadPanelPresentation === "popover" ? ( + + ) : ( + threadPanelToggle + )} + Toggle thread details {threadPanelShortcutLabel ? ` (${threadPanelShortcutLabel})` : ""} @@ -78,13 +82,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ className="flex h-full shrink-0 items-center gap-1 [-webkit-app-region:no-drag]" data-panel-layout-controls > - {showThreadPanelControl - ? threadPanelPresentation === "popover" - ? threadPanelTooltip( - , - ) - : threadPanelTooltip(threadPanelToggle) - : null} + {showThreadPanelControl ? threadPanelControl : null} {showTerminalControl ? ( }> diff --git a/apps/web/src/components/chat/SecretRequestCard.tsx b/apps/web/src/components/chat/SecretRequestCard.tsx index f8dc7579adcaf..50992c2edebf5 100644 --- a/apps/web/src/components/chat/SecretRequestCard.tsx +++ b/apps/web/src/components/chat/SecretRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { SECRET_REQUEST_DEFAULT_PLACEHOLDER, secretRequestAnswerInput, @@ -86,7 +87,7 @@ function PendingSecretRequestForm(props: { return; } if (!isAtomCommandInterrupted(result)) { - setError(secretRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), secretRequestFailureMessage, setError); } }; diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index f954d275ac6fd..2da6019787f5a 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import type { EnvironmentId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; import { @@ -94,7 +95,7 @@ export function ThreadDetailsPrRows({ const rest = currentLink === null ? [] - : pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links))) + : pipe(visibleThreadPullRequests(links), resolveThreadPullRequestChains, pullRequestListLines) .map((line) => line.link) .filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink)); if (rest.length === 0) return currentRow; diff --git a/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx b/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx new file mode 100644 index 0000000000000..c425d560bf131 --- /dev/null +++ b/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx @@ -0,0 +1,122 @@ +import { + FULL_ACCESS_OVERRIDE_QUESTION, + fullAccessOverrideExplanation, +} from "@t3tools/client-runtime/state/thread-requests"; +import { ShieldIcon } from "lucide-react"; +import { memo, use } from "react"; +import { type PendingApproval } from "../../session-logic"; +import { InlineButton } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; +import { ToolActivityIconView, type WorkEntryIconName } from "./TimelineToolActivityIcon"; +import { TimelineRowCtx } from "./timelineRowContext"; +import { WorkLogRow } from "./WorkLog"; + +const APPROVAL_PRESENTATION = { + command: { icon: "terminal", label: "Command approval", detailLabel: "Command" }, + "file-change": { icon: "square-pen", label: "File change approval", detailLabel: "File change" }, + "file-read": { icon: "eye", label: "File read approval", detailLabel: "File to read" }, + permission: { + icon: "wrench", + label: "App permission approval", + detailLabel: "Permission request", + }, + "mcp-elicitation": { + icon: "hammer", + label: "App access approval", + detailLabel: "App access request", + }, +} satisfies Record< + PendingApproval["requestKind"], + { icon: WorkEntryIconName; label: string; detailLabel: string } +>; + +export const TimelinePendingApprovalRow = memo(function TimelinePendingApprovalRow({ + approval, +}: { + approval: PendingApproval; +}) { + const { respondingRequestIds, canOperateThread, onRespondToApproval } = use(TimelineRowCtx); + const presentation = APPROVAL_PRESENTATION[approval.requestKind]; + const canRespond = approval.responseCapability === "live"; + return ( + + } + label={ + + {approval.detail || presentation.label} + + } + > +
    + {approval.appName ? ( + {approval.appName} + ) : null} + {approval.reason ? ( + {approval.reason} + ) : null} +
    + {canRespond ? ( + <> + + + Waiting for approval + + {onRespondToApproval ? ( + <> + + · + + + + ) : null} + + ) : ( + + Provider process is gone. Interrupt or restart the run to respond. + + )} + {approval.fullAccessOverride ? ( + + }> + {FULL_ACCESS_OVERRIDE_QUESTION} + + + {fullAccessOverrideExplanation(approval.fullAccessOverride)} + + + ) : null} +
    +
    +
    + ); +}); diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 897685a849fa6..2b231f6ddfe35 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -524,6 +524,7 @@ function UserTimelineRow({ row }: { row: Extract + {/* Isolate the thumbnail raster from nearby activity repaints in Chromium. */} ) : ( diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 49c2b8c411eb6..a8d5e3e68de77 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -1,3 +1,4 @@ +import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow"; import { WorkLogDetails, WorkLogRow } from "./WorkLog"; import { getQuestionAnswerPreview, @@ -198,6 +199,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { setExpanded(next); }; const failureItem = workEntry.projectedItem?.item; + const pendingApproval = + failureItem?.type === "approval_request" + ? ctx.pendingApprovals.get(failureItem.requestId) + : undefined; + if (pendingApproval) return ; if (failureItem?.type === "error" && failureItem.status === "failed") { const warning = failureItem.failure.class === "usage_limit"; const resetAt = failureItem.failure.resetAt; @@ -530,7 +536,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { environmentId={ctx.activeThreadEnvironmentId} cwd={ctx.markdownCwd} workspaceRoot={workspaceRoot} - onOpenTurnDiff={ctx.onOpenTurnDiff} + onOpenToolDiff={ctx.onOpenToolDiff} onImageExpand={ctx.onImageExpand} source={ctx.turnItemSource} /> diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 22d17e5e748b9..a440b62a584d2 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -1,3 +1,4 @@ +import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow"; import { WorkLogButton, WorkLogList, WorkLogRow } from "./WorkLog"; import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { type ToolActivityIcon } from "@t3tools/contracts"; @@ -66,12 +67,17 @@ export const WorkGroupSection = memo(function WorkGroupSection({ isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { - const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); + const { workspaceRoot, routeThreadKey, onToggleWorkEntry, pendingApprovals } = + use(TimelineRowCtx); const onToggleStandaloneEntry = (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed); - const nonEmptyEntries = groupedEntries.filter((entry) => - workEntryIsVisibleInGroup(entry, isExpandedToolGroup), - ); + const nonEmptyEntries = groupedEntries.filter((entry) => { + const item = entry.projectedItem?.item; + return ( + (item?.type === "approval_request" && pendingApprovals.has(item.requestId)) || + workEntryIsVisibleInGroup(entry, isExpandedToolGroup) + ); + }); if (nonEmptyEntries.length === 0) return null; if (isExpandedToolGroup && nonEmptyEntries.every((entry) => entry.itemType === "reasoning")) { @@ -355,7 +361,11 @@ export function ThinkingTimelineRow({ row }: { row: Extract; - const activity = ; + // A failed group keeps this disclosure while an approval waits, but nothing is thinking then. + const live = ctx.pendingApprovals.size === 0; + const activity = ( + + ); const { groupId } = row; if (groupId === undefined) return activity; return ( @@ -484,6 +494,11 @@ export function LiveWorkEntryTimelineRow({ const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const threadLabel = threadReadLabel(label, threadTarget); + const item = row.entry.projectedItem?.item; + const pendingApproval = + item?.type === "approval_request" ? ctx.pendingApprovals.get(item.requestId) : undefined; + if (pendingApproval) return ; + const failed = workEntryDisplayIndicatesToolFailure(row.entry); const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : ""; const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought; diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 958a56077e51a..ee6072b926b7e 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -1,4 +1,5 @@ -import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, RunId } from "@t3tools/contracts"; +import type { ToolCallDiffTarget } from "../../diffPanelStore"; +import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; import { toolCallLines, turnItemDetailRevision, @@ -32,7 +33,7 @@ interface V2ItemInspectorProps { readonly environmentId: EnvironmentId; readonly cwd?: string | undefined; readonly workspaceRoot?: string | undefined; - readonly onOpenTurnDiff?: ((runId: RunId, filePath?: string) => void) | undefined; + readonly onOpenToolDiff?: ((target: ToolCallDiffTarget) => void) | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; readonly source?: TurnItemSource | undefined; } @@ -289,11 +290,17 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec -{formatDiffCount(item.deletions ?? 0)} ) : null} - {item.runId !== null && props.onOpenTurnDiff ? ( + {props.onOpenToolDiff ? ( diff --git a/apps/web/src/components/chat/composerTypingGuard.test.ts b/apps/web/src/components/chat/composerTypingGuard.test.ts index b9138362f42ed..e98b7c80087dc 100644 --- a/apps/web/src/components/chat/composerTypingGuard.test.ts +++ b/apps/web/src/components/chat/composerTypingGuard.test.ts @@ -19,6 +19,9 @@ describe("composer typing guard", () => { }); state = updateComposerTypingGuard(state, { type }); expect([...state.held]).toEqual([]); + const released = state.held; + state = updateComposerTypingGuard(state, { type }); + expect(state.held).toBe(released); state = updateComposerTypingGuard(state, { type: "focus" }); state = updateComposerTypingGuard(state, { type: "type", now: 2000 }); state = updateComposerTypingGuard(state, { type: "requests", requestIds: [], now: 2100 }); @@ -37,13 +40,16 @@ describe("composer typing guard", () => { requestIds: ["question"], now: 0, }); + const held = state.held; state = updateComposerTypingGuard(state, { type: "focus" }); state = updateComposerTypingGuard(state, { type: "type", now: 1000 }); + expect(state.held).toBe(held); state = updateComposerTypingGuard(state, { type: "requests", requestIds: ["question"], now: 1100, }); expect([...state.held]).toEqual([]); + expect(state.held).toBe(held); }); }); diff --git a/apps/web/src/components/chat/composerTypingGuard.ts b/apps/web/src/components/chat/composerTypingGuard.ts index 01c9cca77d4c3..1f72b98b14011 100644 --- a/apps/web/src/components/chat/composerTypingGuard.ts +++ b/apps/web/src/components/chat/composerTypingGuard.ts @@ -32,7 +32,14 @@ export function updateComposerTypingGuard( } seen.add(id); } - return { ...state, seen, held }; + return { + ...state, + seen, + held: + held.size === state.held.size && [...held].every((id) => state.held.has(id)) + ? state.held + : held, + }; } case "type": return { ...state, lastTypedAt: event.now }; @@ -43,7 +50,7 @@ export function updateComposerTypingGuard( case "idle": return { ...state, - held: new Set(), + held: state.held.size === 0 ? state.held : new Set(), lastTypedAt: -Infinity, focused: event.type === "blur" ? false : state.focused, }; diff --git a/apps/web/src/components/chat/hostImageUrl.ts b/apps/web/src/components/chat/hostImageUrl.ts new file mode 100644 index 0000000000000..2078d40ff4f80 --- /dev/null +++ b/apps/web/src/components/chat/hostImageUrl.ts @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +/** + * Where markdown loads an image file on the host by its absolute path, when the page has no + * environment to ask. The guest page sets it to its own route. + */ +export const HostImageUrlContext = createContext<((path: string) => string) | null>(null); diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index e38dfecffd044..6334ef53bd050 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -1,4 +1,9 @@ -import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; +import type { ToolCallDiffTarget } from "../../diffPanelStore"; +import type { + ProviderApprovalDecision, + RuntimeRequestId, + WorktreeSetupSnapshot, +} from "@t3tools/contracts"; import { type EnvironmentId, type MessageId, @@ -16,6 +21,7 @@ import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRunt import { createContext, type MouseEvent, type ReactNode } from "react"; import { type LegendListRef } from "@legendapp/list/react"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; +import type { PendingApproval } from "../../session-logic"; import { type ChatFileAttachment } from "../../types"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { type AssistantCitationTarget } from "./AssistantCitationSource"; @@ -37,6 +43,12 @@ export interface TurnItemSource { } export interface TimelineRowSharedState { + pendingApprovals: ReadonlyMap; + respondingRequestIds: ReadonlyArray; + canOperateThread: boolean; + onRespondToApproval: + | ((requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => Promise) + | undefined; citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; timestampFormat: TimestampFormat; @@ -56,6 +68,7 @@ export interface TimelineRowSharedState { findActive: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; displayThreadKey?: string; + onOpenToolDiff: ((target: ToolCallDiffTarget) => void) | undefined; /** Unset where there is no diff to open, so the timeline shows no "Open diff" button. */ onOpenTurnDiff: ((runId: RunId, filePath?: string) => void) | undefined; /** diff --git a/apps/web/src/components/chat/useComposerProviderModel.ts b/apps/web/src/components/chat/useComposerProviderModel.ts index 24920d1593b45..7fd1130859fec 100644 --- a/apps/web/src/components/chat/useComposerProviderModel.ts +++ b/apps/web/src/components/chat/useComposerProviderModel.ts @@ -12,10 +12,8 @@ import { } from "@t3tools/contracts"; import { NO_PROVIDER_MODEL_SELECTION, + providerInstanceEntriesFor, type ProviderInstanceEntry, - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, } from "../../providerInstances"; import { type UnifiedSettings } from "@t3tools/contracts/settings"; import { getComposerPromptInjectionState, getComposerProviderState } from "./composerProviderState"; @@ -52,12 +50,10 @@ export function useComposerProviderModel( lockedInstanceId, } = input; const draftModelState = useComposerDraftModelState(threadRef); - const providerInstanceEntries: ReadonlyArray = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - activeProjectId, - ), + const providerInstanceEntries: ReadonlyArray = providerInstanceEntriesFor( + providerStatuses, + settings, + activeProjectId, ); const selection = resolveComposerProviderSelection({ entries: providerInstanceEntries, diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index ac79b0853bf1f..3919df03b6af3 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -1,3 +1,4 @@ +import type { ToolCallDiffTarget } from "../../diffPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { ThreadPreviewState } from "../../previewStateStore"; import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; @@ -302,6 +303,15 @@ export function useRightPanelActions(input: RightPanelActionsInput) { useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }; + const onOpenToolDiffStable = useStableCallback((target: ToolCallDiffTarget) => { + if (!isServerThread || !activeThreadRef) return; + const diffOpen = + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; + explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; + useDiffPanelStore.getState().selectToolCall(activeThreadRef, target); + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + }); const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the preview action bus to toggle the preview panel useEffect( @@ -338,6 +348,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { createBrowserSurface, createBrowserSurfaceStable, moveRightPanelSurfaceStable, + onOpenToolDiffStable, onOpenTurnDiffStable, onToggleDiffStable, openChangesFromThreadPanelStable, diff --git a/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx b/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx new file mode 100644 index 0000000000000..689e04661127c --- /dev/null +++ b/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx @@ -0,0 +1,167 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { T3CHAT_PLAN_TUNNEL_LIMITS, type RelayT3ChatLink } from "@t3tools/contracts/relay"; +import { MessageSquareIcon } from "lucide-react"; +import { useState } from "react"; + +import { + createManagedRelayT3ChatLinkRequestCommand, + unlinkManagedRelayT3ChatCommand, + useManagedRelayT3ChatLink, +} from "../../cloud/managedRelayState"; +import { readLocalApi } from "../../localApi"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { + ClerkUserProfilePage, + ClerkUserProfileRefreshButton, + ClerkUserProfileRow, +} from "./ClerkUserProfilePage"; + +function planLabel(link: RelayT3ChatLink): string { + switch (link.plan) { + case "premier": + return `Premier · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} tunnels`; + case "pro": + return `Pro · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.pro} tunnels, or ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} with Premier`; + case "free": + return "Upgrade on T3 Chat for more tunnels"; + } +} + +function reportFailure(title: string, cause: unknown) { + const traceId = findErrorTraceId(cause); + toastManager.add({ + type: "error", + title, + description: cause instanceof Error ? cause.message : "Try again.", + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); +} + +/** Links a T3 Chat account so paid subscribers get more tunnels. */ +export function T3ChatUserProfilePage() { + const linkState = useManagedRelayT3ChatLink(); + const createLinkRequest = useAtomCommand(createManagedRelayT3ChatLinkRequestCommand, { + reportFailure: false, + }); + const unlink = useAtomCommand(unlinkManagedRelayT3ChatCommand, { reportFailure: false }); + const [pending, setPending] = useState<"link" | "unlink" | null>(null); + // Kept so a blocked popup still leaves the user a link to open. + const [linkUrl, setLinkUrl] = useState(null); + + const handleLink = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("link"); + const result = await createLinkRequest({ accountId }); + if (result._tag === "Success") { + setLinkUrl(result.value); + await readLocalApi() + ?.shell.openExternal(result.value) + .catch(() => undefined); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not start linking", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + const handleUnlink = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("unlink"); + const result = await unlink({ accountId }); + if (result._tag === "Success") { + setLinkUrl(null); + linkState.refresh(); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not unlink T3 Chat", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + const data = linkState.data; + const link = data?.link ?? null; + + return ( + + } + > + {linkState.error ? ( +
    +

    Could not load T3 Chat link

    +

    {linkState.error}

    +
    + ) : data === null ? null : !data.available ? ( +

    + This T3 Connect server does not support T3 Chat linking. +

    + ) : ( +
      + }> +
      +
      +

      + {link ? link.email : "Not linked"} +

      +

      + {link ? ( + planLabel(link) + ) : linkUrl ? ( + <> + Confirm on{" "} + + t3.chat + + , then select Refresh. + + ) : ( + "You confirm the link on t3.chat." + )} +

      +
      + {link ? ( + + ) : ( + + )} +
      +
      +
    + )} +
    + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx index 267bf4cd21c4f..85dcc7dd255c4 100644 --- a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx +++ b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx @@ -1,9 +1,10 @@ import { useAuth, useClerk } from "@clerk/react"; -import { ServerIcon, SmartphoneIcon } from "lucide-react"; +import { MessageSquareIcon, ServerIcon, SmartphoneIcon } from "lucide-react"; import { type ReactNode, useState } from "react"; import { createPortal } from "react-dom"; import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; +import { T3ChatUserProfilePage } from "./T3ChatUserProfilePage"; import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; export const T3_CONNECT_ACCOUNT_PAGES = [ @@ -19,6 +20,12 @@ export const T3_CONNECT_ACCOUNT_PAGES = [ icon: , content: , }, + { + label: "T3 Chat", + url: "t3-chat", + icon: , + content: , + }, ] as const; type PortalTargets = Readonly>; diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index e237a4690b7cc..22ae466acb0ed 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -1,6 +1,5 @@ import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useId, useRef, useState, type ReactNode } from "react"; @@ -197,7 +196,7 @@ export function DeviceStreamView(props: { ? deviceAxElementsAtom({ access, platform: props.platform, deviceId: props.deviceId }) : NO_AX_ATOM, ); - const axElements = Option.getOrElse(AsyncResult.value(axResult), () => NO_AX_ELEMENTS); + const axElements = AsyncResult.getOrElse(axResult, () => NO_AX_ELEMENTS); const pointerActive = useRef(false); const normalizedPoint = (event: React.PointerEvent) => { diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index fb876688dad79..9879edfeedbd8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -51,6 +51,7 @@ import { boundedZip } from "@t3tools/client-runtime/zip/bounded-zip"; import BinaryViewer from "./BinaryViewer"; import { decodeImagePreview } from "./decodeImagePreview"; import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; +import type { TextPreviewResult } from "./largeTextProtocol"; // Real worker imports and WASM initialization can exceed waitFor's default second // when the full browser project runs concurrently on CI. @@ -462,12 +463,20 @@ describe("large text preview recovery", () => { const original = URL.createObjectURL(new Blob(["original\n".repeat(1000)])); const changed = URL.createObjectURL(new Blob(["changed\n".repeat(1000)])); const workers: Worker[] = []; + const indexed: Promise[] = []; using _worker = stubGlobal( "Worker", class extends Worker { constructor(...args: ConstructorParameters) { super(...args); workers.push(this); + indexed.push( + new Promise((resolve) => + this.addEventListener("message", (event: MessageEvent) => { + if (event.data.type === "meta" && event.data.complete) resolve(); + }), + ), + ); } }, ); @@ -499,6 +508,8 @@ describe("large text preview recovery", () => { await using screen = await render(preview("first")); await vi.waitFor(() => expect(workers).toHaveLength(1)); await vi.waitFor(() => expect(screen.container.textContent).toContain("original")); + // A cold worker can take longer than waitFor allows to load and index on CI. + await indexed[0]; await vi.waitFor(() => expect(screen.container.textContent).toContain("1,000 lines")); const viewport = screen.container.querySelector('[tabindex="0"]')!; await act(() => @@ -2143,8 +2154,10 @@ it("refreshes authorization and retries a failed streamed attachment", async () asset={{ environmentId: EnvironmentId.make("test"), attachmentId: "retry" }} />, ); - await vi.waitFor(() => - expect(screen.container.textContent).toContain("This file contains binary data."), + // A cold large-text worker can take longer than waitFor's default to boot on CI. + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file contains binary data."), + { timeout: 10_000 }, ); await act(() => screen.container.querySelector('[role="alert"] button')!.click(), @@ -2206,7 +2219,10 @@ it("copies every indexed line rather than the mounted preview rows", async () => , ); await vi.waitFor(() => expect(screen.container.textContent).toContain("line-0")); - await vi.waitFor(async () => expect(await copyContents.current!()).toBe(text)); + // Copy rejects until the worker finishes indexing, which a cold worker can delay past waitFor's default on CI. + await vi.waitFor(async () => expect(await copyContents.current!()).toBe(text), { + timeout: 10_000, + }); const first = copyContents.current!(); await expect(copyContents.current!()).rejects.toThrow("Another copy is in progress."); expect(await first).toBe(text); @@ -2281,12 +2297,16 @@ it("loads a font attachment through the lazy preview", async () => { file={file} />, ); - await vi.waitFor(() => { - const frame = screen.container.querySelector("iframe"); - expect(frame?.title).toBe("Carlito.ttf"); - expect(frame?.srcdoc).toContain("@font-face"); - expect(frame?.srcdoc).toContain("The quick brown fox"); - }); + // Two cold lazy chunks plus decoding a 600 KB font can take longer than waitFor's default on CI. + await vi.waitFor( + () => { + const frame = screen.container.querySelector("iframe"); + expect(frame?.title).toBe("Carlito.ttf"); + expect(frame?.srcdoc).toContain("@font-face"); + expect(frame?.srcdoc).toContain("The quick brown fox"); + }, + { timeout: 10_000 }, + ); expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); diff --git a/apps/web/src/components/files/extendedDocumentPreview.ts b/apps/web/src/components/files/extendedDocumentPreview.ts index 589ae62265c28..46cf7dbb67a83 100644 --- a/apps/web/src/components/files/extendedDocumentPreview.ts +++ b/apps/web/src/components/files/extendedDocumentPreview.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { IMAGE_DIMENSIONS_HEADER_BYTES, readImageDimensions, @@ -221,7 +222,7 @@ const HTML_TAGS = new Set([ "dd", ]); async function epubHtml(node: XmlNode, archive: JSZip, base: string): Promise { - if (node.name === "#text") return boundedMarkup(escape(text(node))); + if (node.name === "#text") return pipe(text(node), escape, boundedMarkup); const tag = node.name .replace(/^\{[^}]*\}/, "") @@ -249,7 +250,7 @@ async function epubHtml(node: XmlNode, archive: JSZip, base: string): Promise${children}`; } async function odfHtml(node: XmlNode, archive: JSZip): Promise { - if (node.name === "#text") return boundedMarkup(escape(text(node))); + if (node.name === "#text") return pipe(text(node), escape, boundedMarkup); if (node.name === "text:s" || node.name === "text:tab") return boundedMarkup( `${node.name === "text:tab" ? " " : " ".repeat(repeat(node.attrs["text:c"], 100))}`, diff --git a/apps/web/src/components/files/office/fonts.ts b/apps/web/src/components/files/office/fonts.ts index 6328d294d8749..cdac25d4bbe14 100644 --- a/apps/web/src/components/files/office/fonts.ts +++ b/apps/web/src/components/files/office/fonts.ts @@ -1,4 +1,5 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. +import { pipe } from "effect/Function"; import type { BundledFontProvider } from "@betteroffice/docx/layout"; import { loadBundledFontBytes, @@ -23,9 +24,9 @@ export function wordFonts(): BundledFontProvider { return { resolve: (family, bold, italic) => - loader(shippedFace(resolveMetricCompatFace(family, bold, italic))), + pipe(resolveMetricCompatFace(family, bold, italic), shippedFace, loader), resolveFamily: (family, bold, italic) => - loader(shippedFace(resolveBundledFamilyFace(family, bold, italic))), + pipe(resolveBundledFamilyFace(family, bold, italic), shippedFace, loader), resolveScriptFallback: (script, bold, italic) => loader(scriptFace(script, bold, italic)), resolveLastResort: (family, bold, italic) => () => loadFace(lastResortFace(family, bold, italic, "word")), diff --git a/apps/web/src/components/files/office/slide-text.ts b/apps/web/src/components/files/office/slide-text.ts index 396c0cb0b2761..6afe9a6f61b9a 100644 --- a/apps/web/src/components/files/office/slide-text.ts +++ b/apps/web/src/components/files/office/slide-text.ts @@ -1,4 +1,5 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. +import { pipe } from "effect/Function"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { inspectPresentation, type DeckSnapshot } from "@betteroffice/pptx"; @@ -131,50 +132,58 @@ const scriptRanges: readonly (readonly [FallbackScript, RegExp])[] = [ * engine's inspection does not decode, the slides' own runs stand in, without theme fonts or hidden flags. */ export function deckText(bytes: Uint8Array, snapshot: DeckSnapshot): DeckText { - return Option.match(decodeInspected(inspectPresentation(withoutMedia(bytes))), { - onNone: () => fromSnapshot(snapshot), - onSome: (inspected) => { - const schemes = - inspected.themes?.flatMap((theme) => - theme.theme?.fontScheme ? [theme.theme.fontScheme] : [], - ) ?? []; - const minor = schemes[0]?.minorFont.latin ?? ""; - - const drawings = [ - ...inspected.slides.flatMap((slide) => slide.drawings), - ...(inspected.layouts ?? []).flatMap((layout) => layout.drawings), - ...(inspected.masters ?? []).flatMap((master) => master.drawings), - ]; - - const defaults = (inspected.masters ?? []).flatMap((master) => - Object.values(master.textStyles ?? {}).flatMap((levels) => - levels.flatMap((level) => - level.defaultRun ? [{ text: "", style: level.defaultRun }] : [], + return pipe( + withoutMedia(bytes), + inspectPresentation, + decodeInspected, + Option.match({ + onNone: () => fromSnapshot(snapshot), + onSome: (inspected) => { + const schemes = + inspected.themes?.flatMap((theme) => + theme.theme?.fontScheme ? [theme.theme.fontScheme] : [], + ) ?? []; + const minor = schemes[0]?.minorFont.latin ?? ""; + + const drawings = [ + ...inspected.slides.flatMap((slide) => slide.drawings), + ...(inspected.layouts ?? []).flatMap((layout) => layout.drawings), + ...(inspected.masters ?? []).flatMap((master) => master.drawings), + ]; + + const defaults = (inspected.masters ?? []).flatMap((master) => + Object.values(master.textStyles ?? {}).flatMap((levels) => + levels.flatMap((level) => + level.defaultRun ? [{ text: "", style: level.defaultRun }] : [], + ), ), - ), - ); - - return summarize( - [...drawings.flatMap(drawingRuns), ...defaults].flatMap((run) => { - const family = run.style?.fontFamily; - if (!schemes.length || (family && !family.startsWith("+mn") && !family.startsWith("+mj"))) - return [run]; - return schemes.map((scheme) => ({ - ...run, - style: { - ...run.style, - fontFamily: family?.startsWith("+mj") - ? scheme.majorFont.latin - : scheme.minorFont.latin, - }, - })); - }), - (family) => (family?.startsWith("+") ? "" : (family ?? "")), - minor, - inspected.slides.map((slide) => slide.hidden ?? false), - ); - }, - }); + ); + + return summarize( + [...drawings.flatMap(drawingRuns), ...defaults].flatMap((run) => { + const family = run.style?.fontFamily; + if ( + !schemes.length || + (family && !family.startsWith("+mn") && !family.startsWith("+mj")) + ) + return [run]; + return schemes.map((scheme) => ({ + ...run, + style: { + ...run.style, + fontFamily: family?.startsWith("+mj") + ? scheme.majorFont.latin + : scheme.minorFont.latin, + }, + })); + }), + (family) => (family?.startsWith("+") ? "" : (family ?? "")), + minor, + inspected.slides.map((slide) => slide.hidden ?? false), + ); + }, + }), + ); } /** A file in the deck's zip package: where its central directory record and its local data sit. */ diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 3da7fdd6da850..4d7e9ae90d6b9 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -207,7 +207,7 @@ export function useProjectEntriesQuery( const refreshAtom = useAtomRefresh(atom); const refresh = () => refreshAtom(); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error: fileAccess.isPending ? null : canReadFiles @@ -263,7 +263,7 @@ export function useProjectFileQuery( const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = () => refreshAtom(); - const readData = Option.getOrNull(AsyncResult.value(result)); + const readData = AsyncResult.getOrElse(result, () => null); const isMissing = readData?.exists === false; const optimisticResult = useAtomValue( optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), diff --git a/apps/web/src/components/guest/GuestPage.tsx b/apps/web/src/components/guest/GuestPage.tsx index 3ec7b2d542335..6d72741691f92 100644 --- a/apps/web/src/components/guest/GuestPage.tsx +++ b/apps/web/src/components/guest/GuestPage.tsx @@ -45,6 +45,7 @@ import type { ChatComposerHandle } from "../chat/ChatComposer"; import { ComposerControl, ComposerControlIcon } from "../chat/ComposerControl"; import type { ExpandedImagePreview } from "../chat/ExpandedImagePreview"; import { GuestMessageAttachments } from "../chat/GuestMessageAttachments"; +import { HostImageUrlContext } from "../chat/hostImageUrl"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { GuestMessageName, guestBubbleClassName } from "../chat/guestMessageStyle"; import { MessagesTimeline } from "../chat/MessagesTimeline"; @@ -60,6 +61,7 @@ import { applyGuestStreamEvent, formatGuestExpiry, guestAttachmentUrl, + guestMediaUrl, createGuestAttachmentUrls, guestFilesProblem, initialGuestStreamState, @@ -387,6 +389,7 @@ function GuestThread({ const openFile = useStableCallback((file: { readonly id: string }) => openInNewTab(guestAttachmentUrl(shareId, file.id)), ); + const hostImageUrl = useStableCallback((path: string) => guestMediaUrl(shareId, path)); const activityRun = deriveThreadActivityRun(projection); const runtime = deriveThreadStructureRuntime(projection); const runlessWorkStartedAt = deriveRunlessWorkStartedAt(projection); @@ -448,50 +451,52 @@ function GuestThread({
    - - {unplaced.map((message) => ( -
  • - -
  • - ))} - - ) - } - /> + + + {unplaced.map((message) => ( +
  • + +
  • + ))} + + ) + } + /> +
    {follow.showScrollToBottom ? ( `${GUEST_ATTACHMENT_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&id=${encodeURIComponent(attachmentId)}`; +/** Where the guest page loads an image file on the host that an agent message embeds. */ +export const guestMediaUrl = (shareId: string, path: string) => + `${GUEST_MEDIA_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&path=${encodeURIComponent(path)}`; + /** * Why these files cannot go with one message, or null. The server checks the same limits; this * only says so before the upload. diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index 314f7a6435d28..f45ba1e7abc78 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -16,7 +16,6 @@ import { sessionGrantsScope, type SessionGrantInput, } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState, type ReactElement } from "react"; @@ -53,7 +52,7 @@ function allowsHostMedia(session: SessionGrantInput | null) { function canReadHostMedia(environmentId: EnvironmentId | null): boolean { if (environmentId === null) return true; const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); - return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); + return result._tag !== "Failure" && allowsHostMedia(AsyncResult.getOrElse(result, () => null)); } export function useMediaActions(source: MediaActionSource) { diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts index ccb133074baad..8273258d51e54 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -10,7 +10,6 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { getClientSettings } from "../../hooks/useSettings"; @@ -222,7 +221,7 @@ function watchEnvironmentDevices(environmentId: EnvironmentId): () => void { return appAtomRegistry.subscribe( deviceEnvironment.state({ environmentId, input: {} }), (result) => { - const state = Option.getOrNull(AsyncResult.value(result)); + const state = AsyncResult.getOrElse(result, () => null); if (state === null) return; applyDeviceSnapshot(environmentId, state, { sheetLayout: window.matchMedia(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY).matches, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index f8d58cfaa020d..d1b165c62147b 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -106,6 +106,7 @@ import { toViewerSide, } from "./PullRequestReviewAnnotation"; import { + getFileViewedRange, isFileDiffCollapsed, isLineInFileDiff, toggleFileDiffFoldForViewed, @@ -175,7 +176,7 @@ interface FileViewedToggleState { readonly enabled: boolean; readonly isViewed: (path: string) => boolean; readonly isStale: (path: string) => boolean; - readonly setFileViewed: (fileKey: string, path: string, viewed: boolean) => void; + readonly setFileViewed: (fileKey: string, viewed: boolean, extendRange: boolean) => void; } // Context updates viewed toggles without invalidating memoized header portals. @@ -205,7 +206,13 @@ function FileViewedToggle({ viewedFiles.setFileViewed(fileKey, path, next === true)} + onCheckedChange={(next, details) => + viewedFiles.setFileViewed( + fileKey, + next === true, + "shiftKey" in details.event && details.event.shiftKey === true, + ) + } /> {stale ? ( @@ -287,6 +294,7 @@ function PullRequestCodeTab({ wordWrap: settings.wordWrap, })); const [toggledFiles, setToggledFiles] = useState>(() => new Set()); + const [lastViewedFileKey, setLastViewedFileKey] = useState(null); const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); const [foldOverride, setFoldOverride] = useState(null); const effectiveFoldOverride = @@ -326,6 +334,7 @@ function PullRequestCodeTab({ const [stateScopeKey, setStateScopeKey] = useState(scopeKey); if (stateScopeKey !== scopeKey) { setStateScopeKey(scopeKey); + setLastViewedFileKey(null); setDraft(null); setSelectedLines(null); setToggledFiles(new Set()); @@ -689,10 +698,20 @@ function PullRequestCodeTab({ return next; }); - const setFileViewed = (fileKey: string, path: string, viewed: boolean) => { - setViewed(path, viewed); + const setFileViewed = (fileKey: string, viewed: boolean, extendRange: boolean) => { + const range = getFileViewedRange( + annotatedFiles, + fileKey, + extendRange ? lastViewedFileKey : null, + ); + setLastViewedFileKey(fileKey); + for (const file of range) setViewed(file.path, viewed); setToggledFiles((current) => - toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current), + range.reduce( + (next, file) => + toggleFileDiffFoldForViewed(file.fileKey, viewed, effectiveFoldOverride, next), + current, + ), ); }; const fileViewedToggle = { diff --git a/apps/web/src/components/pullRequest/filesViewedStore.ts b/apps/web/src/components/pullRequest/filesViewedStore.ts index 64ae67a355192..cae490ded8d66 100644 --- a/apps/web/src/components/pullRequest/filesViewedStore.ts +++ b/apps/web/src/components/pullRequest/filesViewedStore.ts @@ -8,7 +8,6 @@ import type { PullRequestFilesViewedResult, PullRequestRef, } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -238,7 +237,7 @@ const filesViewedStore = createFilesViewedStore({ subscribeAnswers: (target, onAnswer) => appAtomRegistry.subscribe( filesViewedAtom(target), - (result) => onAnswer(Option.getOrNull(AsyncResult.value(result))), + (result) => onAnswer(AsyncResult.getOrElse(result, () => null)), { immediate: true }, ), send: ({ environmentId, reference }, files) => diff --git a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx index d9c05ae1a9917..f73755ed7d452 100644 --- a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx +++ b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx @@ -1,7 +1,6 @@ import type { AnyRouter } from "@tanstack/react-router"; import type { PullRequestReviewGuideResult } from "@t3tools/contracts"; import { CircleAlertIcon, CircleCheckIcon } from "lucide-react"; -import * as Option from "effect/Option"; import { AsyncResult, type AtomRegistry } from "effect/reactivity"; import { getClientSettings } from "~/hooks/useSettings"; @@ -159,7 +158,7 @@ function watchGuideRun( const unsubscribe = registry.subscribe( atom, (result) => { - const data = Option.getOrNull(AsyncResult.value(result)); + const data = AsyncResult.getOrElse(result, () => null); if (data === null || data === seen) return; seen = data; if (subscribing && watch.answeredRunning === true) return; diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index 4e11e04e1d784..552fa748f07f6 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -2,6 +2,7 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; import { + getFileViewedRange, isFileDiffCollapsed, isLineInFileDiff, toggleFileDiffFoldForViewed, @@ -54,3 +55,19 @@ describe("toggleFileDiffFoldForViewed", () => { expect(toggleFileDiffFoldForViewed("a.ts", false, "expanded", new Set()).size).toBe(0); }); }); + +describe("getFileViewedRange", () => { + const files = ["c.ts", "a.ts", "b.ts", "d.ts"].map((fileKey) => ({ fileKey })); + + it("includes both endpoints in displayed order in either direction", () => { + expect(getFileViewedRange(files, "b.ts", "c.ts")).toEqual(files.slice(0, 3)); + expect(getFileViewedRange(files, "c.ts", "b.ts")).toEqual(files.slice(0, 3)); + expect(getFileViewedRange(files, "a.ts", "a.ts")).toEqual([files[1]]); + }); + + it("changes only the clicked file without a current anchor", () => { + expect(getFileViewedRange(files, "a.ts", null)).toEqual([files[1]]); + expect(getFileViewedRange(files, "a.ts", "removed.ts")).toEqual([files[1]]); + expect(getFileViewedRange(files, "removed.ts", "a.ts")).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index 37fbfe30e9ab4..f175c0151431a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -39,3 +39,15 @@ export function toggleFileDiffFoldForViewed( else next.add(fileKey); return next; } + +export function getFileViewedRange( + files: ReadonlyArray, + fileKey: string, + anchorFileKey: string | null, +): ReadonlyArray { + const index = files.findIndex((file) => file.fileKey === fileKey); + if (index === -1) return []; + const anchorIndex = files.findIndex((file) => file.fileKey === anchorFileKey); + if (anchorIndex === -1) return files.slice(index, index + 1); + return files.slice(Math.min(index, anchorIndex), Math.max(index, anchorIndex) + 1); +} diff --git a/apps/web/src/components/settings/AcpRegistryIcon.tsx b/apps/web/src/components/settings/AcpRegistryIcon.tsx index b7a5938ee8942..4a2a2143fe0e5 100644 --- a/apps/web/src/components/settings/AcpRegistryIcon.tsx +++ b/apps/web/src/components/settings/AcpRegistryIcon.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useId, useState } from "react"; import { @@ -110,10 +109,9 @@ export function AcpRegistryAgentIcon({ const iconUrl = resolveOfficialAcpRegistryIconUrl(icon); // Render only after the load pipeline resolves, so the fallback icon does // not flash a broken image. - const source = Option.getOrNull( - AsyncResult.value( - useAtomValue(iconUrl === null ? NO_ICON_SOURCE_ATOM : acpRegistryIconSourceAtom(iconUrl)), - ), + const source = AsyncResult.getOrElse( + useAtomValue(iconUrl === null ? NO_ICON_SOURCE_ATOM : acpRegistryIconSourceAtom(iconUrl)), + () => null, ); const [image, setImage] = useState<{ readonly source: string; diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx index 50967ab2026e9..a3612a609418c 100644 --- a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { ACP_REGISTRY_MAX_AGENTS, AuthProvidersManageScope } from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { @@ -111,7 +112,7 @@ export function useAcpRegistrySearch(environmentId: EnvironmentId, enabled: bool return; } if (!isAtomCommandInterrupted(result)) { - setPrepareError(errorMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), errorMessage, setPrepareError); } }, }; diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.tsx index 22bb624c27533..23a439c63a475 100644 --- a/apps/web/src/components/settings/AddEnvironmentDialog.tsx +++ b/apps/web/src/components/settings/AddEnvironmentDialog.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { ChevronsLeftRightEllipsisIcon, PlusIcon, TerminalIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react"; @@ -155,7 +156,7 @@ export function AddEnvironmentDialog({ }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { - onErrorChange(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), formatDesktopSshConnectionError, onErrorChange); } setIsAddingSavedBackend(false); return; diff --git a/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx b/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx index c655833ca7c82..d896efe455216 100644 --- a/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx +++ b/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx @@ -28,8 +28,8 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; */ export function useConnectionDiagnosis(environment: EnvironmentPresentation) { const environmentId = environment.environmentId; - const state = Option.getOrElse( - AsyncResult.value(useAtomValue(environmentCatalog.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + useAtomValue(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); const relayStatus = useRelayEnvironmentDiscovery().environments.get(environmentId)?.status; diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index b06f7b04c4fb8..5f944c867db68 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -4,6 +4,7 @@ import { Link } from "@tanstack/react-router"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; import { ScopedSwitch } from "./ScopedSwitch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -15,6 +16,7 @@ export function LabsSettingsPanel() { return ( + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -24,6 +26,27 @@ export function LabsSettingsPanel() { ); } +function BoringSidebarRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ boringSidebarEnabled: Boolean(checked), legacySidebarEnabled: false }) + } + aria-label="Boring sidebar (group by project)" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 9941be071c799..0bfa19498f6d9 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -12,10 +12,8 @@ import { resolveEffectiveDefaultModelSelection, } from "../../modelSelection"; import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, + providerInstanceEntriesFor, resolveDefaultProviderModelSelection, - sortProviderInstanceEntries, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; @@ -70,13 +68,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const selection = storedSelection ? resolveEffectiveDefaultModelSelection(settings, providers, storedSelection) : null; - const entries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providers), - settings, - target?.projectId ?? null, - ), - ); + const entries = providerInstanceEntriesFor(providers, settings, target?.projectId ?? null); const modelOptions = getCustomModelOptionsByInstance( settings, providers, diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 983503c96b5fe..1f64fe501af1d 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { Clock3Icon, @@ -48,11 +47,7 @@ import { import { formatRelativeTime } from "../../timestampFormat"; import { useEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, -} from "../../providerInstances"; +import { providerInstanceEntriesFor } from "../../providerInstances"; import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; import { requestConfirmDialog } from "../../confirmDialog"; import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; @@ -492,7 +487,7 @@ function ScheduledTaskEnvironmentList({ const states = environments.map((environment) => { const index = connectedIds.indexOf(environment.environmentId); const query = index < 0 ? null : queries[index]!; - const data = query === null ? null : Option.getOrNull(AsyncResult.value(query)); + const data = query === null ? null : AsyncResult.getOrElse(query, () => null); return { environment, connected: index >= 0, @@ -1148,12 +1143,10 @@ function ScheduledTaskEditorForm({ : task; const selectedProjectId = draft.projectId || projects[0]?.id || ""; const selectedProject = projects.find((project) => project.id === selectedProjectId); - const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providers), - settings, - selectedProject?.id ?? null, - ), + const instanceEntries = providerInstanceEntriesFor( + providers, + settings, + selectedProject?.id ?? null, ); const firstInstance = instanceEntries[0]; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 25e297470502f..183c12ddf1a49 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -61,11 +61,7 @@ import { resolveAppModelSelectionState, selectsPlanAgent, } from "../../modelSelection"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, -} from "../../providerInstances"; +import { providerInstanceEntriesFor } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; import { useEnvironmentProviders } from "../../state/environments"; @@ -425,9 +421,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.inAppNotificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled ? ["In-app notifications"] : []), - ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount - ? ["Visible threads"] - : []), ...(settings.sidebarProjectGroupingMode !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), @@ -603,7 +596,6 @@ export function useSettingsRestore(onRestored?: () => void) { environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, - sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, @@ -732,19 +724,6 @@ export function LegacyFeaturesSection() { /> } /> - - updateSettings({ legacySidebarEnabled: Boolean(checked) }) - } - aria-label="Sidebar (legacy)" - /> - } - /> ); } @@ -851,7 +830,7 @@ export function GeneralSettingsPanel() { /> entry.enabled && entry.isAvailable, diff --git a/apps/web/src/components/settings/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx index c6cae612b7716..294ed8b2fd41d 100644 --- a/apps/web/src/components/settings/SettingsScopeContext.tsx +++ b/apps/web/src/components/settings/SettingsScopeContext.tsx @@ -1,7 +1,6 @@ import { type EnvironmentId, T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { useAtomValue } from "@effect/atom-react"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { createContext, type ReactNode, useContext } from "react"; @@ -29,7 +28,7 @@ const memberProjectFilesAtom = Atom.family((membersKey: string) => { if (result.waiting) continue; const data = get(optimisticFileAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME))?.data ?? - Option.getOrNull(AsyncResult.value(result)); + AsyncResult.getOrElse(result, () => null); files.set( physicalProjectKey, data === null || data.exists === false || data.truncated diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 7f7b0a90ebcc5..b519f7322c7d0 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -74,13 +74,13 @@ function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryIt function authPresentation(auth: SourceControlProviderAuth): { readonly label: string; - readonly badge: "warning" | null; + readonly badge: "secondary" | null; } { if (auth.status === "authenticated") { return { label: "Authenticated", badge: null }; } if (auth.status === "unauthenticated") { - return { label: "Not authenticated", badge: "warning" }; + return { label: "Not authenticated", badge: "secondary" }; } return { label: "Status unknown", badge: null }; } @@ -98,8 +98,12 @@ function RedactedAccount(props: { readonly account: string | null }) { function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { if (isVcsNotReady(item)) return "bg-muted-foreground/35"; + if (isProviderDiscoveryItem(item)) { + return item.status === "available" && item.auth.status === "authenticated" + ? "bg-success" + : "bg-muted-foreground/35"; + } if (item.status !== "available") return "bg-warning"; - if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; return "bg-success"; } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 2b54bd04d0ffb..dd3618902f8b5 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -19,11 +19,7 @@ import { } from "./useScopedSettings"; import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; import { useSettingsScope } from "./SettingsScopeContext"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, -} from "../../providerInstances"; +import { providerInstanceEntriesFor } from "../../providerInstances"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, @@ -110,12 +106,10 @@ export function SourceControlWritingSettingsSection() { const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, ); - const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(textGenerationProviders), - settings, - target?.projectId ?? null, - ), + const instanceEntries = providerInstanceEntriesFor( + textGenerationProviders, + settings, + target?.projectId ?? null, ); const projectEnabledInstanceIds = new Set( instanceEntries diff --git a/apps/web/src/components/settings/ThemeColorPicker.tsx b/apps/web/src/components/settings/ThemeColorPicker.tsx index ef2c81511b6f1..e8210026370e2 100644 --- a/apps/web/src/components/settings/ThemeColorPicker.tsx +++ b/apps/web/src/components/settings/ThemeColorPicker.tsx @@ -157,7 +157,7 @@ function ThemeColorPickerPanel({ }; return ( -
    +

    {label}

    diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 81fdf02410e39..3013298c6ecb6 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -197,7 +197,7 @@ describe("settings search targets", () => { const streaming = getSettingsSearchTargetScope("response-streaming")!; expect(streaming.scope).toBe("project-defaults"); expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); - for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { + for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "boring-sidebar"]) { expect(getSettingsSearchTargetScope(id)!.scope).toBeNull(); } }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 460a6ef650b61..25b5b7cc6d24e 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -419,6 +419,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["hide fold running monitoring threads inbox sidebar shelf"], }, + { + id: "boring-sidebar", + title: "Boring sidebar (group by project)", + to: "/settings/labs", + searchTerms: ["beta folders project tree legacy old classic sidebar"], + }, { id: "guest-links", title: "Guest links", @@ -708,12 +714,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["composer meter usage tokens circle old"], }, - { - id: "legacy-sidebar", - title: "Sidebar (legacy)", - to: "/settings/labs", - searchTerms: ["project thread tree old flat list"], - }, { id: "keybindings", title: "Keybindings", diff --git a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx new file mode 100644 index 0000000000000..1ee152533a75b --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx @@ -0,0 +1,115 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { ChevronDownIcon, SquarePenIcon } from "lucide-react"; +import { memo } from "react"; + +import { cn } from "~/lib/utils"; +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import { useUiStateStore } from "../../uiStateStore"; +import { resolveSidebarFolderDot, resolveThreadLastVisitedAt } from "../Sidebar.logic"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { Button } from "../ui/button"; + +const FOLDER_DOT_CLASS_NAME = { + attention: "bg-warning", + failed: "bg-error", + unread: "bg-success", + working: "bg-info", +} as const; + +const FOLDER_DOT_LABEL = { + attention: ", needs you", + failed: ", failed", + unread: ", done", + working: ", working", +} as const; + +/** The folder's sidebar tree row; its thread rows name it as their parent. */ +export function sidebarFolderRowId(projectKey: string) { + return `folder:${projectKey}`; +} + +/** + * A project folder in the boring sidebar. It reads like a shelf header: a trailing + * count and chevron, with the count and a status dot only while it is closed, so a + * closed folder still says when a hidden thread needs you. + */ +export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHeader(props: { + group: SidebarProjectSnapshot; + collapsed: boolean; + hiddenThreads: readonly EnvironmentThreadShell[]; + onToggle: (group: SidebarProjectSnapshot) => void; + onNewThread: (group: SidebarProjectSnapshot) => void; + onContextMenu: (group: SidebarProjectSnapshot, position: { x: number; y: number }) => void; +}) { + const { group, collapsed, hiddenThreads } = props; + const rowId = sidebarFolderRowId(group.projectKey); + const localLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const dot = collapsed + ? resolveSidebarFolderDot( + hiddenThreads.map((thread) => ({ + ...thread, + lastVisitedAt: resolveThreadLastVisitedAt( + thread.lastVisitedAt, + localLastVisitedAtById[ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) + ], + ), + })), + ) + : null; + return ( +
  • + + {/* Sits over the count, which hides on hover, and left of the chevron. */} + + + +
  • + ); +}); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index dff97ef21057d..eac7c8591fa1c 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -515,9 +515,26 @@ const dropVerbBadge: Record = { ), }; +const TOP_STATUS_ICONS = { + working: CircleDashedIcon, + waiting: CircleDotDashedIcon, + input: MessageCircleQuestionIcon, + request: UserRoundIcon, + approval: ShieldQuestionIcon, + failed: CircleAlertIcon, + done: CircleCheckIcon, + woke: AlarmClockIcon, +} as const; + +function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: string }) { + const Icon = TOP_STATUS_ICONS[props.icon]; + return ; +} + type SidebarThreadRowProps = { thread: SidebarThreadSummary; - variant: "card" | "slim"; + // "folder" is a one-line row inside a boring sidebar project folder. + variant: "card" | "slim" | "folder"; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -997,7 +1014,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( "min-w-0 flex-1 text-start text-sm transition-opacity motion-reduce:transition-none", shouldRecede ? "font-normal" : "font-medium", - variant === "card" + variant !== "slim" ? cn( "truncate", shouldRecede @@ -1104,7 +1121,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) ) : null; - if (variant === "slim") { + if (variant !== "card") { + const inFolder = variant === "folder"; return (
  • {accessibleTitle} - - {props.project ? : null} - + {inFolder ? null : ( + + {props.project ? ( + + ) : null} + + )} {draftIndicator} {title} {pinIndicator} @@ -1176,7 +1203,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {props.snoozeWakeLabelText} - ) : isWoke ? ( + ) : (inFolder ? isWokeStatus : isWoke) ? ( Dismiss Woke notification + ) : inFolder && topStatus ? ( + // Folder rows have no status line, so the status takes the time slot. + + + {topStatus.label} + ) : ( - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "waiting" ? ( - - ) : topStatus.icon === "input" ? ( - - ) : topStatus.icon === "request" ? ( - - ) : topStatus.icon === "approval" ? ( - - ) : topStatus.icon === "failed" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} + {/* Not a live region: a collapsed sidebar is inert, so BackgroundThreadAnnouncer and the open thread's timeline speak status changes instead. */} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 7d4acf4d392f1..6060a867fed7a 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -97,7 +97,7 @@ function useSidebarVisibility() { return isMobile ? openMobile : open; } -// Same breakpoint as useIsMobile, also read when a shortcut fires. +// The `max-md` breakpoint, also read when a shortcut fires. const SIDEBAR_MOBILE_QUERY = "(max-width: 767px)"; function isMobileViewport(): boolean { @@ -720,8 +720,8 @@ const sidebarMenuButtonVariants = cva( default: "h-8 rounded-[var(--control-radius)] px-[var(--sidebar-row-content-inset)] py-1.5 text-sm", icon: "size-8 justify-center rounded-[var(--control-radius)] p-0", - lg: "h-12 rounded-lg p-2 text-sm group-data-[collapsible=icon]:p-0!", - sm: "h-7 rounded-lg p-2 text-xs", + lg: "h-12 rounded-[var(--control-radius)] px-[var(--sidebar-row-content-inset)] py-2 text-sm group-data-[collapsible=icon]:p-0!", + sm: "h-7 rounded-[var(--control-radius)] px-[var(--sidebar-row-content-inset)] py-2 text-xs", }, variant: { default: "font-medium text-sidebar-muted-foreground/80", diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index bbc481f860c25..eb47ac5586ed3 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthSettingsWriteScope, type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useRef, useState } from "react"; @@ -46,7 +45,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => { const settings = get(serverEnvironment.settingsValueAtom(environmentId)); const session = get(environmentSession.sessionStateAtom(environmentId)); - const sessionData = Option.getOrNull(AsyncResult.value(session)); + const sessionData = AsyncResult.getOrElse(session, () => null); const access = session._tag === "Failure" ? "denied" diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 22c7c8a5c3553..e823d2ddf33a3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4194,9 +4194,7 @@ export function subscribeComposerDraftModelReconciliation(input: { const thread = get(input.threadShellAtom(ref)); if (!thread || !draftModelPickMatchesThread(pick, thread.modelSelection)) continue; // Status and selection must come from the same emission; derived atoms can update apart. - const shell = Option.getOrNull( - AsyncResult.value(get(input.shellStateAtom(ref.environmentId))), - ); + const shell = AsyncResult.getOrElse(get(input.shellStateAtom(ref.environmentId)), () => null); if (shell?.status !== "live") continue; const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( (candidate) => candidate.id === ref.threadId, diff --git a/apps/web/src/connection/desktopLocal.ts b/apps/web/src/connection/desktopLocal.ts index 746344759a76e..eb46a09ab3ac6 100644 --- a/apps/web/src/connection/desktopLocal.ts +++ b/apps/web/src/connection/desktopLocal.ts @@ -26,10 +26,6 @@ export function desktopLocalBackendId(target: ConnectionTarget): string | null { : null; } -export function isWslConnectionTarget(target: ConnectionTarget): boolean { - return desktopLocalBackendId(target)?.startsWith("wsl:") === true; -} - export type DesktopSecondaryBootstrapsRead = | { readonly _tag: "Success"; diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts index b7238e8edf906..82065c86fe3aa 100644 --- a/apps/web/src/diffPanelStore.test.ts +++ b/apps/web/src/diffPanelStore.test.ts @@ -1,6 +1,13 @@ +import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { useDiffPanelSessionStore, useDiffPanelStore } from "./diffPanelStore"; +import { + resolveToolCallDiffTarget, + useDiffPanelSessionStore, + useDiffPanelStore, +} from "./diffPanelStore"; describe("diffPanelStore", () => { beforeEach(() => { @@ -11,6 +18,73 @@ describe("diffPanelStore", () => { useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); }); + it("keeps a tool call selection independent of checkpoints and allows returning to Git changes", () => { + const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; + const target = { + threadId: ThreadId.make("child"), + itemId: TurnItemId.make("edit"), + revision: "1", + }; + const store = useDiffPanelStore.getState(); + store.selectToolCall(ref, target); + store.reconcileTurnSelection(ref, []); + expect(useDiffPanelStore.getState().byThreadKey[scopedThreadKey(ref)]).toEqual({ + kind: "tool-call", + ...target, + }); + store.selectGitScope(ref, "branch"); + expect(useDiffPanelStore.getState().byThreadKey[scopedThreadKey(ref)]).toEqual({ + kind: "branch", + baseRef: null, + }); + }); + + it("refreshes a live tool-call target when its source item completes", () => { + const target = { + threadId: ThreadId.make("child"), + itemId: TurnItemId.make("edit"), + revision: "live", + }; + const now = DateTime.makeUnsafe("2026-10-28T00:00:00Z"); + const row = { + position: 0, + visibility: "local" as const, + sourceThreadId: target.threadId, + sourceItemId: target.itemId, + item: { + id: target.itemId, + threadId: target.threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running" as const, + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "file_change" as const, + fileName: "a.ts", + }, + }; + expect(resolveToolCallDiffTarget(target, [row])).toBe(target); + const completed = { + ...row, + item: { ...row.item, status: "completed" as const, completedAt: now }, + }; + expect(resolveToolCallDiffTarget(target, [completed])).toEqual({ + ...target, + revision: DateTime.formatIso(now), + }); + expect( + resolveToolCallDiffTarget(target, [{ ...completed, sourceThreadId: ThreadId.make("other") }]), + ).toBe(target); + expect(resolveToolCallDiffTarget(target, [])).toBe(target); + }); + it("retains fold choices when a panel consumer returns with fresh defaults", () => { const scopeKey = "environment-1:thread-1:branch"; const defaults = new Set(["src/app.ts", "src/index.ts"]); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index eb6f0e9d455c8..11d96a3909ce7 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -1,11 +1,20 @@ +import type { ThreadStructureVisibleTurnItem } from "@t3tools/client-runtime/state/shell"; +import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { RunId, ScopedThreadRef } from "@t3tools/contracts"; +import type { RunId, ScopedThreadRef, ThreadId, TurnItemId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +export interface ToolCallDiffTarget { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + readonly revision: string; +} + export type DiffPanelSelection = + | ({ kind: "tool-call" } & ToolCallDiffTarget) | { kind: "branch"; baseRef: string | null } | { kind: "unstaged" } | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; @@ -17,6 +26,7 @@ interface DiffPanelStoreState { branchBaseRefByThreadKey: Record; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; + selectToolCall: (ref: ScopedThreadRef, target: ToolCallDiffTarget) => void; selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; removeThread: (ref: ScopedThreadRef) => void; @@ -69,6 +79,13 @@ export const useDiffPanelStore = create()( }, }; }), + selectToolCall: (ref, target) => + set((state) => ({ + byThreadKey: { + ...state.byThreadKey, + [scopedThreadKey(ref)]: { kind: "tool-call", ...target }, + }, + })), selectTurn: (ref, turnId, filePath) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -160,3 +177,15 @@ export function selectThreadDiffPanelSelection( if (!ref) return DEFAULT_SELECTION; return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; } + +export function resolveToolCallDiffTarget( + target: ToolCallDiffTarget, + items: ReadonlyArray, +): ToolCallDiffTarget { + const row = items.find( + (row) => row.sourceThreadId === target.threadId && row.sourceItemId === target.itemId, + ); + if (row?.item.type !== "file_change") return target; + const revision = turnItemDetailRevision(row.item); + return revision === target.revision ? target : { ...target, revision }; +} diff --git a/apps/web/src/environments/primary/sessionState.ts b/apps/web/src/environments/primary/sessionState.ts index 34dc68b2f3ca1..766421a17afd5 100644 --- a/apps/web/src/environments/primary/sessionState.ts +++ b/apps/web/src/environments/primary/sessionState.ts @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; @@ -30,7 +29,7 @@ export function usePrimarySessionState() { error = cause instanceof Error ? cause.message : "Could not read environment session."; } return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error, isPending: result.waiting, refresh: refreshPrimarySessionState, diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 7fee707bf4c0d..9d59a83630a5c 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { fileBasename, stripSlashPrefixedWindowsDrive, @@ -26,8 +27,10 @@ export function formatWorkspaceRelativePath( let displayPath = normalizedPath; if (workspaceRoot) { - const normalizedWorkspaceRoot = stripSlashPrefixedWindowsDrive( - normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), + const normalizedWorkspaceRoot = pipe( + trimTrailingPathSeparators(workspaceRoot), + normalizePathSeparators, + stripSlashPrefixedWindowsDrive, ); const workspaceLabel = fileBasename(normalizedWorkspaceRoot); const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index ec6ee4fa51160..f3df1b3a4b1de 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -77,7 +77,3 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } - -export function useIsMobile(): boolean { - return useMediaQuery("max-md"); -} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 70f8ac321f54f..2c24f13760092 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -415,11 +415,11 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod }); } -/** Wait for hydration: swapping sidebar implementations after mount remounts the whole tree. */ -export function useLegacySidebarEnabled(): boolean { - const settingsHydrated = useClientSettingsHydrated(); - const legacySidebarEnabled = useClientSettings((settings) => settings.legacySidebarEnabled); - return settingsHydrated && legacySidebarEnabled; +/** Legacy sidebar opt-ins still count, so those users land in the boring sidebar. */ +export function useBoringSidebarEnabled(): boolean { + return useClientSettings( + (settings) => settings.boringSidebarEnabled || settings.legacySidebarEnabled, + ); } export async function prepareModelPreferencesPatch( @@ -574,7 +574,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray null); return session !== null && sessionGrantsScope(session, AuthSettingsWriteScope) ? [environmentId] : []; diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 6addce1543cb1..2467d89091b24 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { DesktopBridge } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import * as Schema from "effect/Schema"; @@ -518,7 +519,7 @@ export function setTheme(next: Theme): boolean { if (typeof window === "undefined") return false; try { // Save the current mode first; inferring it from the new theme can switch dark to light. - writeAppearanceModePreference(readAppearanceModePreference(getStored())); + pipe(getStored(), readAppearanceModePreference, writeAppearanceModePreference); // A whole theme replaces the mix; retain it to restore on a failed preference write. const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY); window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); diff --git a/apps/web/src/lib/assistantCitationNavigation.ts b/apps/web/src/lib/assistantCitationNavigation.ts index 70d2c160a1a1e..1320b73aa33c5 100644 --- a/apps/web/src/lib/assistantCitationNavigation.ts +++ b/apps/web/src/lib/assistantCitationNavigation.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { AssistantCitation } from "@t3tools/contracts"; import { formatAssistantCitationHref, @@ -27,8 +28,11 @@ export function assistantCitationFromLocation(href: string) { const hash = href.slice(hashIndex + 1); if (!hash.startsWith(CITATION_HASH_PREFIX) || hash.length > 140_000) return null; try { - return parseAssistantCitationHref( - Result.getOrThrow(Base64Url.decodeString(hash.slice(CITATION_HASH_PREFIX.length))), + return pipe( + hash.slice(CITATION_HASH_PREFIX.length), + Base64Url.decodeString, + Result.getOrThrow, + parseAssistantCitationHref, ); } catch { return null; diff --git a/apps/web/src/lib/contextChipAccents.ts b/apps/web/src/lib/contextChipAccents.ts index 978c3cf6a04ab..dfa0276ee08a7 100644 --- a/apps/web/src/lib/contextChipAccents.ts +++ b/apps/web/src/lib/contextChipAccents.ts @@ -1,5 +1,5 @@ export const CONTEXT_CHIP_ACCENTS = { - image: "oklch(0.62 0.16 16)", + image: "oklch(0.62 0.136 237)", video: "oklch(0.62 0.16 48)", file: "oklch(0.62 0.136 237)", mention: "oklch(0.62 0.11 215)", diff --git a/apps/web/src/lib/toolCallDiff.test.ts b/apps/web/src/lib/toolCallDiff.test.ts new file mode 100644 index 0000000000000..cb33dd45cfb5a --- /dev/null +++ b/apps/web/src/lib/toolCallDiff.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getRenderablePatch, resolveFileDiffPath } from "./diffRendering"; +import { toolCallDiff } from "./toolCallDiff"; + +describe("toolCallDiff", () => { + it("renders recorded hunks without reading a repository, including renamed paths", () => { + for (const changes of [ + undefined, + [{ operation: "update", oldPath: "old.ts", path: "new.ts" }], + ]) { + const { patch } = toolCallDiff({ + fileName: "old.ts", + diffStr: "@@ -1 +1 @@\n-before\n+after\n", + ...(changes ? { changes } : {}), + }); + const rendered = getRenderablePatch(patch); + expect(rendered?.kind).toBe("files"); + if (rendered?.kind !== "files") throw new Error("Expected a file diff"); + expect(rendered.files).toHaveLength(1); + expect(resolveFileDiffPath(rendered.files[0]!)).toBe(changes ? "new.ts" : "old.ts"); + expect(rendered.files[0]!.additionLines.join("")).toContain("after"); + expect(rendered.files[0]!.deletionLines.join("")).toContain("before"); + } + }); + + it("renders added and deleted file contents as a file diff", () => { + for (const operation of ["add", "delete"]) { + for (const [content, ending] of [ + ["first\nsecond", ""], + ["@@ -0,0 +1 @@\n+content", "\n"], + ["@@ -1 +0,0 @@\n-content", "\n"], + ["first\nsecond", "\n"], + ["--- notes\nsecond", "\n"], + ["diff --git notes\nsecond", "\n"], + ["--- a/embedded.ts\n+++ b/embedded.ts\n@@ -1 +1 @@\n-before\n+after", "\n"], + [ + "diff --git a/embedded.ts b/embedded.ts\n--- a/embedded.ts\n+++ b/embedded.ts\n@@ -1 +1 @@\n-before\n+after", + "\n", + ], + ] as const) { + const recordedContent = `${content}${ending}`; + const rendered = getRenderablePatch( + toolCallDiff({ + fileName: "file with spaces.ts", + diffStr: recordedContent, + changes: [{ operation, path: "file with spaces.ts" }], + }).patch, + ); + expect(rendered?.kind).toBe("files"); + if (rendered?.kind !== "files") throw new Error("Expected a file diff"); + expect(resolveFileDiffPath(rendered.files[0]!)).toBe("file with spaces.ts"); + const file = rendered.files[0]!; + const lines = content.split("\n").length; + expect(file.additionLines).toHaveLength(operation === "add" ? lines : 0); + expect(file.deletionLines).toHaveLength(operation === "delete" ? lines : 0); + expect((operation === "add" ? file.additionLines : file.deletionLines).join("")).toBe( + recordedContent.replace(/\n$/, ""), + ); + } + } + }); + + it("keeps content-only writes out of the patch and preserves empty recorded content", () => { + for (const operation of ["add", "delete"]) { + expect( + toolCallDiff({ + fileName: "empty.ts", + diffStr: "", + changes: [{ operation, path: "empty.ts" }], + }).content, + ).toEqual([ + { + label: operation === "add" ? "Recorded content after" : "Recorded content before", + text: "", + }, + ]); + } + expect(toolCallDiff({ fileName: "a.ts", oldStr: "before", newStr: "" })).toEqual({ + patch: undefined, + content: [ + { label: "Recorded content before", text: "before" }, + { label: "Recorded content after", text: "" }, + ], + truncated: false, + }); + expect(toolCallDiff({ fileName: "a.ts", newStr: "after" }).content).toEqual([ + { label: "Recorded content after", text: "after" }, + ]); + }); + + it("removes transport truncation markers before converting file contents", () => { + const suffix = "\n… output truncated for transport"; + const diff = toolCallDiff({ + fileName: "a.ts", + diffStr: `first\nsecond${suffix}`, + changes: [{ operation: "add", path: "a.ts" }], + }); + expect(diff.truncated).toBe(true); + expect(diff.patch).not.toContain("output truncated"); + expect(getRenderablePatch(diff.patch)?.kind).toBe("files"); + expect(toolCallDiff({ fileName: "a.ts", newStr: `content${suffix}` })).toMatchObject({ + content: [{ label: "Recorded content after", text: "content" }], + truncated: true, + }); + }); + + it("preserves complete patches and unsupported provider output", () => { + for (const operation of ["add", "delete"]) { + const diffStr = [ + "diff --git a/a.ts b/a.ts", + `--- ${operation === "add" ? "/dev/null" : "a/a.ts"}`, + `+++ ${operation === "add" ? "b/a.ts" : "/dev/null"}`, + operation === "add" ? "@@ -0,0 +1 @@" : "@@ -1 +0,0 @@", + operation === "add" ? "+content" : "-content", + "", + ].join("\n"); + expect( + toolCallDiff({ fileName: "a.ts", diffStr, changes: [{ operation, path: "a.ts" }] }).patch, + ).toBe(diffStr); + } + for (const diffStr of [ + undefined, + "", + "provider output", + "--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n", + ]) { + expect( + toolCallDiff({ fileName: "a.ts", ...(diffStr === undefined ? {} : { diffStr }) }).patch, + ).toBe(diffStr); + } + }); +}); diff --git a/apps/web/src/lib/toolCallDiff.ts b/apps/web/src/lib/toolCallDiff.ts new file mode 100644 index 0000000000000..2224702601d74 --- /dev/null +++ b/apps/web/src/lib/toolCallDiff.ts @@ -0,0 +1,77 @@ +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { getRenderablePatch, resolveFileDiffPath } from "./diffRendering"; + +type FileChange = Extract; + +function toolCallPatch(item: Pick) { + let patch = item.diffStr; + if (!patch) return patch; + const change = item.changes?.[0]; + const rawFileOperation = change?.operation === "add" || change?.operation === "delete"; + if (patch.startsWith("diff --git ") || patch.startsWith("--- ")) { + if (!rawFileOperation) return patch; + const parsed = getRenderablePatch(patch, "tool-call-format"); + if ( + parsed?.kind === "files" && + parsed.files.every((file) => { + const path = resolveFileDiffPath(file).replaceAll("\\", "/"); + return ( + file.hunks.length > 0 && + item.changes?.some((recorded) => { + const recordedPath = recorded.path.replaceAll("\\", "/"); + return ( + (recorded.operation === "add" + ? file.deletionLines.length === 0 + : recorded.operation === "delete" + ? file.additionLines.length === 0 + : true) && + (recordedPath === path || recordedPath.endsWith(`/${path}`)) + ); + }) + ); + }) + ) + return patch; + } + // Codex can record added/deleted file contents or bare hunks instead of a complete patch. + if (rawFileOperation || !patch.trimStart().startsWith("@@ ")) { + if (!rawFileOperation) return patch; + const lines = patch.split("\n"); + const hasFinalNewline = patch.endsWith("\n"); + if (hasFinalNewline) lines.pop(); + const added = change.operation === "add"; + const range = `1,${lines.length}`; + patch = `@@ -${added ? "0,0" : range} +${added ? range : "0,0"} @@\n${lines.map((line) => `${added ? "+" : "-"}${line}`).join("\n")}\n${hasFinalNewline ? "" : "\\ No newline at end of file\n"}`; + } + const oldPath = change?.oldPath ?? item.fileName; + const newPath = change?.path ?? item.fileName; + const before = change?.operation === "add" ? "/dev/null" : JSON.stringify(`a/${oldPath}`); + const after = change?.operation === "delete" ? "/dev/null" : JSON.stringify(`b/${newPath}`); + return `diff --git ${JSON.stringify(`a/${oldPath}`)} ${JSON.stringify(`b/${newPath}`)}\n--- ${before}\n+++ ${after}\n${patch.trimStart()}`; +} + +function recordedText(value: string | undefined) { + const suffix = "\n… output truncated for transport"; + const truncated = value !== undefined && value.endsWith(suffix); + return { text: truncated ? value.slice(0, -suffix.length) : value, truncated }; +} + +export function toolCallDiff( + item: Pick, +) { + const diff = recordedText(item.diffStr); + const operation = item.changes?.[0]?.operation; + const emptyContent = diff.text === "" ? "" : undefined; + const before = recordedText(item.oldStr ?? (operation === "delete" ? emptyContent : undefined)); + const after = recordedText(item.newStr ?? (operation === "add" ? emptyContent : undefined)); + return { + patch: toolCallPatch({ ...item, ...(diff.text === undefined ? {} : { diffStr: diff.text }) }), + content: diff.text?.trim() + ? [] + : [ + { label: "Recorded content before", text: before.text }, + { label: "Recorded content after", text: after.text }, + ].filter((entry): entry is { label: string; text: string } => entry.text !== undefined), + truncated: diff.truncated || before.truncated || after.truncated, + }; +} diff --git a/apps/web/src/onboarding/useProjectScans.ts b/apps/web/src/onboarding/useProjectScans.ts index e345d77c1170c..bf0ac3a5aecb3 100644 --- a/apps/web/src/onboarding/useProjectScans.ts +++ b/apps/web/src/onboarding/useProjectScans.ts @@ -1,6 +1,5 @@ import { RegistryContext, useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { useContext } from "react"; @@ -18,7 +17,7 @@ export function useProjectScans(environmentIds: readonly EnvironmentId[]) { const result = results[index]!; return { environmentId, - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, isPending: result.waiting || result._tag === "Initial", refresh: () => registry.refresh(scanAtom(environmentId)), diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 2f17157e6b505..a3364df9a15d6 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, @@ -147,7 +148,20 @@ function applyProviderInstanceBranding( }; } -export function sortProviderInstanceEntries( +/** Picker order: each driver's default instance first, then its custom instances. */ +export function providerInstanceEntriesFor( + providers: ReadonlyArray, + settings: Pick, + projectId: ProjectId | null, +): ReadonlyArray { + return pipe( + deriveProviderInstanceEntries(providers), + (entries) => applyProviderInstanceSettings(entries, settings, projectId), + sortProviderInstanceEntries, + ); +} + +function sortProviderInstanceEntries( entries: ReadonlyArray, ): ReadonlyArray { const byKind = new Map(); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index fd10c96aa1b24..de213de2f3c1c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -672,6 +672,27 @@ describe("V2 session presentation", () => { scheduledTaskId: ScheduledTaskId.make("task-queued"), senderThreadId: ThreadId.make("thread-agent-sender"), } satisfies OrchestrationV2TurnItem; + for (const inputIntent of [ + "queued_turn", + "turn_start", + "steer", + "promoted_queued_to_steer", + ] as const) { + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + position: 0, + visibility: "local", + sourceThreadId: projection.thread.id, + sourceItemId: dispatchedItem.id, + item: { ...dispatchedItem, inputIntent, status: "pending" }, + }, + ], + optimisticMessages: [], + }), + ).toHaveLength(inputIntent === "queued_turn" ? 0 : 1); + } const promotedEntries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: [ { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index eb61b244075a3..362d9fb9451a4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -603,6 +603,12 @@ export function deriveTimelineEntriesFromVisibleTurnItems( const { item } = row; if (turnItemIsWorkspacePreparation(item)) continue; if (item.type === "checkpoint") continue; + if ( + item.type === "user_message" && + item.inputIntent === "queued_turn" && + item.status === "pending" + ) + continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; const createdAt = projectedItemCreatedAt(row); const attempt = resolveAttempt(item); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index e832ca295284e..f0f80dfed8830 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -117,8 +117,10 @@ export function installChatRouteCommands(router: AnyRouter): () => void { void scratch.startScratchThread(environmentId); }, "chat.new": (event) => { - // Pre-hydration settings are the defaults, where the legacy sidebar is off. - if (!getClientSettings().legacySidebarEnabled && readProjectGroupCount() > 1) { + // The boring sidebar's folders already show the context, so it starts in + // the current project like the legacy sidebar did. + const { boringSidebarEnabled, legacySidebarEnabled } = getClientSettings(); + if (!boringSidebarEnabled && !legacySidebarEnabled && readProjectGroupCount() > 1) { claim(event); openCommandPalette({ open: "new-thread-in" }); return; diff --git a/apps/web/src/state/projectClones.ts b/apps/web/src/state/projectClones.ts index 544b48f96109b..10904d346937b 100644 --- a/apps/web/src/state/projectClones.ts +++ b/apps/web/src/state/projectClones.ts @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentServerConfigsAtom } from "./server"; @@ -16,7 +15,7 @@ export const environmentProjectClonesAtom = Atom.family((environmentId: Environm Atom.make((get): ReadonlyArray => { if (!get(environmentServerConfigsAtom).has(environmentId)) return EMPTY_CLONES; const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); - return Option.getOrElse(AsyncResult.value(result), () => EMPTY_CLONES); + return AsyncResult.getOrElse(result, () => EMPTY_CLONES); }).pipe(Atom.withLabel(`web-project-clones:${environmentId}`)), ); diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 1a5742a63df2d..c8ac23ced0584 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -547,7 +547,7 @@ function createMergedEnvironmentQuery( if (result._tag === "Failure" && error === null) { error = formatEnvironmentQueryError(result.cause); } - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); if (value !== null) values.push([target.environmentId, value]); } return { values, error, isPending }; @@ -603,7 +603,7 @@ export function usePullRequestTurnRefreshes( export function usePullRequestTurnRefresh(environmentId: EnvironmentId): number | null { const result = useAtomValue(pullRequestEnvironment.refreshes({ environmentId, input: {} })); - return Option.getOrNull(AsyncResult.value(result)); + return AsyncResult.getOrElse(result, () => null); } export interface MergedPullRequestListView { diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 5a999922da380..636913c8898c9 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -27,7 +27,6 @@ import type { VcsRef, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect, useState } from "react"; @@ -208,7 +207,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { pagesKey === null ? EMPTY_VCS_REF_PAGES_ATOM : vcsRefPagesAtom(pagesKey), ); const values = results.flatMap((result) => { - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); return value === null ? [] : [value]; }); const refs = new Map(); diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index a8e86fc56ed83..c96319e4b7553 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -40,7 +40,7 @@ export function useEnvironmentQuery( const result = useAtomValue(selectedAtom); const refresh = useAtomRefresh(selectedAtom); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), dataUpdatedAt: queryUpdatedAt(result), error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, failure: diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index cce8a8ebb8fc7..bced4e959fa8e 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -68,10 +68,11 @@ const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { } const target = { environmentId, input: {} }; - const configProjection = Option.getOrNull( - AsyncResult.value(get(serverEnvironment.configProjection(target))), + const configProjection = AsyncResult.getOrElse( + get(serverEnvironment.configProjection(target)), + () => null, ); - const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target)))); + const welcome = AsyncResult.getOrElse(get(serverEnvironment.welcome(target)), () => null); return { config: get(serverEnvironment.configValueAtom(environmentId)), @@ -98,8 +99,9 @@ export const primaryServerLegacyThreadMigrationAtom = Atom.make( if (environmentId === null) { return null; } - return Option.getOrNull( - AsyncResult.value(get(serverEnvironment.legacyThreadMigration({ environmentId, input: {} }))), + return AsyncResult.getOrElse( + get(serverEnvironment.legacyThreadMigration({ environmentId, input: {} })), + () => null, ); }, ).pipe(Atom.withLabel("web-primary-server-legacy-thread-migration")); @@ -143,8 +145,8 @@ const environmentConnectionsAtom = Atom.make((get) => { Pick >(); for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { - const state = Option.getOrElse( - AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + get(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); environments.set(environmentId, { diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index caf3f99c07d33..13dffb510b93f 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -37,7 +37,7 @@ export function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, ): boolean { - const session = Option.getOrNull(AsyncResult.value(result)); + const session = AsyncResult.getOrElse(result, () => null); return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); } @@ -94,7 +94,7 @@ export function readPreparedConnection(environmentId: EnvironmentId) { export function useEnvironmentSessionState(environmentId: EnvironmentId) { const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), hasError: result._tag === "Failure", isPending: result.waiting, }; diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index ed5a1546b2ccb..c4675d3d01301 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -75,8 +75,8 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { continue; } - const connection = Option.getOrElse( - AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))), + const connection = AsyncResult.getOrElse( + get(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); if (connectionProjectionPhase(connection) !== "disconnected") { diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index dc21df5782468..bfe883122abec 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -21,7 +21,6 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -373,15 +372,14 @@ export function readCachedPullRequestResolution( if (target.environmentId === null || target.cwd === null || target.reference === null) { return null; } - return Option.getOrNull( - AsyncResult.value( - appAtomRegistry.get( - gitEnvironment.pullRequestResolution({ - environmentId: target.environmentId, - input: { cwd: target.cwd, reference: target.reference }, - }), - ), + return AsyncResult.getOrElse( + appAtomRegistry.get( + gitEnvironment.pullRequestResolution({ + environmentId: target.environmentId, + input: { cwd: target.cwd, reference: target.reference }, + }), ), + () => null, ); } diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts index 3683a8fba6da1..a426212cb9a44 100644 --- a/apps/web/src/state/terminalSessions.ts +++ b/apps/web/src/state/terminalSessions.ts @@ -159,8 +159,9 @@ const attachedTerminalSessionFamily = Atom.family((key: string) => { ) : null; const metadata = canRead - ? Option.getOrNull( - AsyncResult.value(get(terminalEnvironment.metadata({ environmentId, input: null }))), + ? AsyncResult.getOrElse( + get(terminalEnvironment.metadata({ environmentId, input: null })), + () => null, ) : null; const summary = @@ -172,7 +173,7 @@ const attachedTerminalSessionFamily = Atom.family((key: string) => { const buffer = attach === null ? EMPTY_TERMINAL_BUFFER_STATE - : Option.getOrElse(AsyncResult.value(attach), () => EMPTY_TERMINAL_BUFFER_STATE); + : AsyncResult.getOrElse(attach, () => EMPTY_TERMINAL_BUFFER_STATE); const state = combineTerminalSessionState(summary, buffer); return attach?._tag === "Failure" ? { ...state, error: formatEnvironmentQueryError(attach.cause), status: "error" } diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 8258e123cf854..07fffa97cfbe0 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -67,7 +67,7 @@ export function useEnvironmentThread( ? environmentThreads.stateAtom(environmentId, threadId) : EMPTY_THREAD_STATE_ATOM, ); - const state = Option.getOrElse(AsyncResult.value(result), () => EMPTY_ENVIRONMENT_THREAD_STATE); + const state = AsyncResult.getOrElse(result, () => EMPTY_ENVIRONMENT_THREAD_STATE); return state; } diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index a5bb95c430c8b..9a68a5fa724f7 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -10,7 +10,6 @@ import { } from "@t3tools/contracts"; import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -42,7 +41,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); const access = resolveUsageAccess({ connectionPhase: presentation.connection.phase, - session: Option.getOrNull(AsyncResult.value(sessionResult)), + session: AsyncResult.getOrElse(sessionResult, () => null), hasSessionError: sessionResult._tag === "Failure", }); if (!access.canReadDiagnostics) { @@ -57,7 +56,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => continue; } const result = get(serverEnvironment.usageSummary({ environmentId, input })); - const summary = Option.getOrNull(AsyncResult.value(result)); + const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, label: presentation.entry.target.label, diff --git a/apps/web/src/state/use-orchestration-command.ts b/apps/web/src/state/use-orchestration-command.ts index 1c801abc3c6b7..98f7efeecbfe6 100644 --- a/apps/web/src/state/use-orchestration-command.ts +++ b/apps/web/src/state/use-orchestration-command.ts @@ -14,8 +14,7 @@ import { AsyncResult } from "effect/reactivity"; import { readEnvironmentScope } from "./session"; import { useAtomCommand } from "./use-atom-command"; -export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = - "This connection cannot change threads or projects."; +const ORCHESTRATION_OPERATE_DENIED_MESSAGE = "This connection cannot change threads or projects."; export function useOrchestrationCommand( command: AtomCommand, diff --git a/apps/web/src/state/waitForQuerySettled.ts b/apps/web/src/state/waitForQuerySettled.ts index 159aed2efdc46..e5339d12cc400 100644 --- a/apps/web/src/state/waitForQuerySettled.ts +++ b/apps/web/src/state/waitForQuerySettled.ts @@ -1,4 +1,3 @@ -import * as Option from "effect/Option"; import { AsyncResult, type Atom, type AtomRegistry } from "effect/reactivity"; /** @@ -27,7 +26,7 @@ export function waitForQuerySettled(input: { const onAbort = () => finish(undefined); const check = (result: AsyncResult.AsyncResult) => { if (AsyncResult.isFailure(result)) return finish(undefined); - const settled = settle(Option.getOrNull(AsyncResult.value(result))); + const settled = settle(AsyncResult.getOrElse(result, () => null)); if (settled !== undefined) finish(settled); }; if (signal.aborted) return finish(undefined); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 3a20981bd3f3d..58dfe4b53ed18 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { EnvironmentTheme } from "@t3tools/contracts"; import * as Equal from "effect/Equal"; import * as Option from "effect/Option"; @@ -752,13 +753,12 @@ function standardStatusColors(canvas: ThemeRgbColor): { const surfaceOf = (value: string) => mixThemeRgbColors(canvas, parseThemeRgbColor(value, canvas), surfaceMix); const readableOn = (foreground: string, surface: ThemeRgbColor) => - themeOklchToThemeColor( - solveOklchLightness( - themeRgbToOklch(parseThemeRgbColor(foreground, canvas)), - [surface], - 4.6, - appearance === "dark" ? "lighter" : "darker", - ), + pipe( + parseThemeRgbColor(foreground, canvas), + themeRgbToOklch, + (color) => + solveOklchLightness(color, [surface], 4.6, appearance === "dark" ? "lighter" : "darker"), + themeOklchToThemeColor, ); const errorSurface = surfaceOf(standard.error); const warningSurface = surfaceOf(standard.warning); diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 6bf3ba74935f7..4c9bbcb780bee 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -131,6 +131,21 @@ and clients keep their bindings. Every mutation on an allocation bumps its `generation`, and deletion locks the row at the generation it claimed, so a host that reconnects mid-sweep wins. +## A paid T3 Chat plan raises the tunnel cap + +T3 Chat uses WorkOS, not Clerk, so the two accounts are linked by consent +rather than by a shared identity. The relay and T3 Chat share one secret +(`T3CHAT_LINK_SECRET` here, `T3CODE_LINK_SECRET` there). The relay signs a +short-lived token naming the Clerk user and email; the user confirms it while +signed in to T3 Chat, and T3 Chat stores the link. T3 Chat owns the link and the +plan, so the relay keeps no copy. It asks T3 Chat only when an account reaches +its normal cap for a new environment, and any failure keeps the normal cap. +An environment that already holds a counted slot restarts without asking, so a +downgrade or a T3 Chat outage only blocks new environments. A manual override +row replaces the default for new environments, and a T3 Chat plan does not +raise it +([limits](../../infra/relay/src/environments/ManagedTunnelLimits.ts)). + ## The relay client follows the server's pin The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in diff --git a/docs/operations/release.md b/docs/operations/release.md index 7e632bdc9bf32..39f4f63ddd91b 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -148,6 +148,11 @@ Required `production` environment secrets: - `CLERK_SECRET_KEY` - `APNS_PRIVATE_KEY` +Optional `production` environment secrets: + +- `T3CHAT_LINK_SECRET` for the T3 Chat account link. It must match + `T3CODE_LINK_SECRET` on T3 Chat. + After changing a variable or secret, run the **Deploy T3 Connect relay** workflow manually from `main` with **force** unchecked. Alchemy compares the values the Worker reads and redeploys it when one changed. Check **force** only to redeploy resources with no detected change. diff --git a/docs/user/faq.md b/docs/user/faq.md index 354d0c63c3164..bb4713b5f25e8 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -25,7 +25,11 @@ Yes. ## How much does T3 Code cost? -Nothing. There's no way to pay us to use T3 Code. You bring your own sub. It is free and open source (MIT). You still pay your provider like you already do. T3 Connect is free too, for now. Every account can connect up to 3 machines running T3 Code. Phones and browsers that connect to them do not count. We don't want to charge for this, but if the bill gets unacceptable we may have to change course. If you hit limits or have issues, you can always fork it, self host, or use Tailscale. [Install](./install.md) +Nothing. There's no way to pay us to use T3 Code. You bring your own sub. It is free and open source (MIT). You still pay your provider like you already do. T3 Connect is free too, for now. Every account can connect up to 3 machines running T3 Code. Phones and browsers that connect to them do not count. Need more? Link a paid T3 Chat account. [More machines](#can-i-connect-more-than-3-machines-with-t3-connect) We don't want to charge for this, but if the bill gets unacceptable we may have to change course. If you hit limits or have issues, you can always fork it, self host, or use Tailscale. [Install](./install.md) + +## Can I connect more than 3 machines with T3 Connect? + +Yes. Link your T3 Chat account and T3 Connect raises your limit: up to 10 machines with T3 Chat Pro, or 100 with Premier. Open your account menu in T3 Code, go to **T3 Chat**, and select **Link T3 Chat**. Sign in to T3 Chat and approve. The free T3 Chat plan keeps the normal limit of 3. [More machines with T3 Chat](./remote-access.md#more-machines-with-t3-chat) ## Why is my Claude 5-hour limit draining so fast? diff --git a/docs/user/guest-links.md b/docs/user/guest-links.md index b97e400c65146..a8631a6c93b28 100644 --- a/docs/user/guest-links.md +++ b/docs/user/guest-links.md @@ -26,9 +26,9 @@ unlinking the machine from T3 Connect also ends it. You can send or dismiss gues ## What the guest sees The guest sees the thread's recent history and everything after, in the same timeline you see, as -it streams: messages and their files, the agent's replies and reasoning, plans, questions, and its -work, with command output when they expand a command. Their composer shows the thread's model, but -they cannot change it. They do not see diffs, and they cannot act on the thread: no forking, +it streams: messages and their files, the agent's replies with the images they show from your +machine, its reasoning, plans, questions, and its work, with command output when they expand a +command. Their composer shows the thread's model, but they cannot change it. They do not see diffs, and they cannot act on the thread: no forking, reverting, opening files on your machine, or answering the agent for you. Anything the agent read or printed can show, such as a value from a file. Share only threads you diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index d1bfe3710d276..b8f9553d95beb 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -32,6 +32,18 @@ connection. Pull request diffs and provider settings keep working after the previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. +### More machines with T3 Chat + +Each T3 Connect account can link up to 3 machines. Phones and browsers do not +count. A paid T3 Chat plan raises the limit to 10 machines with Pro, or 100 with +Premier. + +To link T3 Chat, open your account menu on the web or desktop app, go to +**T3 Chat**, and select **Link T3 Chat**. Sign in to T3 Chat in the window that +opens and approve. The new limit applies the next time you add a machine. If you +cancel or downgrade T3 Chat, machines you already linked stay linked, but you +cannot add more until you are under the limit again. + ## Pair over a LAN or private network Use direct pairing when the other device can reach the host's network address. @@ -79,9 +91,10 @@ URL, SSH, or T3 Connect. Tailscale shares its `100.64.0.0/10` address range with other VPNs such as Cloudflare WARP, so an address in that range shows as VPN unless the machine confirms it is on Tailscale. To add a route, choose **Add route** in the machine's route list, or next to it in the T3 Connect list. -Pairing the same machine again over another address also adds a route instead -of a second machine. A new route is placed by speed, in that order, and you can -reorder routes at any time. +A pairing link for a saved machine is refused when it comes from an address +that machine does not have yet; add that address with **Add route** instead. +Pairing again over an address the machine already has still works. A new route +is placed by speed, in that order, and you can reorder routes at any time. While connected through T3 Connect or a paired address, T3 Code also learns the machine's current LAN and Tailscale addresses and adds them as routes, so diff --git a/docs/user/t3-connect-troubleshooting.md b/docs/user/t3-connect-troubleshooting.md index eaed689fb8c62..8c507a58e6894 100644 --- a/docs/user/t3-connect-troubleshooting.md +++ b/docs/user/t3-connect-troubleshooting.md @@ -112,7 +112,9 @@ T3 Connect does not have an active link between your account and the host. - **This device's sign-in expired, or the host no longer accepts this device.** Sign out of T3 Connect and sign in again. - **Your account reached its tunnel limit.** Remove an environment you do not - use from your T3 account's T3 Connect page, then restart T3 Code on the host. + use from your T3 account's T3 Connect page, or + [link a paid T3 Chat account](./remote-access.md#more-machines-with-t3-chat) + for a higher limit. Then restart T3 Code on the host. ## The clock is off @@ -129,7 +131,7 @@ This app needs a newer T3 Code on the host. Update T3 Code on the host. See | Error | Fix | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `environment_link_limit_exceeded` or managed tunnel limit | Remove an unused environment, then restart T3 Code on the host. | +| `environment_link_limit_exceeded` or managed tunnel limit | Remove an unused environment or link a paid T3 Chat account, then restart T3 Code on the host. | | `auth_invalid` or `invalid_bearer` | Run `t3 connect login`. If credentials were revoked, run `t3 connect logout`, then `t3 connect` again. Restart the server after signing in. | | Expired or invalid link proof | Check the host's date and time, update T3 Code, then restart it. | | HTTP 403 without a recognized error | Check relay access, proxies, and firewall rules. Keep any Cloudflare Ray ID for a bug report. | diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 88769f1c0ecb4..c3448771b3e85 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -148,6 +148,15 @@ notifies you while it waits for your approval, an answer, or a decision on a pro then goes back to Bots once you reply. You can still pin, snooze, or settle bot threads like any other thread. +### Group threads by project (beta) + +Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active +threads in a folder for each project. Click a folder to open or close it. A closed folder shows how +many threads it holds, and a dot when one of them needs you. Use the pen button on a folder, or +right-click it, to start a thread in that project. Pinned threads stay above the folders, and +Snoozed and Settled stay below them. In this mode you cannot drag threads, and the Working section +is off. Each device keeps its own choice. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/infra/relay/README.md b/infra/relay/README.md index 5fe620a835ece..4b5ac35e438bd 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -160,6 +160,9 @@ The `production` GitHub environment must define these Actions secrets: - `CLERK_SECRET_KEY` - `APNS_PRIVATE_KEY` - `FCM_SERVICE_ACCOUNT` when Android push is enabled +- `T3CHAT_LINK_SECRET`, optional, shared with T3 Chat's `T3CODE_LINK_SECRET`. Without it the + T3 Chat account link is off. Local and preview stages can set `T3CHAT_URL` to point + at another T3 Chat deployment; production uses `https://t3.chat`. The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they are not bound into the relay Worker. The production deployment uses an Axiom personal access token, diff --git a/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/migration.sql b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/migration.sql new file mode 100644 index 0000000000000..4c822889bd98e --- /dev/null +++ b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/migration.sql @@ -0,0 +1,12 @@ +-- Teardown claims used to bump updated_at past the link's revoke or publish-only write, so a +-- failed deprovision now reads as an unfinished relink. Give those rows the updated_at the +-- current write rule would have left. Touches only withdrawn links' live tunnels; idempotent. +UPDATE "relay_managed_endpoint_allocations" AS a +SET "updated_at" = l."updated_at" +FROM "relay_environment_links" AS l +WHERE l."user_id" = a."user_id" + AND l."environment_id" = a."environment_id" + AND a."tunnel_id" IS NOT NULL + AND a."tunnel_released_at" IS NULL + AND (l."revoked_at" IS NOT NULL OR l."managed_tunnels_enabled" = false) + AND l."updated_at" < a."updated_at"; diff --git a/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/snapshot.json b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/snapshot.json new file mode 100644 index 0000000000000..e02f3a0c6f2e7 --- /dev/null +++ b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/snapshot.json @@ -0,0 +1,1571 @@ +{ + "id": "4413755a-63f1-4f93-bdc9-d1357283c23e", + "prevIds": ["7d8c1702-6f47-4453-a8ee-66ce5e3bfb41"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_tunnel_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_upstream_api_pauses", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hold_webhooks_while_offline", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recovery_enabled_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recovery_environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_released_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_tunnels", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "android_api_level", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api", + "entityType": "columns", + "schema": "public", + "table": "relay_upstream_api_pauses" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "paused_until_ms", + "entityType": "columns", + "schema": "public", + "table": "relay_upstream_api_pauses" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_created", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + }, + { + "columns": ["user_id"], + "nameExplicit": false, + "name": "relay_managed_tunnel_limits_pkey", + "schema": "public", + "table": "relay_managed_tunnel_limits", + "entityType": "pks" + }, + { + "columns": ["api"], + "nameExplicit": false, + "name": "relay_upstream_api_pauses_pkey", + "schema": "public", + "table": "relay_upstream_api_pauses", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/package.json b/infra/relay/package.json index c75e4b70648d2..197c37819981e 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -33,6 +33,7 @@ "@cloudflare/workers-types": "^4.20260601.1", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@electric-sql/pglite": "0.3.15", "@types/node": "catalog:", "drizzle-kit": "1.0.0-rc.5-ab785fc", "typescript": "catalog:", diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index a8673547ba265..ecc71a5a96a47 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -48,6 +48,12 @@ export interface ApnsCredentials { readonly environment: ApnsEnvironment; } +export interface T3ChatSettings { + readonly baseUrl: string; + // Shared with T3 Chat: signs link tokens and authenticates relay calls. + readonly linkSecret: Redacted.Redacted; +} + export class RelayConfiguration extends Context.Service< RelayConfiguration, { @@ -65,6 +71,7 @@ export class RelayConfiguration extends Context.Service< readonly managedEndpointCleanupMode?: ManagedEndpointCleanupMode; readonly legacyManagedEndpointCleanupMode?: ManagedEndpointCleanupMode; readonly legacyTunnelGraceMinutes?: number; + readonly t3Chat?: T3ChatSettings; } >()("t3code-relay/Config/RelayConfiguration") {} diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts index 931e8a2b8893d..f0cd86bef9669 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts @@ -74,4 +74,31 @@ describe("ManagedEndpointAllocations", () => { ]); }).pipe(Effect.provide(layerWithDb(fakeDb))); }); + + it.effect("teardown claims leave updatedAt to provisioning writes", () => { + // The tunnel limit compares link and allocation updatedAt; a claim that bumps it makes a + // failed revoke cleanup look like a reprovision and keeps holding the user's slot. + const writes: Array> = []; + const fakeDb = { + update: () => ({ + set: (values: Record) => { + writes.push(values); + return { + where: () => ({ returning: () => Effect.succeed([{ generation: 2 }]) }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const key = { userId: "user-1", environmentId: "environment-1", generation: 1 }; + yield* allocations.claimDeprovision(key); + yield* allocations.claimRelease({ ...key, tunnelId: "tunnel-1" }); + yield* allocations.claimRelease({ ...key, tunnelId: "tunnel-1", markReleased: true }); + + expect(writes.map((values) => "updatedAt" in values)).toEqual([false, false, true]); + expect(writes[2]?.["updatedAt"]).toBe(writes[2]?.["tunnelReleasedAt"]); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); }); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 09433cc872520..918c410e35176 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -255,7 +255,14 @@ const make = Effect.gen(function* () { createdAt: now, updatedAt: now, }) - .onConflictDoNothing() + // Refreshing updatedAt makes a re-reserved row count toward the tunnel limit again. + .onConflictDoUpdate({ + target: [ + relayManagedEndpointAllocations.userId, + relayManagedEndpointAllocations.environmentId, + ], + set: { updatedAt: now }, + }) .returning(allocationSelection) .pipe( Effect.mapError( @@ -269,26 +276,7 @@ const make = Effect.gen(function* () { ), ); - const allocation = - inserted[0] ?? - (yield* db - .select(allocationSelection) - .from(relayManagedEndpointAllocations) - .where(whereAllocation(input)) - .limit(1) - .pipe( - Effect.map((rows) => rows[0]), - Effect.mapError( - (cause) => - new ManagedEndpointAllocationPersistenceError({ - operation: "reserve", - stage: "database-request", - ...input, - cause, - }), - ), - )); - + const allocation = inserted[0]; if (allocation === undefined) { return yield* new ManagedEndpointAllocationPersistenceError({ operation: "reserve", @@ -524,9 +512,8 @@ const make = Effect.gen(function* () { const claimed = yield* db .update(relayManagedEndpointAllocations) .set({ - updatedAt: now, generation: sql`${relayManagedEndpointAllocations.generation} + 1`, - ...(input.markReleased === true ? { tunnelReleasedAt: now } : {}), + ...(input.markReleased === true ? { tunnelReleasedAt: now, updatedAt: now } : {}), }) .where( and( @@ -597,10 +584,8 @@ const make = Effect.gen(function* () { ) { const claimed = yield* db .update(relayManagedEndpointAllocations) - .set({ - updatedAt: DateTime.formatIso(yield* DateTime.now), - generation: sql`${relayManagedEndpointAllocations.generation} + 1`, - }) + // Claims move only the generation: the tunnel limit reads updatedAt as the last provisioning write. + .set({ generation: sql`${relayManagedEndpointAllocations.generation} + 1` }) .where( and( whereAllocation(input), diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 7fc21ee959564..01230171be9ba 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -335,10 +335,13 @@ function makeTunnelLimits( result: ManagedTunnelLimits.ManagedTunnelLimitExceeded | null = null, ) { return ManagedTunnelLimits.ManagedTunnelLimits.of({ - ensureCapacity: (input) => - Effect.suspend(() => { + withCapacity: (input, reserve) => + Effect.gen(function* () { calls.push(input); - return result === null ? Effect.void : Effect.fail(result); + if (result !== null) { + return yield* result; + } + return yield* reserve; }), }); } @@ -1403,6 +1406,52 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); + it.effect("marks the tunnel released when removing the allocation fails after deletion", () => { + const allocationCalls: AllocationCall[] = []; + const allocations = makeAllocations(allocationCalls); + const layer = layerProvider(makePersistentTunnelClient(), makeDnsClient(), { + ...allocations, + removeClaimed: (input) => + Effect.suspend(() => { + allocationCalls.push({ operation: "removeClaimed", input }); + return Effect.fail( + new ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError({ + operation: "remove-claimed", + stage: "database-request", + userId: input.userId, + environmentId: input.environmentId, + }), + ); + }), + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + const result = yield* Effect.result(provider.deprovision(key)); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure).toMatchObject({ stage: "remove-allocation" }); + } + // The tunnel is gone; without the mark the tunnel limit would keep counting it. + const claim = allocationCalls.find((call) => call.operation === "claimDeprovision"); + if (claim === undefined) return expect.unreachable("deprovision never claimed the row"); + expect(allocationCalls.at(-1)).toMatchObject({ + operation: "claimRelease", + input: { + tunnelId: "tunnel-id", + generation: (claim.input as { generation: number }).generation + 1, + markReleased: true, + }, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("treats already deleted remote resources as successfully deprovisioned", () => { const allocationCalls: AllocationCall[] = []; const notFound = { _tag: "NotFound" } as const; diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index d63286467b893..9a76b9c09981a 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -882,6 +882,7 @@ const make = Effect.gen(function* () { return false; } const tunnelId = allocation.tunnelId; + let tunnelDeleted = false; const deprovision = Effect.gen(function* () { const dnsRecordId = allocation.dnsRecordId; if (dnsRecordId !== null) { @@ -909,6 +910,7 @@ const make = Effect.gen(function* () { }), ), ); + tunnelDeleted = true; } return yield* allocations .removeClaimed({ @@ -956,6 +958,30 @@ const make = Effect.gen(function* () { }), ), }), + // The rollback keeps the deleted tunnel's id; mark it released so the tunnel limit stops + // counting it. onError also covers an interrupted unlink, which rolls back the same way. + Effect.onError(() => + tunnelDeleted + ? allocations + .claimRelease({ + userId: input.userId, + environmentId: input.environmentId, + tunnelId, + generation: claimedGeneration, + markReleased: true, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to mark a deleted managed tunnel released", { + userId: input.userId, + environmentId: input.environmentId, + tunnelId, + error: error.message, + }), + ), + ) + : Effect.void, + ), ); return Option.getOrElse(removed, () => false); }), @@ -1169,27 +1195,7 @@ const make = Effect.gen(function* () { environmentHash, ); const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash); - yield* tunnelLimits - .ensureCapacity({ - userId: input.userId, - environmentId: input.environmentId, - }) - .pipe( - Effect.catchTags({ - ManagedTunnelLimitPersistenceError: (cause) => - Effect.fail( - new ManagedEndpointProvisioningFailed({ - userId: input.userId, - environmentId: input.environmentId, - stage: "check-tunnel-limit", - hostname: requestedHostname, - tunnelName: requestedTunnelName, - cause, - }), - ), - }), - ); - const allocation = yield* allocations + const reserveAllocation = allocations .reserve({ userId: input.userId, environmentId: input.environmentId, @@ -1209,6 +1215,26 @@ const make = Effect.gen(function* () { }), ), ); + const allocation = yield* tunnelLimits + .withCapacity( + { userId: input.userId, environmentId: input.environmentId }, + reserveAllocation, + ) + .pipe( + Effect.catchTags({ + ManagedTunnelLimitPersistenceError: (cause) => + Effect.fail( + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "check-tunnel-limit", + hostname: requestedHostname, + tunnelName: requestedTunnelName, + cause, + }), + ), + }), + ); const { hostname, tunnelName } = allocation; if (input.reuseReadyTunnel === true) { diff --git a/infra/relay/src/environments/ManagedTunnelLimits.test.ts b/infra/relay/src/environments/ManagedTunnelLimits.test.ts new file mode 100644 index 0000000000000..5837ae3f0eb52 --- /dev/null +++ b/infra/relay/src/environments/ManagedTunnelLimits.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 Semaphore from "effect/Semaphore"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { ne } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import type { RelayT3ChatPlan } from "@t3tools/contracts/relay"; + +import * as RelayDb from "../db.ts"; +import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; +import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; +import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts"; + +const BACKFILL_MIGRATION = "20261028062605_backfill_withdrawn_tunnel_allocations"; + +class FakeTransaction extends Context.Service()( + "t3code-relay/environments/ManagedTunnelLimits.test/FakeTransaction", +) {} + +interface FakeDbInput { + readonly otherSlots: () => number; + readonly ownSlot?: boolean; + readonly override?: number; +} + +// Models Postgres just enough: the advisory lock only works inside a transaction and is held until it ends. +function makeFakeDb(input: FakeDbInput) { + const userLock = Semaphore.makeUnsafe(1); + const dialect = new PgDialect(); + return { + $client: { + withTransaction: (effect: Effect.Effect) => { + const transaction = { holdsLock: false }; + return effect.pipe( + Effect.provideService(FakeTransaction, transaction), + Effect.ensuring( + Effect.suspend(() => (transaction.holdsLock ? userLock.release(1) : Effect.void)), + ), + ); + }, + }, + execute: () => + Effect.gen(function* () { + const transaction = yield* Effect.serviceOption(FakeTransaction); + if (transaction._tag === "None") { + return yield* Effect.die("advisory lock taken outside a transaction"); + } + yield* userLock.take(1); + transaction.value.holdsLock = true; + return []; + }), + select: () => ({ + from: (table: unknown) => ({ + where: (condition: unknown) => { + if (table !== relayManagedEndpointAllocations) { + return { + limit: () => + Effect.succeed( + input.override === undefined ? [] : [{ maxTunnels: input.override }], + ), + }; + } + const countsOthers = dialect + .sqlToQuery(condition as never) + .sql.includes('"relay_managed_endpoint_allocations"."environment_id" <>'); + return Effect.yieldNow.pipe( + Effect.andThen( + Effect.sync(() => [ + { slots: countsOthers ? input.otherSlots() : input.ownSlot ? 1 : 0 }, + ]), + ), + ); + }, + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; +} + +const t3ChatLayer = (plan: RelayT3ChatPlan | "lookup-fails", lookups?: Array) => + Layer.mock(T3ChatAccounts.T3ChatAccounts, { + available: true, + getLink: ({ userId }) => { + lookups?.push(userId); + return plan === "lookup-fails" + ? Effect.fail( + new T3ChatAccounts.T3ChatRequestFailed({ + operation: "get-link", + cause: new Error("down"), + }), + ) + : Effect.succeed({ email: "theo@example.com", plan }); + }, + }); + +const reserve = ( + input: Omit & { + readonly activeTunnels: number; + readonly plan: RelayT3ChatPlan | "lookup-fails"; + readonly lookups?: Array; + }, +) => + Effect.gen(function* () { + const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits; + return yield* limits.withCapacity({ userId: "user-1", environmentId: "env-new" }, Effect.void); + }).pipe( + Effect.provide( + ManagedTunnelLimits.layer.pipe( + Layer.provide( + Layer.succeed( + RelayDb.RelayDb, + makeFakeDb({ ...input, otherSlots: () => input.activeTunnels }), + ), + ), + Layer.provide(t3ChatLayer(input.plan, input.lookups)), + ), + ), + ); + +describe("ManagedTunnelLimits", () => { + it.effect("rejects concurrent reservations past the limit", () => { + const reserved: Array = []; + const attempts = ManagedTunnelLimits.DEFAULT_MANAGED_TUNNEL_LIMIT + 2; + + return Effect.gen(function* () { + const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits; + const results = yield* Effect.forEach( + Array.from({ length: attempts }, (_, index) => `environment-${index}`), + (environmentId) => + limits + .withCapacity( + { userId: "user-1", environmentId }, + Effect.yieldNow.pipe(Effect.andThen(Effect.sync(() => reserved.push(environmentId)))), + ) + .pipe(Effect.result), + { concurrency: "unbounded" }, + ); + + expect(reserved).toHaveLength(ManagedTunnelLimits.DEFAULT_MANAGED_TUNNEL_LIMIT); + expect( + results.filter( + (result) => + result._tag === "Failure" && result.failure._tag === "ManagedTunnelLimitExceeded", + ), + ).toHaveLength(2); + }).pipe( + Effect.provide( + ManagedTunnelLimits.layer.pipe( + Layer.provide( + Layer.succeed(RelayDb.RelayDb, makeFakeDb({ otherSlots: () => reserved.length })), + ), + Layer.provide(t3ChatLayer("free")), + ), + ), + ); + }); + + it.effect("counts each allocation by its tunnel and link state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pg = yield* Effect.acquireRelease( + Effect.promise(() => PGlite.create()), + (client) => Effect.promise(() => client.close()), + ); + const migrationsDir = new URL("../../migrations/postgres/", import.meta.url).pathname; + const migrations = (yield* fs.readDirectory(migrationsDir)).toSorted(); + const applyMigrations = (names: ReadonlyArray) => + Effect.forEach(names, (name) => + fs + .readFileString(`${migrationsDir}${name}/migration.sql`) + .pipe(Effect.flatMap((migration) => Effect.promise(() => pg.exec(migration)))), + ); + const backfill = migrations.indexOf(BACKFILL_MIGRATION); + expect(backfill).toBeGreaterThan(-1); + yield* applyMigrations(migrations.slice(0, backfill)); + + const at = (minutes: number) => `2026-10-27T12:${String(minutes).padStart(2, "0")}:00.000Z`; + const cases: ReadonlyArray<{ + readonly environmentId: string; + readonly counted: boolean; + readonly allocation: { + readonly tunnelId?: string; + readonly releasedAt?: number; + readonly updatedAt: number; + }; + readonly link?: { + readonly revoked?: boolean; + readonly publishOnly?: boolean; + readonly updatedAt: number; + }; + }> = [ + { environmentId: "pending", counted: true, allocation: { updatedAt: 50 } }, + { environmentId: "abandoned-reservation", counted: false, allocation: { updatedAt: 10 } }, + { + environmentId: "live", + counted: true, + allocation: { tunnelId: "t", updatedAt: 10 }, + link: { updatedAt: 12 }, + }, + { + environmentId: "released", + counted: false, + allocation: { tunnelId: "t", releasedAt: 20, updatedAt: 20 }, + }, + { + environmentId: "re-reserved-after-release", + counted: true, + allocation: { tunnelId: "t", releasedAt: 20, updatedAt: 50 }, + }, + { + environmentId: "revoked", + counted: false, + allocation: { tunnelId: "t", updatedAt: 10 }, + link: { revoked: true, updatedAt: 20 }, + }, + { + environmentId: "revoked-pending", + counted: false, + allocation: { updatedAt: 50 }, + link: { revoked: true, updatedAt: 55 }, + }, + { + environmentId: "publish-only", + counted: false, + allocation: { tunnelId: "t", updatedAt: 10 }, + link: { publishOnly: true, updatedAt: 20 }, + }, + // Reprovisioned after a revoke, but the Worker died before rewriting the link. + { + environmentId: "reprovision-before-link", + counted: true, + allocation: { tunnelId: "t", updatedAt: 20 }, + link: { revoked: true, updatedAt: 10 }, + }, + { + environmentId: "reserved-before-link-rewrite", + counted: true, + allocation: { updatedAt: 50 }, + link: { revoked: true, updatedAt: 48 }, + }, + // Tunnel deleted but the row survived; deprovision marked it released, then a relink stalled. + { + environmentId: "released-after-failed-removal-then-reserved", + counted: false, + allocation: { tunnelId: "t", releasedAt: 20, updatedAt: 30 }, + link: { revoked: true, updatedAt: 15 }, + }, + { + environmentId: "withdrawn-at-provisioning-write", + counted: false, + allocation: { tunnelId: "t", updatedAt: 20 }, + link: { revoked: true, updatedAt: 20 }, + }, + { environmentId: "self", counted: false, allocation: { tunnelId: "t", updatedAt: 10 } }, + ]; + // Written before the deploy: claimDeprovision bumped updatedAt past the revoke, then cleanup failed. + const legacyCases: typeof cases = [ + { + environmentId: "legacy-failed-deprovision", + counted: false, + allocation: { tunnelId: "t", updatedAt: 30 }, + link: { revoked: true, updatedAt: 20 }, + }, + { + environmentId: "legacy-failed-publish-only-deprovision", + counted: false, + allocation: { tunnelId: "t", updatedAt: 30 }, + link: { publishOnly: true, updatedAt: 20 }, + }, + { + environmentId: "legacy-live", + counted: true, + allocation: { tunnelId: "t", updatedAt: 30 }, + link: { updatedAt: 20 }, + }, + ]; + const insert = (entries: typeof cases) => + Effect.forEach(entries, (entry) => + Effect.promise(async () => { + await pg.query( + `insert into relay_managed_endpoint_allocations + (user_id, environment_id, hostname, tunnel_id, tunnel_name, tunnel_released_at, created_at, updated_at) + values ('user-1', $1::text, $1::text, $2, $1::text, $3, $4, $4)`, + [ + entry.environmentId, + entry.allocation.tunnelId ?? null, + entry.allocation.releasedAt === undefined ? null : at(entry.allocation.releasedAt), + at(entry.allocation.updatedAt), + ], + ); + const link = entry.link; + if (link !== undefined) { + await pg.query( + `insert into relay_environment_links + (user_id, environment_id, environment_public_key, endpoint_http_base_url, + endpoint_ws_base_url, endpoint_provider_kind, revoked_at, managed_tunnels_enabled, + created_at, updated_at) + values ('user-1', $1, 'key', 'https://e', 'wss://e', 'cloudflare_tunnel', $2, $3, $4, $4)`, + [ + entry.environmentId, + link.revoked === true ? at(link.updatedAt) : null, + link.publishOnly !== true, + at(link.updatedAt), + ], + ); + } + }), + ); + yield* insert(legacyCases); + yield* applyMigrations(migrations.slice(backfill)); + yield* insert(cases); + yield* Effect.promise(() => + pg.query( + `insert into relay_managed_endpoint_allocations + (user_id, environment_id, hostname, tunnel_id, tunnel_name, created_at, updated_at) + values ('user-2', 'other-user', 'other-user', 't', 'other-user', $1, $1)`, + [at(10)], + ), + ); + + const counted = yield* Effect.promise(() => + drizzle({ client: pg }) + .select({ environmentId: relayManagedEndpointAllocations.environmentId }) + .from(relayManagedEndpointAllocations) + .where( + ManagedTunnelLimits.countedAllocations({ + userId: "user-1", + environmentId: "self", + pendingCutoff: at(45), + environment: ne, + }), + ), + ); + + expect(counted.map((row) => row.environmentId).toSorted()).toEqual( + [...legacyCases, ...cases] + .filter((entry) => entry.counted) + .map((entry) => entry.environmentId) + .toSorted(), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not ask T3 Chat while the account is under its cap", () => + Effect.gen(function* () { + const lookups: Array = []; + yield* reserve({ activeTunnels: 2, plan: "free", lookups }); + expect(lookups).toEqual([]); + }), + ); + + it.effect("raises the cap to the linked T3 Chat plan's limit", () => + Effect.gen(function* () { + for (const [plan, limit] of [ + ["pro", 10], + ["premier", 100], + ] as const) { + yield* reserve({ activeTunnels: limit - 1, plan }); + const error = yield* Effect.flip(reserve({ activeTunnels: limit, plan })); + expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: limit }); + } + }), + ); + + it.effect("keeps the default cap for free plans and failed lookups", () => + Effect.gen(function* () { + for (const plan of ["free", "lookup-fails"] as const) { + const error = yield* Effect.flip(reserve({ activeTunnels: 3, plan })); + expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: 3 }); + } + }), + ); + + it.effect("treats a manual override as the exact cap", () => + Effect.gen(function* () { + const lookups: Array = []; + for (const override of [1, 200]) { + const error = yield* Effect.flip( + reserve({ override, activeTunnels: override, plan: "premier", lookups }), + ); + expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: override }); + } + expect(lookups).toEqual([]); + }), + ); + + it.effect("lets an environment holding a slot relink without asking T3 Chat", () => + Effect.gen(function* () { + const lookups: Array = []; + yield* reserve({ activeTunnels: 10, plan: "lookup-fails", ownSlot: true, lookups }); + expect(lookups).toEqual([]); + }), + ); +}); diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index f6a271c3a92c8..93aea073c71d3 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -1,8 +1,12 @@ -import { and, count, eq, isNull, ne } from "drizzle-orm"; +import { and, count, eq, exists, gt, gte, isNotNull, isNull, ne, not, or, sql } from "drizzle-orm"; +import { QueryBuilder } from "drizzle-orm/pg-core"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlError from "effect/sql/SqlError"; +import { T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; import * as RelayDb from "../db.ts"; import { @@ -10,13 +14,16 @@ import { relayManagedEndpointAllocations, relayManagedTunnelLimits, } from "../persistence/schema.ts"; +import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; -const DEFAULT_MANAGED_TUNNEL_LIMIT = 3; +export const DEFAULT_MANAGED_TUNNEL_LIMIT = 3; +// Reserve-to-tunnel takes two Cloudflare calls capped at 8 seconds each; a reservation older than this was abandoned. +const MANAGED_TUNNEL_PENDING_WINDOW_MINUTES = 15; export class ManagedTunnelLimitPersistenceError extends Schema.TaggedError()( "ManagedTunnelLimitPersistenceError", { - operation: Schema.Literals(["load-limit", "count-tunnels"]), + operation: Schema.Literals(["transaction", "lock-user", "load-limit", "count-tunnels"]), userId: Schema.String, cause: Schema.Defect(), }, @@ -40,77 +47,182 @@ export class ManagedTunnelLimitExceeded extends Schema.TaggedError + exists( + new QueryBuilder() + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.userId, allocation.userId), + eq(relayEnvironmentLinks.environmentId, allocation.environmentId), + or( + isNotNull(relayEnvironmentLinks.revokedAt), + eq(relayEnvironmentLinks.managedTunnelsEnabled, false), + ), + gte(relayEnvironmentLinks.updatedAt, since), + ), + ), + ); + +// Counts allocations, not links: a link is written only after its tunnel exists. +// `environment` is `ne` for the user's other environments or `eq` for this one, which relinks without counting itself. +// updatedAt moves only on provisioning writes and release, never on teardown claims, so a link +// withdrawn at or after it means cleanup is owed; an older withdrawal means a relink that never finished. +export const countedAllocations = (input: { + readonly userId: string; + readonly environmentId: string; + readonly pendingCutoff: string; + readonly environment: typeof eq; +}) => + and( + eq(allocation.userId, input.userId), + input.environment(allocation.environmentId, input.environmentId), + or( + // Reserved but not yet holding a tunnel. Release writes updatedAt = tunnelReleasedAt and + // a relink reserves before rewriting its link, so only a newer reservation counts here. + and( + gt(allocation.updatedAt, input.pendingCutoff), + or( + isNull(allocation.tunnelReleasedAt), + gt(allocation.updatedAt, allocation.tunnelReleasedAt), + ), + not(withdrawnLink(allocation.updatedAt)), + ), + and( + isNotNull(allocation.tunnelId), + isNull(allocation.tunnelReleasedAt), + not(withdrawnLink(allocation.updatedAt)), + ), + ), + ); + export class ManagedTunnelLimits extends Context.Service< ManagedTunnelLimits, { - readonly ensureCapacity: (input: { - readonly userId: string; - readonly environmentId: string; - }) => Effect.Effect; + /** Runs `reserve` only while the user is under their limit, serialized per user in one transaction. */ + readonly withCapacity: ( + input: { readonly userId: string; readonly environmentId: string }, + reserve: Effect.Effect, + ) => Effect.Effect; } >()("t3code-relay/environments/ManagedTunnelLimits") {} const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; + const t3Chat = yield* T3ChatAccounts.T3ChatAccounts; + + // Asked only at the cap, so most links never call T3 Chat. Any failure keeps the lower cap. + const t3ChatTunnelLimit = (userId: string) => + t3Chat.getLink({ userId }).pipe( + Effect.map((link) => + link && link.plan !== "free" ? T3CHAT_PLAN_TUNNEL_LIMITS[link.plan] : 0, + ), + Effect.catch((error) => + Effect.logWarning("T3 Chat plan check failed", { userId, error: error.message }).pipe( + Effect.as(0), + ), + ), + ); return ManagedTunnelLimits.of({ - ensureCapacity: Effect.fn("relay.managed_tunnel_limits.ensure_capacity")(function* (input) { - const overrides = yield* db - .select({ maxTunnels: relayManagedTunnelLimits.maxTunnels }) - .from(relayManagedTunnelLimits) - .where(eq(relayManagedTunnelLimits.userId, input.userId)) - .limit(1) - .pipe( - Effect.mapError( - (cause) => - new ManagedTunnelLimitPersistenceError({ - operation: "load-limit", - userId: input.userId, - cause, - }), - ), + withCapacity: Effect.fn("relay.managed_tunnel_limits.with_capacity")(function* ( + input: { readonly userId: string; readonly environmentId: string }, + reserve: Effect.Effect, + ): Effect.fn.Return { + const checkAndReserve = Effect.gen(function* () { + // Held until commit, so the count below sees every reservation that took the lock first. + yield* db + .execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`managed-tunnel-limit:${input.userId}`}, 0))`, + ) + .pipe( + Effect.mapError( + (cause) => + new ManagedTunnelLimitPersistenceError({ + operation: "lock-user", + userId: input.userId, + cause, + }), + ), + ); + + const overrides = yield* db + .select({ maxTunnels: relayManagedTunnelLimits.maxTunnels }) + .from(relayManagedTunnelLimits) + .where(eq(relayManagedTunnelLimits.userId, input.userId)) + .limit(1) + .pipe( + Effect.mapError( + (cause) => + new ManagedTunnelLimitPersistenceError({ + operation: "load-limit", + userId: input.userId, + cause, + }), + ), + ); + const baseMaxTunnels = overrides[0]?.maxTunnels ?? DEFAULT_MANAGED_TUNNEL_LIMIT; + + const pendingCutoff = DateTime.formatIso( + DateTime.subtract(yield* DateTime.now, { + minutes: MANAGED_TUNNEL_PENDING_WINDOW_MINUTES, + }), ); - const maxTunnels = overrides[0]?.maxTunnels ?? DEFAULT_MANAGED_TUNNEL_LIMIT; - - // Exclude this environment so relinking remains idempotent at the account limit. - const counted = yield* db - .select({ activeTunnels: count() }) - .from(relayManagedEndpointAllocations) - .innerJoin( - relayEnvironmentLinks, - and( - eq(relayEnvironmentLinks.userId, relayManagedEndpointAllocations.userId), - eq(relayEnvironmentLinks.environmentId, relayManagedEndpointAllocations.environmentId), - ), - ) - .where( - and( - eq(relayManagedEndpointAllocations.userId, input.userId), - ne(relayManagedEndpointAllocations.environmentId, input.environmentId), - isNull(relayEnvironmentLinks.revokedAt), - eq(relayEnvironmentLinks.managedTunnelsEnabled, true), - ), - ) - .pipe( - Effect.mapError( - (cause) => - new ManagedTunnelLimitPersistenceError({ - operation: "count-tunnels", + const countSlots = (environment: typeof eq) => + db + .select({ slots: count() }) + .from(allocation) + .where(countedAllocations({ ...input, pendingCutoff, environment })) + .pipe( + Effect.map((rows) => rows[0]?.slots ?? 0), + Effect.mapError( + (cause) => + new ManagedTunnelLimitPersistenceError({ + operation: "count-tunnels", + userId: input.userId, + cause, + }), + ), + ); + const activeTunnels = yield* countSlots(ne); + if (activeTunnels < baseMaxTunnels) return yield* reserve; + + // An environment that already holds a counted slot relinks without asking T3 Chat, + // so a downgrade or a T3 Chat outage only blocks new environments. + if ((yield* countSlots(eq)) > 0) return yield* reserve; + + // A manual override is the exact cap; a T3 Chat plan raises only the default. + // Runs under the user lock, bounded by the T3 Chat request timeout. + const maxTunnels = + t3Chat.available && overrides.length === 0 + ? Math.max(baseMaxTunnels, yield* t3ChatTunnelLimit(input.userId)) + : baseMaxTunnels; + if (activeTunnels >= maxTunnels) { + return yield* new ManagedTunnelLimitExceeded({ + userId: input.userId, + environmentId: input.environmentId, + maxTunnels, + activeTunnels, + }); + } + return yield* reserve; + }); + + // Statements inside map their own SqlErrors, so any left here come from opening or committing the transaction. + return yield* db.$client.withTransaction(checkAndReserve).pipe( + Effect.mapError((cause) => + SqlError.isSqlError(cause) + ? new ManagedTunnelLimitPersistenceError({ + operation: "transaction", userId: input.userId, cause, - }), - ), - ); - const activeTunnels = counted[0]?.activeTunnels ?? 0; - - if (activeTunnels >= maxTunnels) { - return yield* new ManagedTunnelLimitExceeded({ - userId: input.userId, - environmentId: input.environmentId, - maxTunnels, - activeTunnels, - }); - } + }) + : cause, + ), + ); }), }); }); diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 39cdd619febe1..a20989cd3c025 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -66,6 +66,7 @@ import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllo import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; +import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; vi.mock("@clerk/backend", () => ({ createClerkClient: vi.fn(), @@ -128,6 +129,7 @@ describe("managed endpoint provisioning errors", () => { Layer.mock(EnvironmentLinks.EnvironmentLinks, {}), Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), Layer.mock(Devices.Devices, {}), + Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), ), ), Layer.provideMerge( @@ -238,6 +240,7 @@ describe("device listing compatibility", () => { return Effect.succeed([iphone, android]); }, }), + Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), ), ), Layer.provideMerge( @@ -318,6 +321,7 @@ describe("Cloudflare rate-limit retry hint", () => { Layer.mock(EnvironmentLinks.EnvironmentLinks, {}), Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), Layer.mock(Devices.Devices, {}), + Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), ), ), Layer.provideMerge( diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 53551d6d344f1..5a4d77c28d3c2 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -77,6 +77,7 @@ import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvide import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; +import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; import { withSpanAttributes } from "../observability.ts"; import { isRelayHookPath, redactRelayHookUrl } from "../hooks/HookForwarder.ts"; import * as RelayDb from "../db.ts"; @@ -823,6 +824,7 @@ export const layerClientApi = HttpApiBuilder.group( const links = yield* EnvironmentLinks.EnvironmentLinks; const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; const devices = yield* Devices.Devices; + const t3Chat = yield* T3ChatAccounts.T3ChatAccounts; return handlers .handle( "listEnvironments", @@ -989,6 +991,41 @@ export const layerClientApi = HttpApiBuilder.group( .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); return { ok: released }; }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "getT3ChatLink", + Effect.fn("relay.api.client.getT3ChatLink")(function* () { + yield* appendRelayCredentialResponseHeaders; + if (!t3Chat.available) return { available: false, link: null }; + const { userId } = yield* RelayClientPrincipal; + const link = yield* t3Chat + .getLink({ userId }) + .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); + return { available: true, link }; + }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "createT3ChatLinkRequest", + Effect.fn("relay.api.client.createT3ChatLinkRequest")(function* () { + const { userId } = yield* RelayClientPrincipal; + const url = yield* t3Chat.createLinkUrl({ userId }).pipe( + Effect.catchTags({ + T3ChatNotConfigured: () => relayInternalErrorResponse("internal_error"), + ClerkUserLookupFailed: () => relayInternalErrorResponse("upstream_unavailable"), + }), + ); + return { url }; + }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "unlinkT3Chat", + Effect.fn("relay.api.client.unlinkT3Chat")(function* () { + const { userId } = yield* RelayClientPrincipal; + yield* t3Chat + .unlink({ userId }) + .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); + return { ok: true }; + }, mapRelayCommonApiErrors("not_authorized")), ); }), ); diff --git a/infra/relay/src/t3chat/T3ChatAccounts.test.ts b/infra/relay/src/t3chat/T3ChatAccounts.test.ts new file mode 100644 index 0000000000000..2fc9cc9e3e512 --- /dev/null +++ b/infra/relay/src/t3chat/T3ChatAccounts.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import * as RelayConfiguration from "../Config.ts"; +import * as T3ChatAccounts from "./T3ChatAccounts.ts"; + +const clerk = vi.hoisted(() => ({ + getUser: async (): Promise => ({ primaryEmailAddress: { emailAddress: "a@b.co" } }), +})); +vi.mock("@clerk/backend", () => ({ + createClerkClient: () => ({ users: { getUser: () => clerk.getUser() } }), +})); + +const SECRET = "shared-link-secret"; + +const relaySettings = ( + t3Chat: RelayConfiguration.T3ChatSettings | null, +): RelayConfiguration.RelayConfiguration["Service"] => ({ + relayIssuer: "https://relay.example.test", + apns: null, + clerkSecretKey: Redacted.make("clerk-secret-key"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"), + cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"), + cloudMintPublicKey: "cloud-mint-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, + ...(t3Chat ? { t3Chat } : {}), +}); + +const fakeT3Chat = (body: unknown, status = 200) => { + const requests: Array<{ url: string; authorization: string | undefined; body: unknown }> = []; + const client = HttpClient.make((request) => + Effect.gen(function* () { + requests.push({ + url: request.url, + authorization: request.headers["authorization"], + body: + request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : null, + }); + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + }), + ); + return { client, requests }; +}; + +const provide = ( + client: HttpClient.HttpClient, + t3Chat: RelayConfiguration.T3ChatSettings | null = { + baseUrl: "https://chat.example.test", + linkSecret: Redacted.make(SECRET), + }, +) => + Effect.provide( + T3ChatAccounts.layer.pipe( + Layer.provide(RelayConfiguration.layer(relaySettings(t3Chat))), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ); + +describe("T3ChatAccounts", () => { + it.effect("signs link tokens in the format T3 Chat verifies", () => + Effect.gen(function* () { + const accounts = yield* T3ChatAccounts.T3ChatAccounts; + const url = new URL(yield* accounts.createLinkUrl({ userId: "user_1" })); + expect(`${url.origin}${url.pathname}`).toBe("https://chat.example.test/link/t3code"); + + const [payload = "", signature = ""] = (url.searchParams.get("token") ?? "").split("."); + // Verify with Web Crypto, as T3 Chat does, rather than the signing library. + const verified = yield* Effect.promise(async () => { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + return crypto.subtle.verify( + "HMAC", + key, + Buffer.from(signature, "base64url"), + encoder.encode(payload), + ); + }); + expect(verified).toBe(true); + expect(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))).toEqual({ + v: 1, + sub: "user_1", + email: "a@b.co", + exp: 600, + }); + }).pipe(provide(fakeT3Chat({}).client)), + ); + + it.effect("asks T3 Chat for the link with the shared secret", () => { + const t3Chat = fakeT3Chat({ link: { email: "theo@t3.chat", plan: "premier", linkedAt: 1 } }); + return Effect.gen(function* () { + const accounts = yield* T3ChatAccounts.T3ChatAccounts; + const link = yield* accounts.getLink({ userId: "user_1" }); + expect(link).toEqual({ email: "theo@t3.chat", plan: "premier" }); + expect(t3Chat.requests).toEqual([ + { + url: "https://chat.example.test/api/t3code/link", + authorization: `Bearer ${SECRET}`, + body: { clerkUserId: "user_1" }, + }, + ]); + }).pipe(provide(t3Chat.client)); + }); + + it.effect("fails instead of guessing when T3 Chat errors", () => + Effect.gen(function* () { + const accounts = yield* T3ChatAccounts.T3ChatAccounts; + const error = yield* Effect.flip(accounts.getLink({ userId: "user_1" })); + expect(error._tag).toBe("T3ChatRequestFailed"); + }).pipe(provide(fakeT3Chat({ error: "nope" }, 401).client)), + ); + + it.effect("times out when T3 Chat stalls while sending the body", () => { + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(new ReadableStream({ start: () => {} }), { status: 200 }), + ), + ), + ); + return Effect.gen(function* () { + const accounts = yield* T3ChatAccounts.T3ChatAccounts; + const fiber = yield* Effect.forkChild(Effect.flip(accounts.getLink({ userId: "user_1" }))); + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(fiber); + expect(error._tag).toBe("T3ChatRequestFailed"); + }).pipe(provide(client)); + }); + + it.effect("times out when Clerk stalls on the email lookup", () => + Effect.gen(function* () { + const getUser = clerk.getUser; + clerk.getUser = () => new Promise(() => {}); + const accounts = yield* T3ChatAccounts.T3ChatAccounts; + const fiber = yield* Effect.forkChild(Effect.flip(accounts.createLinkUrl({ userId: "u" }))); + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(fiber).pipe( + Effect.ensuring(Effect.sync(() => (clerk.getUser = getUser))), + ); + expect(error._tag).toBe("ClerkUserLookupFailed"); + }).pipe(provide(fakeT3Chat({}).client)), + ); + + it.effect("reports itself unavailable without a shared secret", () => + Effect.gen(function* () { + const accounts = yield* T3ChatAccounts.T3ChatAccounts; + expect(accounts.available).toBe(false); + const error = yield* Effect.flip(accounts.getLink({ userId: "user_1" })); + expect(error._tag).toBe("T3ChatNotConfigured"); + }).pipe(provide(fakeT3Chat({}).client, null)), + ); +}); diff --git a/infra/relay/src/t3chat/T3ChatAccounts.ts b/infra/relay/src/t3chat/T3ChatAccounts.ts new file mode 100644 index 0000000000000..d81bc049fd8dc --- /dev/null +++ b/infra/relay/src/t3chat/T3ChatAccounts.ts @@ -0,0 +1,166 @@ +import { createClerkClient } from "@clerk/backend"; +import { hmac } from "@noble/hashes/hmac"; +import { sha256 } from "@noble/hashes/sha2"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Base64Url from "effect/encoding/Base64Url"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { RelayT3ChatLink } from "@t3tools/contracts/relay"; + +import * as RelayConfiguration from "../Config.ts"; + +const LINK_TOKEN_TTL_SECONDS = 10 * 60; +const T3CHAT_REQUEST_TIMEOUT = "5 seconds"; + +const decodeLinkResponse = Schema.decodeUnknownEffect( + Schema.Struct({ link: Schema.NullOr(RelayT3ChatLink) }), +); + +export class T3ChatNotConfigured extends Schema.TaggedError()( + "T3ChatNotConfigured", + {}, +) { + override get message(): string { + return "T3 Chat linking is not configured on this relay"; + } +} + +export class ClerkUserLookupFailed extends Schema.TaggedError()( + "ClerkUserLookupFailed", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not read the Clerk user's primary email"; + } +} + +export class T3ChatRequestFailed extends Schema.TaggedError()( + "T3ChatRequestFailed", + { + operation: Schema.Literals(["get-link", "unlink"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `T3 Chat '${this.operation}' request failed`; + } +} + +/** + * Bridge to T3 Chat. T3 Chat owns the account link; the relay + * signs link requests and asks T3 Chat for the linked account's plan. + */ +export class T3ChatAccounts extends Context.Service< + T3ChatAccounts, + { + readonly available: boolean; + readonly createLinkUrl: (input: { + readonly userId: string; + }) => Effect.Effect; + readonly getLink: (input: { + readonly userId: string; + }) => Effect.Effect; + readonly unlink: (input: { + readonly userId: string; + }) => Effect.Effect; + } +>()("t3code-relay/t3chat/T3ChatAccounts") {} + +/** Token format shared with T3 Chat: `.`. */ +export const signLinkToken = ( + secret: string, + payload: { readonly sub: string; readonly email: string; readonly exp: number }, +) => { + const encoded = Base64Url.encode(JSON.stringify({ v: 1, ...payload })); + const signature = hmac( + sha256, + new TextEncoder().encode(secret), + new TextEncoder().encode(encoded), + ); + return `${encoded}.${Base64Url.encode(signature)}`; +}; + +export const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const client = yield* HttpClient.HttpClient; + const settings = config.t3Chat; + + const requireSettings = settings + ? Effect.succeed(settings) + : Effect.fail(new T3ChatNotConfigured()); + + // The timeout covers the body read so a stalled response still fails fast. + const post = Effect.fn("relay.t3chat.post")(function* ( + operation: T3ChatRequestFailed["operation"], + path: string, + userId: string, + ) { + const { baseUrl, linkSecret } = yield* requireSettings; + return yield* HttpClientRequest.post(new URL(path, baseUrl).toString()).pipe( + HttpClientRequest.bearerToken(Redacted.value(linkSecret)), + HttpClientRequest.bodyJsonUnsafe({ clerkUserId: userId }), + client.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeout(T3CHAT_REQUEST_TIMEOUT), + Effect.mapError((cause) => new T3ChatRequestFailed({ operation, cause })), + ); + }); + + const lookupClerkPrimaryEmail = Effect.fn("relay.t3chat.lookup_clerk_primary_email")(function* ( + userId: string, + ) { + return yield* Effect.tryPromise({ + try: async () => { + const user = await createClerkClient({ + secretKey: Redacted.value(config.clerkSecretKey), + publishableKey: config.clerkPublishableKey, + }).users.getUser(userId); + const email = user.primaryEmailAddress?.emailAddress; + if (!email) throw new Error("Clerk user has no primary email address."); + return email; + }, + catch: (cause) => new ClerkUserLookupFailed({ cause }), + }).pipe( + Effect.timeoutOrElse({ + duration: T3CHAT_REQUEST_TIMEOUT, + orElse: () => Effect.fail(new ClerkUserLookupFailed({ cause: "timed out" })), + }), + ); + }); + + return T3ChatAccounts.of({ + available: settings !== undefined, + createLinkUrl: Effect.fn("relay.t3chat.create_link_url")(function* (input) { + const { baseUrl, linkSecret } = yield* requireSettings; + const email = yield* lookupClerkPrimaryEmail(input.userId); + const now = yield* DateTime.now; + const token = signLinkToken(Redacted.value(linkSecret), { + sub: input.userId, + email, + exp: Math.floor(now.epochMilliseconds / 1_000) + LINK_TOKEN_TTL_SECONDS, + }); + const url = new URL("/link/t3code", baseUrl); + url.searchParams.set("token", token); + return url.toString(); + }), + getLink: Effect.fn("relay.t3chat.get_link")(function* (input) { + const body = yield* post("get-link", "/api/t3code/link", input.userId); + const decoded = yield* decodeLinkResponse(body).pipe( + Effect.mapError((cause) => new T3ChatRequestFailed({ operation: "get-link", cause })), + ); + return decoded.link; + }), + unlink: Effect.fn("relay.t3chat.unlink")(function* (input) { + yield* post("unlink", "/api/t3code/unlink", input.userId); + }), + }); +}); + +export const layer = Layer.effect(T3ChatAccounts, make); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 45414b2c68f0c..d59fc2f6c9189 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -64,6 +64,7 @@ import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublish import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts"; import * as ManagedEndpointReaper from "./environments/ManagedEndpointReaper.ts"; import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts"; +import * as T3ChatAccounts from "./t3chat/T3ChatAccounts.ts"; import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts"; import * as HookForwarder from "./hooks/HookForwarder.ts"; import * as HeldHooks from "./hooks/HeldHooks.ts"; @@ -166,6 +167,15 @@ export const layer = Api.make( const clerkSecretKey = yield* Config.Redacted("CLERK_SECRET_KEY"); const clerkPublishableKey = yield* Config.String("CLERK_PUBLISHABLE_KEY"); const clerkJwtAudience = yield* Config.String("CLERK_JWT_AUDIENCE"); + const t3ChatLinkSecret = Option.getOrUndefined( + Option.filter( + yield* Config.option(Config.Redacted("T3CHAT_LINK_SECRET")), + (value) => Redacted.value(value).trim().length > 0, + ), + ); + const t3ChatBaseUrl = yield* Config.String("T3CHAT_URL").pipe( + Config.withDefault("https://t3.chat"), + ); const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; @@ -236,6 +246,9 @@ export const layer = Api.make( managedEndpointCleanupMode, legacyManagedEndpointCleanupMode, ...(legacyTunnelGraceMinutes === undefined ? {} : { legacyTunnelGraceMinutes }), + ...(t3ChatLinkSecret + ? { t3Chat: { baseUrl: t3ChatBaseUrl, linkSecret: t3ChatLinkSecret } } + : {}), }); }); @@ -329,7 +342,7 @@ export const layer = Api.make( ManagedTunnelLimits.layer, ), ), - Layer.provideMerge(LiveActivities.layer), + Layer.provideMerge(Layer.mergeAll(LiveActivities.layer, T3ChatAccounts.layer)), Layer.provideMerge(DeliveryAttempts.layer), Layer.provideMerge(RelayTokens.layer), Layer.provideMerge( diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index f7d8665549e7f..b98d019e1af07 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -180,6 +180,9 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( createEnvironmentLinkChallenge: unexpected, linkEnvironment: unexpected, unlinkEnvironment: unexpected, + getT3ChatLink: unexpected, + createT3ChatLinkRequest: unexpected, + unlinkT3Chat: unexpected, getEnvironmentStatus: unexpected, connectEnvironment: (request) => Effect.gen(function* () { diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 59d28bc25ffa1..2131507e547f7 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; @@ -37,6 +38,7 @@ import { } from "./onboarding.ts"; import * as EnvironmentRegistry from "./registry.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; +import { connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; import * as ConnectionWakeups from "./wakeups.ts"; const layerClientPresentation = Layer.succeed( @@ -58,6 +60,7 @@ function layerPairingHttp( readonly protocolVersion?: number; readonly selfUpdate?: boolean; readonly grantScopes?: ReadonlyArray; + readonly exchangeGate?: { readonly reached: () => void; readonly release: Promise }; }, ) { const grantScopes = options?.grantScopes ?? AuthStandardClientScopes; @@ -121,15 +124,17 @@ function layerPairingHttp( ), ); } - return Promise.resolve( - Response.json({ - access_token: "bearer-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "Bearer", - expires_in: 3600, - scope: sessionScopes.join(" "), - }), - ); + const granted = Response.json({ + access_token: "bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: sessionScopes.join(" "), + }); + const gate = options?.exchangeGate; + if (gate === undefined) return Promise.resolve(granted); + gate.reached(); + return gate.release.then(() => granted); } if (url.endsWith("/api/auth/session")) { @@ -154,10 +159,13 @@ function layerPairingHttp( return RpcHttp.layerRemoteHttpClient(fetchFn); } -function layerPairingOnboarding(options?: Parameters[1]) { +function layerPairingOnboarding( + options?: Parameters[1], + calls: Array<{ readonly url: string; readonly init: RequestInit }> = [], +) { const dependencies = Layer.mergeAll( layerClientPresentation, - layerPairingHttp([], options), + layerPairingHttp(calls, options), Layer.mock(Persistence.ConnectionTargetStore)({ list: Effect.succeed([]), listDisabled: Effect.succeed([]), @@ -168,7 +176,10 @@ function layerPairingOnboarding(options?: Parameters[1] }), Layer.mock(Persistence.EnvironmentCacheStore)({}), Layer.mock(ConnectionProfileStore.ConnectionProfileStore)({}), - Layer.mock(ConnectionCredentialStore.ConnectionCredentialStore)({}), + Layer.mock(ConnectionCredentialStore.ConnectionCredentialStore)({ + get: () => Effect.succeedNone, + put: () => Effect.void, + }), Layer.mock(ClientCapabilities.SshEnvironmentGateway)({}), Layer.succeed( Connectivity.Connectivity, @@ -230,7 +241,7 @@ describe("connection onboarding", () => { const registration = yield* preparePairingRegistration({ host: "remote.example.test", pairingCode: "pairing-token", - }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, layerPairingHttp(calls)))); + }).pipe(Effect.provide(layerPairingOnboarding(undefined, calls))); expect(registration).toMatchObject({ _tag: "BearerConnectionRegistration", @@ -294,6 +305,72 @@ describe("connection onboarding", () => { }).pipe(Effect.provide(layerPairingOnboarding())), ); + it.effect("refuses a pairing link that claims a saved environment from a new address", () => { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + return Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const onboarding = yield* Onboarding.ConnectionOnboarding; + const before = yield* registerSwitchedOffEnvironment; + + const error = yield* onboarding + .registerPairing({ host: "attacker.example.test", pairingCode: "pairing-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration" }); + expect(yield* SubscriptionRef.get(registry.entries)).toEqual(before); + expect(calls.map((call) => call.url)).toEqual([ + "https://attacker.example.test/.well-known/t3/environment", + ]); + }).pipe(Effect.provide(layerPairingOnboarding(undefined, calls))); + }); + + it.effect("refuses a new address when the environment is saved during the exchange", () => { + let reached!: () => void; + let release!: () => void; + const exchangeReached = new Promise((resolve) => (reached = resolve)); + const exchangeGate = { + reached: () => reached(), + release: new Promise((resolve) => (release = resolve)), + }; + return Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const onboarding = yield* Onboarding.ConnectionOnboarding; + const pairing = yield* onboarding + .registerPairing({ host: "attacker.example.test", pairingCode: "pairing-token" }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => exchangeReached); + const before = yield* registerSwitchedOffEnvironment; + release(); + + const outcome = yield* Fiber.join(pairing); + + expect(outcome).toMatchObject({ + _tag: "Failure", + failure: { _tag: "ConnectionBlockedError", reason: "configuration" }, + }); + expect(yield* SubscriptionRef.get(registry.entries)).toEqual(before); + }).pipe(Effect.provide(layerPairingOnboarding({ exchangeGate }))); + }); + + it.effect("adds a new address as a route when pairing targets that environment", () => + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const onboarding = yield* Onboarding.ConnectionOnboarding; + yield* registerSwitchedOffEnvironment; + + yield* onboarding.registerPairing({ + host: "lan.example.test", + pairingCode: "pairing-token", + expectedEnvironmentId: PAIRED_ENVIRONMENT_ID, + }); + + const entry = (yield* SubscriptionRef.get(registry.entries)).get(PAIRED_ENVIRONMENT_ID); + expect(entry === undefined ? [] : connectionRoutes(entry).map(routeHttpBaseUrl)).toEqual( + expect.arrayContaining(["https://remote.example.test/", "https://lan.example.test/"]), + ); + }).pipe(Effect.provide(layerPairingOnboarding())), + ); + it.effect( "re-pairs an outdated self-updatable environment so its connection can be rechecked", () => @@ -334,34 +411,55 @@ describe("connection onboarding", () => { ), ); - it.effect("leaves a platform-managed environment untouched when its id is paired", () => - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const onboarding = yield* Onboarding.ConnectionOnboarding; - yield* registry.reconcilePlatform([ - new PrimaryConnectionRegistration({ - target: new PrimaryConnectionTarget({ - environmentId: PAIRED_ENVIRONMENT_ID, - label: "Host environment", - httpBaseUrl: "http://127.0.0.1:3773", - wsBaseUrl: "ws://127.0.0.1:3773", - }), + it.effect.each([ + { + kind: "primary", + registration: new PrimaryConnectionRegistration({ + target: new PrimaryConnectionTarget({ + environmentId: PAIRED_ENVIRONMENT_ID, + label: "Host environment", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", }), - ]); - yield* registry.setCompatibility(PAIRED_ENVIRONMENT_ID, UNSUPPORTED_ERROR); - const before = yield* SubscriptionRef.get(registry.entries); - expect(before.get(PAIRED_ENVIRONMENT_ID)).toMatchObject({ - enabled: true, - unsupportedReason: UNSUPPORTED_ERROR.message, - }); + }), + }, + { + kind: "bearer", + registration: new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: PAIRED_ENVIRONMENT_ID, + connectionId: "platform:wsl", + label: "WSL", + }), + profile: new BearerConnectionProfile({ + ...PAIRED_PROFILE, + connectionId: "platform:wsl", + label: "WSL", + httpBaseUrl: "http://127.0.0.1:4000", + wsBaseUrl: "ws://127.0.0.1:4000", + }), + credential: new BearerConnectionCredential({ token: "platform-token" }), + }), + }, + ] as const)( + "leaves a platform-managed $kind environment untouched when its id is paired", + ({ registration }) => + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const onboarding = yield* Onboarding.ConnectionOnboarding; + yield* registry.reconcilePlatform([registration]); + yield* registry.setCompatibility(PAIRED_ENVIRONMENT_ID, UNSUPPORTED_ERROR); + const before = yield* SubscriptionRef.get(registry.entries); + expect(before.get(PAIRED_ENVIRONMENT_ID)).toMatchObject({ + enabled: true, + unsupportedReason: UNSUPPORTED_ERROR.message, + }); - const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT); + const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT); - expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID); - const after = yield* SubscriptionRef.get(registry.entries); - expect(after).toEqual(before); - expect(after.get(PAIRED_ENVIRONMENT_ID)?.unsupportedReason).toBe(UNSUPPORTED_ERROR.message); - }).pipe(Effect.provide(layerPairingOnboarding())), + expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID); + expect(yield* SubscriptionRef.get(registry.entries)).toEqual(before); + }).pipe(Effect.provide(layerPairingOnboarding())), ); it.effect("leaves a switched-off environment unchanged when the pairing exchange fails", () => @@ -392,10 +490,7 @@ describe("connection onboarding", () => { pairingCode: "pairing-token", }).pipe( Effect.provide( - Layer.mergeAll( - layerClientPresentation, - layerPairingHttp(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1 }), - ), + layerPairingOnboarding({ protocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1 }, calls), ), Effect.flip, ); @@ -410,21 +505,19 @@ describe("connection onboarding", () => { { label: "default", scopes: AuthStandardClientScopes }, ] as const)("preserves the $label grant when pairing a remote environment", ({ scopes }) => Effect.gen(function* () { - const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; - const httpLayer = layerPairingHttp(calls, { grantScopes: scopes }); const registration = yield* preparePairingRegistration({ host: "remote.example.test", pairingCode: "pairing-token", - }).pipe(Effect.provide(Layer.mergeAll(layerClientPresentation, httpLayer))); + }); const session = yield* fetchRemoteSessionState({ httpBaseUrl: registration.profile.httpBaseUrl, bearerToken: registration.credential.token, - }).pipe(Effect.provide(httpLayer)); + }); expect(session.authenticated).toBe(true); expect(session.scopes).toEqual(scopes); - }), + }).pipe(Effect.provide(layerPairingOnboarding({ grantScopes: scopes }))), ); it.effect("refuses to add a route that reaches a different machine, keeping the code", () => @@ -434,10 +527,7 @@ describe("connection onboarding", () => { host: "remote.example.test", pairingCode: "pairing-token", expectedEnvironmentId: EnvironmentId.make("some-other-machine"), - }).pipe( - Effect.provide(Layer.mergeAll(layerClientPresentation, layerPairingHttp(calls))), - Effect.flip, - ); + }).pipe(Effect.provide(layerPairingOnboarding(undefined, calls)), Effect.flip); expect(error).toMatchObject({ reason: "configuration" }); expect(error.message).toContain("different machine"); expect(calls.map((call) => call.url)).toEqual([ @@ -454,10 +544,7 @@ describe("connection onboarding", () => { pairingCode: "pairing-token", }).pipe( Effect.provide( - Layer.mergeAll( - layerClientPresentation, - layerPairingHttp(calls, { protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1 }), - ), + layerPairingOnboarding({ protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1 }, calls), ), Effect.flip, ); @@ -476,15 +563,7 @@ describe("connection onboarding", () => { yield* preparePairingRegistration({ host: "remote.example.test", pairingCode: "pairing-token", - }).pipe( - Effect.provide( - Layer.mergeAll( - layerClientPresentation, - layerPairingHttp(calls, { failDescriptor: true }), - ), - ), - Effect.flip, - ); + }).pipe(Effect.provide(layerPairingOnboarding({ failDescriptor: true }, calls)), Effect.flip); expect(calls.map((call) => call.url)).toEqual([ "https://remote.example.test/.well-known/t3/environment", diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 66f14f2eca664..48e8b614c67c8 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -113,19 +113,8 @@ export const preparePairingRegistration = Effect.fn( ) { return yield* differentMachineError(descriptor.label); } - const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); - // An outdated server is still saved so it can be updated from this client. - if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { - return yield* compatibilityError; - } - const access = yield* bootstrapRemoteBearerSession({ - httpBaseUrl: target.httpBaseUrl, - credential: target.credential, - clientMetadata: presentation.metadata, - }).pipe(Effect.mapError(mapRemoteEnvironmentError)); const connectionId = bearerConnectionId(descriptor.environmentId, target.httpBaseUrl); - - return new BearerConnectionRegistration({ + const route = { target: new BearerConnectionTarget({ environmentId: descriptor.environmentId, label: descriptor.label, @@ -138,6 +127,25 @@ export const preparePairingRegistration = Effect.fn( httpBaseUrl: target.httpBaseUrl, wsBaseUrl: target.wsBaseUrl, }), + }; + // The registry checks again under its lease; this early check keeps a refused code unspent. + if (input.expectedEnvironmentId === undefined) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.assertKnownAddress({ ...route, profile: Option.some(route.profile) }); + } + const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); + // An outdated server is still saved so it can be updated from this client. + if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { + return yield* compatibilityError; + } + const access = yield* bootstrapRemoteBearerSession({ + httpBaseUrl: target.httpBaseUrl, + credential: target.credential, + clientMetadata: presentation.metadata, + }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + + return new BearerConnectionRegistration({ + ...route, credential: new BearerConnectionCredential({ token: access.access_token, }), @@ -149,7 +157,10 @@ const registerPairingConnection = Effect.fn( )(function* (input: PairingConnectionInput) { const registration = yield* preparePairingRegistration(input); const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - if (!(yield* registry.register(registration))) return registration.target.environmentId; + const knownAddressOnly = input.expectedEnvironmentId === undefined; + if (!(yield* registry.register(registration, { knownAddressOnly }))) { + return registration.target.environmentId; + } yield* registry.setCompatibility(registration.target.environmentId, null); yield* registry .setEnabled(registration.target.environmentId, true) diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 1db7eb2274959..4d4a06fccc1ae 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -101,11 +101,20 @@ export class EnvironmentRegistry extends Context.Service< readonly start: Effect.Effect; /** * Succeeds with `false` and changes nothing when the environment is - * platform-managed, since the host owns that entry. + * platform-managed, since the host owns that entry. With `knownAddressOnly`, it fails like + * `assertKnownAddress`, checked under the environment's lease. */ readonly register: ( registration: ConnectionRegistration, - ) => Effect.Effect; + options?: { readonly knownAddressOnly?: boolean }, + ) => Effect.Effect; + /** + * Fails when the route would add a new address to a saved user environment. Pairing that does + * not target an environment checks this, because the server reports its own environment id. + */ + readonly assertKnownAddress: ( + route: ConnectionRoute, + ) => Effect.Effect; readonly reconcilePlatform: ( registrations: ReadonlyArray, ) => Effect.Effect; @@ -532,21 +541,42 @@ export const make = Effect.gen(function* () { return { entry: rest, addressesChanged: true }; }; + const assertKnownAddress = Effect.fn("EnvironmentRegistry.assertKnownAddress")(function* ( + route: ConnectionRoute, + ) { + const environmentId = route.target.environmentId; + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) return; + const saved = (yield* SubscriptionRef.get(entries)).get(environmentId); + if ( + saved !== undefined && + findRouteToSameAddress(connectionRoutes(saved), route) === undefined + ) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: `That address claims to be ${saved.target.label}, which is already saved. Add it as a route from that environment's settings instead.`, + }); + } + }); + const register = Effect.fn("EnvironmentRegistry.register")(function* ( registration: ConnectionRegistration, + options?: { readonly knownAddressOnly?: boolean }, ) { const registered = connectionRegistrationCatalogEntry(registration); const environmentId = registered.target.environmentId; + const route: ConnectionRoute = { target: registered.target, profile: registered.profile }; return yield* withLeaseLock( environmentId, Effect.gen(function* () { if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { return false; } + if (options?.knownAddressOnly === true) { + yield* assertKnownAddress(route); + } const previous = (yield* SubscriptionRef.get(entries)).get(environmentId); let entry = registered; if (previous !== undefined) { - const route: ConnectionRoute = { target: registered.target, profile: registered.profile }; const existing = connectionRoutes(previous); const sameAddress = findRouteToSameAddress(existing, route); const routes = existing.filter( @@ -1132,6 +1162,7 @@ export const make = Effect.gen(function* () { networkStatus, start, register, + assertKnownAddress, reconcilePlatform, remove, removeRoute, diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index d920a8f0856bf..347bde9cc44c8 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1181,6 +1181,9 @@ describe("EnvironmentSupervisor", () => { createEnvironmentLinkChallenge: unused, linkEnvironment: unused, unlinkEnvironment: unused, + getT3ChatLink: unused, + createT3ChatLinkRequest: unused, + unlinkT3Chat: unused, getEnvironmentStatus: unused, connectEnvironment: Effect.fn("TestConnectionHttp.connectEnvironment")(function* () { yield* Ref.update(bootstrapCalls, (count) => count + 1); diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index 924220ec27ce4..94cba5c9f842b 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -1,16 +1,14 @@ import { isNetworkPath, isWindowsAbsolutePath, - stripSlashPrefixedWindowsDrive, workspaceRelativeFilePath, } from "@t3tools/shared/path"; import { + markdownDestinationHostPath, normalizeMarkdownLinkDestination, - safeDecodeURIComponent, splitMarkdownLinkSearchAndHash, } from "@t3tools/shared/markdownLinks"; -import { parseFileUrlHref } from "@t3tools/shared/fileLinks"; const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; @@ -59,19 +57,9 @@ export function classifyMarkdownImageSource( return { _tag: "Direct", uri: source }; } - if (/^file:/i.test(source)) { - const target = parseFileUrlHref(source); - return target === null - ? { _tag: "Blocked" } - : hostFileSource( - stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)), - workspaceRoot, - ); - } - - const path = stripSlashPrefixedWindowsDrive( - safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path), - ); + const path = markdownDestinationHostPath(source); + if (path === null) return { _tag: "Blocked" }; + if (/^file:/i.test(source)) return hostFileSource(path, workspaceRoot); if (path.length === 0) return { _tag: "Blocked" }; if (path.startsWith("/") || isWindowsAbsolutePath(path)) { return hostFileSource(path, workspaceRoot); diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 53ded8855bddb..2e15b5448c2ce 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -110,6 +110,9 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { createEnvironmentLinkChallenge: () => Effect.die("unused"), linkEnvironment: () => Effect.die("unused"), unlinkEnvironment: () => Effect.die("unused"), + getT3ChatLink: () => Effect.die("unused"), + createT3ChatLinkRequest: () => Effect.die("unused"), + unlinkT3Chat: () => Effect.die("unused"), connectEnvironment: () => Effect.die("unused"), registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), @@ -275,6 +278,9 @@ describe("RelayEnvironmentDiscovery", () => { createEnvironmentLinkChallenge: () => Effect.die("unused"), linkEnvironment: () => Effect.die("unused"), unlinkEnvironment: () => Effect.die("unused"), + getT3ChatLink: () => Effect.die("unused"), + createT3ChatLinkRequest: () => Effect.die("unused"), + unlinkT3Chat: () => Effect.die("unused"), connectEnvironment: () => Effect.die("unused"), registerDevice: () => Effect.die("unused"), unregisterDevice: () => Effect.die("unused"), diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index e3dd743bd14e1..a446cb2489361 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -1,5 +1,9 @@ import type { DpopFailureReason } from "@t3tools/contracts"; -import type { RelayEnvironmentStatusResponse, RelayProtectedError } from "@t3tools/contracts/relay"; +import { + T3CHAT_PLAN_TUNNEL_LIMITS, + type RelayEnvironmentStatusResponse, + type RelayProtectedError, +} from "@t3tools/contracts/relay"; export const DPOP_CLOCK_HINT = "Hint: Check that automatic date and time is enabled on both devices, then try again."; @@ -63,7 +67,9 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { error.provisioningStage ? ` Failed stage: ${error.provisioningStage}.` : "" }`; case "RelayEnvironmentLinkLimitExceededError": - return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; + return error.maxTunnels < T3CHAT_PLAN_TUNNEL_LIMITS.premier + ? `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment, or link a paid T3 Chat account in your T3 Connect account settings on web or desktop. Pro raises the default limit to ${T3CHAT_PLAN_TUNNEL_LIMITS.pro} and Premier to ${T3CHAT_PLAN_TUNNEL_LIMITS.premier}.` + : `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; case "RelayAgentActivityPublishProofExpiredError": return "Relay rejected an expired agent activity publish proof."; case "RelayAgentActivityPublishProofInvalidError": diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 35135466759cb..7c30ff640fb4c 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -22,6 +22,8 @@ import { RelayMobileRegistrationScope, type RelayOkResponse, type RelayPublicClientId, + type RelayT3ChatLinkRequestResponse, + type RelayT3ChatLinkResponse, RelayRegisterDeviceEndpoint, RelayAgentActivitySnapshotEndpoint, RelayRegisterLiveActivityEndpoint, @@ -96,6 +98,9 @@ export const ManagedRelayRequestAction = Schema.Literals([ "unregister relay mobile device", "register relay live activity", "read relay agent activity snapshot", + "read linked T3 Chat account", + "start T3 Chat linking", + "unlink T3 Chat account", ]); export type ManagedRelayRequestAction = typeof ManagedRelayRequestAction.Type; @@ -112,6 +117,9 @@ export const ManagedRelayRequestActivity = Schema.Literals([ "Relay mobile device unregistration", "Relay Live Activity registration", "Relay agent activity snapshot", + "Relay T3 Chat link lookup", + "Relay T3 Chat link request", + "Relay T3 Chat unlinking", ]); export type ManagedRelayRequestActivity = typeof ManagedRelayRequestActivity.Type; @@ -272,6 +280,15 @@ export class ManagedRelayClient extends Context.Service< readonly clerkToken: string; readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; }) => Effect.Effect; + readonly getT3ChatLink: (input: { + readonly clerkToken: string; + }) => Effect.Effect; + readonly createT3ChatLinkRequest: (input: { + readonly clerkToken: string; + }) => Effect.Effect; + readonly unlinkT3Chat: (input: { + readonly clerkToken: string; + }) => Effect.Effect; readonly getEnvironmentStatus: (input: { readonly clerkToken: string; readonly scopes: ReadonlyArray; @@ -426,6 +443,9 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi ), linkEnvironment: unavailable("clientRuntime.managedRelay.linkEnvironment"), unlinkEnvironment: unavailable("clientRuntime.managedRelay.unlinkEnvironment"), + getT3ChatLink: unavailable("clientRuntime.managedRelay.getT3ChatLink"), + createT3ChatLinkRequest: unavailable("clientRuntime.managedRelay.createT3ChatLinkRequest"), + unlinkT3Chat: unavailable("clientRuntime.managedRelay.unlinkT3Chat"), getEnvironmentStatus: unavailable("clientRuntime.managedRelay.getEnvironmentStatus"), connectEnvironment: unavailable("clientRuntime.managedRelay.connectEnvironment"), registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), @@ -791,6 +811,42 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), withRelayClientTracing, ), + getT3ChatLink: Effect.fnUntraced( + function* (input) { + return yield* client.client + .getT3ChatLink({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.mapError(relayRequestError("read linked T3 Chat account")), + timeoutRelayRequest("Relay T3 Chat link lookup"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getT3ChatLink"), + withRelayClientTracing, + ), + createT3ChatLinkRequest: Effect.fnUntraced( + function* (input) { + return yield* client.client + .createT3ChatLinkRequest({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.mapError(relayRequestError("start T3 Chat linking")), + timeoutRelayRequest("Relay T3 Chat link request"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.createT3ChatLinkRequest"), + withRelayClientTracing, + ), + unlinkT3Chat: Effect.fnUntraced( + function* (input) { + return yield* client.client + .unlinkT3Chat({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.mapError(relayRequestError("unlink T3 Chat account")), + timeoutRelayRequest("Relay T3 Chat unlinking"), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unlinkT3Chat"), + withRelayClientTracing, + ), getEnvironmentStatus: Effect.fnUntraced( function* (input) { yield* Effect.annotateCurrentSpan({ diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index eb7772b9746da..113de2e40bf1a 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -66,6 +66,9 @@ function createClient(overrides?: Partial Effect.die("unused"), linkEnvironment: () => Effect.die("unused"), unlinkEnvironment: () => Effect.die("unused"), + getT3ChatLink: () => Effect.die("unused"), + createT3ChatLinkRequest: () => Effect.die("unused"), + unlinkT3Chat: () => Effect.die("unused"), getEnvironmentStatus: () => Effect.succeed({ environmentId: environment.environmentId, diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 7153f203cffa0..c949faf2c3225 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -4,7 +4,6 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -42,7 +41,7 @@ export interface ManagedRelaySnapshotState { } export interface ManagedRelayQueryEvent { - readonly operation: "environments" | "devices"; + readonly operation: "environments" | "devices" | "t3chat-link"; readonly stage: "clerk-token" | "relay-request"; readonly phase: "start" | "success" | "failure"; readonly accountId: string; @@ -178,23 +177,56 @@ function readSessionClerkToken( ); } +function requireSessionClerkToken( + registry: AtomRegistry.AtomRegistry, + accountId: string, + action: string, +): Effect.Effect { + const session = registry.get(managedRelaySessionAtom); + if (!session || session.accountId !== accountId) { + return Effect.fail( + new ManagedRelaySessionError({ message: `Sign in to T3 Connect before ${action}.` }), + ); + } + return readSessionClerkToken(session); +} + export const deregisterManagedRelayEnvironment = Effect.fn( "clientRuntime.managedRelaySession.deregisterEnvironment", )(function* ( registry: AtomRegistry.AtomRegistry, input: { readonly accountId: string; readonly environmentId: EnvironmentId }, ) { - const session = registry.get(managedRelaySessionAtom); - if (!session || session.accountId !== input.accountId) { - return yield* new ManagedRelaySessionError({ - message: "Sign in to T3 Connect before deregistering an environment.", - }); - } - const clerkToken = yield* readSessionClerkToken(session); + const clerkToken = yield* requireSessionClerkToken( + registry, + input.accountId, + "deregistering an environment", + ); const relay = yield* ManagedRelay.ManagedRelayClient; yield* relay.unlinkEnvironment({ clerkToken, environmentId: input.environmentId }); }); +/** Returns the T3 Chat page URL where the signed-in user confirms the link. */ +export const createManagedRelayT3ChatLinkRequest = Effect.fn( + "clientRuntime.managedRelaySession.createT3ChatLinkRequest", +)(function* (registry: AtomRegistry.AtomRegistry, input: { readonly accountId: string }) { + const clerkToken = yield* requireSessionClerkToken(registry, input.accountId, "linking T3 Chat"); + const relay = yield* ManagedRelay.ManagedRelayClient; + return (yield* relay.createT3ChatLinkRequest({ clerkToken })).url; +}); + +export const unlinkManagedRelayT3Chat = Effect.fn("clientRuntime.managedRelaySession.unlinkT3Chat")( + function* (registry: AtomRegistry.AtomRegistry, input: { readonly accountId: string }) { + const clerkToken = yield* requireSessionClerkToken( + registry, + input.accountId, + "unlinking T3 Chat", + ); + const relay = yield* ManagedRelay.ManagedRelayClient; + yield* relay.unlinkT3Chat({ clerkToken }); + }, +); + function requireClerkToken( get: Atom.AtomContext, accountId: string, @@ -231,7 +263,7 @@ export function readManagedRelaySnapshotState( ): ManagedRelaySnapshotState { const failure = result._tag === "Failure" ? describeManagedRelayFailure(result.cause) : null; return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error: failure?.message ?? null, errorTraceId: failure?.traceId ?? null, isPending: result.waiting, @@ -333,14 +365,41 @@ export function createManagedRelayQueryManager( ), ); + const t3ChatLinkAtom = Atom.family((accountId: string) => + runtime + .atom((get) => + Effect.gen(function* () { + const base = { operation: "t3chat-link" as const, accountId }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); + const relay = yield* ManagedRelay.ManagedRelayClient; + return yield* observe( + { ...base, stage: "relay-request" }, + relay.getT3ChatLink({ clerkToken }), + ); + }), + ) + .pipe( + Atom.swr({ staleTime, revalidateOnMount: true }), + Atom.setIdleTTL(idleTtl), + Atom.withLabel(`managed-relay:t3chat-link:${accountId}`), + ), + ); + return { environmentsAtom, devicesAtom, + t3ChatLinkAtom, refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { registry.refresh(environmentsAtom(accountId)); }, refreshDevices(registry: AtomRegistry.AtomRegistry, accountId: string): void { registry.refresh(devicesAtom(accountId)); }, + refreshT3ChatLink(registry: AtomRegistry.AtomRegistry, accountId: string): void { + registry.refresh(t3ChatLinkAtom(accountId)); + }, }; } diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 60c822c981c31..3a67dc1c52fcf 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -1,7 +1,6 @@ import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; -import * as Option from "effect/Option"; import * as Order from "effect/Order"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -53,7 +52,7 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { const result = get(options.getSnapshotAtom(environmentId)); isLoading ||= result.waiting; - const snapshot = Option.getOrNull(AsyncResult.value(result)); + const snapshot = AsyncResult.getOrElse(result, () => null); if (snapshot !== null) { snapshots.push({ environmentId, snapshot }); } diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 298772ca05cc3..7017046f8bea8 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -257,7 +257,7 @@ export function createProjectFaviconUrlAtomFamily(input: { return Atom.make((get): string | null => { const result = get(image); if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; - return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); + return AsyncResult.getOrElse(result, () => cache.peek(target)); }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); }); return (target: ProjectFaviconTarget) => diff --git a/packages/client-runtime/src/state/commandPermissions.ts b/packages/client-runtime/src/state/commandPermissions.ts index e8008230107ad..078f7c47508bb 100644 --- a/packages/client-runtime/src/state/commandPermissions.ts +++ b/packages/client-runtime/src/state/commandPermissions.ts @@ -26,7 +26,7 @@ function makeCommandPermissions( const scopes = scopeKey.split(",").filter(Boolean) as AuthEnvironmentScope[]; if (scopes.length === 0) return true; const result = get(sessions.sessionStateAtom(environmentId)); - const session = Option.getOrNull(AsyncResult.value(result)); + const session = AsyncResult.getOrElse(result, () => null); return ( result._tag !== "Failure" && session !== null && diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 82afc19c69a72..206f75a600416 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -1,6 +1,5 @@ import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -63,7 +62,7 @@ export function createEnvironmentCatalogAtoms( ); const catalogValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), + AsyncResult.getOrElse(get(catalogAtom), () => EMPTY_ENVIRONMENT_CATALOG_STATE), ).pipe(Atom.withLabel("environment-catalog-value")); const pullRequestRoutingPermissionsAtom = runtime.atom( @@ -73,7 +72,7 @@ export function createEnvironmentCatalogAtoms( { initialValue: [] as ReadonlyArray }, ); const pullRequestRoutingPermissionsValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(pullRequestRoutingPermissionsAtom)), () => []), + AsyncResult.getOrElse(get(pullRequestRoutingPermissionsAtom), () => []), ).pipe(Atom.withLabel("environment-pull-request-routing-permissions")); const setPullRequestRoutingPermission = createRuntimeCommand(runtime, { label: "environment-catalog:pull-request-routing-permission", @@ -104,7 +103,7 @@ export function createEnvironmentCatalogAtoms( ); const networkStatusValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(networkStatusAtom)), () => "unknown" as const), + AsyncResult.getOrElse(get(networkStatusAtom), () => "unknown" as const), ).pipe(Atom.withLabel("environment-network-status-value")); const stateAtom = Atom.family((environmentId: EnvironmentIdType) => diff --git a/packages/client-runtime/src/state/olderSettledThreads.test.ts b/packages/client-runtime/src/state/olderSettledThreads.test.ts index 42fcfb4157a61..50e1d39df13f9 100644 --- a/packages/client-runtime/src/state/olderSettledThreads.test.ts +++ b/packages/client-runtime/src/state/olderSettledThreads.test.ts @@ -522,7 +522,7 @@ describe("createOlderSettledThreadAtoms", () => { yield* Queue.offer(replies, Effect.succeed({ ...found, snapshotSequence: 8 })); yield* until( search, - (result) => Option.getOrNull(AsyncResult.value(result))?.snapshotSequence === 8, + (result) => AsyncResult.getOrElse(result, () => null)?.snapshotSequence === 8, ); expect(requests).toEqual([input, input]); }), diff --git a/packages/client-runtime/src/state/olderSettledThreads.ts b/packages/client-runtime/src/state/olderSettledThreads.ts index c13cddb367972..1125bbdb4eeb0 100644 --- a/packages/client-runtime/src/state/olderSettledThreads.ts +++ b/packages/client-runtime/src/state/olderSettledThreads.ts @@ -227,7 +227,7 @@ export function createOlderSettledThreadAtoms( ) => Atom.Atom>, ) { const shellStateValue = (state: AsyncResult.AsyncResult) => - Option.getOrNull(AsyncResult.value(state)); + AsyncResult.getOrElse(state, () => null); const countReadAtom = createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:orchestration:older-settled-counts", diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 3c3e2ef78b906..f3ffb03b0bcfe 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -77,8 +77,8 @@ export function createEnvironmentPresentationAtoms(input: { if (entry === undefined) { return null; } - const state = Option.getOrElse( - AsyncResult.value(get(input.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + get(input.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); const previous = Option.getOrNull(get.self()); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 6866c02b08cfd..a23fd4a79e0bc 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -78,7 +78,7 @@ export function derivePhysicalProjectKey( return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); } -export function deriveProjectGroupingOverrideKey( +function deriveProjectGroupingOverrideKey( project: Pick, ): string { return derivePhysicalProjectKey(project); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 966c68dabd9e3..a8627e2d9977e 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1430,15 +1430,11 @@ it.effect("updates cached labels after successful edits without rereading the ho registry.refresh(detail); yield* detailRefreshStarted.await; expect(registry.get(detail).waiting).toBe(true); - expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual([ - addedLabel, - ]); + expect(AsyncResult.getOrThrow(registry.get(detail)).labels).toEqual([addedLabel]); yield* releaseDetailRefresh.open; yield* Effect.exit(AtomRegistry.getResult(registry, detail, { suspendOnWaiting: true })); expect(AsyncResult.isFailure(registry.get(detail))).toBe(true); - expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual([ - addedLabel, - ]); + expect(AsyncResult.getOrThrow(registry.get(detail)).labels).toEqual([addedLabel]); failDetail = false; registry.refresh(detail); expect( @@ -1562,7 +1558,7 @@ it.effect("publishes the accepted guide run before the follow-up read finishes", const result = yield* Effect.promise(() => atoms.generateReviewGuide.run(registry, target)); expect(result._tag).toBe("Success"); yield* refreshStarted.await; - expect(Option.getOrNull(AsyncResult.value(registry.get(query)))).toEqual(accepted); + expect(AsyncResult.getOrElse(registry.get(query), () => null)).toEqual(accepted); }), ), ); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 6b675f36a7858..ca5fb6d7a77d5 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -520,7 +520,7 @@ export function createPullRequestEnvironmentAtoms( Effect.sync(() => { const { reviewers, requested } = target.input; const candidatesAtom = reviewerCandidates(target); - const candidates = Option.getOrNull(AsyncResult.value(registry.get(candidatesAtom))); + const candidates = AsyncResult.getOrElse(registry.get(candidatesAtom), () => null); const selected = candidates?.candidates.filter((candidate) => reviewers.some( @@ -606,7 +606,7 @@ export function createPullRequestEnvironmentAtoms( Effect.sync(() => { const { labels, applied } = target.input; const candidatesAtom = labelCandidates(target); - const candidates = Option.getOrNull(AsyncResult.value(registry.get(candidatesAtom))); + const candidates = AsyncResult.getOrElse(registry.get(candidatesAtom), () => null); const names = new Set(labels); updateCached(registry, candidatesAtom, (value) => ({ ...value, diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts index 34b8c662c8821..b31dd67ca9dad 100644 --- a/packages/client-runtime/src/state/relayDiscovery.ts +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -19,8 +18,8 @@ export function createRelayEnvironmentDiscoveryAtoms( { initialValue: RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, ); const stateValueAtom = Atom.make((get) => - Option.getOrElse( - AsyncResult.value(get(stateAtom)), + AsyncResult.getOrElse( + get(stateAtom), () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, ), ).pipe(Atom.withLabel("relay-environment-discovery-value")); diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index bb69334bc7870..963b3a7bfdeb7 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1306,7 +1306,7 @@ describe("persisted environment queries", () => { ); const registry = yield* mountEnvironmentQuery(harness.atom); yield* Effect.result(readEnvironmentQuery(registry, harness.atom)); - expect(Option.getOrNull(AsyncResult.value(registry.get(harness.atom)))).toBe("cached"); + expect(AsyncResult.getOrElse(registry.get(harness.atom), () => null)).toBe("cached"); }), ), ); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 20054521a778a..fde1790d338a3 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -558,7 +558,7 @@ const refreshEveryUntilFinal = * reader of only this skips the refetches that changed nothing. */ export const selectEnvironmentQueryData = (result: AsyncResult.AsyncResult): A | null => - Option.getOrNull(AsyncResult.value(result)); + AsyncResult.getOrElse(result, () => null); type EnvironmentConnection = readonly [ SupervisorConnectionState, @@ -628,7 +628,7 @@ export function environmentSessionGenerationAtom( get.self<{ readonly session: RpcSession.RpcSession | null; readonly count: number }>(), () => ({ session: null, count: 0 }), ); - const connection = Option.getOrNull(AsyncResult.value(get(connectionAtom(id)))); + const connection = AsyncResult.getOrElse(get(connectionAtom(id)), () => null); if (connection === null || connection[0].phase !== "connected") return previous; const session = Option.getOrNull(connection[1]); return session === null || session === previous.session @@ -674,8 +674,9 @@ export function createEnvironmentQueryAtomFamily( | EnvironmentRegistry.EnvironmentNotRegisteredError | EnvironmentRpcUnavailableError >((get) => { - const connection = Option.getOrNull( - AsyncResult.value(get(connectionAtom(target.environmentId))), + const connection = AsyncResult.getOrElse( + get(connectionAtom(target.environmentId)), + () => null, ); // A refetch that returns equal content keeps the previous value's // references, so readers of the data skip re-rendering on idle polls. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 892817d0b8067..3cb4f77325fa6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -792,8 +792,9 @@ export function createServerEnvironmentAtoms( } let previousProviders: ServerConfig["providers"] | undefined; return Atom.make((get): ServerConfig | null => { - const projection = Option.getOrNull( - AsyncResult.value(get(configProjection({ environmentId, input: {} }))), + const projection = AsyncResult.getOrElse( + get(configProjection({ environmentId, input: {} })), + () => null, ); const config = resolveServerConfigValue( projection, diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 0d7963e42e0cb..efdf0b37c0a09 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -104,10 +105,11 @@ function makeEnvironmentSessionAtoms( // createServerEnvironmentAtoms(...).configValueAtom instead. const initialConfigValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get): ServerConfig | null => - Option.getOrNull( - Option.getOrElse(AsyncResult.value(get(initialConfigAtom(environmentId))), () => - Option.none(), - ), + pipe( + get(initialConfigAtom(environmentId)), + AsyncResult.value, + Option.flatten, + Option.getOrNull, ), ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)), ); @@ -128,7 +130,7 @@ function makeEnvironmentSessionAtoms( const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => + AsyncResult.getOrElse(get(preparedConnectionAtom(environmentId)), () => Option.none(), ), ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), @@ -165,7 +167,7 @@ function makeEnvironmentSessionAtoms( const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make( (get): AuthSessionState | null => - Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null, + AsyncResult.getOrElse(get(sessionStateAtom(environmentId)), () => null) ?? null, ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 1a396cf8c74f6..bce13d315b425 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -596,7 +596,7 @@ export function createEnvironmentShellAtoms( const stateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(stateAtom(environmentId))), () => EMPTY_SHELL_STATE), + AsyncResult.getOrElse(get(stateAtom(environmentId)), () => EMPTY_SHELL_STATE), ).pipe(Atom.withLabel(`environment-shell-state-value:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index a86579edcb339..a39e47b1340b4 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -10,7 +10,6 @@ import { WS_METHODS, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; @@ -323,7 +322,7 @@ export function createTerminalSession( result: AsyncResult.AsyncResult, ) => { resizeRegistry = registry; - const buffer = Option.getOrElse(AsyncResult.value(result), () => EMPTY_TERMINAL_BUFFER_STATE); + const buffer = AsyncResult.getOrElse(result, () => EMPTY_TERMINAL_BUFFER_STATE); const status = result._tag === "Failure" ? "error" : buffer.status; if (status !== lastStatus) { lastStatus = status; diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index a53494ebc5929..0c93800378797 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -199,8 +199,8 @@ export function createEnvironmentThreadDetailAtoms( const threadStateValueAtomFamily = Atom.family((key: string) => { const ref = parseThreadKey(key); return Atom.make((get) => - Option.getOrElse( - AsyncResult.value(get(threadStateAtom(ref.environmentId, ref.threadId))), + AsyncResult.getOrElse( + get(threadStateAtom(ref.environmentId, ref.threadId)), () => EMPTY_ENVIRONMENT_THREAD_STATE, ), ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-state-value:${key}`)); diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 0f40ac1939461..0f1bb8d9cd274 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -78,7 +78,7 @@ export function createThreadFamilyAtoms( ) => Atom.Atom>, ) { const shellStateValue = (state: AsyncResult.AsyncResult) => - Option.getOrNull(AsyncResult.value(state)); + AsyncResult.getOrElse(state, () => null); const generationAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => shellStateValue(get(shellStateAtom(environmentId)))?.generation ?? 0).pipe( Atom.withLabel(`environment-shell-generation:${environmentId}`), diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts index 90a561f8cf783..caaebb016ecd4 100644 --- a/packages/client-runtime/src/state/threadSearch.ts +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -150,7 +150,7 @@ export function createThreadSearchResultsAtomFamily(options: { }), ); isLoading ||= result.waiting; - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); if (value !== null) { matches.push(...value.matches.map((match) => ({ ...match, environmentId }))); if (value.threads !== undefined && value.threads.length > 0) { diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index ec40d5f679ec9..fda1054064f78 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -108,7 +108,7 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); - it("counts a subagent question on its parent only while the subagent runs", () => { + it("keeps a live subagent question inside the parent without flagging its thread", () => { const { registry, threads, snapshotAtom } = makeHarness(); const root = v2ThreadShell; const child = { @@ -132,6 +132,10 @@ describe("v2 thread shell lists", () => { const dispose = registry.mount(inputs); registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, child] }); expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]); + expect( + registry.get(threads.threadShellAtom({ environmentId, threadId: root.id })) + ?.pendingSubagentRequest, + ).toBeNull(); registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, { ...child, status: "queued" as const }], @@ -272,6 +276,16 @@ describe("v2 thread shell lists", () => { `${remoteEnvironmentId}/${helper.id}`, ]); + registry.set(snapshotAtom(remoteEnvironmentId), { + ...v2ShellSnapshot, + threads: [ + task, + { ...helper, pendingRuntimeRequest: { ...approval, kind: "user_input" as const } }, + ], + }); + expect(registry.get(threads.threadShellAtom(parentRef))?.pendingSubagentRequest).toBeNull(); + expect(registry.get(inputs).map((thread) => thread.id)).toEqual([helper.id]); + // A task whose parent is gone there is listed on its own, as is one whose // parent's environment is switched off. registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [] }); diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 69df72418c2d0..15718da1eca65 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -76,7 +76,7 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq } /** - * The threads to flag for a subagent's live question or approval, up its + * The threads to flag for a subagent's live approval, up its * subagent chain. `delegated` are tasks this environment's threads delegated * to linked environments, with what each one, or its own subagents, asks. */ @@ -94,6 +94,8 @@ function pendingSubagentRequestsByThread( request: PendingSubagentRequest, seen: Set, ) => { + // The coordinator answers child questions; only approvals need user attention. + if (request === "user_input") return; byId ??= new Map(threads.map((thread) => [thread.id, thread] as const)); let parentId = firstParentId; while (parentId !== null && !seen.has(parentId)) { diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 32cf8fec49945..23263d432444c 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -128,6 +128,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? networkStatus: yield* SubscriptionRef.make("online"), start: Effect.void, register: () => Effect.die("Unexpected environment registration"), + assertKnownAddress: () => Effect.die("Unexpected address check"), reconcilePlatform: () => Effect.die("Unexpected environment reconciliation"), remove: () => Effect.die("Unexpected environment removal"), removeRoute: () => Effect.die("Unexpected route removal"), diff --git a/packages/contracts/src/guestShare.ts b/packages/contracts/src/guestShare.ts index fa4b3a256f013..fd991fe1e4c3b 100644 --- a/packages/contracts/src/guestShare.ts +++ b/packages/contracts/src/guestShare.ts @@ -136,3 +136,8 @@ export const GUEST_COMMAND_OUTPUT_ROUTE = `${GUEST_ROUTE_PREFIX}/command-output` * Images load inline; other files download. */ export const GUEST_ATTACHMENT_ROUTE = `${GUEST_ROUTE_PREFIX}/attachment`; +/** + * An image file on the host that an agent message in the shared thread embeds: + * `?share=&path=`. + */ +export const GUEST_MEDIA_ROUTE = `${GUEST_ROUTE_PREFIX}/media`; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index f27d977767024..8027c90799dbc 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -140,7 +140,7 @@ export const MUSE_DEFAULT_MODEL = "default"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, - [CLAUDE_DRIVER_KIND]: "claude-fable-5-1", + [CLAUDE_DRIVER_KIND]: "claude-opus-5-5", [CURSOR_DRIVER_KIND]: "auto", // Product slug meaning the current session model, never an ACP model id. [GROK_DRIVER_KIND]: "grok-build", diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index b11b7cca5ef55..983f74de418fa 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -963,6 +963,30 @@ export const RelayOkResponse = Schema.Struct({ }); export type RelayOkResponse = typeof RelayOkResponse.Type; +export const RelayT3ChatPlan = Schema.Literals(["free", "pro", "premier"]); +export type RelayT3ChatPlan = typeof RelayT3ChatPlan.Type; + +// A linked paid T3 Chat plan raises the default managed tunnel cap. +export const T3CHAT_PLAN_TUNNEL_LIMITS = { pro: 10, premier: 100 } as const; + +export const RelayT3ChatLink = Schema.Struct({ + email: Schema.String, + plan: RelayT3ChatPlan, +}); +export type RelayT3ChatLink = typeof RelayT3ChatLink.Type; + +export const RelayT3ChatLinkResponse = Schema.Struct({ + // False when this relay has no T3 Chat secret, so clients hide the feature. + available: Schema.Boolean, + link: Schema.NullOr(RelayT3ChatLink), +}); +export type RelayT3ChatLinkResponse = typeof RelayT3ChatLinkResponse.Type; + +export const RelayT3ChatLinkRequestResponse = Schema.Struct({ + url: TrimmedNonEmptyString, +}); +export type RelayT3ChatLinkRequestResponse = typeof RelayT3ChatLinkRequestResponse.Type; + export const RelayPublishResponse = Schema.Struct({ ok: Schema.Boolean, deliveries: Schema.Array(RelayDeliveryResult), @@ -1107,6 +1131,21 @@ const RelayClientGroup = HttpApiGroup.make("client") OpenApi.Description, "Deletes the provisioned Cloudflare tunnel while keeping the environment link and its hostname reservation, so a later link re-provisions the tunnel under the same URL. Environments call this when they shut down; Cloudflare bills per provisioned tunnel, so idle tunnels should not outlive their environment.", ), + HttpApiEndpoint.get("getT3ChatLink", "/v1/client/t3chat-link", { + headers: RelayBearerRequestHeaders, + success: RelayT3ChatLinkResponse, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "Get the linked T3 Chat account"), + HttpApiEndpoint.post("createT3ChatLinkRequest", "/v1/client/t3chat-link-requests", { + headers: RelayBearerRequestHeaders, + success: RelayT3ChatLinkRequestResponse, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "Start linking a T3 Chat account"), + HttpApiEndpoint.delete("unlinkT3Chat", "/v1/client/t3chat-link", { + headers: RelayBearerRequestHeaders, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "Unlink the T3 Chat account"), ) .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.") .middleware(RelayClientAuth); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a21013004c6cb..8fc9a46116e78 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,16 +72,6 @@ export const SidebarProjectGroupingMode = Schema.Literals([ ]); export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; -export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; -export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; -export const SidebarThreadPreviewCount = Schema.Int.check( - Schema.isBetween({ - minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT, - maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - }), -); -export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; -const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; export const SidebarAutoSettleAfterDays = Schema.Number.check( @@ -479,6 +469,9 @@ export const ClientSettingsSchema = Schema.Struct({ ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + boringSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Opt-in for the removed legacy sidebar. Still read so those users land in + // the boring sidebar; the boring sidebar toggle clears it. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarWorkingShelfEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( @@ -494,9 +487,6 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), - sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), - ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -1712,6 +1702,7 @@ export const ClientSettingsPatch = Schema.Struct({ followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), + boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), @@ -1720,7 +1711,6 @@ export const ClientSettingsPatch = Schema.Struct({ ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), - sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 6b1c8155830b3..23ce2e1edd360 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { pipe } from "effect/Function"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; @@ -636,15 +637,15 @@ const generateFiles = Effect.fn("generateFiles")(function* () { ); for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) { - aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults( - normalizeNullableTypes( - rewriteExternalRefs( - DefinitionOverrides[definitionName] ?? definitionSchema, - localDefinitionNames, - file.namespace, - exportNameByQualifiedName, - ), + aggregateSchemas[localDefinitionNames.get(definitionName)!] = pipe( + rewriteExternalRefs( + DefinitionOverrides[definitionName] ?? definitionSchema, + localDefinitionNames, + file.namespace, + exportNameByQualifiedName, ), + normalizeNullableTypes, + stripNullDefaults, ); } @@ -655,15 +656,15 @@ const generateFiles = Effect.fn("generateFiles")(function* () { } } - aggregateSchemas[file.exportName] = stripNullDefaults( - normalizeNullableTypes( - rewriteExternalRefs( - topLevelSchema, - localDefinitionNames, - file.namespace, - exportNameByQualifiedName, - ), + aggregateSchemas[file.exportName] = pipe( + rewriteExternalRefs( + topLevelSchema, + localDefinitionNames, + file.namespace, + exportNameByQualifiedName, ), + normalizeNullableTypes, + stripNullDefaults, ); } diff --git a/packages/effect-codex-app-server/src/replay.ts b/packages/effect-codex-app-server/src/replay.ts index cd99a4bb859e3..11ef80c02f5bf 100644 --- a/packages/effect-codex-app-server/src/replay.ts +++ b/packages/effect-codex-app-server/src/replay.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -269,7 +270,11 @@ function sameFrame( ignoredConfigKeys: ReadonlySet = new Set(), ): boolean { const normalize = (frame: unknown) => - stableStringify(normalizeThreadRequest(normalizeReplayFrame(frame), ignoredConfigKeys)); + pipe( + normalizeReplayFrame(frame), + (request) => normalizeThreadRequest(request, ignoredConfigKeys), + stableStringify, + ); return normalize(left) === normalize(right); } diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index ead4f553fc43d..7e7f43fa632cc 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -838,7 +838,24 @@ export function acpToolCallDiffPatch(content: unknown): string | undefined { }); for (const diff of diffs) { const patch = unknownRecord(diff.patch); - if (typeof patch?.text === "string") return patch.text; + if (typeof patch?.text === "string") { + const change = + Array.isArray(diff.changes) && diff.changes.length === 1 + ? unknownRecord(diff.changes[0]) + : undefined; + if ( + patch.format === "git_patch" && + patch.text.trimStart().startsWith("@@ ") && + typeof change?.path === "string" + ) { + const oldPath = typeof change.oldPath === "string" ? change.oldPath : change.path; + const before = change.operation === "add" ? "/dev/null" : JSON.stringify(`a/${oldPath}`); + const after = + change.operation === "delete" ? "/dev/null" : JSON.stringify(`b/${change.path}`); + return `diff --git ${JSON.stringify(`a/${oldPath}`)} ${JSON.stringify(`b/${change.path}`)}\n--- ${before}\n+++ ${after}\n${patch.text.trimStart()}`; + } + return patch.text; + } } const v1Patches = diffs.flatMap((diff) => { if (typeof diff.path !== "string" || typeof diff.newText !== "string") return []; diff --git a/packages/provider-core/src/server/handoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts index 06adb39b44689..4658bc44fc6d3 100644 --- a/packages/provider-core/src/server/handoffBudget.ts +++ b/packages/provider-core/src/server/handoffBudget.ts @@ -197,14 +197,16 @@ export function renderHistory( return [context, ...messages.map(renderHistoricalMessage)].join("\n\n"); } +const jsonByteLength = (value: unknown) => Buffer.byteLength(JSON.stringify(value)); + export function historyCost( messages: ReadonlyArray, context: string, ): number { return ( Math.max( - Buffer.byteLength(JSON.stringify(historyResponseItems(messages, context))), - Buffer.byteLength(JSON.stringify(renderHistory(messages, context))), + jsonByteLength(historyResponseItems(messages, context)), + jsonByteLength(renderHistory(messages, context)), ) + 256 ); } diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index de0459bafac1d..759545fff0d19 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -296,6 +296,112 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("applies a changed runtime policy to existing subagent sessions", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); + const updates: Array<{ + sessionID: string; + permission: ReturnType; + }> = []; + const fullAccess = runtimePolicy("full-access", { cwd: "/workspace" }); + const supervised = runtimePolicy("approval-required", { cwd: "/workspace" }); + const root = "ses_root"; + const child = "ses_child"; + const taskDeny = { permission: "task", pattern: "*", action: "deny" as const }; + const nativePermissions = new Map([ + [root, openCodePermissionRules(fullAccess)], + [child, [taskDeny]], + ]); + let failNextChildUpdate = false; + const harness = yield* makeOpenCodeRuntimeHarness("child-policy-change", root, { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), + get: async ({ sessionID }: { sessionID: string }) => ({ + data: { + id: sessionID, + ...(sessionID === child ? { parentID: root } : {}), + permission: nativePermissions.get(sessionID), + time: { created: 1, updated: 1 }, + }, + }), + update: async (input: (typeof updates)[number]) => { + if (input.sessionID === child && failNextChildUpdate) { + failNextChildUpdate = false; + throw new Error("child update failed"); + } + updates.push(input); + nativePermissions.set(input.sessionID, input.permission); + return { data: { id: input.sessionID, time: { created: 1, updated: 2 } } }; + }, + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + + yield* harness.startTurn("hello", harness.providerThread, fullAccess); + yield* push({ + type: "message.part.updated", + properties: { + sessionID: root, + part: { + id: "prt_task", + sessionID: root, + messageID: "msg_root_assistant", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status: "running", + input: { description: "Explore", prompt: "look around", subagent_type: "general" }, + title: "Explore", + metadata: { parentSessionId: root, sessionId: child }, + time: { start: 3 }, + }, + }, + }, + }); + const childUpdates = () => updates.filter((update) => update.sessionID === child); + assert.equal(permissionAction(childUpdates().at(-1)!.permission, "bash"), "allow"); + + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: supervised, + }); + const childRules = childUpdates().at(-1)!.permission; + assert.equal(childUpdates().length, 2); + assert.equal(permissionAction(childRules, "bash"), "ask"); + assert.equal(permissionAction(childRules, "task"), "deny"); + assert.deepStrictEqual(childRules, openCodeChildPermissionRules(supervised, [taskDeny])); + + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: fullAccess, + }); + assert.equal(permissionAction(nativePermissions.get(child)!, "bash"), "allow"); + + failNextChildUpdate = true; + const failed = yield* Effect.exit( + harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: supervised, + }), + ); + assert.isTrue(Exit.isFailure(failed)); + yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + runtimePolicy: supervised, + }); + assert.equal(permissionAction(nativePermissions.get(child)!, "bash"), "ask"); + assert.equal(permissionAction(nativePermissions.get(child)!, "task"), "deny"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect.each(["completed", "failed", "unavailable"] as const)( "normalizes OpenCode step usage for %s turns", (ending) => @@ -2428,6 +2534,17 @@ describe("OpenCodeAdapterV2", () => { ]); assert.equal(permissionAction(childApprovalRules, "bash"), "ask"); assert.equal(permissionAction(childApprovalRules, "task"), "deny"); + + const fullAccessChildRules = openCodeChildPermissionRules(runtimePolicy("full-access"), [ + { permission: "task", pattern: "*", action: "deny" }, + ]); + const reappliedRules = openCodeChildPermissionRules( + approvalRequiredPolicy, + fullAccessChildRules, + openCodePermissionRules(runtimePolicy("full-access")), + ); + assert.equal(permissionAction(reappliedRules, "bash"), "ask"); + assert.equal(permissionAction(reappliedRules, "task"), "deny"); }); it("uses the next native user message as the exclusive fork and revert boundary", () => { diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 31ee2e632f27f..bad3e8cd7b89a 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -378,6 +378,8 @@ interface OpenCodeThreadState { nextChildTurnOrdinal: number; nextAdmissionGeneration: number; permission: PermissionRuleset; + /** Parent rules last written into this child's `permission`; empty for root sessions. */ + inheritedPermission: PermissionRuleset; } interface OpenCodeRequestOwner { @@ -691,16 +693,23 @@ function permissionRuleEquals( ); } -/** Children inherit only deny/external-directory rules; restore parent policy while retaining agent restrictions. */ +/** + * Children inherit only deny/external-directory rules; restore parent policy while retaining agent restrictions. + * `previousParentRules` are the parent rules already written into `childRules`. + */ export function openCodeChildPermissionRules( runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, - nativeChildRules: PermissionRuleset, + childRules: PermissionRuleset, + previousParentRules: PermissionRuleset = [], ): PermissionRuleset { const parentRules = openCodePermissionRules(runtimePolicy); - const inheritedRules = parentRules.filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ); - const childSpecificRules = nativeChildRules.filter( + const inheritedRules = [ + ...parentRules.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...previousParentRules, + ]; + const childSpecificRules = childRules.filter( (childRule) => !inheritedRules.some((inheritedRule) => permissionRuleEquals(childRule, inheritedRule)), ); @@ -1395,6 +1404,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, permission: childPermission, + inheritedPermission: openCodePermissionRules(turn.runtimePolicy), }); yield* emitProviderEvent({ type: "app_thread.created", @@ -2777,6 +2787,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function existing.permission = nativeSession.permission; return existing; } + const parentSubagent = subagentsByChildSessionId.get(nativeSession.id) ?? null; const state: OpenCodeThreadState = { nativeSessionId: nativeSession.id, providerThread, @@ -2786,30 +2797,44 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function runtimeRequests: new Map(), messageRoles: new Map(), userMessageIds: [], - parentSubagent: subagentsByChildSessionId.get(nativeSession.id) ?? null, + parentSubagent, nextChildTurnOrdinal: 1, nextAdmissionGeneration: 1, permission: nativeSession.permission ?? [], + inheritedPermission: parentSubagent?.parentState.permission ?? [], }; threads.set(nativeSession.id, state); return state; }; + // OpenCode copies parent rules into a child only at creation, so children are updated here too. const applyPermissionRules = Effect.fnUntraced(function* ( state: OpenCodeThreadState, policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) { - const permission = openCodePermissionRules(policy); - if ( - permission.length === state.permission.length && - permission.every((rule, index) => permissionRuleEquals(rule, state.permission[index]!)) - ) { - return; + const parentRules = openCodePermissionRules(policy); + const pending = [state]; + for (let target = pending.pop(); target !== undefined; target = pending.pop()) { + const previous = target.permission; + const permission = + target.parentSubagent === null + ? parentRules + : openCodeChildPermissionRules(policy, previous, target.inheritedPermission); + if ( + permission.length !== previous.length || + !permission.every((rule, index) => permissionRuleEquals(rule, previous[index]!)) + ) { + const sessionID = target.nativeSessionId; + yield* sdkCall("session.update", { sessionID, permission }, () => + client.session.update({ sessionID, permission }), + ); + target.permission = permission; + } + if (target.parentSubagent !== null) target.inheritedPermission = parentRules; + for (const child of threads.values()) { + if (child.parentSubagent?.parentState === target) pending.push(child); + } } - yield* sdkCall("session.update", { sessionID: state.nativeSessionId, permission }, () => - client.session.update({ sessionID: state.nativeSessionId, permission }), - ); - state.permission = permission; }); const resolvePromptParts = Effect.fnUntraced(function* ( diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 5fe9db02904fb..3e0a22128d3c5 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { p256 } from "@noble/curves/nist"; import { sha256 } from "@noble/hashes/sha2"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -94,7 +95,12 @@ function dpopThumbprintInput(jwk: DpopPublicJwkType): string { } export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string { - return Base64Url.encode(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); + return pipe( + dpopThumbprintInput(jwk), + (input) => new TextEncoder().encode(input), + sha256, + Base64Url.encode, + ); } export function computeDpopAccessTokenHash(accessToken: string): string { diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 0445fa42d8a0a..177e6fcbf5008 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -227,6 +227,22 @@ export function splitMarkdownLinkSearchAndHash(value: string): { }; } +/** + * The decoded path a markdown destination names on the host, before any workspace resolution, or + * null for a `file:` URL that does not parse. Clients and the guest media route both read it here. + */ +export function markdownDestinationHostPath(source: string): string | null { + if (/^file:/i.test(source)) { + const target = parseFileUrlHref(source); + return target === null + ? null + : stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)); + } + return stripSlashPrefixedWindowsDrive( + safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path), + ); +} + export function isMarkdownFileLinkLabel(label: string, href: string): boolean { const destination = parseMarkdownFileLink(href); if (!destination) return false; diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 275b93b562f6b..fe3d73ddac65c 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -1,6 +1,8 @@ import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast"; import { remarkKeepWindowsPaths } from "./markdownWindowsPaths.ts"; -import type { PluggableList } from "unified"; +import { type PluggableList, unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; @@ -11,7 +13,7 @@ import { CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, remarkCodexDirectives, } from "./codexMarkdownDirectives.ts"; -import { isWindowsDrivePathHref } from "./markdownLinks.ts"; +import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination } from "./markdownLinks.ts"; import { THREAD_LINK_PROTOCOL } from "./threadLinks.ts"; type MarkdownImageHastNode = { @@ -304,3 +306,38 @@ function remarkNormalizeLinksAndTagInlineCode() { export function shouldPreserveAssistantLineBreaks(text: string): boolean { return /^★ Insight(?:\s|─)/mu.test(text); } + +const imageSourceProcessor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); + +interface ImageSourceNode { + readonly type: string; + readonly tagName?: string; + readonly properties?: { readonly src?: unknown; readonly dataLocalSrc?: unknown }; + readonly children?: ReadonlyArray; +} + +/** + * The sources of the images chat markdown shows, read as ChatMarkdown's `img` renderer reads + * them. Code, comments, and plain links show no image, so they add none. + */ +export function chatMarkdownImageSources(markdown: string): Array { + const sources: Array = []; + const visit = (node: ImageSourceNode) => { + if (node.type === "element" && node.tagName === "img") { + const { src, dataLocalSrc } = node.properties ?? {}; + // The sanitizer drops a Windows drive path from `src`; the renderer reads it from here. + if (typeof dataLocalSrc === "string") { + sources.push(normalizeMarkdownLinkDestination(dataLocalSrc).replaceAll("\\", "/")); + } else if (typeof src === "string") { + sources.push(normalizeMarkdownLinkDestination(src)); + } + } + node.children?.forEach(visit); + }; + visit(imageSourceProcessor.runSync(imageSourceProcessor.parse(markdown)) as ImageSourceNode); + return sources; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0adcb65c50062..6bdece6bd1839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1033,9 +1033,6 @@ importers: '@fontsource-variable/inter': specifier: ^5.3.0 version: 5.3.0 - '@formkit/auto-animate': - specifier: ^0.9.0 - version: 0.9.0 '@legendapp/list': specifier: 3.3.5 version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -1354,6 +1351,9 @@ importers: '@effect/vitest': specifier: 4.0.5 version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@electric-sql/pglite': + specifier: 0.3.15 + version: 0.3.15 '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -4311,9 +4311,6 @@ packages: '@fontsource-variable/inter@5.3.0': resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} - '@formkit/auto-animate@0.9.0': - resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} - '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -13573,8 +13570,7 @@ snapshots: - vue-tsc - yaml - '@electric-sql/pglite@0.3.15': - optional: true + '@electric-sql/pglite@0.3.15': {} '@electron-internal/extract-zip@1.0.5': {} @@ -14427,8 +14423,6 @@ snapshots: '@fontsource-variable/inter@5.3.0': {} - '@formkit/auto-animate@0.9.0': {} - '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7 diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index ff8897efe5313..7fe8d05386cd1 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -19,9 +19,7 @@ export interface T3CodePublicConfig { type Environment = Readonly>; -const REPO_ROOT = NodePath.dirname( - NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))), -); +const REPO_ROOT = NodePath.resolve(NodeURL.fileURLToPath(import.meta.url), "../../.."); export function loadRepoEnv({ baseEnv = process.env,