From d144363538fa71a5e42083cf4b14e6a06e23be16 Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 06:45:13 +0000 Subject: [PATCH 1/7] feat(mobile): build and sideload a Beknown-branded mobile app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork had no mobile artifact. app.config.ts and eas.json are branded end to end for upstream's Expo project — owner, EAS project id, Apple team, com.t3tools.t3code bundle ids and an OTA update URL — so there was nothing to install that could pair with a bkt3 server, even though the protocol side of apps/mobile has been forked for a while. Adds a fork build identity in a new app.config.bk.ts (BK T3 Code, work.beknown.bkt3code.mobile, t3code-bk://, expo-updates disabled) applied through a two-line seam in app.config.ts, an Android release-signing config plugin, a build script for both platforms, and a push-triggered workflow that publishes the APK and an unsigned IPA as GitHub release assets. Android sideloads the signed APK; iOS ships unsigned for SideStore to re-sign with a free Apple ID, which is why the build rides upstream's existing T3CODE_IOS_PERSONAL_TEAM path — that already strips the entitlements a free Apple ID cannot sign. Builds report ${version}+bk.${sha7} as client_version so a binary older than the server it paired with is identifiable: T3 has no protocol handshake, and a stale client silently stops receiving orchestration updates. Pairing needed no changes; the web QR is a plain https pairing URL the release build's scanner already accepts. Claude Opus 5 in T3 Code (Claude Code harness). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/mobile-bk-release.yml | 297 ++++++++++++++++++ apps/mobile/app.config.bk.test.ts | 128 ++++++++ apps/mobile/app.config.bk.ts | 127 ++++++++ apps/mobile/app.config.ts | 7 +- apps/mobile/package.json | 2 + .../plugins/withBkAndroidReleaseSigning.cjs | 83 +++++ .../withBkAndroidReleaseSigning.test.ts | 66 ++++ apps/mobile/src/App.tsx | 9 +- apps/mobile/src/lib/authClientMetadata.ts | 8 +- apps/mobile/src/lib/bkBuildIdentity.test.ts | 37 +++ apps/mobile/src/lib/bkBuildIdentity.ts | 37 +++ apps/mobile/src/lib/bkBuildManifest.ts | 11 + docs/operations/bk-mobile-build.md | 167 ++++++++++ docs/operations/expbkt3-customizations.md | 1 + package.json | 1 + scripts/build-bk-mobile.test.ts | 82 +++++ scripts/build-bk-mobile.ts | 289 +++++++++++++++++ scripts/lib/bk-mobile.ts | 119 +++++++ 18 files changed, 1467 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/mobile-bk-release.yml create mode 100644 apps/mobile/app.config.bk.test.ts create mode 100644 apps/mobile/app.config.bk.ts create mode 100644 apps/mobile/plugins/withBkAndroidReleaseSigning.cjs create mode 100644 apps/mobile/plugins/withBkAndroidReleaseSigning.test.ts create mode 100644 apps/mobile/src/lib/bkBuildIdentity.test.ts create mode 100644 apps/mobile/src/lib/bkBuildIdentity.ts create mode 100644 apps/mobile/src/lib/bkBuildManifest.ts create mode 100644 docs/operations/bk-mobile-build.md create mode 100644 scripts/build-bk-mobile.test.ts create mode 100644 scripts/build-bk-mobile.ts create mode 100644 scripts/lib/bk-mobile.ts diff --git a/.github/workflows/mobile-bk-release.yml b/.github/workflows/mobile-bk-release.yml new file mode 100644 index 000000000000..96539c671d09 --- /dev/null +++ b/.github/workflows/mobile-bk-release.yml @@ -0,0 +1,297 @@ +# Fork-owned. Builds the sideloadable Beknown mobile artifacts. +# +# There is no Expo/EAS account behind this fork and no store listing, so the +# release is two files attached to a GitHub release: +# +# bk-t3code--.apk signed with the fork keystore, sideloaded +# bk-t3code--.ipa UNSIGNED, re-signed on-device by SideStore +# +# WHY PUSH-TRIGGERED AND NOT PATH-FILTERED. T3 has no client/server protocol +# handshake. A mobile binary older than the server it pairs with hard-fails its +# orchestration subscription as an Effect defect, so it stops updating while +# still displaying "connected". The mitigation is an artifact at every deployed +# server SHA, and a contracts change can arrive through any file — so every push +# to a deploy branch builds. Re-runs are idempotent (see the skip step). +# +# SECURITY. The android job holds the keystore secret AND runs every +# dependency's install scripts, so it gets `contents: read` and nothing else; +# only the publish job, which runs no build scripts, gets `contents: write`. +# Triggers stay push/dispatch-only: DO NOT add `pull_request`, +# `pull_request_target` or `issue_comment`, which would run fork-authored code +# in the same job as the signing key. +# +# Actions are pinned to commit SHAs for the same reason. +# +# See docs/operations/bk-mobile-build.md. +name: BK mobile release + +on: + push: + branches: [expbkmain, bkmain] + # NOTE: this will not appear in the Actions UI. GitHub only offers + # workflow_dispatch for workflows present on the DEFAULT branch, and this + # fork's default branch is `main` — the pure upstream mirror, which by design + # never carries fork-owned workflows. Declared anyway so it works if that ever + # changes; until then, push to expbkmain/bkmain to build. + workflow_dispatch: + +# Per branch, and NOT cancel-in-progress: cancelling mid-publish can leave a +# half-uploaded release behind. Releases queue instead. +concurrency: + group: bk-mobile-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + guard: + name: Guard + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + already-published: ${{ steps.existing.outputs.found }} + short-sha: ${{ steps.identity.outputs.short-sha }} + version: ${{ steps.identity.outputs.version }} + tag: ${{ steps.identity.outputs.tag }} + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Resolve build identity + id: identity + # app-version.ts is the single source of the version the app reports to + # servers, so the release tag is derived from it rather than restated. + run: | + set -euo pipefail + version=$(grep -oE '"[0-9]+\.[0-9]+\.[0-9]+"' apps/mobile/app-version.ts | head -1 | tr -d '"') + if [[ -z "$version" ]]; then + echo "::error::Could not read MOBILE_APP_VERSION from apps/mobile/app-version.ts." >&2 + exit 1 + fi + short_sha="${GITHUB_SHA::7}" + { + echo "version=$version" + echo "short-sha=$short_sha" + echo "tag=bk-mobile-v${version}-${short_sha}" + } >> "$GITHUB_OUTPUT" + echo "Building BK T3 Code ${version}+bk.${short_sha}." + + - name: Skip if this commit is already released + id: existing + # Makes a re-run idempotent. Without it, re-running a green workflow + # publishes a second identical release for the same code. + run: | + set -uo pipefail + found=$(gh api "repos/${{ github.repository }}/releases?per_page=100" \ + --jq '[.[] | select(.tag_name == "${{ steps.identity.outputs.tag }}")] + | first // empty | .tag_name') + if [[ -n "$found" ]]; then + echo "::notice::${{ github.sha }} is already published as $found. Nothing to do." + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + android: + name: Android APK + needs: guard + if: needs.guard.outputs.already-published != 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Assert the build stays keyless + # BK builds MUST NOT carry a Clerk publishable key: identity on this + # fleet comes from the device-bound pairing credential. build-bk-mobile.ts + # refuses one too; this is the earlier, louder copy of that check. + run: | + for file in .env .env.local; do + if [[ -f "$file" ]] && grep -qE '^\s*[A-Z0-9_]*CLERK' "$file"; then + echo "::error::$file sets a Clerk variable. BK builds must be keyless." >&2 + exit 1 + fi + done + echo "Keyless build confirmed." + + - name: Setup JDK + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v5 + with: + distribution: temurin + java-version: "17" + + # Installs dependencies itself via run-install. Do NOT add a separate + # `pnpm install` step: this action provides `vp`, not `pnpm`. + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@8a7496fd44e8a1b0a88a7459e36213b2fefc1d15 # v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... + + - name: Decode signing keystore + id: keystore + env: + KEYSTORE_BASE64: ${{ secrets.BK_ANDROID_KEYSTORE_BASE64 }} + # A missing keystore is a warning rather than an error: the APK still + # builds (Expo's template falls back to its shared debug key) and is + # still useful for a smoke test. The publish job refuses to attach an + # unsigned-identity APK, so nothing distributable escapes. + run: | + if [[ -z "$KEYSTORE_BASE64" ]]; then + echo "::warning::BK_ANDROID_KEYSTORE_BASE64 is unset; this APK cannot be distributed." \ + "See docs/operations/bk-mobile-build.md." + echo "present=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + path="$RUNNER_TEMP/bk-mobile-release.keystore" + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$path" + echo "path=$path" >> "$GITHUB_OUTPUT" + echo "present=true" >> "$GITHUB_OUTPUT" + + - name: Build APK + env: + BK_GIT_SHA: ${{ github.sha }} + # Monotonic across CI builds so Android accepts each release as an + # upgrade of the last. + BK_ANDROID_VERSION_CODE: ${{ github.run_number }} + BK_ANDROID_KEYSTORE_PATH: ${{ steps.keystore.outputs.path }} + BK_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.BK_ANDROID_KEYSTORE_PASSWORD }} + BK_ANDROID_KEY_ALIAS: ${{ secrets.BK_ANDROID_KEY_ALIAS }} + BK_ANDROID_KEY_PASSWORD: ${{ secrets.BK_ANDROID_KEY_PASSWORD }} + run: node scripts/build-bk-mobile.ts --platform android + + - name: Upload APK + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: bk-mobile-android-${{ github.sha }} + path: release/mobile/*.apk + retention-days: 7 + if-no-files-found: error + + ios: + name: iOS IPA + needs: guard + if: needs.guard.outputs.already-published != 'true' + runs-on: macos-26 + # Slower than a real Mac: the standard hosted runner is 3 vCPU / 7 GB, and + # this job runs `expo prebuild` (which installs pods) before xcodebuild. + timeout-minutes: 120 + permissions: + contents: read + # Holds no secrets at all: the archive is deliberately unsigned, because + # SideStore re-signs it on the device with the user's own free Apple ID. + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@8a7496fd44e8a1b0a88a7459e36213b2fefc1d15 # v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... + + - name: Build unsigned IPA + env: + BK_GIT_SHA: ${{ github.sha }} + run: node scripts/build-bk-mobile.ts --platform ios + + - name: Upload IPA + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: bk-mobile-ios-${{ github.sha }} + path: release/mobile/*.ipa + retention-days: 7 + if-no-files-found: error + + publish: + name: Publish + needs: [guard, android, ios] + # Runs when either platform produced something. `always()` plus an explicit + # result check, so a broken iOS toolchain does not withhold a good APK. + if: >- + always() + && needs.guard.outputs.already-published != 'true' + && (needs.android.result == 'success' || needs.ios.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + # The only job that can write releases, and it runs no build scripts and + # holds no signing key. + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: release/mobile + pattern: bk-mobile-*-${{ github.sha }} + merge-multiple: true + + - name: Publish prerelease + # A prerelease, always: these are sideload builds pinned to one server + # SHA, not a general-availability app. + # + # --target is what stops the tag being cut from the default branch, + # which for this fork is the pure upstream mirror. + run: | + set -euo pipefail + shopt -s nullglob + assets=(release/mobile/*.apk release/mobile/*.ipa) + if [[ ${#assets[@]} -eq 0 ]]; then + echo "::error::No artifacts to publish." >&2 + exit 1 + fi + printf 'Publishing:\n'; printf ' %s\n' "${assets[@]}" + + { + echo "Sideload build of BK T3 Code from \`${{ github.ref_name }}\` at" + echo "\`${{ github.sha }}\`, reporting \`client_version\`" + echo "\`${{ needs.guard.outputs.version }}+bk.${{ needs.guard.outputs.short-sha }}\`." + echo + echo "- **Android**: download the \`.apk\` on the phone and install it." + echo " It upgrades a previous BK build in place." + echo "- **iOS**: download the \`.ipa\` on your Mac and open it with SideStore," + echo " which re-signs it with your free Apple ID." + echo + echo "Pair from **Settings → Connections** on the server this was built for," + echo "then scan the QR code in the app." + echo + echo "> Install the build whose SHA matches the running server. There is no" + echo "> client/server protocol handshake: an older binary can silently stop" + echo "> receiving orchestration updates. See" + echo "> \`docs/operations/bk-mobile-build.md\`." + } > release-notes.md + + gh release create "${{ needs.guard.outputs.tag }}" "${assets[@]}" \ + --title "BK T3 Code ${{ needs.guard.outputs.version }} (${{ needs.guard.outputs.short-sha }})" \ + --notes-file release-notes.md \ + --prerelease \ + --target "${{ github.sha }}" diff --git a/apps/mobile/app.config.bk.test.ts b/apps/mobile/app.config.bk.test.ts new file mode 100644 index 000000000000..b7fc6774e669 --- /dev/null +++ b/apps/mobile/app.config.bk.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { applyBkMobileConfig, isBkMobileBuild } from "./app.config.bk.ts"; + +const BK_ENV = { + T3CODE_BK_MOBILE: "1", + T3CODE_IOS_PERSONAL_TEAM: "1", + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: "work.beknown.bkt3code.mobile", +}; + +// A trimmed stand-in for the finished upstream config: only the fields the +// fork override rewrites, so a change upstream makes elsewhere cannot break +// these assertions. +const upstreamConfig = { + name: "T3 Code", + slug: "t3-code", + scheme: "t3code", + icon: "../../assets/prod/black-ios-1024.png", + updates: { + enabled: true, + url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", + }, + ios: { + icon: "../../assets/prod/app-icon.icon", + bundleIdentifier: "work.beknown.bkt3code.mobile", + appleTeamId: "ARK85ZXQ4Z", + associatedDomains: ["applinks:clerk.t3.codes"], + supportsTablet: true, + }, + android: { + package: "com.t3tools.t3code", + adaptiveIcon: { backgroundColor: "#000000", monochromeImage: "./assets/android-icon-mark.png" }, + }, + plugins: [ + "expo-asset", + ["expo-splash-screen", { image: "../../assets/prod/black-ios-1024.png", dark: { image: "x" } }], + ], + extra: { + appVariant: "production", + eas: { projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454" }, + }, + owner: "pingdotgg", + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +describe("isBkMobileBuild", () => { + it("only activates on the explicit opt-in", () => { + expect(isBkMobileBuild({})).toBe(false); + expect(isBkMobileBuild({ T3CODE_BK_MOBILE: "0" })).toBe(false); + expect(isBkMobileBuild({ T3CODE_BK_MOBILE: "1" })).toBe(true); + }); +}); + +describe("applyBkMobileConfig", () => { + it("rewrites identity and strips upstream's distribution channel", () => { + const config = applyBkMobileConfig(upstreamConfig, BK_ENV); + + expect(config.name).toBe("BK T3 Code"); + expect(config.scheme).toBe("t3code-bk"); + expect(config.android?.package).toBe("work.beknown.bkt3code.mobile"); + // The upstream app must stay installable alongside the fork one. + expect(config.android?.package).not.toBe(upstreamConfig.android.package); + expect(config.updates).toEqual({ enabled: false }); + expect(config.owner).toBeUndefined(); + expect(config.extra?.eas).toBeUndefined(); + expect(config.extra?.appVariant).toBe("production"); + }); + + it("drops the entitlements a free Apple ID cannot sign", () => { + const config = applyBkMobileConfig(upstreamConfig, BK_ENV); + + expect(config.ios?.appleTeamId).toBeUndefined(); + expect(config.ios?.associatedDomains).toBeUndefined(); + // Left alone on purpose: app.config.ts derives the widget and share + // extension bundle ids from it, so rewriting it here would desync them. + expect(config.ios?.bundleIdentifier).toBe("work.beknown.bkt3code.mobile"); + }); + + it("records the build SHA for the server-side client_version", () => { + const config = applyBkMobileConfig(upstreamConfig, { ...BK_ENV, BK_GIT_SHA: "a1b2c3d4" }); + expect(config.extra?.bk).toEqual({ gitSha: "a1b2c3d4", server: "bkt3.dev.beknown.live" }); + + const withoutSha = applyBkMobileConfig(upstreamConfig, BK_ENV); + expect(withoutSha.extra?.bk).toEqual({ gitSha: null, server: "bkt3.dev.beknown.live" }); + }); + + it("appends the signing plugin and repoints the splash icon", () => { + const config = applyBkMobileConfig(upstreamConfig, BK_ENV); + + expect(config.plugins).toContain("./plugins/withBkAndroidReleaseSigning.cjs"); + const splash = config.plugins?.find( + (plugin) => Array.isArray(plugin) && plugin[0] === "expo-splash-screen", + ) as [string, { image: string; dark: { image: string } }]; + expect(splash[1].image).toBe("../../assets/bk/bk-universal-1024.png"); + expect(splash[1].dark.image).toBe("../../assets/bk/bk-universal-1024.png"); + }); + + it("does not mutate the upstream config", () => { + applyBkMobileConfig(upstreamConfig, BK_ENV); + expect(upstreamConfig.name).toBe("T3 Code"); + expect(upstreamConfig.owner).toBe("pingdotgg"); + }); + + it("refuses a build that could inherit upstream's iOS identity", () => { + expect(() => applyBkMobileConfig(upstreamConfig, { T3CODE_BK_MOBILE: "1" })).toThrow( + /T3CODE_IOS_PERSONAL_TEAM=1/, + ); + expect(() => + applyBkMobileConfig(upstreamConfig, { + ...BK_ENV, + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: "com.t3tools.t3code", + }), + ).toThrow(/T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID/); + }); + + it("accepts an explicit Android version code and rejects nonsense", () => { + const config = applyBkMobileConfig(upstreamConfig, { + ...BK_ENV, + BK_ANDROID_VERSION_CODE: "42", + }); + expect(config.android?.versionCode).toBe(42); + + expect(() => + applyBkMobileConfig(upstreamConfig, { ...BK_ENV, BK_ANDROID_VERSION_CODE: "nope" }), + ).toThrow(/positive integer/); + }); +}); + diff --git a/apps/mobile/app.config.bk.ts b/apps/mobile/app.config.bk.ts new file mode 100644 index 000000000000..0a10059ca071 --- /dev/null +++ b/apps/mobile/app.config.bk.ts @@ -0,0 +1,127 @@ +// T3-CUSTOM(expbkt3): Fork build identity for the Beknown mobile app. +// +// Upstream's `app.config.ts` is branded end to end for the T3 Tools Expo +// project: `owner: "pingdotgg"`, an EAS project id, upstream's Apple team, the +// `com.t3tools.t3code` bundle identifiers and an OTA update URL pointing at +// upstream's Expo channel. None of that is usable here, and none of it should +// be edited in place — every line the fork changes inside an upstream file is a +// line the next upstream merge has to reconcile. +// +// So the fork keeps its identity out here and `app.config.ts` grows a single +// two-line seam that hands its finished config over when `T3CODE_BK_MOBILE=1`. +// +// BK mobile builds are sideloaded, never store-distributed: +// - Android ships a release APK signed with the fork keystore. +// - iOS ships an UNSIGNED .ipa that SideStore re-signs with a free Apple ID. +// +// The constants and guards live in scripts/lib/bk-mobile.ts so the build script +// can share them; only the ExpoConfig rewrite is here, because that is where +// the type is available. +// +// See docs/operations/bk-mobile-build.md. +import type { ExpoConfig } from "expo/config"; + +import { + assertBkBuildEnvironment, + BK_MOBILE_APP_NAME, + BK_MOBILE_BUNDLE_IDENTIFIER, + BK_MOBILE_DEFAULT_SERVER, + BK_MOBILE_ICON_PATH, + BK_MOBILE_SCHEME, + parseBkAndroidVersionCode, + type BkRepoEnv, +} from "../../scripts/lib/bk-mobile.ts"; + +export { isBkMobileBuild } from "../../scripts/lib/bk-mobile.ts"; + +/** + * Rewrites a finished upstream Expo config into the fork's identity. + * + * Returns a copy; the input is never mutated, so `app.config.ts` stays a pure + * description of upstream's app. + */ +export function applyBkMobileConfig(config: ExpoConfig, repoEnv: BkRepoEnv): ExpoConfig { + assertBkBuildEnvironment(repoEnv); + + const gitSha = repoEnv.BK_GIT_SHA?.trim() || null; + const androidVersionCode = parseBkAndroidVersionCode(repoEnv.BK_ANDROID_VERSION_CODE); + + const { + owner: _owner, + ios: upstreamIos, + android: upstreamAndroid, + extra: upstreamExtra, + ...rest + } = config; + + const { + appleTeamId: _appleTeamId, + associatedDomains: _associatedDomains, + icon: _iosIcon, + ...ios + } = upstreamIos ?? {}; + const { eas: _eas, ...extra } = upstreamExtra ?? {}; + + return { + ...rest, + name: BK_MOBILE_APP_NAME, + scheme: BK_MOBILE_SCHEME, + icon: BK_MOBILE_ICON_PATH, + // No EAS project, so no OTA channel to check. Leaving this enabled would + // point release binaries at upstream's `u.expo.dev` project. + updates: { enabled: false }, + ios: { + ...ios, + // `bundleIdentifier` is already the BK id: app.config.ts resolves it from + // T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID, which assertBkBuildEnvironment + // pins. Rewriting it here would desynchronise it from the extension + // bundle ids upstream derives from the same value. + // + // `appleTeamId` and `associatedDomains` are dropped above: the team is + // upstream's, and Associated Domains is another entitlement a free Apple + // ID cannot sign. BK mobile has no Clerk sign-in, so nothing needs the + // applinks/webcredentials association. + }, + android: { + ...upstreamAndroid, + package: BK_MOBILE_BUNDLE_IDENTIFIER, + ...(androidVersionCode === null ? {} : { versionCode: androidVersionCode }), + adaptiveIcon: { + ...upstreamAndroid?.adaptiveIcon, + backgroundColor: "#000000", + foregroundImage: BK_MOBILE_ICON_PATH, + }, + }, + web: { ...config.web, favicon: BK_MOBILE_ICON_PATH }, + plugins: [ + ...withBkSplashIcon(config.plugins ?? []), + "./plugins/withBkAndroidReleaseSigning.cjs", + ], + extra: { + ...extra, + bk: { + gitSha, + server: BK_MOBILE_DEFAULT_SERVER, + }, + }, + }; +} + +/** + * Repoints the splash screen at the BK mark. The image path lives inside the + * `expo-splash-screen` plugin tuple rather than on the config root, so it has + * to be rewritten in place. + */ +function withBkSplashIcon( + plugins: NonNullable, +): NonNullable { + return plugins.map((plugin) => { + if (!Array.isArray(plugin) || plugin[0] !== "expo-splash-screen") return plugin; + const options = (plugin[1] ?? {}) as Record; + const dark = (options.dark ?? {}) as Record; + return [ + "expo-splash-screen", + { ...options, image: BK_MOBILE_ICON_PATH, dark: { ...dark, image: BK_MOBILE_ICON_PATH } }, + ]; + }); +} diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index f85ac939bf52..3e68b4d47ef9 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -5,6 +5,9 @@ import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; // T3-CUSTOM(expbkt3): BEGIN - share the manifest version with connection metadata. import { MOBILE_APP_VERSION } from "./app-version.ts"; // T3-CUSTOM(expbkt3): END +// T3-CUSTOM(expbkt3): BEGIN - fork build identity, applied to the finished config below. +import { applyBkMobileConfig, isBkMobileBuild } from "./app.config.bk.ts"; +// T3-CUSTOM(expbkt3): END type AppVariant = "development" | "preview" | "production"; @@ -377,4 +380,6 @@ const config: ExpoConfig = { owner: "pingdotgg", }; -export default config; +// T3-CUSTOM(expbkt3): BEGIN - swap in the Beknown identity for fork builds. +export default isBkMobileBuild(repoEnv) ? applyBkMobileConfig(config, repoEnv) : config; +// T3-CUSTOM(expbkt3): END diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1097f6a33762..1fcd10d330f5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,7 @@ "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", + "android:bk": "node ../../scripts/build-bk-mobile.ts --platform android", "eas:android:dev": "eas build --profile development -p android", "eas:android:preview": "eas build --profile preview -p android", "eas:android:preview:dev": "eas build --profile preview:dev -p android", @@ -26,6 +27,7 @@ "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:release": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios --configuration Release --no-bundler", + "ios:bk": "node ../../scripts/build-bk-mobile.ts --platform ios", "eas:ios:dev": "eas build --profile development -p ios", "eas:ios:preview": "eas build --profile preview -p ios", "eas:ios:preview:dev": "eas build --profile preview:dev -p ios", diff --git a/apps/mobile/plugins/withBkAndroidReleaseSigning.cjs b/apps/mobile/plugins/withBkAndroidReleaseSigning.cjs new file mode 100644 index 000000000000..9f41c3f6b33d --- /dev/null +++ b/apps/mobile/plugins/withBkAndroidReleaseSigning.cjs @@ -0,0 +1,83 @@ +// T3-CUSTOM(expbkt3): Sign the fork's Android release APK with the BK keystore. +// +// Expo's bare template signs the `release` build type with the bundled DEBUG +// keystore. That produces an installable APK, but every machine generates the +// same well-known key, so a release built on CI and one built on a laptop are +// signed by different-but-equally-forgeable identities and Android refuses to +// upgrade one with the other. BK mobile is sideloaded and upgraded in place, so +// it needs one stable key. +// +// The keystore never touches the repository. CI decodes it from a secret into +// RUNNER_TEMP and exports the four BK_ANDROID_* variables; the generated +// build.gradle reads them through System.getenv so no password is ever written +// to disk. With BK_ANDROID_KEYSTORE_PATH unset the plugin leaves the template +// untouched, which keeps unsigned local smoke builds working. +// +// See docs/operations/bk-mobile-build.md. +const { withAppBuildGradle } = require("expo/config-plugins"); + +const SIGNING_CONFIG_NAME = "bkRelease"; + +const SIGNING_CONFIG_BLOCK = ` ${SIGNING_CONFIG_NAME} { + // Injected by plugins/withBkAndroidReleaseSigning.cjs. Values come + // from the environment so the keystore password is never written + // into the generated project. + storeFile file(System.getenv("BK_ANDROID_KEYSTORE_PATH")) + storePassword System.getenv("BK_ANDROID_KEYSTORE_PASSWORD") + keyAlias System.getenv("BK_ANDROID_KEY_ALIAS") + keyPassword System.getenv("BK_ANDROID_KEY_PASSWORD") + } +`; + +module.exports = function withBkAndroidReleaseSigning(config) { + return withAppBuildGradle(config, (nextConfig) => { + if (!process.env.BK_ANDROID_KEYSTORE_PATH) return nextConfig; + if (nextConfig.modResults.language !== "groovy") { + throw new Error( + "withBkAndroidReleaseSigning only understands the Groovy build.gradle Expo generates.", + ); + } + + nextConfig.modResults.contents = applyBkSigning(nextConfig.modResults.contents); + return nextConfig; + }); +}; + +/** + * Exported for the unit test: the two anchors below come from Expo's template + * and are exactly the kind of thing a template bump moves. Failing loudly here + * is the point — a silent miss ships a debug-signed release APK that can never + * be upgraded in place. + */ +function applyBkSigning(contents) { + const signingConfigsAnchor = /(\n\s*signingConfigs\s*\{\n)/; + if (!signingConfigsAnchor.test(contents)) { + throw new Error("Could not find the `signingConfigs {` block in android/app/build.gradle."); + } + let next = contents.replace(signingConfigsAnchor, `$1${SIGNING_CONFIG_BLOCK}`); + + // Scoped to the release block: the debug block legitimately keeps + // `signingConfigs.debug`, and a global replace would rewrite it too. + const releaseBlock = /(\n\s*release\s*\{\n)([\s\S]*?)(\n\s*\})/; + const match = releaseBlock.exec(next); + if (match === null) { + throw new Error("Could not find the `release {` build type in android/app/build.gradle."); + } + const body = match[2]; + if (!body.includes("signingConfig ")) { + throw new Error("The `release {` build type has no signingConfig line to repoint."); + } + const signedBody = body.replace( + /signingConfig\s+signingConfigs\.\w+/, + `signingConfig signingConfigs.${SIGNING_CONFIG_NAME}`, + ); + next = next.replace(releaseBlock, `$1${signedBody}$3`); + + if (!next.includes(`signingConfig signingConfigs.${SIGNING_CONFIG_NAME}`)) { + throw new Error("Failed to repoint the release build type at the BK signing config."); + } + return next; +} + +module.exports.applyBkSigning = applyBkSigning; +module.exports.BK_SIGNING_CONFIG_NAME = SIGNING_CONFIG_NAME; diff --git a/apps/mobile/plugins/withBkAndroidReleaseSigning.test.ts b/apps/mobile/plugins/withBkAndroidReleaseSigning.test.ts new file mode 100644 index 000000000000..402e018af4b2 --- /dev/null +++ b/apps/mobile/plugins/withBkAndroidReleaseSigning.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { applyBkSigning } = require("./withBkAndroidReleaseSigning.cjs") as { + applyBkSigning: (contents: string) => string; +}; + +// The shape Expo's bare template generates. Both anchors this plugin depends on +// are template-owned, so this fixture is the early warning that a template bump +// moved one of them. +const TEMPLATE = `android { + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + signingConfig signingConfigs.debug + shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false) + minifyEnabled enableProguardInReleaseBuilds + } + } +}`; + +describe("withBkAndroidReleaseSigning", () => { + it("points the release build type at the BK keystore", () => { + const result = applyBkSigning(TEMPLATE); + + expect(result).toContain("signingConfig signingConfigs.bkRelease"); + expect(result).toContain('storeFile file(System.getenv("BK_ANDROID_KEYSTORE_PATH"))'); + + // The BK password must never be materialised into the generated project. + // (The template's own debug key keeps its literal 'android' password.) + const bkBlock = result.slice(result.indexOf("bkRelease {"), result.indexOf("debug {")); + expect(bkBlock).toContain('storePassword System.getenv("BK_ANDROID_KEYSTORE_PASSWORD")'); + expect(bkBlock).not.toMatch(/storePassword\s+'/); + expect(bkBlock).toContain('keyAlias System.getenv("BK_ANDROID_KEY_ALIAS")'); + expect(bkBlock).toContain('keyPassword System.getenv("BK_ANDROID_KEY_PASSWORD")'); + }); + + it("leaves the debug build type signing with the debug key", () => { + const result = applyBkSigning(TEMPLATE); + const debugBlock = result.slice(result.indexOf("debug {", result.indexOf("buildTypes"))); + expect(debugBlock).toContain("signingConfig signingConfigs.debug"); + }); + + it("preserves the rest of the release block", () => { + const result = applyBkSigning(TEMPLATE); + expect(result).toContain("minifyEnabled enableProguardInReleaseBuilds"); + expect(result).toContain("shrinkResources (findProperty("); + }); + + it("fails loudly when the template anchors move", () => { + // Silently missing here would ship a debug-signed release APK that can + // never upgrade a properly signed one in place. + expect(() => applyBkSigning("android {\n buildTypes {\n }\n}")).toThrow(/signingConfigs/); + expect(() => applyBkSigning("android {\n signingConfigs {\n }\n}")).toThrow(/release/); + }); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8b219afcc078..97e914f2188c 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -35,7 +35,14 @@ void SplashScreen.preventAutoHideAsync().catch(() => { }); const appLinking = { - prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], + // T3-CUSTOM(expbkt3): "t3code-bk://" is the fork build's scheme (app.config.bk.ts). + prefixes: [ + Linking.createURL("/"), + "t3code://", + "t3code-dev://", + "t3code-preview://", + "t3code-bk://", + ], // The Expo dev client launches the app via // ://expo-development-client/?url= — that URL addresses // the launcher, not app navigation. Without this filter it falls through diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index d33fd2123aab..488e73234701 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -3,6 +3,8 @@ import * as Device from "expo-device"; import { Platform } from "react-native"; // T3-CUSTOM(expbkt3): BEGIN - attach a test-safe native build identity. import { MOBILE_APP_VERSION } from "../../app-version"; +import { bkAppVersion } from "./bkBuildIdentity"; +import { bkBuildGitSha } from "./bkBuildManifest"; // T3-CUSTOM(expbkt3): END export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { @@ -13,8 +15,10 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM label: "T3 Code Mobile", deviceType: "mobile", // T3-CUSTOM(expbkt3): always expose the native version to connected servers; - // a caller-provided version still wins. - appVersion: appVersion ?? MOBILE_APP_VERSION, + // a caller-provided version still wins. Fork builds append their source SHA + // so a stale sideloaded binary is identifiable from the server's audit of + // `client_version` (see bkBuildIdentity.ts). + appVersion: appVersion ?? bkAppVersion(MOBILE_APP_VERSION, bkBuildGitSha()), ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), ...(deviceModel ? { deviceModel } : {}), diff --git a/apps/mobile/src/lib/bkBuildIdentity.test.ts b/apps/mobile/src/lib/bkBuildIdentity.test.ts new file mode 100644 index 000000000000..9defd2ce9901 --- /dev/null +++ b/apps/mobile/src/lib/bkBuildIdentity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { bkAppVersion, readBkGitSha } from "./bkBuildIdentity"; + +describe("bkAppVersion", () => { + it("appends the abbreviated build SHA for fork builds", () => { + expect(bkAppVersion("1.0.4", "a1b2c3d4e5f6789")).toBe("1.0.4+bk.a1b2c3d"); + }); + + it("stays the plain version when no SHA was stamped", () => { + // Upstream builds, and any dev client started from Metro. + expect(bkAppVersion("1.0.4", null)).toBe("1.0.4"); + }); + + it("produces valid semver build metadata", () => { + // The server stores this verbatim as `client_version`; anything that parses + // it must still see 1.0.4. + expect(bkAppVersion("1.0.4", "abcdef1234")).toMatch(/^\d+\.\d+\.\d+\+[0-9A-Za-z.-]+$/); + }); +}); + +describe("readBkGitSha", () => { + it("reads the SHA the build script stamped", () => { + expect(readBkGitSha({ bk: { gitSha: "a1b2c3d" } })).toBe("a1b2c3d"); + }); + + it("returns null for every shape a non-fork manifest can have", () => { + expect(readBkGitSha(undefined)).toBeNull(); + expect(readBkGitSha(null)).toBeNull(); + expect(readBkGitSha({})).toBeNull(); + // The public manifest serialises an unset value to {}, which is truthy. + expect(readBkGitSha({ bk: {} })).toBeNull(); + expect(readBkGitSha({ bk: { gitSha: null } })).toBeNull(); + expect(readBkGitSha({ bk: { gitSha: " " } })).toBeNull(); + expect(readBkGitSha({ bk: { gitSha: 42 } })).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/bkBuildIdentity.ts b/apps/mobile/src/lib/bkBuildIdentity.ts new file mode 100644 index 000000000000..edf14f1b1a92 --- /dev/null +++ b/apps/mobile/src/lib/bkBuildIdentity.ts @@ -0,0 +1,37 @@ +// T3-CUSTOM(expbkt3): Resolve the fork build's exact source revision. +// +// There is no protocol version handshake between a T3 client and a T3 server. +// A mobile binary older than the server it pairs with hard-fails its +// orchestration subscription — an Effect defect, not a typed failure, so it +// does not retry and the UI simply stops updating while still reading +// "connected". The fix is always "re-sideload from the server's SHA", and the +// only way to know that is needed is for the client to say which SHA it is. +// +// scripts/build-bk-mobile.ts stamps BK_GIT_SHA into `extra.bk.gitSha` +// (app.config.bk.ts); the server records the resulting version string as +// `client_version` at token exchange. +// +// Kept free of react-native and expo-constants imports so it stays unit +// testable — the Expo manifest read lives in bkBuildManifest.ts. + +/** Seven characters: the same abbreviation `git log --oneline` prints. */ +const SHORT_SHA_LENGTH = 7; + +/** Narrows whatever the Expo manifest actually holds down to a usable SHA. */ +export function readBkGitSha(extra: unknown): string | null { + if (typeof extra !== "object" || extra === null) return null; + const bk = (extra as { bk?: unknown }).bk; + if (typeof bk !== "object" || bk === null) return null; + const gitSha = (bk as { gitSha?: unknown }).gitSha; + return typeof gitSha === "string" && gitSha.trim() !== "" ? gitSha.trim() : null; +} + +/** + * `1.0.4+bk.a1b2c3d` for a fork build, plain `1.0.4` otherwise — a semver + * build-metadata suffix, so it stays a valid version for anything that parses + * it and reads naturally in a server log. + */ +export function bkAppVersion(baseVersion: string, gitSha: string | null): string { + if (gitSha === null) return baseVersion; + return `${baseVersion}+bk.${gitSha.slice(0, SHORT_SHA_LENGTH)}`; +} diff --git a/apps/mobile/src/lib/bkBuildManifest.ts b/apps/mobile/src/lib/bkBuildManifest.ts new file mode 100644 index 000000000000..133250841c9b --- /dev/null +++ b/apps/mobile/src/lib/bkBuildManifest.ts @@ -0,0 +1,11 @@ +// T3-CUSTOM(expbkt3): The Expo-manifest half of the fork build identity. +// +// Split from bkBuildIdentity.ts so the version-formatting logic stays testable +// without pulling react-native into the unit test environment. +import Constants from "expo-constants"; + +import { readBkGitSha } from "./bkBuildIdentity"; + +export function bkBuildGitSha(): string | null { + return readBkGitSha(Constants.expoConfig?.extra); +} diff --git a/docs/operations/bk-mobile-build.md b/docs/operations/bk-mobile-build.md new file mode 100644 index 000000000000..0504d2fc7388 --- /dev/null +++ b/docs/operations/bk-mobile-build.md @@ -0,0 +1,167 @@ +# BK mobile build and sideload + +The fork ships its own mobile app. It is the same React Native codebase as +upstream's, built with a different identity and distributed by sideload rather +than through the App Store or Play Store — this fork has no Expo/EAS account, +no Apple Developer team and no store listings. + +| | | +| --------------------------- | ------------------------------------------------------ | +| App name | **BK T3 Code** | +| Bundle id / package | `work.beknown.bkt3code.mobile` | +| URL scheme | `t3code-bk://` | +| Version reported to servers | `+bk.` | +| Android | release APK, signed with the fork keystore, sideloaded | +| iOS | **unsigned** `.ipa`, re-signed on device by SideStore | +| OTA updates | **disabled** — every release is a fresh sideload | + +Identity lives in `apps/mobile/app.config.bk.ts`, which rewrites the finished +upstream Expo config when `T3CODE_BK_MOBILE=1`. `apps/mobile/app.config.ts` +carries a two-line seam and nothing else, so upstream merges have nothing to +reconcile here. + +## Read this before installing anything + +**Install the build whose SHA matches the running server.** T3 has no +client/server protocol handshake. When the server emits an orchestration event +type a client does not know, the client's subscription fails schema decoding — +and because the RPC layer raises that as an Effect _defect_ rather than a typed +failure, it does not retry. The app keeps saying "connected" while silently +receiving no further updates. + +So: a deploy that changes `@t3tools/contracts` — above all +`OrchestrationEvent`, `OrchestrationShellStreamItem` or +`OrchestrationThreadStreamItem` — requires re-sideloading the mobile app from +that same commit. The workflow builds an artifact for **every** push to +`expbkmain` and `bkmain` precisely so a matching build always exists. + +To check what a device is running, look at the `client_version` the server +recorded at token exchange: it reads `1.0.4+bk.a1b2c3d`, and `a1b2c3d` is the +commit the binary was built from. + +## Getting a build + +Releases are published by `.github/workflows/mobile-bk-release.yml` on every +push to `expbkmain` or `bkmain`, as a GitHub **prerelease** tagged +`bk-mobile-v-` with the `.apk` and `.ipa` attached. Re-running a +green workflow republishes nothing (the guard job skips an already-released +SHA). + +Both platforms build on GitHub-hosted runners. Nothing is built on the shared +dev server, and nothing needs to be. + +### Android + +1. Open the release page **on the phone** and download the `.apk`. +2. Install it. Android will ask once for permission to install from that + browser. +3. Later releases install straight over it — the fork keystore keeps the signing + identity stable, so it is an upgrade, not a conflicting app. + +### iOS, via SideStore + +1. Download the `.ipa` from the release page on your Mac. +2. Open it with SideStore, which re-signs it using your free Apple ID. +3. Refresh it within 7 days, which is how long a free-team signature lasts. + +Free Apple IDs cannot sign App Groups, extension targets, push notifications, +Associated Domains or Sign in with Apple. BK iOS builds therefore reuse +upstream's `T3CODE_IOS_PERSONAL_TEAM=1` path, which strips exactly those, and +**give up**: + +- Live Activities and the home-screen widget +- the system share-sheet target +- push notifications (agent activity arrives over the live connection instead) + +SideStore also limits a free Apple ID to three sideloaded apps at a time. + +## Pairing + +Unchanged from upstream, and no fork code was needed for it: + +1. On the server — e.g. — open + **Settings → Connections** and create a pairing credential. It renders a QR + code. +2. In the app: **Settings → Environments → New**, then scan the QR code. Manual + host + code entry works too. + +Pairing tokens are single-use and short-lived; mint a fresh one per device. + +The pairing QR encodes a plain `https://host#token=…` URL, which the app's +scanner accepts in release builds. (Only _deep-link_ prefill — +`t3code-bk://connections/new?pairingUrl=…` — is restricted to dev builds, which +is why the dev-only helper script in the `test-t3-mobile` skill cannot be used +against a sideloaded release build.) + +Identity comes from the pairing credential itself: when the grant names an +operator, the server attributes the device to that user. BK mobile carries no +Clerk key and never signs in — a Clerk publishable key in a BK build is a build +failure, enforced in both `scripts/build-bk-mobile.ts` and the workflow. + +## The Android keystore + +One-time setup. **Not on the shared dev server, and never committed.** + +```bash +keytool -genkeypair -v -keystore bk-mobile-release.keystore -alias bk-t3code \ + -keyalg RSA -keysize 2048 -validity 10000 +``` + +Keep `bk-mobile-release.keystore` in a password manager, then add four +repository secrets: + +| Secret | Value | +| ------------------------------ | --------------------------------------- | +| `BK_ANDROID_KEYSTORE_BASE64` | `base64 -w0 bk-mobile-release.keystore` | +| `BK_ANDROID_KEYSTORE_PASSWORD` | the store password | +| `BK_ANDROID_KEY_ALIAS` | `bk-t3code` | +| `BK_ANDROID_KEY_PASSWORD` | the key password | + +**Losing the keystore means every installed device has to uninstall before it +can take another update.** Android identifies an app by package name _and_ +signature; there is no recovery path for a sideloaded app. + +Until the secrets exist, the workflow still builds an APK — Expo's template +falls back to its shared debug key — but it warns loudly, and such a build must +not be handed to anyone: everyone's debug key is the same key, and it cannot +upgrade a properly signed build in place. + +The keystore is decoded into `RUNNER_TEMP` and read through `System.getenv` by +the generated Gradle project (`apps/mobile/plugins/withBkAndroidReleaseSigning.cjs`), +so no password is ever written to disk. + +## Building locally + +CI is the supported path. If you need a local build: + +```bash +# Android — needs a JDK 17 and the Android SDK. +node scripts/build-bk-mobile.ts --platform android + +# iOS — macOS with Xcode only. +node scripts/build-bk-mobile.ts --platform ios +``` + +Also available as `vp run dist:mobile:bk -- --platform android` from the repo +root, or `android:bk` / `ios:bk` inside `apps/mobile`. Artifacts land in +`release/mobile/`. + +Do not run either on the shared dev server: `expo prebuild` plus a Gradle +release build is exactly the kind of load that has OOM-crashed that box. + +To inspect the resolved config without building anything: + +```bash +cd apps/mobile +T3CODE_BK_MOBILE=1 T3CODE_IOS_PERSONAL_TEAM=1 \ + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=work.beknown.bkt3code.mobile \ + APP_VARIANT=production vp exec expo config --type public +``` + +## Version bumps + +`apps/mobile/app-version.ts` holds `MOBILE_APP_VERSION`, which is at once the +native manifest version, the release tag and the base of the `client_version` +the app reports. Bump it there and nowhere else. The Android `versionCode` comes +from the workflow run number, so it stays monotonic without being tracked by +hand. diff --git a/docs/operations/expbkt3-customizations.md b/docs/operations/expbkt3-customizations.md index 616e4dd9be4b..dbb88c68b152 100644 --- a/docs/operations/expbkt3-customizations.md +++ b/docs/operations/expbkt3-customizations.md @@ -111,6 +111,7 @@ turn-settlement rewrite in `state/threadReducer.ts`, the restart predicate in | Session identity | `apps/server/src/identity/SessionIdentityEnvironment.ts`, `apps/server/src/provider/claudeSessionIdentity.expbkt3.ts` | `ProviderCommandReactor.ts` execution-options seam, `ProviderService.ts` adapter-spawn seam, `identityEnvironment` on `ProviderSessionExecutionOptions`, conditional scrub in `SourceControlExecutionEnvironment.ts`, `server.ts` layer, `ClaudeAdapter.ts` system-prompt append + `UserPromptSubmit` hook seams | | Agent views in chat | `apps/server/src/agentui/`, `persistence/AgentUiRenders.ts`, migration 1022, `packages/contracts/src/agentUi.ts`, `packages/client-runtime/src/state/agentUi.ts`, `apps/web/src/fork/agentUiSurface.tsx`, `apps/web/src/state/agentUi.ts` | `t3_show_ui` in the MCP control toolkit, fork RPC group + scopes + handlers, one `ws.ts` dep, one `server.ts` layer, marked handle passthrough in `ActivityPayloadProjection.ts`, `agentUi` field + read in `session-logic.ts`, one import and early return in `MessagesTimeline.tsx`, Experiments toggle + `settingsSearch.ts` entry | | Experimental deployment | `.github/workflows/deploy-expbkt3.yml`, `deploy/expbkt3/` | none | +| BK mobile distribution | `apps/mobile/app.config.bk.ts`, `apps/mobile/plugins/withBkAndroidReleaseSigning.cjs`, `apps/mobile/src/lib/bkBuildIdentity.ts`, `scripts/build-bk-mobile.ts`, `.github/workflows/mobile-bk-release.yml` | two lines in `app.config.ts` (import + export), one linking prefix in `App.tsx`, one version call in `authClientMetadata.ts` | Agent views currently have an enforced runtime-off gate in `apps/web/src/fork/agentUiRuntime.ts`. Framed collaboration apps can decline to diff --git a/package.json b/package.json index 7472f10205b5..fe9721f4cf7f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "build:contracts": "vp run --filter @t3tools/contracts build", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:bk": "node scripts/build-bk-desktop-dmg.ts", + "dist:mobile:bk": "node scripts/build-bk-mobile.ts", "publish:desktop:bk": "node scripts/publish-bk-desktop-dmg.ts", "version:desktop:bk": "node scripts/resolve-bk-desktop-version.ts", "icons:bk": "node scripts/generate-bk-brand-icons.ts", diff --git a/scripts/build-bk-mobile.test.ts b/scripts/build-bk-mobile.test.ts new file mode 100644 index 000000000000..4714e083651e --- /dev/null +++ b/scripts/build-bk-mobile.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + assertKeylessBuild, + parseArgs, + xcodeSchemeName, +} from "./build-bk-mobile.ts"; +import { + bkAppVersionString, + bkArtifactFileName, + bkBuildEnv, + bkReleaseTag, + parseMobileAppVersion, +} from "./lib/bk-mobile.ts"; + +describe("parseArgs", () => { + it("requires a supported platform", () => { + expect(parseArgs(["--platform", "ios"]).platform).toBe("ios"); + expect(() => parseArgs([])).toThrow(/--platform is required/); + expect(() => parseArgs(["--platform", "web"])).toThrow(/android/); + expect(() => parseArgs(["--wat"])).toThrow(/Unknown argument/); + }); +}); + +describe("assertKeylessBuild", () => { + it("rejects a Clerk key from either source", () => { + expect(() => assertKeylessBuild({}, [])).not.toThrow(); + expect(() => assertKeylessBuild({ PATH: "/usr/bin" }, ["FOO=bar\n"])).not.toThrow(); + expect(() => assertKeylessBuild({}, ["EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test\n"])).toThrow( + /keyless/, + ); + expect(() => assertKeylessBuild({ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk" }, [])).toThrow( + /keyless/, + ); + }); + + it("ignores a commented-out key", () => { + expect(() => + assertKeylessBuild({}, ["# EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk\n"]), + ).not.toThrow(); + }); +}); + +describe("build identity", () => { + it("stamps version and artifact names from the build SHA", () => { + expect(bkAppVersionString("1.0.4", "a1b2c3d4e5f6")).toBe("1.0.4+bk.a1b2c3d"); + expect(bkArtifactFileName("android", "1.0.4", "a1b2c3d4e5f6")).toBe( + "bk-t3code-1.0.4-a1b2c3d.apk", + ); + expect(bkArtifactFileName("ios", "1.0.4", "a1b2c3d4e5f6")).toBe("bk-t3code-1.0.4-a1b2c3d.ipa"); + // The release tag has to agree with the artifact names, because the + // workflow derives one and the build script the other. + expect(bkReleaseTag("1.0.4", "a1b2c3d4e5f6")).toBe("bk-mobile-v1.0.4-a1b2c3d"); + }); + + it("pins the environment both platforms need", () => { + const env = bkBuildEnv("abc1234"); + expect(env.T3CODE_BK_MOBILE).toBe("1"); + // Required on Android too: it is the single source of the fork bundle id. + expect(env.T3CODE_IOS_PERSONAL_TEAM).toBe("1"); + expect(env.MOBILE_VERSION_POLICY).toBe("appVersion"); + expect(env.BK_GIT_SHA).toBe("abc1234"); + }); + + it("derives the Xcode scheme the way Expo sanitises the app name", () => { + expect(xcodeSchemeName("BK T3 Code")).toBe("BKT3Code"); + }); +}); + +describe("parseMobileAppVersion", () => { + it("reads the version the mobile app reports to servers", () => { + expect( + parseMobileAppVersion('export const MOBILE_APP_VERSION = "1.0.4";\n'), + ).toBe("1.0.4"); + }); + + it("fails rather than guessing when the constant moves", () => { + // Parsed instead of imported to keep the script out of the mobile + // TypeScript project, so a rename there must fail loudly here. + expect(() => parseMobileAppVersion("export const OTHER = 1;")).toThrow(/MOBILE_APP_VERSION/); + }); +}); diff --git a/scripts/build-bk-mobile.ts b/scripts/build-bk-mobile.ts new file mode 100644 index 000000000000..43e77e323c53 --- /dev/null +++ b/scripts/build-bk-mobile.ts @@ -0,0 +1,289 @@ +#!/usr/bin/env node + +/** + * T3-CUSTOM(expbkt3): Builds a sideloadable Beknown mobile artifact. + * + * node scripts/build-bk-mobile.ts --platform android # -> release APK + * node scripts/build-bk-mobile.ts --platform ios # -> UNSIGNED .ipa + * + * Both platforms are sideloaded, never store-distributed. The Android APK is + * signed with the fork keystore (see plugins/withBkAndroidReleaseSigning.cjs) + * so it upgrades in place; the iOS .ipa ships unsigned and is re-signed on the + * device by SideStore with the user's free Apple ID. + * + * The iOS half must run on macOS. CI does it on a GitHub-hosted macOS runner; + * the same command is the documented fallback for a real Mac. + * + * See docs/operations/bk-mobile-build.md. + */ +// @effect-diagnostics nodeBuiltinImport:off - a build wrapper, not app code. +// @effect-diagnostics globalConsole:off - plain CLI output, no Effect runtime. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { + bkAppVersionString, + bkArtifactFileName, + bkBuildEnv, + BK_MOBILE_APP_NAME, + parseMobileAppVersion, + type BkMobilePlatform, +} from "./lib/bk-mobile.ts"; + +const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps", "mobile"); +const DEFAULT_OUTPUT_DIR = NodePath.join(REPO_ROOT, "release", "mobile"); + +interface Options { + readonly platform: BkMobilePlatform; + readonly outputDir: string; +} + +export function parseArgs(argv: ReadonlyArray): Options { + let platform: BkMobilePlatform | null = null; + let outputDir = DEFAULT_OUTPUT_DIR; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--platform") { + const value = argv[index + 1]; + if (value !== "android" && value !== "ios") { + throw new Error(`--platform must be "android" or "ios" (received "${value ?? ""}").`); + } + platform = value; + index += 1; + continue; + } + if (arg === "--output-dir") { + const value = argv[index + 1]; + if (!value) throw new Error("--output-dir needs a path."); + outputDir = NodePath.resolve(value); + index += 1; + continue; + } + throw new Error(`Unknown argument "${arg}".`); + } + + if (platform === null) { + throw new Error("--platform is required: android or ios."); + } + return { platform, outputDir }; +} + +/** + * BK builds must be keyless. A Clerk publishable key makes the app mount the + * hosted-auth provider, which renders nothing until Clerk answers — identity + * on this fleet comes from the device-bound pairing credential instead. This + * mirrors the guard in build-bk-desktop-dmg.ts and the desktop workflow. + */ +export function assertKeylessBuild( + env: Record, + dotenvContents: ReadonlyArray, +): void { + for (const contents of dotenvContents) { + if (/^\s*[A-Z0-9_]*CLERK/m.test(contents)) { + throw new Error("A dotenv file sets a Clerk variable. BK mobile builds must be keyless."); + } + } + const offender = Object.keys(env).find((key) => /^[A-Z0-9_]*CLERK[A-Z0-9_]*$/.test(key)); + if (offender !== undefined) { + throw new Error(`${offender} is set. BK mobile builds must be keyless.`); + } +} + +function run(command: string, args: ReadonlyArray, cwd: string, env: NodeJS.ProcessEnv) { + console.log(`\n$ ${command} ${args.join(" ")} (in ${NodePath.relative(REPO_ROOT, cwd) || "."})`); + const result = NodeChildProcess.spawnSync(command, [...args], { + cwd, + env, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with ${result.status ?? "a signal"}.`); + } +} + +function readMobileAppVersion(): string { + return parseMobileAppVersion( + NodeFS.readFileSync(NodePath.join(MOBILE_ROOT, "app-version.ts"), "utf8"), + ); +} + +function readDotenvFiles(): ReadonlyArray { + return [".env", ".env.local"] + .map((name) => NodePath.join(REPO_ROOT, name)) + .filter((path) => NodeFS.existsSync(path)) + .map((path) => NodeFS.readFileSync(path, "utf8")); +} + +function resolveGitSha(): string { + const fromEnv = process.env.BK_GIT_SHA?.trim(); + if (fromEnv) return fromEnv; + return NodeChildProcess.execFileSync("git", ["rev-parse", "HEAD"], { + cwd: REPO_ROOT, + encoding: "utf8", + }).trim(); +} + +/** The Xcode scheme prebuild generates, derived from the Expo app name. */ +export function xcodeSchemeName(appName: string): string { + return appName.replace(/[^A-Za-z0-9]/g, ""); +} + +function buildAndroid(env: NodeJS.ProcessEnv, outputPath: string): void { + run( + NodePath.join(MOBILE_ROOT, "node_modules", ".bin", "expo"), + ["prebuild", "--clean", "--platform", "android"], + MOBILE_ROOT, + env, + ); + + if (!process.env.BK_ANDROID_KEYSTORE_PATH) { + console.warn( + "\n::warning:: BK_ANDROID_KEYSTORE_PATH is unset, so this APK is signed with Expo's " + + "shared debug keystore. It installs, but it cannot upgrade a properly signed BK build " + + "in place. Do not distribute it.", + ); + } + + const androidRoot = NodePath.join(MOBILE_ROOT, "android"); + run("./gradlew", [":app:assembleRelease", "--no-daemon"], androidRoot, env); + + const apk = NodePath.join( + androidRoot, + "app", + "build", + "outputs", + "apk", + "release", + "app-release.apk", + ); + if (!NodeFS.existsSync(apk)) { + throw new Error(`Gradle finished but ${apk} does not exist.`); + } + NodeFS.copyFileSync(apk, outputPath); +} + +function buildIos(env: NodeJS.ProcessEnv, outputPath: string): void { + // A plain build wrapper: no Effect runtime to inject HostProcessPlatform from. + // oxlint-disable-next-line t3code/no-global-process-runtime -- see above. + const hostPlatform = NodeOS.platform(); + if (hostPlatform !== "darwin") { + throw new Error( + `An iOS archive can only be built on macOS (host is "${hostPlatform}"). ` + + "CI builds this on a GitHub-hosted macOS runner; see docs/operations/bk-mobile-build.md.", + ); + } + + run( + NodePath.join(MOBILE_ROOT, "node_modules", ".bin", "expo"), + ["prebuild", "--clean", "--platform", "ios"], + MOBILE_ROOT, + env, + ); + + const iosRoot = NodePath.join(MOBILE_ROOT, "ios"); + const scheme = xcodeSchemeName(BK_MOBILE_APP_NAME); + let workspace = NodePath.join(iosRoot, `${scheme}.xcworkspace`); + if (!NodeFS.existsSync(workspace)) { + // The scheme is derived from the Expo app name by Expo's own sanitiser, so + // a change to either can drift. Recover when the answer is unambiguous + // rather than failing a 40-minute build on a naming detail. + const found = NodeFS.readdirSync(iosRoot).filter((entry) => entry.endsWith(".xcworkspace")); + if (found.length !== 1) { + throw new Error( + `Expected ${scheme}.xcworkspace after prebuild; found ${found.join(", ") || "none"}.`, + ); + } + workspace = NodePath.join(iosRoot, found[0]!); + console.warn(`::warning:: Using ${found[0]} instead of the expected ${scheme}.xcworkspace.`); + } + const resolvedScheme = NodePath.basename(workspace, ".xcworkspace"); + + const stagingDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "bk-ipa-")); + const archivePath = NodePath.join(stagingDir, "bk.xcarchive"); + + // Unsigned on purpose: SideStore re-signs on the device with the user's own + // free Apple ID, and a signature applied here would only be stripped. + run( + "xcodebuild", + [ + "archive", + "-workspace", + workspace, + "-scheme", + resolvedScheme, + "-configuration", + "Release", + "-destination", + "generic/platform=iOS", + "-archivePath", + archivePath, + "CODE_SIGNING_ALLOWED=NO", + "CODE_SIGNING_REQUIRED=NO", + "CODE_SIGN_IDENTITY=", + "EXPANDED_CODE_SIGN_IDENTITY=", + ], + iosRoot, + env, + ); + + const productsDir = NodePath.join(archivePath, "Products", "Applications"); + const apps = NodeFS.readdirSync(productsDir).filter((entry) => entry.endsWith(".app")); + if (apps.length !== 1) { + throw new Error(`Expected exactly one .app in the archive, found ${apps.length}.`); + } + + const payloadDir = NodePath.join(stagingDir, "Payload"); + NodeFS.mkdirSync(payloadDir, { recursive: true }); + // `cp -R` rather than fs.cpSync: an .app is a bundle with symlinks and + // executable bits that Node's copy does not reliably preserve. + run("cp", ["-R", NodePath.join(productsDir, apps[0]!), payloadDir], stagingDir, env); + run("zip", ["-qry", outputPath, "Payload"], stagingDir, env); + NodeFS.rmSync(stagingDir, { recursive: true, force: true }); +} + +function main(): void { + const options = parseArgs(process.argv.slice(2)); + assertKeylessBuild(process.env, readDotenvFiles()); + + const gitSha = resolveGitSha(); + const env: NodeJS.ProcessEnv = { ...process.env, ...bkBuildEnv(gitSha) }; + + const baseVersion = readMobileAppVersion(); + NodeFS.mkdirSync(options.outputDir, { recursive: true }); + const outputPath = NodePath.join( + options.outputDir, + bkArtifactFileName(options.platform, baseVersion, gitSha), + ); + + console.log( + `Building ${BK_MOBILE_APP_NAME} ${bkAppVersionString(baseVersion, gitSha)} ` + + `for ${options.platform}.`, + ); + + if (options.platform === "android") buildAndroid(env, outputPath); + else buildIos(env, outputPath); + + console.log(`\nArtifact: ${outputPath}`); + if (process.env.GITHUB_OUTPUT) { + NodeFS.appendFileSync( + process.env.GITHUB_OUTPUT, + `artifact-path=${outputPath}\nartifact-name=${NodePath.basename(outputPath)}\n` + + `artifact-version=${bkAppVersionString(baseVersion, gitSha)}\n`, + ); + } +} + +if (process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`) { + try { + main(); + } catch (error) { + console.error(`\n${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/lib/bk-mobile.ts b/scripts/lib/bk-mobile.ts new file mode 100644 index 000000000000..82f0eed19c9e --- /dev/null +++ b/scripts/lib/bk-mobile.ts @@ -0,0 +1,119 @@ +/** + * T3-CUSTOM(expbkt3): Shared identity for the fork's mobile builds. + * + * Lives here rather than under apps/mobile because both sides need it and only + * this direction is allowed: `apps/mobile/app.config.ts` already imports from + * `scripts/lib`, while a script importing out of the mobile package crosses a + * TypeScript project boundary. + * + * The Expo-config rewrite itself stays in `apps/mobile/app.config.bk.ts`, which + * is where the `ExpoConfig` type is available. + * + * See docs/operations/bk-mobile-build.md. + */ + +/** Reverse-DNS identity for the fork app. Must not collide with upstream's. */ +export const BK_MOBILE_BUNDLE_IDENTIFIER = "work.beknown.bkt3code.mobile"; +export const BK_MOBILE_APP_NAME = "BK T3 Code"; +export const BK_MOBILE_SCHEME = "t3code-bk"; +/** The fork production server BK builds are expected to pair with. */ +export const BK_MOBILE_DEFAULT_SERVER = "bkt3.dev.beknown.live"; +/** Relative to apps/mobile, which is where the Expo config is resolved. */ +export const BK_MOBILE_ICON_PATH = "../../assets/bk/bk-universal-1024.png"; + +/** Seven characters: the same abbreviation `git log --oneline` prints. */ +const SHORT_SHA_LENGTH = 7; + +export type BkMobilePlatform = "android" | "ios"; +export type BkRepoEnv = Record; + +export function isBkMobileBuild(repoEnv: BkRepoEnv): boolean { + return repoEnv.T3CODE_BK_MOBILE === "1"; +} + +/** + * BK builds must be personal-team signed with the fork bundle id. Both are + * correctness requirements rather than preferences, so they fail the config + * rather than the build 40 minutes later. + * + * `T3CODE_IOS_PERSONAL_TEAM=1` is required on BOTH platforms: on Android every + * capability it removes is iOS-only, so it changes nothing there, and requiring + * it unconditionally means a BK build can never silently inherit upstream's + * bundle identifier. + */ +export function assertBkBuildEnvironment(repoEnv: BkRepoEnv): void { + if (repoEnv.T3CODE_IOS_PERSONAL_TEAM !== "1") { + throw new Error( + "BK mobile builds require T3CODE_IOS_PERSONAL_TEAM=1 so the iOS archive drops the " + + "entitlements a free Apple ID cannot sign. Build through scripts/build-bk-mobile.ts.", + ); + } + if (repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim() !== BK_MOBILE_BUNDLE_IDENTIFIER) { + throw new Error( + `BK mobile builds require T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=${BK_MOBILE_BUNDLE_IDENTIFIER}. ` + + "Anything else risks colliding with the upstream app on the same device.", + ); + } +} + +export function parseBkAndroidVersionCode(value: string | undefined): number | null { + if (value === undefined || value.trim() === "") return null; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`BK_ANDROID_VERSION_CODE must be a positive integer (received "${value}").`); + } + return parsed; +} + +/** The environment every BK build shares. See assertBkBuildEnvironment. */ +export function bkBuildEnv(gitSha: string): Record { + return { + APP_VARIANT: "production", + T3CODE_BK_MOBILE: "1", + T3CODE_IOS_PERSONAL_TEAM: "1", + T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID: BK_MOBILE_BUNDLE_IDENTIFIER, + // Fingerprint runtime versions only matter for OTA updates, which BK + // builds do not use (app.config.bk.ts disables expo-updates). + MOBILE_VERSION_POLICY: "appVersion", + EXPO_NO_GIT_STATUS: "1", + BK_GIT_SHA: gitSha, + }; +} + +/** + * `1.0.4+bk.a1b2c3d` — a semver build-metadata suffix, so it stays a valid + * version for anything that parses it. This is what the server records as + * `client_version`, and the only way to tell a stale sideloaded binary from a + * current one. + */ +export function bkAppVersionString(baseVersion: string, gitSha: string): string { + return `${baseVersion}+bk.${gitSha.slice(0, SHORT_SHA_LENGTH)}`; +} + +export function bkArtifactFileName( + platform: BkMobilePlatform, + baseVersion: string, + gitSha: string, +): string { + const extension = platform === "android" ? "apk" : "ipa"; + return `bk-t3code-${baseVersion}-${gitSha.slice(0, SHORT_SHA_LENGTH)}.${extension}`; +} + +export function bkReleaseTag(baseVersion: string, gitSha: string): string { + return `bk-mobile-v${baseVersion}-${gitSha.slice(0, SHORT_SHA_LENGTH)}`; +} + +/** + * Reads MOBILE_APP_VERSION out of `apps/mobile/app-version.ts`. + * + * Parsed rather than imported: that file belongs to the mobile TypeScript + * project, and importing across the boundary is what this module exists to + * avoid. It is a one-line constant, so a regex is honest here. + */ +export function parseMobileAppVersion(contents: string): string { + const match = /MOBILE_APP_VERSION\s*=\s*"(\d+\.\d+\.\d+[^"]*)"/.exec(contents); + if (match === null) { + throw new Error("Could not read MOBILE_APP_VERSION from apps/mobile/app-version.ts."); + } + return match[1]!; +} From a768835c40fe2581c393aa4fd6fd945754373025 Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 07:23:36 +0000 Subject: [PATCH 2/7] refactor(client-runtime): share the phase-grouped sidebar logic with mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fork's experimental control-center sidebar keeps all of its pure logic under apps/web, which the React Native app cannot import. Porting the phase-grouped session list to mobile therefore had to either duplicate ~1900 lines or move them, and duplication of the cycle guard and the settle rules would drift. Moves the logic into three fork-owned client-runtime subpaths — state/phaseSidebar, state/phaseSidebarTree, state/provider-rate-limits — and folds in the sibling modules the mobile port also needs: the lifecycle counters, the running-session emphasis decision, the move-under-session candidates, and the unread predicate. Every apps/web file left behind is a re-export shim, so PhaseGroupedSidebar.tsx and its 150-odd existing tests are untouched and still pass. What stayed in apps/web: the helpers that emit Tailwind class names. Tailwind finds literal class strings by scanning source under apps/web, so moving those would have silently dropped the styles from the build. Two things the move had to fix. Every .toSorted() call is now sort-a-copy — Hermes ships no ES2023 change-array-by-copy methods, so those would have worked in every browser test and crashed on a phone; a new test asserts this by deleting the methods from Array.prototype. And resolveSettledTimestamp is copied rather than imported, because its home is an upstream-owned file and re-exporting from there would put a fork edit inside it for no gain. No behaviour change. Claude Opus 5 in T3 Code (Claude Code harness). Co-Authored-By: Claude Opus 5 (1M context) --- .../sidebar/MoveUnderSessionDialog.logic.ts | 87 +- .../sidebar/PhaseGroupedSidebar.logic.ts | 1014 +------------ .../sidebar/PhaseSidebarTree.logic.ts | 427 +----- .../sidebar/RunningSessionGlint.logic.ts | 30 +- .../SidebarProviderRateLimits.logic.ts | 380 +---- .../sidebar/sidebarSessionCounters.ts | 81 +- apps/web/src/threadVisitTimestamp.ts | 27 +- packages/client-runtime/package.json | 12 + .../src/state/phaseSidebar.test.ts | 329 +++++ .../client-runtime/src/state/phaseSidebar.ts | 1297 +++++++++++++++++ .../src/state/phaseSidebarTree.ts | 430 ++++++ .../state/providerRateLimitsPresentation.ts | 383 +++++ 12 files changed, 2507 insertions(+), 1990 deletions(-) create mode 100644 packages/client-runtime/src/state/phaseSidebar.test.ts create mode 100644 packages/client-runtime/src/state/phaseSidebar.ts create mode 100644 packages/client-runtime/src/state/phaseSidebarTree.ts create mode 100644 packages/client-runtime/src/state/providerRateLimitsPresentation.ts diff --git a/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts b/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts index e056055ad9a3..2fe4174ff181 100644 --- a/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts +++ b/apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts @@ -1,79 +1,8 @@ -// T3-CUSTOM(expbkt3): candidate resolution for the "move under session" picker. -// -// Kept separate from the dialog so the rule that decides which sessions may -// become a parent is testable on its own — it is the client-side mirror of the -// server's cycle guard, and the two must not drift. -import type { ThreadShell } from "../../types"; - -export interface MoveUnderCandidate { - readonly thread: ThreadShell; - readonly label: string; - readonly repositoryLabel: string; -} - -/** - * Every thread reachable downwards from `threadId`, excluding itself. Bounded - * by the thread count: each id is enqueued at most once, so a corrupt cycle in - * the projection cannot make this loop forever. - */ -export function collectDescendantThreadIds( - threads: ReadonlyArray, - threadId: string, -): ReadonlySet { - const descendants = new Set(); - const queue: string[] = [threadId]; - while (queue.length > 0) { - const current = queue.pop() as string; - for (const thread of threads) { - if ((thread.parentThreadId ?? null) !== current) continue; - if (thread.id === threadId || descendants.has(thread.id)) continue; - descendants.add(thread.id); - queue.push(thread.id); - } - } - return descendants; -} - -/** - * Candidate parents for `subject`, newest first, filtered by `query`. - * - * Excluded: the thread itself, its descendants (the server would reject those - * as cycles, so offering them would only produce a confusing failure toast), - * archived threads, its current parent (already there), and — because lineage - * is a bare thread id resolved within one environment — anything from a - * different environment. - */ -export function resolveMoveUnderCandidates(input: { - readonly threads: ReadonlyArray; - readonly subject: ThreadShell; - readonly query: string; - readonly repositoryLabelFor: (thread: ThreadShell) => string; - readonly limit?: number; -}): ReadonlyArray { - const sameEnvironment = input.threads.filter( - (thread) => thread.environmentId === input.subject.environmentId, - ); - const blocked = collectDescendantThreadIds(sameEnvironment, input.subject.id); - const needle = input.query.trim().toLowerCase(); - - return sameEnvironment - .filter( - (thread) => - thread.id !== input.subject.id && - !blocked.has(thread.id) && - thread.archivedAt === null && - thread.id !== (input.subject.parentThreadId ?? null) && - (needle.length === 0 || thread.title.toLowerCase().includes(needle)), - ) - .toSorted( - (left, right) => - Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || - String(left.id).localeCompare(String(right.id)), - ) - .slice(0, input.limit ?? 50) - .map((thread) => ({ - thread, - label: thread.title, - repositoryLabel: input.repositoryLabelFor(thread), - })); -} +// T3-CUSTOM(expbkt3): moved into @t3tools/client-runtime/state/phase-sidebar so +// the mobile "move under session" sheet applies the same cycle guard. +// Re-exported here so existing apps/web imports keep working. +export { + collectDescendantThreadIds, + resolveMoveUnderCandidates, + type MoveUnderCandidate, +} from "@t3tools/client-runtime/state/phase-sidebar"; diff --git a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts index c67150b4916d..316b275e017d 100644 --- a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts +++ b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts @@ -1,105 +1,18 @@ -import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import type { UserId, VcsStatusResult } from "@t3tools/contracts"; -import { - effectiveSettled, - effectiveSnoozed, - type ChangeRequestStateLike, -} from "@t3tools/client-runtime/state/thread-settled"; -import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; -// T3-CUSTOM(expbkt3): memorable worktree codenames. -import { - disambiguateWorktreeCodenames, - resolveWorktreeCodename, - worktreeCodenameToneIndex, -} from "@t3tools/shared/worktreeCodename"; +// T3-CUSTOM(expbkt3): Web bindings for the phase-grouped session list. +// +// The pure logic moved to @t3tools/client-runtime/state/phase-sidebar so the +// mobile thread list can share it. It is re-exported here so every existing +// import in apps/web keeps working unchanged — including the 3000-line +// PhaseGroupedSidebar.tsx, which was not touched by the move. +// +// What stays behind: the helpers that emit Tailwind class names. Tailwind only +// finds literal class strings by scanning source under apps/web, so moving +// these would silently drop the styles from the build. +export * from "@t3tools/client-runtime/state/phase-sidebar"; + +import type { PhaseSidebarPhaseId } from "@t3tools/client-runtime/state/phase-sidebar"; -import { deriveLogicalProjectKey } from "../../logicalProject"; -import type { Project, ThreadShell } from "../../types"; -import { getThreadSortTimestamp } from "../../lib/threadSort"; import { cn } from "../../lib/utils"; -import { resolveSettledTimestamp } from "../Sidebar.logic"; - -export const PHASE_SIDEBAR_PHASE_IDS = [ - "needs_input", - "plan_ready", - "ready", - "planning", - "implementing", -] as const; - -export type PhaseSidebarPhaseId = (typeof PHASE_SIDEBAR_PHASE_IDS)[number]; - -export interface PhaseSidebarCheckoutMetadata { - readonly kind: "current" | "worktree"; - readonly label: string; - readonly tooltip: string; - /** - * Color bucket for the codename, or `null` for a current checkout. Consumers - * map this through a static class table — see `PHASE_SIDEBAR_CHECKOUT_TONES`. - */ - readonly toneIndex: number | null; -} - -/** - * T3-CUSTOM(expbkt3): Other threads sharing this thread's worktree. Two agents - * editing one directory at the same time is a real hazard, and without this it - * is invisible. - */ -export interface PhaseSidebarWorktreeSharing { - /** Threads occupying the worktree. Always >= 2 when present. */ - readonly count: number; - /** - * Pre-joined thread titles for the tooltip. A string rather than an array so - * it can cross the memo'd row boundary as a prop without defeating the memo. - */ - readonly summary: string; -} - -/** - * T3-CUSTOM(expbkt3): Keep checkout semantics explicit in the experimental - * sidebar. Current checkouts show their live branch; dedicated worktrees show - * their codename — a short, memorable name derived from the worktree path, so - * that two rows in the same worktree read identically and two rows in different - * worktrees read differently at a glance. The ref the worktree was created from - * moves into the tooltip, which is where it was actually being read anyway. - */ -export function resolvePhaseSidebarCheckoutMetadata( - thread: Pick, - vcsStatus: Pick | null | undefined, - options?: { - /** Label from `disambiguateWorktreeCodenames`, when the view resolved one. */ - readonly codename?: string | null; - readonly sharing?: PhaseSidebarWorktreeSharing | null; - }, -): PhaseSidebarCheckoutMetadata { - if (thread.worktreePath) { - const baseRef = vcsStatus?.pr?.baseRef ?? vcsStatus?.baseRef ?? null; - const codename = options?.codename ?? resolveWorktreeCodename(thread.worktreePath); - const sharing = options?.sharing ?? null; - - const tooltipParts = [`Worktree ${codename}`]; - if (baseRef) tooltipParts.push(`from ${baseRef}`); - tooltipParts.push(thread.worktreePath); - if (sharing) { - tooltipParts.push(`Shared by ${sharing.count} threads: ${sharing.summary}`); - } - - return { - kind: "worktree", - label: sharing ? `${codename} ×${sharing.count}` : codename, - tooltip: tooltipParts.join(" · "), - toneIndex: worktreeCodenameToneIndex(codename), - }; - } - - const branch = vcsStatus?.refName ?? thread.branch; - return { - kind: "current", - label: branch ?? "Current checkout", - tooltip: branch ? `Current checkout on ${branch}` : "Current checkout", - toneIndex: null, - }; -} /** * T3-CUSTOM(expbkt3): Static tone table for worktree codenames. Tailwind scans @@ -125,88 +38,6 @@ export function phaseSidebarCheckoutToneClassName(toneIndex: number | null): str return PHASE_SIDEBAR_CHECKOUT_TONES[toneIndex % PHASE_SIDEBAR_CHECKOUT_TONES.length] ?? ""; } -/** - * T3-CUSTOM(expbkt3): Codename label and shared-worktree state for every thread - * on screen, resolved together because both answers depend on the whole visible - * set: codenames disambiguate against each other, and sharing is a count across - * rows. Archived threads do not participate — the rest of the UI hides them, so - * they must not inflate a worktree's occupancy. - */ -export interface PhaseSidebarWorktreeView { - readonly codenameByPath: ReadonlyMap; - readonly sharingByPath: ReadonlyMap; -} - -export function resolvePhaseSidebarWorktreeView( - threads: ReadonlyArray>, -): PhaseSidebarWorktreeView { - const titlesByPath = new Map(); - for (const thread of threads) { - const worktreePath = thread.worktreePath?.trim(); - if (!worktreePath || thread.archivedAt != null) continue; - titlesByPath.set(worktreePath, [...(titlesByPath.get(worktreePath) ?? []), thread.title]); - } - - const sharingByPath = new Map(); - for (const [worktreePath, titles] of titlesByPath) { - if (titles.length < 2) continue; - sharingByPath.set(worktreePath, { count: titles.length, summary: titles.join(", ") }); - } - - return { - codenameByPath: disambiguateWorktreeCodenames([...titlesByPath.keys()]), - sharingByPath, - }; -} - -/** - * T3-CUSTOM(expbkt3): Flatten one thread's worktree state into primitives. The - * row is memo'd and the sidebar re-renders on every shell event, so the props - * crossing that boundary have to compare by value. - */ -export interface PhaseSidebarWorktreeRowProps { - readonly worktreeCodename: string | null; - /** 0 when the worktree is not shared. */ - readonly worktreeSharedCount: number; - readonly worktreeSharedSummary: string | null; -} - -export function phaseSidebarWorktreeRowProps( - view: PhaseSidebarWorktreeView, - worktreePath: string | null, -): PhaseSidebarWorktreeRowProps { - const path = worktreePath?.trim(); - if (!path) { - return { worktreeCodename: null, worktreeSharedCount: 0, worktreeSharedSummary: null }; - } - const sharing = view.sharingByPath.get(path) ?? null; - return { - // An archived thread is absent from the view but still renders on the - // shelf, so fall back to deriving its codename directly. - worktreeCodename: view.codenameByPath.get(path) ?? resolveWorktreeCodename(path), - worktreeSharedCount: sharing?.count ?? 0, - worktreeSharedSummary: sharing?.summary ?? null, - }; -} - -export interface PhaseSidebarPhaseDefinition { - readonly id: PhaseSidebarPhaseId; - readonly label: string; - readonly helperText: string; -} - -export const PHASE_SIDEBAR_PHASES: ReadonlyArray = [ - { - id: "needs_input", - label: "Needs Input", - helperText: "Agent is waiting for your answer", - }, - { id: "plan_ready", label: "Plan Ready", helperText: "Planning session is stopped" }, - { id: "ready", label: "Ready", helperText: "No active agent work" }, - { id: "planning", label: "Planning", helperText: "Agent is preparing a plan" }, - { id: "implementing", label: "Implementing", helperText: "Agent is changing code" }, -]; - /** * Theme-aware lifecycle header surfaces. The hue is intentionally restrained: * headers should make the groups scannable without competing with urgent row @@ -232,358 +63,6 @@ export function phaseSidebarGroupHeaderClassName(phaseId: PhaseSidebarPhaseId): ); } -export interface PhaseSidebarWorkBadge { - readonly label: string; - readonly monitoring: boolean; -} - -/** - * Mirror Sidebar V2's execution precedence in the experimental sidebar. - * Foreground execution keeps its provider label (for example, Running), - * background agent fleets read as Working, and only watch loops read as - * Monitoring. Monitoring is steady and therefore does not trigger row - * shimmer. Plan Ready remains actionable and outranks lingering background - * liveness. - */ -export function resolvePhaseSidebarWorkBadge(input: { - readonly phaseId: PhaseSidebarPhaseId; - readonly backgroundLiveness?: "working" | "monitoring" | null; - readonly executionPresentation: { - readonly active: boolean; - readonly label: string | null; - }; -}): PhaseSidebarWorkBadge | null { - if (input.executionPresentation.active && input.executionPresentation.label !== null) { - return { label: input.executionPresentation.label, monitoring: false }; - } - - if (input.phaseId === "plan_ready") return null; - - if (input.backgroundLiveness === "working") { - return { label: "Working", monitoring: false }; - } - - if (input.backgroundLiveness === "monitoring") { - return { label: "Monitoring", monitoring: true }; - } - - return null; -} - -const PHASE_ID_SET = new Set(PHASE_SIDEBAR_PHASE_IDS); -const LINEAR_BRANCH_PATTERN = /^linear\/([a-z][a-z0-9]*-\d+)(?:-|$)/i; -const LINEAR_ISSUE_URL_PATTERN = - /^https:\/\/linear\.app\/([^/]+)\/issue\/([a-z][a-z0-9]*-\d+)(?:\/[^?#]*)?(?:[?#].*)?$/i; - -export interface PhaseSidebarLinearIssue { - readonly identifier: string; - readonly url: string; -} - -export function resolvePhaseSidebarLinearIssue( - branch: string | null, - manualUrl?: string | null, -): PhaseSidebarLinearIssue | null { - const trimmedManualUrl = manualUrl?.trim(); - if (trimmedManualUrl) { - const match = LINEAR_ISSUE_URL_PATTERN.exec(trimmedManualUrl); - const workspace = match?.[1]; - const identifier = match?.[2]?.toUpperCase(); - if (workspace && identifier) { - return { - identifier, - url: `https://linear.app/${workspace}/issue/${identifier}`, - }; - } - } - if (branch === null) return null; - const identifier = LINEAR_BRANCH_PATTERN.exec(branch)?.[1]?.toUpperCase(); - if (!identifier) return null; - return { - identifier, - url: `https://linear.app/beknown/issue/${identifier}`, - }; -} - -/** - * T3-CUSTOM(expbkt3): The row's change request, rendered beside the Linear tag - * so the two trackers a session answers to read as one line: ticket, then PR. - * - * The number is the whole label. State is carried by COLOR ALONE — the row's - * metadata lane is already the densest text in the app, and "#1234 (merged)" - * spends a third of the lane restating what the hue says. Hues match - * `prStatusIndicator` so a PR never reads one colour here and another in the - * thread header: green open, violet merged, red closed. Draft, checks, and - * review state stay in the tooltip — they are modifiers on "open", not states, - * and giving each its own hue would make the lane unreadable. - */ -export interface PhaseSidebarChangeRequestBadge { - /** "#1234" — the visible label. */ - readonly label: string; - readonly url: string; - readonly state: ChangeRequestStateLike; - /** Static Tailwind classes; Tailwind cannot scan interpolated hues. */ - readonly colorClassName: string; - /** Full state in words, for the tooltip and the accessible name. */ - readonly statusText: string; - readonly tooltip: string; -} - -const PHASE_SIDEBAR_CHANGE_REQUEST_TONES = { - open: "text-emerald-600 dark:text-emerald-300/90", - merged: "text-violet-600 dark:text-violet-300/90", - closed: "text-red-600 dark:text-red-300/90", -} satisfies Record; - -export function resolvePhaseSidebarChangeRequestBadge( - vcsStatus: Pick | null | undefined, -): PhaseSidebarChangeRequestBadge | null { - const pr = vcsStatus?.pr; - if (!pr) return null; - const shortName = resolveChangeRequestPresentation(vcsStatus?.sourceControlProvider).shortName; - - const modifiers: string[] = []; - if (pr.state === "open") { - if (pr.isDraft === true) modifiers.push("draft"); - if (pr.mergeability === "conflicting") modifiers.push("conflicting"); - if (pr.reviewDecision === "approved") modifiers.push("approved"); - if (pr.reviewDecision === "changes-requested") modifiers.push("changes requested"); - if (pr.checksStatus === "fail") modifiers.push("checks failing"); - if (pr.checksStatus === "pending") modifiers.push("checks running"); - } - const statusText = modifiers.length === 0 ? pr.state : `${pr.state} · ${modifiers.join(" · ")}`; - - return { - label: `#${pr.number}`, - url: pr.url, - state: pr.state, - colorClassName: PHASE_SIDEBAR_CHANGE_REQUEST_TONES[pr.state], - statusText, - tooltip: `${shortName} #${pr.number} — ${statusText} · ${pr.title}`, - }; -} - -/** T3-CUSTOM(expbkt3): compact sidebar timestamps, including zero minutes. */ -export function compactPhaseSidebarTimeLabel(label: string): string { - return label === "just now" ? "0m" : label.replace(" ago", ""); -} - -export interface PhaseSidebarFilters { - readonly repositoryKeys: ReadonlyArray; - readonly phaseIds: ReadonlyArray; - readonly providerKinds: ReadonlyArray; - /** - * T3-CUSTOM(expbkt3): sessions this operator started. - * - * NOT "owner or tagged": that is the server's own visibility rule, so every - * thread you can see already satisfies it and the filter selected everything. - * Ownership is the distinction that means something — my sessions versus the - * ones I was pulled into. - */ - readonly ownedByMe: boolean; - /** - * T3-CUSTOM(expbkt3): show only sessions ALL of these people are on. Anyone - * listed here is a co-participant on threads you can already see, since - * visibility never widens — the filter narrows to shared work. - */ - readonly participantUserIds: ReadonlyArray; -} - -export const EMPTY_PHASE_SIDEBAR_FILTERS: PhaseSidebarFilters = { - repositoryKeys: [], - phaseIds: [], - providerKinds: [], - ownedByMe: false, - participantUserIds: [], -}; - -/** T3-CUSTOM(expbkt3): everyone on a thread, owner included. */ -export function phaseSidebarThreadParticipantIds( - thread: Pick, -): ReadonlyArray { - return thread.ownerUserId === null - ? thread.memberUserIds - : [thread.ownerUserId, ...thread.memberUserIds.filter((id) => id !== thread.ownerUserId)]; -} - -/** - * "Assigned to me" = owned by, or directly tagged on, the thread. A thread made - * visible only by a project tag is not "assigned" (matches the server rule). - */ -export function isThreadAssignedToUser( - thread: Pick, - userId: UserId, -): boolean { - return thread.ownerUserId === userId || thread.memberUserIds.includes(userId); -} - -/** - * T3-CUSTOM(expbkt3): whose face, if anyone's, belongs on a sidebar row. - * - * A row only earns an owner avatar when the thread was started by *somebody - * else*: my own sessions are the default case and a wall of my own face would - * carry no information. Returns the owner to show, or `null` to show nothing. - * - * `null` when the thread is unowned (single-user mode, or awaiting backfill), - * when we cannot identify the operator (no team identity — every row would - * light up), or when the operator *is* the owner. - */ -export function phaseSidebarRowOwnerAvatarUserId(input: { - readonly ownerUserId: UserId | null; - readonly currentUserId: UserId | null; -}): UserId | null { - if (input.ownerUserId === null || input.currentUserId === null) return null; - return input.ownerUserId === input.currentUserId ? null : input.ownerUserId; -} - -export interface PhaseSidebarRow { - readonly thread: ThreadShell; - readonly phaseId: PhaseSidebarPhaseId; - readonly repositoryKey: string; - readonly repositoryLabel: string; - readonly providerKind: string; - readonly providerName: string; - readonly isAssignedToMe: boolean; - // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. - readonly isOwnedByMe: boolean; - readonly participantUserIds: ReadonlyArray; - // T3-CUSTOM(expbkt3): END - readonly attentionPriority: number; - readonly isUnreadCompletion: boolean; - /** False on environments whose server predates thread.settle/unsettle: - the row can never be classified settled (the user could not undo it) - and its lifecycle affordances stay hidden. */ - readonly settlementSupported: boolean; - /** Same version-skew contract for thread.snooze/unsnooze. */ - readonly snoozeSupported: boolean; - /** Same version-skew contract for priority on thread.meta.update. */ - readonly prioritySupported: boolean; - /** Same version-skew contract for manual Linear tags on thread.meta.update. */ - readonly linearIssueSupported?: boolean; - /** Same version-skew contract for regenerateTitle on thread.meta.update, - which backs "Regenerate title" on the row's context menu. */ - readonly titleRegenerationSupported?: boolean; - /** Same version-skew contract for thread.bootstrap.request, which backs - "Create new thread" on the row's context menu. */ - readonly threadBootstrapSupported?: boolean; - /** The row's pull-request state, when its VCS probe has reported one: a - closed (abandoned) change request auto-settles the thread, an open one - holds it active, and a merge settles only when the user allows it. */ - readonly changeRequestState: ChangeRequestStateLike | null; - /** When the change request last changed, so a merge older than the thread's - own activity does not settle a thread the user has since worked on. */ - readonly changeRequestUpdatedAt?: string | null; -} - -/** - * T3-CUSTOM(expbkt3): Where a row renders in the experimental sidebar — - * inside its lifecycle group, or parked on one of the two shelves below - * them. - */ -export type PhaseSidebarSection = "active" | "snoozed" | "settled"; - -export interface PhaseSidebarPartition { - readonly activeRows: ReadonlyArray; - readonly snoozedRows: ReadonlyArray; - readonly settledRows: ReadonlyArray; -} - -function snoozeWakeMs(row: PhaseSidebarRow): number { - const parsed = Date.parse(row.thread.snoozedUntil ?? ""); - return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; -} - -/** Soonest wake first: the shelf reads as a queue of what comes back next. */ -export function sortSnoozedPhaseSidebarRows( - rows: ReadonlyArray, -): ReadonlyArray { - return rows.toSorted( - (left, right) => - snoozeWakeMs(left) - snoozeWakeMs(right) || - String(left.thread.id).localeCompare(String(right.thread.id)), - ); -} - -/** - * Settled rows are history, so they order by when the work ENDED — the same - * timestamp their label reads, so order and label can never disagree. - */ -export function sortSettledPhaseSidebarRows( - rows: ReadonlyArray, -): ReadonlyArray { - const timestampMs = (row: PhaseSidebarRow) => { - const timestamp = resolveSettledTimestamp(row.thread); - return timestamp === null ? 0 : Date.parse(timestamp); - }; - return rows.toSorted( - (left, right) => - timestampMs(right) - timestampMs(left) || - String(left.thread.id).localeCompare(String(right.thread.id)), - ); -} - -/** - * T3-CUSTOM(expbkt3): Split visible rows into the lifecycle inbox and the - * two parked shelves. Snooze deliberately outranks settled classification: - * an explicitly snoozed thread belongs on the snoozed shelf even when it - * would also auto-settle, because the shelf carries its wake time. - * - * Both classifications are capability-gated per row — auto-settling a - * thread on a server that cannot un-settle it would strand the row. - * - * `preciseNow` classifies snooze (wake times are second-precise) while - * `now` may be quantized for the day-granular auto-settle window. - */ -export function partitionPhaseSidebarRows( - rows: ReadonlyArray, - options: { - readonly now: string; - readonly preciseNow: string; - readonly autoSettleAfterDays: number | null; - readonly autoSettleOnMerge?: boolean; - }, -): PhaseSidebarPartition { - const activeRows: PhaseSidebarRow[] = []; - const snoozedRows: PhaseSidebarRow[] = []; - const settledRows: PhaseSidebarRow[] = []; - - for (const row of rows) { - if (row.snoozeSupported && effectiveSnoozed(row.thread, { now: options.preciseNow })) { - snoozedRows.push(row); - continue; - } - if ( - row.settlementSupported && - effectiveSettled(row.thread, { - now: options.now, - autoSettleAfterDays: options.autoSettleAfterDays, - ...(options.autoSettleOnMerge !== undefined - ? { autoSettleOnMerge: options.autoSettleOnMerge } - : {}), - changeRequest: - row.changeRequestState === null - ? null - : { - state: row.changeRequestState, - ...(row.changeRequestUpdatedAt !== undefined - ? { updatedAt: row.changeRequestUpdatedAt } - : {}), - }, - }) - ) { - settledRows.push(row); - continue; - } - activeRows.push(row); - } - - return { - activeRows, - snoozedRows: sortSnoozedPhaseSidebarRows(snoozedRows), - settledRows: sortSettledPhaseSidebarRows(settledRows), - }; -} - /** * Keep the routed thread visually distinct from multi-selected rows. The * persistent right-edge accent is rendered by PhaseThreadRow; these surfaces @@ -621,263 +100,6 @@ export function phaseSidebarRowActionsClassName(isSurfaceOpen: boolean): string ); } -/** A stopped projection can still hide a live provider, so any recorded session remains stoppable. */ -export function phaseSidebarCanForceStopAgent(session: ThreadShell["session"]): boolean { - return session !== null; -} - -export interface PhaseSidebarRepositoryOption { - readonly key: string; - readonly label: string; - readonly searchText: string; - readonly project: Project; -} - -export interface PhaseSidebarGroup extends PhaseSidebarPhaseDefinition { - readonly rows: ReadonlyArray; -} - -export interface PhaseSidebarFilterChip { - // T3-CUSTOM(expbkt3): "person" is the co-participant facet. - readonly facet: "repository" | "phase" | "provider" | "assignment" | "person"; - readonly value: string; - readonly label: string; -} - -export function isStrictlyMergeReady(status: VcsStatusResult | null | undefined): boolean { - const pr = status?.pr; - if (!pr || pr.state !== "open" || status?.sourceControlProvider?.kind !== "github") return false; - if (pr.isDraft !== false || pr.mergeability !== "mergeable") return false; - if (pr.mergeStateStatus?.toUpperCase() !== "CLEAN") return false; - if (pr.reviewDecision === "changes-requested" || pr.reviewDecision === "review-required") { - return false; - } - return pr.checksStatus === "pass"; -} - -export function resolvePhaseSidebarAttentionPriority( - thread: ThreadShell, - status?: VcsStatusResult | null, -): number { - if (phaseSidebarNeedsUserInput(thread)) return 0; - if (thread.execution?.turn?.state === "waiting-for-approval") return 1; - if (thread.execution?.activity === "failed") return 2; - if ( - status?.pr?.state === "open" && - (status.pr.mergeability === "conflicting" || - status.pr.checksStatus === "fail" || - status.pr.reviewDecision === "changes-requested") - ) { - return 3; - } - if (thread.execution === null || thread.execution === undefined) return 4; - return 5; -} - -/** - * T3-CUSTOM(expbkt3): Treat both the durable pending-input bit and the live - * execution state as authority. This keeps urgent questions promoted even - * during short execution-snapshot reconnects. - */ -export function phaseSidebarNeedsUserInput( - thread: Pick, -): boolean { - return thread.hasPendingUserInput || thread.execution?.turn?.state === "waiting-for-input"; -} - -export type PhaseSidebarAttentionKind = "input" | "approval" | "error"; - -export function resolvePhaseSidebarAttentionKind( - thread: Pick, -): PhaseSidebarAttentionKind | null { - if (phaseSidebarNeedsUserInput(thread)) return "input"; - if (thread.hasPendingApprovals || thread.execution?.turn?.state === "waiting-for-approval") { - return "approval"; - } - if (thread.execution?.activity === "failed") return "error"; - return null; -} - -export function resolvePhaseSidebarPhase( - thread: ThreadShell, - _status?: VcsStatusResult | null, -): PhaseSidebarPhaseId { - if (phaseSidebarNeedsUserInput(thread)) return "needs_input"; - - // A failed provider is actionable even if a stale durable intent or - // background-liveness projection has not cleared yet. - const hasFailure = thread.execution?.activity === "failed" || thread.session?.status === "error"; - if (hasFailure) { - return thread.interactionMode === "plan" ? "plan_ready" : "ready"; - } - - // T3-CUSTOM(expbkt3): BEGIN — group from the same durable intent as the badge. - const isActive = - (thread.execution?.intent !== undefined && - thread.execution.intent.phase !== "recovery-exhausted") || - thread.execution?.activity === "active" || - thread.execution?.activity === "blocked" || - thread.execution?.activity === "stopping" || - thread.session?.status === "starting" || - thread.session?.status === "running"; - // T3-CUSTOM(expbkt3): END - if (isActive) { - return thread.interactionMode === "plan" ? "planning" : "implementing"; - } - - // Sidebar V2's reliability ordering: a failure or an actionable plan must - // not be hidden by liveness that can linger while background work winds - // down. Those states keep their ordinary group and attention treatment. - if (thread.interactionMode === "plan" && thread.hasActionableProposedPlan) { - return "plan_ready"; - } - - // A settled foreground turn can still own native subagents, workflows, or - // watch scripts. Keep it among agent-work rows until the authoritative - // server projection clears instead of prematurely dropping it into Ready. - if (thread.backgroundLiveness === "working" || thread.backgroundLiveness === "monitoring") { - return thread.interactionMode === "plan" ? "planning" : "implementing"; - } - - return thread.interactionMode === "plan" ? "plan_ready" : "ready"; -} - -/** - * Keep a thread in its last rendered lifecycle group while live execution - * authority is temporarily unavailable. The underlying execution snapshot is - * still cleared on disconnect; this only stabilizes sidebar presentation until - * a fresh execution frame arrives. - */ -export function resolvePhaseSidebarDisplayPhase( - currentPhase: PhaseSidebarPhaseId, - _previousPhase: PhaseSidebarPhaseId | null, -): PhaseSidebarPhaseId { - return currentPhase; -} - -export function derivePhaseSidebarRepositoryKey(project: Project): string { - return deriveLogicalProjectKey(project, { groupingMode: "repository" }); -} - -export function buildPhaseSidebarRepositoryOptions( - projects: ReadonlyArray, -): ReadonlyArray { - const grouped = new Map(); - for (const project of projects) { - const key = derivePhaseSidebarRepositoryKey(project); - const members = grouped.get(key); - if (members) members.push(project); - else grouped.set(key, [project]); - } - - return [...grouped.entries()] - .map(([key, members]) => { - const sortedMembers = members.toSorted((left, right) => - `${left.environmentId}:${left.id}`.localeCompare(`${right.environmentId}:${right.id}`), - ); - const nicknames = [...new Set(sortedMembers.map((project) => project.title))].toSorted( - (left, right) => left.localeCompare(right), - ); - const canonicalLabels = [ - ...new Set( - sortedMembers.flatMap((project) => { - const identity = project.repositoryIdentity; - if (!identity) return []; - return [identity.displayName, identity.name].filter( - (value): value is string => typeof value === "string" && value.length > 0, - ); - }), - ), - ].toSorted((left, right) => left.localeCompare(right)); - const label = - nicknames.length === 1 - ? nicknames[0]! - : (canonicalLabels[0] ?? nicknames[0] ?? "Unknown repository"); - const searchText = [ - ...nicknames, - ...canonicalLabels, - ...sortedMembers.flatMap((project) => { - const identity = project.repositoryIdentity; - return identity ? [identity.canonicalKey, identity.owner ?? ""] : []; - }), - ].join(" "); - return { key, label, searchText, project: sortedMembers[0]! }; - }) - .toSorted( - (left, right) => left.label.localeCompare(right.label) || left.key.localeCompare(right.key), - ); -} - -const KNOWN_PROVIDER_CODES: Readonly> = { - claudeAgent: "cc", - codex: "cx", - cursor: "cu", - grok: "gr", - opencode: "oc", -}; - -export function resolvePhaseSidebarProviderCode(providerKind: string): string { - const known = KNOWN_PROVIDER_CODES[providerKind]; - if (known) return known; - - const normalized = providerKind - .toLowerCase() - .replace(/[^a-z]+/g, " ") - .trim(); - if (!normalized) return "uk"; - const words = normalized.split(/\s+/); - if (words.length > 1) { - return `${words[0]?.[0] ?? "?"}${words[1]?.[0] ?? "?"}`; - } - return normalized.length === 1 ? normalized.repeat(2) : normalized.slice(0, 2); -} - -export function matchesPhaseSidebarFilters( - row: PhaseSidebarRow, - filters: PhaseSidebarFilters, -): boolean { - return ( - (filters.repositoryKeys.length === 0 || filters.repositoryKeys.includes(row.repositoryKey)) && - (filters.phaseIds.length === 0 || filters.phaseIds.includes(row.phaseId)) && - (filters.providerKinds.length === 0 || filters.providerKinds.includes(row.providerKind)) && - // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. - (!filters.ownedByMe || row.isOwnedByMe) && - // Every selected person must be on the thread: selecting two people asks - // for their shared sessions, not the union of their work. - (filters.participantUserIds.length === 0 || - filters.participantUserIds.every((userId) => row.participantUserIds.includes(userId))) - // T3-CUSTOM(expbkt3): END - ); -} - -/** - * The rows this sidebar may render at all. Shared by the lifecycle groups and - * the parked shelves so a filter chip means the same thing everywhere. - */ -export function filterVisiblePhaseSidebarRows( - rows: ReadonlyArray, - filters: PhaseSidebarFilters, -): ReadonlyArray { - return rows.filter( - (row) => row.thread.archivedAt === null && matchesPhaseSidebarFilters(row, filters), - ); -} - -/** - * T3-CUSTOM(expbkt3): sort rank for a thread's priority. Unprioritised rows - * rank after P4 so an explicit P4 still outranks "no opinion". - */ -export function phaseSidebarPriorityRank(thread: ThreadShell): number { - return thread.priority ?? PHASE_SIDEBAR_UNPRIORITISED_RANK; -} - -export const PHASE_SIDEBAR_UNPRIORITISED_RANK = 5; - -/** T3-CUSTOM(expbkt3): render label for a priority value ("P0".."P4"). */ -export function formatThreadPriority(priority: number): string { - return `P${priority}`; -} - /** * T3-CUSTOM(expbkt3): The badge is now the only place priority is expressed, so * it carries the whole scale on its own. @@ -903,213 +125,3 @@ export function phaseSidebarPriorityBadgeClassName(priority: number): string { PHASE_SIDEBAR_PRIORITY_BADGE_CLASS_NAMES.at(-1)! ); } - -/** T3-CUSTOM(expbkt3): the priority values offered in the row context menu. */ -export const PHASE_SIDEBAR_PRIORITY_CHOICES = [ - { value: 0, label: "P0 — Urgent" }, - { value: 1, label: "P1 — High" }, - { value: 2, label: "P2 — Medium" }, - { value: 3, label: "P3 — Low" }, - { value: 4, label: "P4 — Lowest" }, -] as const satisfies ReadonlyArray<{ readonly value: 0 | 1 | 2 | 3 | 4; readonly label: string }>; - -/** T3-CUSTOM(expbkt3): Which end of the time axis leads inside a lifecycle group. */ -export type PhaseSidebarSortDirection = "newest_first" | "oldest_first"; - -export interface PhaseSidebarSortPreferences { - readonly direction: PhaseSidebarSortDirection; - /** When true, P0 outranks every lower priority; ties fall through to time. */ - readonly priorityFirst: boolean; -} - -export const DEFAULT_PHASE_SIDEBAR_SORT: PhaseSidebarSortPreferences = { - direction: "newest_first", - priorityFirst: true, -}; - -export const PHASE_SIDEBAR_SORT_DIRECTION_LABELS: Record = { - newest_first: "Most recent on top", - oldest_first: "Oldest on top", -}; - -export function sanitizePhaseSidebarSort(value: unknown): PhaseSidebarSortPreferences { - if (!value || typeof value !== "object") return DEFAULT_PHASE_SIDEBAR_SORT; - const candidate = value as Partial>; - return { - direction: - candidate.direction === "oldest_first" || candidate.direction === "newest_first" - ? candidate.direction - : DEFAULT_PHASE_SIDEBAR_SORT.direction, - priorityFirst: - typeof candidate.priorityFirst === "boolean" - ? candidate.priorityFirst - : DEFAULT_PHASE_SIDEBAR_SORT.priorityFirst, - }; -} - -/** - * T3-CUSTOM(expbkt3): Ordering inside a lifecycle group is deliberately STRICT — - * it reads only the thread's priority, its sort timestamp, and stable tiebreaks. - * - * It used to also fold in `attentionPriority` and `isUnreadCompletion`. Both flip - * the moment you open a row, so simply reading a thread reordered the group under - * the pointer. Those states are already visible on the row (glint, unread dot) and - * hoisted into their own groups upstream, so ordering does not need to repeat them - * at the cost of a list that moves while you use it. - */ -export function comparePhaseSidebarRows( - left: PhaseSidebarRow, - right: PhaseSidebarRow, - sortOrder: SidebarThreadSortOrder, - sort: PhaseSidebarSortPreferences, -): number { - const priorityDelta = sort.priorityFirst - ? phaseSidebarPriorityRank(left.thread) - phaseSidebarPriorityRank(right.thread) - : 0; - const leftTime = getThreadSortTimestamp(left.thread, sortOrder); - const rightTime = getThreadSortTimestamp(right.thread, sortOrder); - const timeDelta = sort.direction === "oldest_first" ? leftTime - rightTime : rightTime - leftTime; - return ( - priorityDelta || - timeDelta || - left.thread.title.localeCompare(right.thread.title) || - String(left.thread.id).localeCompare(String(right.thread.id)) - ); -} - -export function buildPhaseSidebarGroups( - rows: ReadonlyArray, - filters: PhaseSidebarFilters, - sortOrder: SidebarThreadSortOrder, - sort: PhaseSidebarSortPreferences = DEFAULT_PHASE_SIDEBAR_SORT, -): ReadonlyArray { - const visibleRows = filterVisiblePhaseSidebarRows(rows, filters); - - return PHASE_SIDEBAR_PHASES.flatMap((phase) => { - const phaseRows = visibleRows - .filter((row) => row.phaseId === phase.id) - .toSorted((left, right) => comparePhaseSidebarRows(left, right, sortOrder, sort)); - return phaseRows.length > 0 ? [{ ...phase, rows: phaseRows }] : []; - }); -} - -function sanitizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return [ - ...new Set( - value.filter( - (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, - ), - ), - ]; -} - -export function sanitizePhaseSidebarFilters(value: unknown): PhaseSidebarFilters { - if (!value || typeof value !== "object") return EMPTY_PHASE_SIDEBAR_FILTERS; - const candidate = value as Partial>; - return { - repositoryKeys: sanitizeStringArray(candidate.repositoryKeys), - phaseIds: sanitizeStringArray(candidate.phaseIds).filter( - (phaseId): phaseId is PhaseSidebarPhaseId => PHASE_ID_SET.has(phaseId), - ), - providerKinds: sanitizeStringArray(candidate.providerKinds), - // T3-CUSTOM(expbkt3): missing on blobs written before these facets existed, - // so both default off; storage stays v1. - ownedByMe: candidate.ownedByMe === true, - participantUserIds: sanitizeStringArray(candidate.participantUserIds), - }; -} - -export function reconcilePhaseSidebarFilters( - filters: PhaseSidebarFilters, - options: { - readonly repositoryKeys: ReadonlySet; - readonly providerKinds: ReadonlySet; - // False on single-user builds (no operator identity): a persisted - // ownership filter would otherwise hide every thread. - readonly assignmentAvailable: boolean; - // T3-CUSTOM(expbkt3): people still present in the directory. A teammate who - // leaves must not keep an invisible filter pinned over the sidebar. - readonly participantUserIds?: ReadonlySet; - }, -): PhaseSidebarFilters { - const knownParticipants = options.participantUserIds; - return { - repositoryKeys: filters.repositoryKeys.filter((key) => options.repositoryKeys.has(key)), - phaseIds: filters.phaseIds.filter((phaseId) => PHASE_ID_SET.has(phaseId)), - providerKinds: filters.providerKinds.filter((kind) => options.providerKinds.has(kind)), - ownedByMe: options.assignmentAvailable ? filters.ownedByMe : false, - participantUserIds: !options.assignmentAvailable - ? [] - : knownParticipants === undefined - ? filters.participantUserIds - : filters.participantUserIds.filter((userId) => knownParticipants.has(userId)), - }; -} - -export function buildPhaseSidebarFilterChips( - filters: PhaseSidebarFilters, - labels: { - readonly repositories: ReadonlyMap; - readonly providers: ReadonlyMap; - // T3-CUSTOM(expbkt3): display names for the co-participant facet. - readonly people?: ReadonlyMap; - }, -): ReadonlyArray { - const phaseLabels = new Map(PHASE_SIDEBAR_PHASES.map((phase) => [phase.id, phase.label])); - return [ - ...filters.repositoryKeys.map((value) => ({ - facet: "repository" as const, - value, - label: labels.repositories.get(value) ?? value, - })), - ...filters.phaseIds.map((value) => ({ - facet: "phase" as const, - value, - label: phaseLabels.get(value) ?? value, - })), - ...filters.providerKinds.map((value) => ({ - facet: "provider" as const, - value, - label: labels.providers.get(value) ?? value, - })), - // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant chips. - ...(filters.ownedByMe - ? [{ facet: "assignment" as const, value: "owned-by-me", label: "Started by me" }] - : []), - ...filters.participantUserIds.map((value) => ({ - facet: "person" as const, - value, - label: labels.people?.get(value) ?? "Teammate", - })), - // T3-CUSTOM(expbkt3): END - ]; -} - -export function flattenPhaseSidebarGroups( - groups: ReadonlyArray, -): ReadonlyArray { - return groups.flatMap((group) => group.rows); -} - -export function resolvePhaseSidebarTraversalTarget(input: { - readonly visibleThreadKeys: ReadonlyArray; - readonly currentThreadKey: string | null; - readonly direction: "previous" | "next"; -}): string | null { - if (input.visibleThreadKeys.length === 0) return null; - const currentIndex = input.currentThreadKey - ? input.visibleThreadKeys.indexOf(input.currentThreadKey) - : -1; - if (currentIndex === -1) { - return input.direction === "previous" - ? (input.visibleThreadKeys.at(-1) ?? null) - : (input.visibleThreadKeys[0] ?? null); - } - if (input.direction === "previous") { - return currentIndex > 0 ? (input.visibleThreadKeys[currentIndex - 1] ?? null) : null; - } - return currentIndex < input.visibleThreadKeys.length - 1 - ? (input.visibleThreadKeys[currentIndex + 1] ?? null) - : null; -} diff --git a/apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts b/apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts index e9d7dfdbc6a9..e7abb128ec5d 100644 --- a/apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts +++ b/apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts @@ -1,423 +1,4 @@ -// T3-CUSTOM(expbkt3): session trees for the experimental sidebar. -// -// A session that fans work out — typically cross-repo, via the t3_create_session -// MCP tool — records the session that spawned it. This module turns that flat -// `parentThreadId` link into the nested rows the sidebar renders, and decides -// which lifecycle group a parent belongs in once its children are folded into it. -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; - -import { - matchesPhaseSidebarFilters, - resolvePhaseSidebarAttentionKind, - PHASE_SIDEBAR_PHASES, - type PhaseSidebarAttentionKind, - type PhaseSidebarFilters, - type PhaseSidebarPhaseDefinition, - type PhaseSidebarPhaseId, - type PhaseSidebarRow, -} from "./PhaseGroupedSidebar.logic"; - -/** - * Indentation stops growing past this depth. Deep chains still nest logically — - * traversal, counts and the phase override all keep working — but the sidebar is - * ~260px wide, so past three levels the indent costs more title than it buys in - * legibility. - */ -export const PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH = 3; - -/** - * Backstop for a projection that already contains a cycle. The server rejects - * commands that would create one, but a client must never hang on bad data. - */ -export const PHASE_SIDEBAR_TREE_MAX_DEPTH = 16; - -/** - * A descendant counts as "busy" when its own phase says an agent is actively - * working. This is the single input to the parent's phase override. - */ -const BUSY_PHASE_IDS: ReadonlySet = new Set([ - "planning", - "implementing", -]); - -/** - * Most-blocking first. A subtree can hold several stuck sessions at once, and - * the parent has room for exactly one derived badge, so it reports the worst. - */ -const ATTENTION_RANK: ReadonlyArray = ["input", "approval", "error"]; - -/** - * A descendant needs a human when it is parked in the Needs Input phase or is - * flying an attention badge of its own — a pending approval does not change a - * session's phase, so both signals matter. - */ -function attentionKindOf(row: PhaseSidebarRow): PhaseSidebarAttentionKind | null { - const kind = resolvePhaseSidebarAttentionKind(row.thread); - if (kind !== null) return kind; - return row.phaseId === "needs_input" ? "input" : null; -} - -function moreUrgent( - left: PhaseSidebarAttentionKind | null, - right: PhaseSidebarAttentionKind | null, -): PhaseSidebarAttentionKind | null { - if (left === null) return right; - if (right === null) return left; - return ATTENTION_RANK.indexOf(left) <= ATTENTION_RANK.indexOf(right) ? left : right; -} - -export interface PhaseSidebarTreeNode { - readonly row: PhaseSidebarRow; - readonly key: string; - readonly children: ReadonlyArray; - /** 0 for a root row; used for indentation and for the aria tree semantics. */ - readonly depth: number; - /** Every descendant, not just direct children — this is the count the pill shows. */ - readonly descendantCount: number; - /** True when any descendant is planning or implementing (see BUSY_PHASE_IDS). */ - readonly hasBusyDescendant: boolean; - /** - * How many descendants finished a turn the user has not read yet, and how - * many have an agent working. Counts rather than the booleans above: a parent - * with a wide fan-out needs to know whether one child or nine are waiting to - * be read, and the number is the whole reason to open the subtree. - */ - readonly descendantUnreadCount: number; - readonly descendantRunningCount: number; - /** - * The most blocking thing any descendant is waiting on, or null. Drives both - * the parent's group placement and its derived badge: work buried in a - * collapsed subtree is invisible, so the parent has to raise its hand. - */ - readonly descendantAttention: PhaseSidebarAttentionKind | null; - /** - * Set only on a row whose recorded parent is not rendering in this section — - * archived, settled, filtered out, in another environment, or deleted. The row - * renders at the top level with this breadcrumb instead of silently losing its - * lineage. - */ - readonly orphanedFrom: { readonly key: string; readonly title: string } | null; -} - -export function phaseSidebarRowKey(row: PhaseSidebarRow): string { - return scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)); -} - -/** - * The parent link is a bare thread id: a session can only be created by a caller - * on the same server, so parent and child always share an environment. Scoping - * the lookup by the child's environment is therefore both correct and the only - * way to avoid colliding ids across connected environments. - */ -function parentKeyOf(row: PhaseSidebarRow): string | null { - const parentThreadId = row.thread.parentThreadId; - if (parentThreadId == null) return null; - return scopedThreadKey(scopeThreadRef(row.thread.environmentId, parentThreadId)); -} - -interface MutableNode { - readonly row: PhaseSidebarRow; - readonly key: string; - readonly children: MutableNode[]; - depth: number; - descendantCount: number; - hasBusyDescendant: boolean; - descendantUnreadCount: number; - descendantRunningCount: number; - descendantAttention: PhaseSidebarAttentionKind | null; - orphanedFrom: { readonly key: string; readonly title: string } | null; -} - -function isBusy(row: PhaseSidebarRow): boolean { - return BUSY_PHASE_IDS.has(row.phaseId); -} - -/** - * Resolve the row's effective parent, or null when it should render as a root. - * - * A row nests only if its parent is present in the SAME row set. That one rule - * absorbs every edge case — parent archived, settled, snoozed, filtered out, - * deleted, or in another environment — without special-casing any of them, and - * guarantees the result is a forest rooted in rows that actually render. - */ -function resolveParent( - node: MutableNode, - byKey: ReadonlyMap, -): MutableNode | null { - const parentKey = parentKeyOf(node.row); - if (parentKey === null) return null; - const parent = byKey.get(parentKey); - if (parent === undefined || parent.key === node.key) return null; - - // Walk to the root before accepting the link. A cycle here means the stored - // data is already corrupt; promoting the row to a root keeps the sidebar - // usable instead of dropping the row or looping forever. - const seen = new Set([node.key]); - let cursor: MutableNode | undefined = parent; - for (let depth = 0; cursor !== undefined && depth < PHASE_SIDEBAR_TREE_MAX_DEPTH; depth += 1) { - if (seen.has(cursor.key)) return null; - seen.add(cursor.key); - const nextKey = parentKeyOf(cursor.row); - cursor = nextKey === null ? undefined : byKey.get(nextKey); - } - return cursor === undefined ? parent : null; -} - -/** - * Bottom-up rollup of the two derived facts a parent row renders: how many - * sessions live under it, and whether any of them is doing work. - */ -function finalize(node: MutableNode, depth: number): void { - node.depth = depth; - let descendantCount = 0; - let hasBusyDescendant = false; - let descendantUnreadCount = 0; - let descendantRunningCount = 0; - let descendantAttention: PhaseSidebarAttentionKind | null = null; - for (const child of node.children) { - finalize(child, depth + 1); - descendantCount += 1 + child.descendantCount; - hasBusyDescendant = hasBusyDescendant || isBusy(child.row) || child.hasBusyDescendant; - descendantUnreadCount += (child.row.isUnreadCompletion ? 1 : 0) + child.descendantUnreadCount; - descendantRunningCount += (isBusy(child.row) ? 1 : 0) + child.descendantRunningCount; - descendantAttention = moreUrgent( - descendantAttention, - moreUrgent(attentionKindOf(child.row), child.descendantAttention), - ); - } - node.descendantCount = descendantCount; - node.hasBusyDescendant = hasBusyDescendant; - node.descendantUnreadCount = descendantUnreadCount; - node.descendantRunningCount = descendantRunningCount; - node.descendantAttention = descendantAttention; -} - -function freeze(node: MutableNode): PhaseSidebarTreeNode { - return { - row: node.row, - key: node.key, - children: node.children.map(freeze), - depth: node.depth, - descendantCount: node.descendantCount, - hasBusyDescendant: node.hasBusyDescendant, - descendantUnreadCount: node.descendantUnreadCount, - descendantRunningCount: node.descendantRunningCount, - descendantAttention: node.descendantAttention, - orphanedFrom: node.orphanedFrom, - }; -} - -/** - * Build the forest for one sidebar section (active / snoozed / settled). - * - * `compareSiblings` orders both the returned roots and every child list, so a - * subtree reads with the same ordering rules as the list it sits in. - * `titleForKey` resolves orphan breadcrumbs against the full thread set, not - * just this section, so "↳ Parent title" still names a settled or filtered - * parent. - */ -export function buildPhaseSidebarTree( - rows: ReadonlyArray, - options: { - readonly compareSiblings: (left: PhaseSidebarRow, right: PhaseSidebarRow) => number; - readonly titleForKey?: (key: string) => string | null; - }, -): ReadonlyArray { - const nodes: MutableNode[] = rows.map((row) => ({ - row, - key: phaseSidebarRowKey(row), - children: [], - depth: 0, - descendantCount: 0, - hasBusyDescendant: false, - descendantUnreadCount: 0, - descendantRunningCount: 0, - descendantAttention: null, - orphanedFrom: null, - })); - const byKey = new Map(nodes.map((node) => [node.key, node])); - - const roots: MutableNode[] = []; - for (const node of nodes) { - const parent = resolveParent(node, byKey); - if (parent === null) { - const parentKey = parentKeyOf(node.row); - if (parentKey !== null) { - const title = options.titleForKey?.(parentKey) ?? null; - if (title !== null) node.orphanedFrom = { key: parentKey, title }; - } - roots.push(node); - continue; - } - parent.children.push(node); - } - - const sortRecursively = (list: MutableNode[]): void => { - list.sort((left, right) => options.compareSiblings(left.row, right.row)); - for (const node of list) sortRecursively(node.children); - }; - sortRecursively(roots); - for (const root of roots) finalize(root, 0); - - return roots.map(freeze); -} - -/** - * The phase a ROOT row is grouped under. - * - * Precedence, most urgent first: - * - * 1. Anything in the subtree is waiting on a human → Needs Input - * 2. Anything in the subtree is doing work → Implementing - * 3. Otherwise → the row's own phase - * - * Attention outranks work because a collapsed subtree hides it completely: an - * approval sitting two levels down under a parent filed as "Implementing" is - * invisible until someone happens to expand the right row. Hoisting the parent - * costs one row of churn and is the whole reason the Needs Input group is worth - * scanning first. - */ -export function resolvePhaseSidebarTreePhase(node: PhaseSidebarTreeNode): PhaseSidebarPhaseId { - if (node.descendantAttention !== null) return "needs_input"; - return node.hasBusyDescendant ? "implementing" : node.row.phaseId; -} - -export function flattenPhaseSidebarTree( - nodes: ReadonlyArray, - isExpanded: (key: string) => boolean, -): ReadonlyArray { - const flattened: PhaseSidebarTreeNode[] = []; - const visit = (node: PhaseSidebarTreeNode): void => { - flattened.push(node); - if (node.children.length === 0 || !isExpanded(node.key)) return; - for (const child of node.children) visit(child); - }; - for (const node of nodes) visit(node); - return flattened; -} - -/** Every key in a subtree except its root — backs "Expand/Collapse all children". */ -export function collectPhaseSidebarSubtreeKeys(node: PhaseSidebarTreeNode): ReadonlyArray { - const keys: string[] = []; - const visit = (current: PhaseSidebarTreeNode): void => { - for (const child of current.children) { - keys.push(child.key); - visit(child); - } - }; - visit(node); - return keys; -} - -/** - * Keys of parents that must be force-expanded because a filter matched - * something inside them. Without this, filtering by repository would silently - * hide matches nested under a collapsed parent from another repository — the - * exact cross-repo case this feature exists to make visible. - */ -export function resolveForcedExpansionKeys( - nodes: ReadonlyArray, - matches: (row: PhaseSidebarRow) => boolean, -): ReadonlySet { - const forced = new Set(); - const visit = (node: PhaseSidebarTreeNode): boolean => { - let descendantMatched = false; - for (const child of node.children) { - descendantMatched = visit(child) || descendantMatched; - } - if (descendantMatched) forced.add(node.key); - return descendantMatched || matches(node.row); - }; - for (const node of nodes) visit(node); - return forced; -} - -/** Indentation in px for a nested row, capped so deep chains stay readable. */ -export function phaseSidebarTreeIndent(depth: number): number { - return Math.min(depth, PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH) * 14; -} - -export function phaseSidebarFiltersActive(filters: PhaseSidebarFilters): boolean { - return ( - filters.repositoryKeys.length > 0 || - filters.phaseIds.length > 0 || - filters.providerKinds.length > 0 || - // T3-CUSTOM(expbkt3): ownership and co-participant facets. - filters.participantUserIds.length > 0 || - filters.ownedByMe - ); -} - -export interface PhaseSidebarTreeGroup extends PhaseSidebarPhaseDefinition { - readonly nodes: ReadonlyArray; -} - -export interface PhaseSidebarTreeGroupsResult { - readonly groups: ReadonlyArray; - /** - * Parents the user did not open but that must render open anyway, because a - * filter matched something inside them. Transient — never written to the - * expansion store, so clearing the filter restores the user's own state. - */ - readonly forcedExpansionKeys: ReadonlySet; -} - -/** - * The full pipeline for one section: filter, nest, then group the roots. - * - * Filtering runs against the tree rather than the flat row list so a match is - * never hidden inside a collapsed parent that does not itself match. A row - * survives when it matches, or when anything in its subtree matches (its - * ancestors are carried along to keep the path renderable). - */ -export function buildPhaseSidebarTreeGroups(input: { - readonly rows: ReadonlyArray; - readonly filters: PhaseSidebarFilters; - readonly compareSiblings: (left: PhaseSidebarRow, right: PhaseSidebarRow) => number; - readonly titleForKey?: (key: string) => string | null; -}): PhaseSidebarTreeGroupsResult { - const candidates = input.rows.filter((row) => row.thread.archivedAt === null); - const matches = (row: PhaseSidebarRow) => matchesPhaseSidebarFilters(row, input.filters); - const filtersActive = phaseSidebarFiltersActive(input.filters); - - let survivingRows = candidates; - if (filtersActive) { - // Nest against the UNFILTERED set first, so ancestry is true lineage rather - // than an artefact of what the filter happened to leave behind. A row is - // kept when it matches; its ancestors come along to keep the path to it - // renderable. - const keep = new Set(); - const visit = (node: PhaseSidebarTreeNode, ancestorKeys: ReadonlyArray): void => { - if (matches(node.row)) { - keep.add(node.key); - for (const ancestorKey of ancestorKeys) keep.add(ancestorKey); - } - const nextAncestors = [...ancestorKeys, node.key]; - for (const child of node.children) visit(child, nextAncestors); - }; - for (const node of buildPhaseSidebarTree(candidates, { - compareSiblings: input.compareSiblings, - })) { - visit(node, []); - } - survivingRows = candidates.filter((row) => keep.has(phaseSidebarRowKey(row))); - } - - // Descendant counts and the busy rollup describe what actually renders. - const tree = buildPhaseSidebarTree(survivingRows, { - compareSiblings: input.compareSiblings, - ...(input.titleForKey ? { titleForKey: input.titleForKey } : {}), - }); - - const groups = PHASE_SIDEBAR_PHASES.flatMap((phase) => { - const nodes = tree.filter((node) => resolvePhaseSidebarTreePhase(node) === phase.id); - return nodes.length > 0 ? [{ ...phase, nodes }] : []; - }); - - return { - groups, - forcedExpansionKeys: filtersActive - ? resolveForcedExpansionKeys(tree, matches) - : new Set(), - }; -} +// T3-CUSTOM(expbkt3): moved to @t3tools/client-runtime/state/phase-sidebar-tree +// so the mobile thread list nests sessions the same way. Re-exported here so +// existing apps/web imports keep working. +export * from "@t3tools/client-runtime/state/phase-sidebar-tree"; diff --git a/apps/web/src/components/sidebar/RunningSessionGlint.logic.ts b/apps/web/src/components/sidebar/RunningSessionGlint.logic.ts index 1af2f4065aa5..b9e28254acea 100644 --- a/apps/web/src/components/sidebar/RunningSessionGlint.logic.ts +++ b/apps/web/src/components/sidebar/RunningSessionGlint.logic.ts @@ -1,21 +1,9 @@ -import type { PhaseSidebarPhaseId, PhaseSidebarSection } from "./PhaseGroupedSidebar.logic"; - -export function isRunningSessionPhase(phaseId: PhaseSidebarPhaseId): boolean { - return phaseId === "planning" || phaseId === "implementing"; -} - -/** Running motion belongs only to live lifecycle rows, never parked history. */ -export function shouldShowRunningSessionGlint( - phaseId: PhaseSidebarPhaseId, - section: PhaseSidebarSection, -): boolean { - return section === "active" && isRunningSessionPhase(phaseId); -} - -/** Place one quiet boundary before running work when idle groups are also visible. */ -export function runningSessionDividerPhase( - phaseIds: ReadonlyArray, -): PhaseSidebarPhaseId | null { - if (!phaseIds.some((phaseId) => !isRunningSessionPhase(phaseId))) return null; - return phaseIds.find(isRunningSessionPhase) ?? null; -} +// T3-CUSTOM(expbkt3): moved into @t3tools/client-runtime/state/phase-sidebar. +// Only the decision is shared — web renders the emphasis as an animated glint, +// mobile renders it statically. Re-exported here so existing apps/web imports +// keep working. +export { + isRunningSessionPhase, + runningSessionDividerPhase, + shouldShowRunningSessionGlint, +} from "@t3tools/client-runtime/state/phase-sidebar"; diff --git a/apps/web/src/components/sidebar/SidebarProviderRateLimits.logic.ts b/apps/web/src/components/sidebar/SidebarProviderRateLimits.logic.ts index 86eda54c06c1..8b6007ef46b5 100644 --- a/apps/web/src/components/sidebar/SidebarProviderRateLimits.logic.ts +++ b/apps/web/src/components/sidebar/SidebarProviderRateLimits.logic.ts @@ -1,375 +1,5 @@ -import { - defaultInstanceIdForDriver, - type EnvironmentId, - ProviderDriverKind, - type ProviderInstanceId, - type ProviderRateLimitSnapshot, - type ProviderRateLimitWindow, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; - -const STALE_AFTER_MS = 10 * 60 * 1_000; -const MINUTE_MS = 60 * 1_000; -const MINUTES_PER_DAY = 24 * 60; -const CODEX = ProviderDriverKind.make("codex"); -const CLAUDE = ProviderDriverKind.make("claudeAgent"); -const DISPLAY_ORDER = [CODEX, CLAUDE] as const; - -/** - * T3-CUSTOM(expbkt3): the headline reading is the weekly window only. - * - * It used to be `min()` across every window, which meant the short rolling - * window (Claude's five-hour, Codex's primary) almost always won and the - * sidebar silently reported a five-hour number under a weekly-looking meter. - * The rolling window is now surfaced separately, and only once it actually - * constrains you -- see ROLLING_CHIP_VISIBLE_BELOW_PERCENT. - */ -const ROLLING_CHIP_VISIBLE_BELOW_PERCENT = 50; - -export function selectProviderRateLimitEnvironmentId( - activeEnvironmentId: EnvironmentId | null, - primaryEnvironmentId: EnvironmentId | null, -): EnvironmentId | null { - return activeEnvironmentId ?? primaryEnvironmentId; -} - -export type ProviderRateLimitTone = "healthy" | "warning" | "danger" | "unknown"; -export type ProviderRateLimitFreshness = "fresh" | "stale" | "unknown" | "not-applicable" | "error"; - -export interface ProviderRateLimitHeaderProvider { - readonly instanceId: ProviderInstanceId; - readonly driver: ProviderDriverKind; - readonly enabled: boolean; -} - -export interface ProviderRateLimitWindowView { - readonly window: ProviderRateLimitWindow; - readonly remainingPercent: number | null; - readonly status: "active" | "stale" | "awaiting-refresh"; -} - -/** - * The short rolling window (five-hour on Claude, primary on Codex), shown beside - * the weekly meter only while it is the tighter constraint. - */ -export interface ProviderRateLimitRollingView { - readonly remainingPercent: number; - readonly minutesUntilReset: number | null; - readonly resetsAtMs: number | null; - readonly tone: ProviderRateLimitTone; - /** Compact window length, e.g. `5h`. Null when the provider omits a duration. */ - readonly windowLabel: string | null; -} - -/** - * Minutes as the shortest readable unit: `47m`, `1h 8m`, `5h`. Used for both the - * window length and its reset countdown so the chip reads consistently. - */ -export function formatCompactMinutes(minutes: number): string { - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - const rest = minutes % 60; - return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`; -} - -/** - * A single unit, rounded up: `6d`, `18h`, `45m`. The weekly countdown sits in the - * sidebar permanently, so it trades the compound form's precision for a label - * that never grows past three characters. - */ -export function formatSingleUnitMinutes(minutes: number): string { - if (minutes >= MINUTES_PER_DAY) return `${Math.ceil(minutes / MINUTES_PER_DAY)}d`; - if (minutes >= 60) return `${Math.ceil(minutes / 60)}h`; - return `${Math.max(1, minutes)}m`; -} - -/** - * When {@link formatSingleUnitMinutes} would next print something different. - * Rounding up means the label changes as the remaining time crosses each whole - * unit, so the sidebar can wake exactly then instead of ticking every minute for - * a week. - */ -export function singleUnitBoundaryMs(resetsAtMs: number, minutesUntilReset: number): number { - const unitMinutes = - minutesUntilReset >= MINUTES_PER_DAY ? MINUTES_PER_DAY : minutesUntilReset >= 60 ? 60 : 1; - const wholeUnits = Math.ceil(minutesUntilReset / unitMinutes); - return resetsAtMs - (wholeUnits - 1) * unitMinutes * MINUTE_MS; -} - -export interface ProviderRateLimitRowView { - readonly driverKind: ProviderDriverKind; - readonly providerInstanceId: ProviderInstanceId; - readonly displayName: "Codex" | "Claude"; - readonly availability: ProviderRateLimitSnapshot["availability"]; - /** Weekly window only. Null when the provider reports no weekly quota. */ - readonly remainingPercent: number | null; - /** - * When the window behind {@link remainingPercent} refills. Always rendered, so - * the headline percentage is never read without knowing how long it has to last. - */ - readonly headlineMinutesUntilReset: number | null; - readonly headlineResetsAtMs: number | null; - readonly rolling: ProviderRateLimitRollingView | null; - readonly tone: ProviderRateLimitTone; - readonly freshness: ProviderRateLimitFreshness; - readonly observedAt: DateTime.Utc | null; - readonly lastRefreshFailed: boolean; - readonly source: "live" | "cache"; - readonly windows: ReadonlyArray; -} - -export function providerRateLimitTone(remainingPercent: number | null): ProviderRateLimitTone { - if (remainingPercent === null) return "unknown"; - if (remainingPercent >= 50) return "healthy"; - if (remainingPercent >= 20) return "warning"; - return "danger"; -} - -function roundedRemaining(usedPercent: number): number { - return Math.round(100 - usedPercent); -} - -function isSnapshotStale(snapshot: ProviderRateLimitSnapshot, now: number): boolean { - return ( - snapshot.observedAt !== null && - now - DateTime.toEpochMillis(snapshot.observedAt) > STALE_AFTER_MS - ); -} - -function windowView( - window: ProviderRateLimitWindow, - now: number, - stale: boolean, -): ProviderRateLimitWindowView { - if (window.resetsAt !== null && DateTime.toEpochMillis(window.resetsAt) <= now) { - return { - window, - remainingPercent: roundedRemaining(window.usedPercent), - status: "awaiting-refresh", - }; - } - if (stale) { - return { window, remainingPercent: roundedRemaining(window.usedPercent), status: "stale" }; - } - return { window, remainingPercent: roundedRemaining(window.usedPercent), status: "active" }; -} - -function minutesUntilReset(resetsAt: DateTime.Utc | null, now: number): number | null { - if (resetsAt === null) return null; - return Math.max(0, Math.ceil((DateTime.toEpochMillis(resetsAt) - now) / MINUTE_MS)); -} - -function displayName(driver: ProviderDriverKind): "Codex" | "Claude" { - return driver === CODEX ? "Codex" : "Claude"; -} - -function unknownRow( - driverKind: ProviderDriverKind, - providerInstanceId: ProviderInstanceId, -): ProviderRateLimitRowView { - return { - driverKind, - providerInstanceId, - displayName: displayName(driverKind), - availability: "unknown", - remainingPercent: null, - headlineMinutesUntilReset: null, - headlineResetsAtMs: null, - rolling: null, - tone: "unknown", - freshness: "unknown", - observedAt: null, - lastRefreshFailed: false, - source: "live", - windows: [], - }; -} - -function projectRow( - snapshot: ProviderRateLimitSnapshot, - now: number, - source: "live" | "cache", -): ProviderRateLimitRowView { - const stale = - snapshot.availability === "available" && - (source === "cache" || snapshot.lastRefreshFailed || isSnapshotStale(snapshot, now)); - const windows = snapshot.windows - .map((window) => windowView(window, now, stale)) - .toSorted( - (left, right) => - (left.remainingPercent ?? Number.POSITIVE_INFINITY) - - (right.remainingPercent ?? Number.POSITIVE_INFINITY), - ); - const activeRemainingValues = windows.flatMap((window) => - window.status !== "active" || window.remainingPercent === null ? [] : [window.remainingPercent], - ); - // `windows` is already sorted ascending by remaining, so the head of a pool is - // its lowest reading. - const lowestOf = ( - views: ReadonlyArray, - ): ProviderRateLimitWindowView | null => { - const known = views.filter((view) => view.remainingPercent !== null); - const active = known.filter((view) => view.status === "active"); - const pool = stale ? known : active.length > 0 ? active : known; - return pool[0] ?? null; - }; - const weeklyWindows = windows.filter(({ window }) => window.category === "weekly"); - // Providers that report no weekly quota keep the previous all-window reading - // rather than degrading the meter to an em dash. - const headline = lowestOf(weeklyWindows.length > 0 ? weeklyWindows : windows); - const remainingPercent = - snapshot.availability === "available" ? (headline?.remainingPercent ?? null) : null; - const headlineResetsAt = remainingPercent === null ? null : (headline?.window.resetsAt ?? null); - const rollingLowest = lowestOf(windows.filter(({ window }) => window.category === "rolling")); - const rollingRemaining = rollingLowest?.remainingPercent ?? null; - const rolling: ProviderRateLimitRollingView | null = - snapshot.availability === "available" && - rollingRemaining !== null && - rollingRemaining < ROLLING_CHIP_VISIBLE_BELOW_PERCENT - ? { - remainingPercent: rollingRemaining, - minutesUntilReset: minutesUntilReset(rollingLowest?.window.resetsAt ?? null, now), - resetsAtMs: - rollingLowest?.window.resetsAt == null - ? null - : DateTime.toEpochMillis(rollingLowest.window.resetsAt), - tone: providerRateLimitTone(rollingRemaining), - windowLabel: - rollingLowest?.window.windowDurationMinutes === undefined - ? null - : formatCompactMinutes(rollingLowest.window.windowDurationMinutes), - } - : null; - const hasOnlyExpiredWindows = - snapshot.availability === "available" && - windows.length > 0 && - activeRemainingValues.length === 0; - const freshness: ProviderRateLimitFreshness = - snapshot.availability === "not-applicable" - ? "not-applicable" - : snapshot.availability === "error" - ? "error" - : stale || hasOnlyExpiredWindows - ? "stale" - : snapshot.observedAt === null - ? "unknown" - : "fresh"; - - return { - driverKind: snapshot.driverKind, - providerInstanceId: snapshot.providerInstanceId, - displayName: displayName(snapshot.driverKind), - availability: snapshot.availability, - remainingPercent, - headlineMinutesUntilReset: minutesUntilReset(headlineResetsAt, now), - headlineResetsAtMs: headlineResetsAt === null ? null : DateTime.toEpochMillis(headlineResetsAt), - rolling: - rolling === null ? null : freshness === "fresh" ? rolling : { ...rolling, tone: "unknown" }, - tone: freshness === "fresh" ? providerRateLimitTone(remainingPercent) : "unknown", - freshness, - observedAt: snapshot.observedAt, - lastRefreshFailed: snapshot.lastRefreshFailed, - source, - windows, - }; -} - -function canUseLiveSnapshot(snapshot: ProviderRateLimitSnapshot): boolean { - return ( - snapshot.availability === "not-applicable" || - (snapshot.availability === "available" && - snapshot.observedAt !== null && - snapshot.windows.length > 0) - ); -} - -export function buildProviderRateLimitRows(input: { - readonly providers: ReadonlyArray; - readonly entries: ReadonlyArray; - readonly cachedEntries?: ReadonlyArray; - readonly now: number; -}): ReadonlyArray { - const entryById = new Map(input.entries.map((entry) => [entry.providerInstanceId, entry])); - const cachedEntryById = new Map( - (input.cachedEntries ?? []).map((entry) => [entry.providerInstanceId, entry]), - ); - const providerById = new Map(input.providers.map((provider) => [provider.instanceId, provider])); - - return DISPLAY_ORDER.flatMap((driverKind) => { - const defaultId = defaultInstanceIdForDriver(driverKind); - const provider = providerById.get(defaultId); - if (!provider?.enabled) return []; - const liveEntry = entryById.get(defaultId); - if (liveEntry !== undefined && canUseLiveSnapshot(liveEntry)) { - return [projectRow(liveEntry, input.now, "live")]; - } - const cachedEntry = cachedEntryById.get(defaultId); - if (cachedEntry !== undefined && cachedEntry.availability === "available") { - return [projectRow(cachedEntry, input.now, "cache")]; - } - return [ - liveEntry === undefined - ? unknownRow(driverKind, defaultId) - : projectRow(liveEntry, input.now, "live"), - ]; - }); -} - -export function summarizeProviderRateLimitRows( - rows: ReadonlyArray, -): string { - const readings = rows.map((row) => { - const rolling = - row.rolling === null - ? "" - : `, ${row.rolling.windowLabel ?? "rolling"} window ${ - row.rolling.remainingPercent - }% remaining${ - row.rolling.minutesUntilReset === null - ? "" - : ` and resets in ${formatCompactMinutes(row.rolling.minutesUntilReset)}` - }`; - const headlineReset = - row.headlineMinutesUntilReset === null - ? "" - : `, resets in ${formatSingleUnitMinutes(row.headlineMinutesUntilReset)}`; - return row.remainingPercent === null - ? `${row.displayName} unavailable${rolling}` - : `${row.displayName} ${row.remainingPercent}% weekly remaining${headlineReset}${ - row.source === "cache" ? ", cached" : row.freshness === "stale" ? ", stale" : "" - }${rolling}`; - }); - return `Provider usage limits: ${readings.join("; ")}`; -} - -export function providerRateLimitBoundaryTimes( - rows: ReadonlyArray, -): ReadonlyArray { - return rows.flatMap((row) => [ - ...(row.observedAt === null - ? [] - : [DateTime.toEpochMillis(row.observedAt) + STALE_AFTER_MS + 1]), - ...row.windows.flatMap(({ window }) => - window.resetsAt === null ? [] : [DateTime.toEpochMillis(window.resetsAt)], - ), - ...rollingMinuteBoundaries(row.rolling), - // The always-on weekly countdown moves a unit at a time, so one wake-up per - // unit is enough — a per-minute schedule would be ~10k timers for a week. - ...(row.headlineResetsAtMs === null || row.headlineMinutesUntilReset === null - ? [] - : [singleUnitBoundaryMs(row.headlineResetsAtMs, row.headlineMinutesUntilReset)]), - ]); -} - -/** - * While the rolling countdown is on screen it has to re-render every minute, so - * emit each remaining minute boundary before its reset. - */ -function rollingMinuteBoundaries( - rolling: ProviderRateLimitRollingView | null, -): ReadonlyArray { - const resetsAtMs = rolling?.resetsAtMs; - const minutes = rolling?.minutesUntilReset; - if (resetsAtMs == null || minutes == null) return []; - return Array.from({ length: minutes }, (_, index) => resetsAtMs - (index + 1) * MINUTE_MS); -} +// T3-CUSTOM(expbkt3): moved to +// @t3tools/client-runtime/state/provider-rate-limits so the mobile status strip +// renders the same numbers. Re-exported here so existing apps/web imports keep +// working. +export * from "@t3tools/client-runtime/state/provider-rate-limits"; diff --git a/apps/web/src/components/sidebar/sidebarSessionCounters.ts b/apps/web/src/components/sidebar/sidebarSessionCounters.ts index b22d22d000fb..3c5205dd1df2 100644 --- a/apps/web/src/components/sidebar/sidebarSessionCounters.ts +++ b/apps/web/src/components/sidebar/sidebarSessionCounters.ts @@ -1,71 +1,10 @@ -/** - * T3-CUSTOM(expbkt3): Pure lifecycle aggregation used by the experimental - * wordmark counters and kept separate from upstream sidebar grouping. - */ -import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; - -import type { ThreadShell } from "../../types"; - -export interface SidebarSessionCounts { - readonly nonRunning: number; - readonly running: number; - readonly nextSnoozeWakeAt: string | null; -} - -export interface SidebarSessionCountOptions { - readonly now: string; - readonly snoozeSupported: (thread: ThreadShell) => boolean; -} - -export function threadNeedsHumanAttention(thread: ThreadShell): boolean { - return ( - thread.hasPendingApprovals || - thread.hasPendingUserInput || - thread.hasActionableProposedPlan || - thread.execution?.turn?.state === "waiting-for-approval" || - thread.execution?.turn?.state === "waiting-for-input" || - thread.execution?.activity === "failed" || - thread.session?.status === "error" - ); -} - -export function threadIsRunning(thread: ThreadShell): boolean { - return ( - thread.execution?.activity === "active" || - thread.execution?.activity === "blocked" || - thread.execution?.activity === "stopping" || - thread.session?.status === "starting" || - thread.session?.status === "running" || - thread.backgroundLiveness === "working" || - thread.backgroundLiveness === "monitoring" - ); -} - -export function summarizeSidebarSessions( - threads: ReadonlyArray, - options: SidebarSessionCountOptions, -): SidebarSessionCounts { - let nonRunning = 0; - let running = 0; - let nextSnoozeWakeAt: string | null = null; - let nextSnoozeWakeAtMs = Number.POSITIVE_INFINITY; - - for (const thread of threads) { - if (thread.archivedAt !== null || thread.settledAt !== null) continue; - if (threadIsRunning(thread)) { - running += 1; - continue; - } - if (options.snoozeSupported(thread) && effectiveSnoozed(thread, { now: options.now })) { - const wakeAtMs = Date.parse(thread.snoozedUntil ?? ""); - if (wakeAtMs < nextSnoozeWakeAtMs) { - nextSnoozeWakeAt = thread.snoozedUntil ?? null; - nextSnoozeWakeAtMs = wakeAtMs; - } - continue; - } - nonRunning += 1; - } - - return { nonRunning, running, nextSnoozeWakeAt }; -} +// T3-CUSTOM(expbkt3): moved into @t3tools/client-runtime/state/phase-sidebar so +// the mobile home list shows the same lifecycle counters. Re-exported here so +// existing apps/web imports keep working. +export { + summarizeSidebarSessions, + threadIsRunning, + threadNeedsHumanAttention, + type SidebarSessionCountOptions, + type SidebarSessionCounts, +} from "@t3tools/client-runtime/state/phase-sidebar"; diff --git a/apps/web/src/threadVisitTimestamp.ts b/apps/web/src/threadVisitTimestamp.ts index 517ea36cbc53..9620d40b0c17 100644 --- a/apps/web/src/threadVisitTimestamp.ts +++ b/apps/web/src/threadVisitTimestamp.ts @@ -1,20 +1,7 @@ -export interface ThreadVisitTimestampInput { - readonly threadUpdatedAt: string; - readonly latestTurnCompletedAt: string | null | undefined; -} - -export function resolveThreadVisitTimestamp(input: ThreadVisitTimestampInput): string { - const threadUpdatedAtMs = Date.parse(input.threadUpdatedAt); - const latestTurnCompletedAt = input.latestTurnCompletedAt; - const latestTurnCompletedAtMs = latestTurnCompletedAt - ? Date.parse(latestTurnCompletedAt) - : Number.NaN; - if ( - latestTurnCompletedAt != null && - Number.isFinite(latestTurnCompletedAtMs) && - (!Number.isFinite(threadUpdatedAtMs) || latestTurnCompletedAtMs > threadUpdatedAtMs) - ) { - return latestTurnCompletedAt; - } - return input.threadUpdatedAt; -} +// T3-CUSTOM(expbkt3): moved into @t3tools/client-runtime/state/phase-sidebar so +// mobile can decide "unread" the same way. Re-exported here so existing +// apps/web imports keep working. +export { + resolveThreadVisitTimestamp, + type ThreadVisitTimestampInput, +} from "@t3tools/client-runtime/state/phase-sidebar"; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 201407bd95cc..73c544daf289 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -167,6 +167,18 @@ "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" }, + "./state/phase-sidebar": { + "types": "./src/state/phaseSidebar.ts", + "default": "./src/state/phaseSidebar.ts" + }, + "./state/phase-sidebar-tree": { + "types": "./src/state/phaseSidebarTree.ts", + "default": "./src/state/phaseSidebarTree.ts" + }, + "./state/provider-rate-limits": { + "types": "./src/state/providerRateLimitsPresentation.ts", + "default": "./src/state/providerRateLimitsPresentation.ts" + }, "./state/thread-execution-presentation": { "types": "./src/state/threadExecutionPresentation.ts", "default": "./src/state/threadExecutionPresentation.ts" diff --git a/packages/client-runtime/src/state/phaseSidebar.test.ts b/packages/client-runtime/src/state/phaseSidebar.test.ts new file mode 100644 index 000000000000..3da3c7893f36 --- /dev/null +++ b/packages/client-runtime/src/state/phaseSidebar.test.ts @@ -0,0 +1,329 @@ +import { + DEFAULT_RUNTIME_MODE, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type ThreadExecutionSnapshot, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPhaseSidebarGroups, + collectDescendantThreadIds, + DEFAULT_PHASE_SIDEBAR_SORT, + EMPTY_PHASE_SIDEBAR_FILTERS, + isRunningSessionPhase, + isThreadUnread, + partitionPhaseSidebarRows, + resolveMoveUnderCandidates, + resolvePhaseSidebarPhase, + resolveThreadVisitTimestamp, + runningSessionDividerPhase, + shouldShowRunningSessionGlint, + summarizeSidebarSessions, + type PhaseSidebarRow, +} from "./phaseSidebar.ts"; +import type { EnvironmentThreadShell } from "./shell.ts"; + +// The bulk of this module's behaviour is covered by the web suite that has +// exercised it since it lived under apps/web (it still imports it, through the +// re-export shim). What is asserted here is what the MOVE is responsible for: +// that the shared code runs under React Native's engine, and that the pieces +// folded in from sibling web modules survived intact. + +const now = "2026-01-01T00:00:00.000Z"; +const environmentId = EnvironmentId.make("env-1"); +const projectId = ProjectId.make("project-1"); + +function makeExecution( + overrides: Partial = {}, +): ThreadExecutionSnapshot { + return { + activity: "idle", + canStop: false, + // `intent` is an optionalKey on the contract, so absent — not null — is the + // shape the wire produces when a thread has no durable intent. + providerSession: { + state: "stopped", + providerInstanceId: ProviderInstanceId.make("codex"), + startedAt: now, + lastObservedAt: now, + lastError: null, + }, + turn: null, + ...overrides, + } as ThreadExecutionSnapshot; +} + +function makeThread(overrides: Partial = {}): EnvironmentThreadShell { + return { + id: ThreadId.make("thread-1"), + environmentId, + projectId, + ownerUserId: null, + memberUserIds: [], + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: "default", + branch: null, + worktreePath: null, + sourceControlProfileId: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + execution: makeExecution(), + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + } as EnvironmentThreadShell; +} + +function makeRow(thread: EnvironmentThreadShell): PhaseSidebarRow { + return { + thread, + phaseId: resolvePhaseSidebarPhase(thread), + repositoryKey: "repo", + repositoryLabel: "repo", + providerKind: "codex", + providerName: "Codex", + isAssignedToMe: false, + isOwnedByMe: false, + participantUserIds: [], + attentionPriority: 0, + isUnreadCompletion: false, + settlementSupported: true, + snoozeSupported: true, + prioritySupported: true, + changeRequestState: null, + }; +} + +/** + * Runs `body` with Array.prototype.toSorted removed. + * + * Hermes — the engine the React Native app runs on — does not ship the ES2023 + * change-array-by-copy methods. This module used to live in apps/web, where a + * browser always has them, so every sort in it is exactly the kind of thing + * that would work in every test and then crash on a phone. + */ +function withoutHermesUnsafeArrayMethods(body: () => A): A { + const descriptors = (["toSorted", "toReversed", "toSpliced", "with"] as const).map( + (name) => [name, Object.getOwnPropertyDescriptor(Array.prototype, name)] as const, + ); + for (const [name] of descriptors) Reflect.deleteProperty(Array.prototype, name); + try { + return body(); + } finally { + for (const [name, descriptor] of descriptors) { + if (descriptor !== undefined) Reflect.defineProperty(Array.prototype, name, descriptor); + } + } +} + +describe("Hermes compatibility", () => { + it("groups and partitions without ES2023 array methods", () => { + const threads = [ + makeThread({ id: ThreadId.make("thread-1"), title: "Alpha" }), + makeThread({ + id: ThreadId.make("thread-2"), + title: "Beta", + execution: makeExecution({ activity: "active" }), + }), + // settledOverride is the explicit settle; settledAt alone only stamps + // when it happened. + makeThread({ + id: ThreadId.make("thread-3"), + title: "Gamma", + settledOverride: "settled", + settledAt: now, + }), + ]; + + const result = withoutHermesUnsafeArrayMethods(() => { + const partition = partitionPhaseSidebarRows(threads.map(makeRow), { + now, + preciseNow: now, + autoSettleAfterDays: null, + }); + return { + partition, + groups: buildPhaseSidebarGroups( + partition.activeRows, + EMPTY_PHASE_SIDEBAR_FILTERS, + "updated_at", + DEFAULT_PHASE_SIDEBAR_SORT, + ), + }; + }); + + expect(result.partition.settledRows).toHaveLength(1); + expect(result.partition.activeRows).toHaveLength(2); + expect(result.groups.flatMap((group) => group.rows)).toHaveLength(2); + }); + + it("resolves move-under candidates without ES2023 array methods", () => { + const subject = makeThread({ id: ThreadId.make("subject") }); + const other = makeThread({ id: ThreadId.make("other"), title: "Other session" }); + + const candidates = withoutHermesUnsafeArrayMethods(() => + resolveMoveUnderCandidates({ + threads: [subject, other], + subject, + query: "", + repositoryLabelFor: () => "repo", + }), + ); + + expect(candidates.map((candidate) => candidate.thread.id)).toEqual([other.id]); + }); +}); + +describe("move-under candidates", () => { + it("refuses a descendant, mirroring the server's cycle guard", () => { + const parent = makeThread({ id: ThreadId.make("parent") }); + const child = makeThread({ id: ThreadId.make("child"), parentThreadId: parent.id }); + const grandchild = makeThread({ id: ThreadId.make("grandchild"), parentThreadId: child.id }); + + expect([...collectDescendantThreadIds([parent, child, grandchild], parent.id)]).toEqual([ + child.id, + grandchild.id, + ]); + + const candidates = resolveMoveUnderCandidates({ + threads: [parent, child, grandchild], + subject: parent, + query: "", + repositoryLabelFor: () => "repo", + }); + expect(candidates).toHaveLength(0); + }); + + it("never offers a thread from another environment", () => { + // Lineage is a bare thread id resolved within one environment. + const subject = makeThread({ id: ThreadId.make("subject") }); + const foreign = makeThread({ + id: ThreadId.make("foreign"), + environmentId: EnvironmentId.make("env-2"), + }); + + const candidates = resolveMoveUnderCandidates({ + threads: [subject, foreign], + subject, + query: "", + repositoryLabelFor: () => "repo", + }); + expect(candidates).toHaveLength(0); + }); +}); + +describe("lifecycle counters", () => { + it("splits running from idle and reports the next wake", () => { + const counts = summarizeSidebarSessions( + [ + makeThread({ id: ThreadId.make("a"), execution: makeExecution({ activity: "active" }) }), + makeThread({ id: ThreadId.make("b") }), + makeThread({ + id: ThreadId.make("c"), + snoozedUntil: "2026-01-01T03:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("d"), + snoozedUntil: "2026-01-01T01:00:00.000Z", + }), + // Archived and settled threads are not part of the working set. + makeThread({ id: ThreadId.make("e"), archivedAt: now }), + makeThread({ id: ThreadId.make("f"), settledAt: now }), + ], + { now, snoozeSupported: () => true }, + ); + + expect(counts.running).toBe(1); + expect(counts.nonRunning).toBe(1); + expect(counts.nextSnoozeWakeAt).toBe("2026-01-01T01:00:00.000Z"); + }); + + it("counts a snoozed thread as idle where the server cannot snooze", () => { + const counts = summarizeSidebarSessions( + [makeThread({ snoozedUntil: "2026-01-01T01:00:00.000Z" })], + { now, snoozeSupported: () => false }, + ); + expect(counts.nonRunning).toBe(1); + expect(counts.nextSnoozeWakeAt).toBeNull(); + }); +}); + +describe("running-session emphasis", () => { + it("marks only live lifecycle phases", () => { + expect(isRunningSessionPhase("planning")).toBe(true); + expect(isRunningSessionPhase("implementing")).toBe(true); + expect(isRunningSessionPhase("ready")).toBe(false); + }); + + it("never emphasises parked history", () => { + expect(shouldShowRunningSessionGlint("planning", "active")).toBe(true); + expect(shouldShowRunningSessionGlint("planning", "settled")).toBe(false); + expect(shouldShowRunningSessionGlint("planning", "snoozed")).toBe(false); + }); + + it("places one divider before running work, and none when all rows run", () => { + expect(runningSessionDividerPhase(["ready", "planning", "implementing"])).toBe("planning"); + expect(runningSessionDividerPhase(["planning", "implementing"])).toBeNull(); + expect(runningSessionDividerPhase(["ready"])).toBeNull(); + }); +}); + +describe("unread tracking", () => { + it("prefers the newer of thread update and turn completion", () => { + expect( + resolveThreadVisitTimestamp({ + threadUpdatedAt: "2026-01-01T00:00:00.000Z", + latestTurnCompletedAt: "2026-01-01T01:00:00.000Z", + }), + ).toBe("2026-01-01T01:00:00.000Z"); + + expect( + resolveThreadVisitTimestamp({ + threadUpdatedAt: "2026-01-01T02:00:00.000Z", + latestTurnCompletedAt: "2026-01-01T01:00:00.000Z", + }), + ).toBe("2026-01-01T02:00:00.000Z"); + }); + + it("is unread only when activity is newer than this device's last visit", () => { + const base = { + threadUpdatedAt: "2026-01-01T02:00:00.000Z", + latestTurnCompletedAt: null, + }; + expect(isThreadUnread({ ...base, lastVisitedAt: "2026-01-01T01:00:00.000Z" })).toBe(true); + expect(isThreadUnread({ ...base, lastVisitedAt: "2026-01-01T03:00:00.000Z" })).toBe(false); + }); + + it("treats a never-visited thread as read", () => { + // Otherwise a fresh install marks every row in the list unread. + expect( + isThreadUnread({ + threadUpdatedAt: now, + latestTurnCompletedAt: null, + lastVisitedAt: null, + }), + ).toBe(false); + }); + + it("does not mark a row unread on an unparseable timestamp", () => { + expect( + isThreadUnread({ + threadUpdatedAt: "not-a-date", + latestTurnCompletedAt: null, + lastVisitedAt: now, + }), + ).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/phaseSidebar.ts b/packages/client-runtime/src/state/phaseSidebar.ts new file mode 100644 index 000000000000..10c16dba7d24 --- /dev/null +++ b/packages/client-runtime/src/state/phaseSidebar.ts @@ -0,0 +1,1297 @@ +// T3-CUSTOM(expbkt3): Phase-grouped session list logic, shared by web and mobile. +// +// This is the pure half of the fork's experimental "control center" sidebar: +// which lifecycle phase a thread is in, how rows partition into active, +// snoozed and settled shelves, what the priority / Linear / pull-request / +// worktree badges say, how filters and sorting behave, and the lifecycle +// counters. +// +// It lives in client-runtime rather than apps/web because the mobile app needs +// exactly the same answers and cannot import from apps/web. The web sidebar +// keeps a re-export shim at +// apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts, which also +// holds the Tailwind class-name helpers — those must stay under apps/web, +// where Tailwind scans for literal class names. +// +// HERMES. Everything here also runs on React Native, whose Hermes engine does +// not ship the ES2023 change-array-by-copy methods. Sort a copy with `.sort()`; +// never reach for `.toSorted()`. phaseSidebar.test.ts asserts this by deleting +// the method from Array.prototype. +import type { UserId, VcsStatusResult } from "@t3tools/contracts"; +import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; +// T3-CUSTOM(expbkt3): memorable worktree codenames. +import { + disambiguateWorktreeCodenames, + resolveWorktreeCodename, + worktreeCodenameToneIndex, +} from "@t3tools/shared/worktreeCodename"; + +import { deriveLogicalProjectKey } from "./projectGrouping.ts"; +import type { EnvironmentProject, EnvironmentThreadShell } from "./shell.ts"; +import { effectiveSettled, effectiveSnoozed, type ChangeRequestStateLike } from "./threadSettled.ts"; +import { getThreadSortTimestamp } from "./threadSort.ts"; + +/** + * The two shapes every helper here works with. Aliased rather than imported + * under these names so the moved code reads exactly as it did under apps/web, + * where `../../types` aliases the same two client-runtime types. + */ +type Project = EnvironmentProject; +type ThreadShell = EnvironmentThreadShell; + +/** + * Copied from upstream's apps/web/src/components/Sidebar.logic.ts rather than + * imported: that file is upstream-owned, and re-exporting from it would put a + * fork edit inside it for no functional gain. Both are small, pure and stable + * — but if upstream changes the settled-sort rule, change it here too. + */ +function firstValidTimestamp( + ...candidates: ReadonlyArray +): string | null { + for (const candidate of candidates) { + if (candidate == null) continue; + if (!Number.isNaN(Date.parse(candidate))) return candidate; + } + return null; +} + +/** + * The timestamp a settled row sorts and labels by: settledAt when stamped, + * otherwise last activity, with updatedAt as the final net. See the note on + * firstValidTimestamp above for why this is a copy. + */ +function resolveSettledTimestamp( + thread: Pick, +): string | null { + const settledAt = firstValidTimestamp(thread.settledAt); + if (settledAt !== null) return settledAt; + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const candidate of [ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed) && parsed > latestMs) { + latest = candidate; + latestMs = parsed; + } + } + return latest ?? firstValidTimestamp(thread.updatedAt); +} + +export const PHASE_SIDEBAR_PHASE_IDS = [ + "needs_input", + "plan_ready", + "ready", + "planning", + "implementing", +] as const; + +export type PhaseSidebarPhaseId = (typeof PHASE_SIDEBAR_PHASE_IDS)[number]; + +export interface PhaseSidebarCheckoutMetadata { + readonly kind: "current" | "worktree"; + readonly label: string; + readonly tooltip: string; + /** + * Color bucket for the codename, or `null` for a current checkout. Consumers + * map this through a static class table — see `PHASE_SIDEBAR_CHECKOUT_TONES`. + */ + readonly toneIndex: number | null; +} + +/** + * T3-CUSTOM(expbkt3): Other threads sharing this thread's worktree. Two agents + * editing one directory at the same time is a real hazard, and without this it + * is invisible. + */ +export interface PhaseSidebarWorktreeSharing { + /** Threads occupying the worktree. Always >= 2 when present. */ + readonly count: number; + /** + * Pre-joined thread titles for the tooltip. A string rather than an array so + * it can cross the memo'd row boundary as a prop without defeating the memo. + */ + readonly summary: string; +} + +/** + * T3-CUSTOM(expbkt3): Keep checkout semantics explicit in the experimental + * sidebar. Current checkouts show their live branch; dedicated worktrees show + * their codename — a short, memorable name derived from the worktree path, so + * that two rows in the same worktree read identically and two rows in different + * worktrees read differently at a glance. The ref the worktree was created from + * moves into the tooltip, which is where it was actually being read anyway. + */ +export function resolvePhaseSidebarCheckoutMetadata( + thread: Pick, + vcsStatus: Pick | null | undefined, + options?: { + /** Label from `disambiguateWorktreeCodenames`, when the view resolved one. */ + readonly codename?: string | null; + readonly sharing?: PhaseSidebarWorktreeSharing | null; + }, +): PhaseSidebarCheckoutMetadata { + if (thread.worktreePath) { + const baseRef = vcsStatus?.pr?.baseRef ?? vcsStatus?.baseRef ?? null; + const codename = options?.codename ?? resolveWorktreeCodename(thread.worktreePath); + const sharing = options?.sharing ?? null; + + const tooltipParts = [`Worktree ${codename}`]; + if (baseRef) tooltipParts.push(`from ${baseRef}`); + tooltipParts.push(thread.worktreePath); + if (sharing) { + tooltipParts.push(`Shared by ${sharing.count} threads: ${sharing.summary}`); + } + + return { + kind: "worktree", + label: sharing ? `${codename} ×${sharing.count}` : codename, + tooltip: tooltipParts.join(" · "), + toneIndex: worktreeCodenameToneIndex(codename), + }; + } + + const branch = vcsStatus?.refName ?? thread.branch; + return { + kind: "current", + label: branch ?? "Current checkout", + tooltip: branch ? `Current checkout on ${branch}` : "Current checkout", + toneIndex: null, + }; +} + +/** + * T3-CUSTOM(expbkt3): Codename label and shared-worktree state for every thread + * on screen, resolved together because both answers depend on the whole visible + * set: codenames disambiguate against each other, and sharing is a count across + * rows. Archived threads do not participate — the rest of the UI hides them, so + * they must not inflate a worktree's occupancy. + */ +export interface PhaseSidebarWorktreeView { + readonly codenameByPath: ReadonlyMap; + readonly sharingByPath: ReadonlyMap; +} + +export function resolvePhaseSidebarWorktreeView( + threads: ReadonlyArray>, +): PhaseSidebarWorktreeView { + const titlesByPath = new Map(); + for (const thread of threads) { + const worktreePath = thread.worktreePath?.trim(); + if (!worktreePath || thread.archivedAt != null) continue; + titlesByPath.set(worktreePath, [...(titlesByPath.get(worktreePath) ?? []), thread.title]); + } + + const sharingByPath = new Map(); + for (const [worktreePath, titles] of titlesByPath) { + if (titles.length < 2) continue; + sharingByPath.set(worktreePath, { count: titles.length, summary: titles.join(", ") }); + } + + return { + codenameByPath: disambiguateWorktreeCodenames([...titlesByPath.keys()]), + sharingByPath, + }; +} + +/** + * T3-CUSTOM(expbkt3): Flatten one thread's worktree state into primitives. The + * row is memo'd and the sidebar re-renders on every shell event, so the props + * crossing that boundary have to compare by value. + */ +export interface PhaseSidebarWorktreeRowProps { + readonly worktreeCodename: string | null; + /** 0 when the worktree is not shared. */ + readonly worktreeSharedCount: number; + readonly worktreeSharedSummary: string | null; +} + +export function phaseSidebarWorktreeRowProps( + view: PhaseSidebarWorktreeView, + worktreePath: string | null, +): PhaseSidebarWorktreeRowProps { + const path = worktreePath?.trim(); + if (!path) { + return { worktreeCodename: null, worktreeSharedCount: 0, worktreeSharedSummary: null }; + } + const sharing = view.sharingByPath.get(path) ?? null; + return { + // An archived thread is absent from the view but still renders on the + // shelf, so fall back to deriving its codename directly. + worktreeCodename: view.codenameByPath.get(path) ?? resolveWorktreeCodename(path), + worktreeSharedCount: sharing?.count ?? 0, + worktreeSharedSummary: sharing?.summary ?? null, + }; +} + +export interface PhaseSidebarPhaseDefinition { + readonly id: PhaseSidebarPhaseId; + readonly label: string; + readonly helperText: string; +} + +export const PHASE_SIDEBAR_PHASES: ReadonlyArray = [ + { + id: "needs_input", + label: "Needs Input", + helperText: "Agent is waiting for your answer", + }, + { id: "plan_ready", label: "Plan Ready", helperText: "Planning session is stopped" }, + { id: "ready", label: "Ready", helperText: "No active agent work" }, + { id: "planning", label: "Planning", helperText: "Agent is preparing a plan" }, + { id: "implementing", label: "Implementing", helperText: "Agent is changing code" }, +]; + +export interface PhaseSidebarWorkBadge { + readonly label: string; + readonly monitoring: boolean; +} + +/** + * Mirror Sidebar V2's execution precedence in the experimental sidebar. + * Foreground execution keeps its provider label (for example, Running), + * background agent fleets read as Working, and only watch loops read as + * Monitoring. Monitoring is steady and therefore does not trigger row + * shimmer. Plan Ready remains actionable and outranks lingering background + * liveness. + */ +export function resolvePhaseSidebarWorkBadge(input: { + readonly phaseId: PhaseSidebarPhaseId; + readonly backgroundLiveness?: "working" | "monitoring" | null; + readonly executionPresentation: { + readonly active: boolean; + readonly label: string | null; + }; +}): PhaseSidebarWorkBadge | null { + if (input.executionPresentation.active && input.executionPresentation.label !== null) { + return { label: input.executionPresentation.label, monitoring: false }; + } + + if (input.phaseId === "plan_ready") return null; + + if (input.backgroundLiveness === "working") { + return { label: "Working", monitoring: false }; + } + + if (input.backgroundLiveness === "monitoring") { + return { label: "Monitoring", monitoring: true }; + } + + return null; +} + +const PHASE_ID_SET = new Set(PHASE_SIDEBAR_PHASE_IDS); +const LINEAR_BRANCH_PATTERN = /^linear\/([a-z][a-z0-9]*-\d+)(?:-|$)/i; +const LINEAR_ISSUE_URL_PATTERN = + /^https:\/\/linear\.app\/([^/]+)\/issue\/([a-z][a-z0-9]*-\d+)(?:\/[^?#]*)?(?:[?#].*)?$/i; + +export interface PhaseSidebarLinearIssue { + readonly identifier: string; + readonly url: string; +} + +export function resolvePhaseSidebarLinearIssue( + branch: string | null, + manualUrl?: string | null, +): PhaseSidebarLinearIssue | null { + const trimmedManualUrl = manualUrl?.trim(); + if (trimmedManualUrl) { + const match = LINEAR_ISSUE_URL_PATTERN.exec(trimmedManualUrl); + const workspace = match?.[1]; + const identifier = match?.[2]?.toUpperCase(); + if (workspace && identifier) { + return { + identifier, + url: `https://linear.app/${workspace}/issue/${identifier}`, + }; + } + } + if (branch === null) return null; + const identifier = LINEAR_BRANCH_PATTERN.exec(branch)?.[1]?.toUpperCase(); + if (!identifier) return null; + return { + identifier, + url: `https://linear.app/beknown/issue/${identifier}`, + }; +} + +/** + * T3-CUSTOM(expbkt3): The row's change request, rendered beside the Linear tag + * so the two trackers a session answers to read as one line: ticket, then PR. + * + * The number is the whole label. State is carried by COLOR ALONE — the row's + * metadata lane is already the densest text in the app, and "#1234 (merged)" + * spends a third of the lane restating what the hue says. Hues match + * `prStatusIndicator` so a PR never reads one colour here and another in the + * thread header: green open, violet merged, red closed. Draft, checks, and + * review state stay in the tooltip — they are modifiers on "open", not states, + * and giving each its own hue would make the lane unreadable. + */ +export interface PhaseSidebarChangeRequestBadge { + /** "#1234" — the visible label. */ + readonly label: string; + readonly url: string; + readonly state: ChangeRequestStateLike; + /** Static Tailwind classes; Tailwind cannot scan interpolated hues. */ + readonly colorClassName: string; + /** Full state in words, for the tooltip and the accessible name. */ + readonly statusText: string; + readonly tooltip: string; +} + +const PHASE_SIDEBAR_CHANGE_REQUEST_TONES = { + open: "text-emerald-600 dark:text-emerald-300/90", + merged: "text-violet-600 dark:text-violet-300/90", + closed: "text-red-600 dark:text-red-300/90", +} satisfies Record; + +export function resolvePhaseSidebarChangeRequestBadge( + vcsStatus: Pick | null | undefined, +): PhaseSidebarChangeRequestBadge | null { + const pr = vcsStatus?.pr; + if (!pr) return null; + const shortName = resolveChangeRequestPresentation(vcsStatus?.sourceControlProvider).shortName; + + const modifiers: string[] = []; + if (pr.state === "open") { + if (pr.isDraft === true) modifiers.push("draft"); + if (pr.mergeability === "conflicting") modifiers.push("conflicting"); + if (pr.reviewDecision === "approved") modifiers.push("approved"); + if (pr.reviewDecision === "changes-requested") modifiers.push("changes requested"); + if (pr.checksStatus === "fail") modifiers.push("checks failing"); + if (pr.checksStatus === "pending") modifiers.push("checks running"); + } + const statusText = modifiers.length === 0 ? pr.state : `${pr.state} · ${modifiers.join(" · ")}`; + + return { + label: `#${pr.number}`, + url: pr.url, + state: pr.state, + colorClassName: PHASE_SIDEBAR_CHANGE_REQUEST_TONES[pr.state], + statusText, + tooltip: `${shortName} #${pr.number} — ${statusText} · ${pr.title}`, + }; +} + +/** T3-CUSTOM(expbkt3): compact sidebar timestamps, including zero minutes. */ +export function compactPhaseSidebarTimeLabel(label: string): string { + return label === "just now" ? "0m" : label.replace(" ago", ""); +} + +export interface PhaseSidebarFilters { + readonly repositoryKeys: ReadonlyArray; + readonly phaseIds: ReadonlyArray; + readonly providerKinds: ReadonlyArray; + /** + * T3-CUSTOM(expbkt3): sessions this operator started. + * + * NOT "owner or tagged": that is the server's own visibility rule, so every + * thread you can see already satisfies it and the filter selected everything. + * Ownership is the distinction that means something — my sessions versus the + * ones I was pulled into. + */ + readonly ownedByMe: boolean; + /** + * T3-CUSTOM(expbkt3): show only sessions ALL of these people are on. Anyone + * listed here is a co-participant on threads you can already see, since + * visibility never widens — the filter narrows to shared work. + */ + readonly participantUserIds: ReadonlyArray; +} + +export const EMPTY_PHASE_SIDEBAR_FILTERS: PhaseSidebarFilters = { + repositoryKeys: [], + phaseIds: [], + providerKinds: [], + ownedByMe: false, + participantUserIds: [], +}; + +/** T3-CUSTOM(expbkt3): everyone on a thread, owner included. */ +export function phaseSidebarThreadParticipantIds( + thread: Pick, +): ReadonlyArray { + return thread.ownerUserId === null + ? thread.memberUserIds + : [thread.ownerUserId, ...thread.memberUserIds.filter((id) => id !== thread.ownerUserId)]; +} + +/** + * "Assigned to me" = owned by, or directly tagged on, the thread. A thread made + * visible only by a project tag is not "assigned" (matches the server rule). + */ +export function isThreadAssignedToUser( + thread: Pick, + userId: UserId, +): boolean { + return thread.ownerUserId === userId || thread.memberUserIds.includes(userId); +} + +/** + * T3-CUSTOM(expbkt3): whose face, if anyone's, belongs on a sidebar row. + * + * A row only earns an owner avatar when the thread was started by *somebody + * else*: my own sessions are the default case and a wall of my own face would + * carry no information. Returns the owner to show, or `null` to show nothing. + * + * `null` when the thread is unowned (single-user mode, or awaiting backfill), + * when we cannot identify the operator (no team identity — every row would + * light up), or when the operator *is* the owner. + */ +export function phaseSidebarRowOwnerAvatarUserId(input: { + readonly ownerUserId: UserId | null; + readonly currentUserId: UserId | null; +}): UserId | null { + if (input.ownerUserId === null || input.currentUserId === null) return null; + return input.ownerUserId === input.currentUserId ? null : input.ownerUserId; +} + +export interface PhaseSidebarRow { + readonly thread: ThreadShell; + readonly phaseId: PhaseSidebarPhaseId; + readonly repositoryKey: string; + readonly repositoryLabel: string; + readonly providerKind: string; + readonly providerName: string; + readonly isAssignedToMe: boolean; + // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. + readonly isOwnedByMe: boolean; + readonly participantUserIds: ReadonlyArray; + // T3-CUSTOM(expbkt3): END + readonly attentionPriority: number; + readonly isUnreadCompletion: boolean; + /** False on environments whose server predates thread.settle/unsettle: + the row can never be classified settled (the user could not undo it) + and its lifecycle affordances stay hidden. */ + readonly settlementSupported: boolean; + /** Same version-skew contract for thread.snooze/unsnooze. */ + readonly snoozeSupported: boolean; + /** Same version-skew contract for priority on thread.meta.update. */ + readonly prioritySupported: boolean; + /** Same version-skew contract for manual Linear tags on thread.meta.update. */ + readonly linearIssueSupported?: boolean; + /** Same version-skew contract for regenerateTitle on thread.meta.update, + which backs "Regenerate title" on the row's context menu. */ + readonly titleRegenerationSupported?: boolean; + /** Same version-skew contract for thread.bootstrap.request, which backs + "Create new thread" on the row's context menu. */ + readonly threadBootstrapSupported?: boolean; + /** The row's pull-request state, when its VCS probe has reported one: a + closed (abandoned) change request auto-settles the thread, an open one + holds it active, and a merge settles only when the user allows it. */ + readonly changeRequestState: ChangeRequestStateLike | null; + /** When the change request last changed, so a merge older than the thread's + own activity does not settle a thread the user has since worked on. */ + readonly changeRequestUpdatedAt?: string | null; +} + +/** + * T3-CUSTOM(expbkt3): Where a row renders in the experimental sidebar — + * inside its lifecycle group, or parked on one of the two shelves below + * them. + */ +export type PhaseSidebarSection = "active" | "snoozed" | "settled"; + +export interface PhaseSidebarPartition { + readonly activeRows: ReadonlyArray; + readonly snoozedRows: ReadonlyArray; + readonly settledRows: ReadonlyArray; +} + +function snoozeWakeMs(row: PhaseSidebarRow): number { + const parsed = Date.parse(row.thread.snoozedUntil ?? ""); + return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; +} + +/** Soonest wake first: the shelf reads as a queue of what comes back next. */ +export function sortSnoozedPhaseSidebarRows( + rows: ReadonlyArray, +): ReadonlyArray { + return rows.slice().sort( + (left, right) => + snoozeWakeMs(left) - snoozeWakeMs(right) || + String(left.thread.id).localeCompare(String(right.thread.id)), + ); +} + +/** + * Settled rows are history, so they order by when the work ENDED — the same + * timestamp their label reads, so order and label can never disagree. + */ +export function sortSettledPhaseSidebarRows( + rows: ReadonlyArray, +): ReadonlyArray { + const timestampMs = (row: PhaseSidebarRow) => { + const timestamp = resolveSettledTimestamp(row.thread); + return timestamp === null ? 0 : Date.parse(timestamp); + }; + return rows.slice().sort( + (left, right) => + timestampMs(right) - timestampMs(left) || + String(left.thread.id).localeCompare(String(right.thread.id)), + ); +} + +/** + * T3-CUSTOM(expbkt3): Split visible rows into the lifecycle inbox and the + * two parked shelves. Snooze deliberately outranks settled classification: + * an explicitly snoozed thread belongs on the snoozed shelf even when it + * would also auto-settle, because the shelf carries its wake time. + * + * Both classifications are capability-gated per row — auto-settling a + * thread on a server that cannot un-settle it would strand the row. + * + * `preciseNow` classifies snooze (wake times are second-precise) while + * `now` may be quantized for the day-granular auto-settle window. + */ +export function partitionPhaseSidebarRows( + rows: ReadonlyArray, + options: { + readonly now: string; + readonly preciseNow: string; + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge?: boolean; + }, +): PhaseSidebarPartition { + const activeRows: PhaseSidebarRow[] = []; + const snoozedRows: PhaseSidebarRow[] = []; + const settledRows: PhaseSidebarRow[] = []; + + for (const row of rows) { + if (row.snoozeSupported && effectiveSnoozed(row.thread, { now: options.preciseNow })) { + snoozedRows.push(row); + continue; + } + if ( + row.settlementSupported && + effectiveSettled(row.thread, { + now: options.now, + autoSettleAfterDays: options.autoSettleAfterDays, + ...(options.autoSettleOnMerge !== undefined + ? { autoSettleOnMerge: options.autoSettleOnMerge } + : {}), + changeRequest: + row.changeRequestState === null + ? null + : { + state: row.changeRequestState, + ...(row.changeRequestUpdatedAt !== undefined + ? { updatedAt: row.changeRequestUpdatedAt } + : {}), + }, + }) + ) { + settledRows.push(row); + continue; + } + activeRows.push(row); + } + + return { + activeRows, + snoozedRows: sortSnoozedPhaseSidebarRows(snoozedRows), + settledRows: sortSettledPhaseSidebarRows(settledRows), + }; +} + +/** A stopped projection can still hide a live provider, so any recorded session remains stoppable. */ +export function phaseSidebarCanForceStopAgent(session: ThreadShell["session"]): boolean { + return session !== null; +} + +export interface PhaseSidebarRepositoryOption { + readonly key: string; + readonly label: string; + readonly searchText: string; + readonly project: Project; +} + +export interface PhaseSidebarGroup extends PhaseSidebarPhaseDefinition { + readonly rows: ReadonlyArray; +} + +export interface PhaseSidebarFilterChip { + // T3-CUSTOM(expbkt3): "person" is the co-participant facet. + readonly facet: "repository" | "phase" | "provider" | "assignment" | "person"; + readonly value: string; + readonly label: string; +} + +export function isStrictlyMergeReady(status: VcsStatusResult | null | undefined): boolean { + const pr = status?.pr; + if (!pr || pr.state !== "open" || status?.sourceControlProvider?.kind !== "github") return false; + if (pr.isDraft !== false || pr.mergeability !== "mergeable") return false; + if (pr.mergeStateStatus?.toUpperCase() !== "CLEAN") return false; + if (pr.reviewDecision === "changes-requested" || pr.reviewDecision === "review-required") { + return false; + } + return pr.checksStatus === "pass"; +} + +export function resolvePhaseSidebarAttentionPriority( + thread: ThreadShell, + status?: VcsStatusResult | null, +): number { + if (phaseSidebarNeedsUserInput(thread)) return 0; + if (thread.execution?.turn?.state === "waiting-for-approval") return 1; + if (thread.execution?.activity === "failed") return 2; + if ( + status?.pr?.state === "open" && + (status.pr.mergeability === "conflicting" || + status.pr.checksStatus === "fail" || + status.pr.reviewDecision === "changes-requested") + ) { + return 3; + } + if (thread.execution === null || thread.execution === undefined) return 4; + return 5; +} + +/** + * T3-CUSTOM(expbkt3): Treat both the durable pending-input bit and the live + * execution state as authority. This keeps urgent questions promoted even + * during short execution-snapshot reconnects. + */ +export function phaseSidebarNeedsUserInput( + thread: Pick, +): boolean { + return thread.hasPendingUserInput || thread.execution?.turn?.state === "waiting-for-input"; +} + +export type PhaseSidebarAttentionKind = "input" | "approval" | "error"; + +export function resolvePhaseSidebarAttentionKind( + thread: Pick, +): PhaseSidebarAttentionKind | null { + if (phaseSidebarNeedsUserInput(thread)) return "input"; + if (thread.hasPendingApprovals || thread.execution?.turn?.state === "waiting-for-approval") { + return "approval"; + } + if (thread.execution?.activity === "failed") return "error"; + return null; +} + +export function resolvePhaseSidebarPhase( + thread: ThreadShell, + _status?: VcsStatusResult | null, +): PhaseSidebarPhaseId { + if (phaseSidebarNeedsUserInput(thread)) return "needs_input"; + + // A failed provider is actionable even if a stale durable intent or + // background-liveness projection has not cleared yet. + const hasFailure = thread.execution?.activity === "failed" || thread.session?.status === "error"; + if (hasFailure) { + return thread.interactionMode === "plan" ? "plan_ready" : "ready"; + } + + // T3-CUSTOM(expbkt3): BEGIN — group from the same durable intent as the badge. + const isActive = + (thread.execution?.intent !== undefined && + thread.execution.intent.phase !== "recovery-exhausted") || + thread.execution?.activity === "active" || + thread.execution?.activity === "blocked" || + thread.execution?.activity === "stopping" || + thread.session?.status === "starting" || + thread.session?.status === "running"; + // T3-CUSTOM(expbkt3): END + if (isActive) { + return thread.interactionMode === "plan" ? "planning" : "implementing"; + } + + // Sidebar V2's reliability ordering: a failure or an actionable plan must + // not be hidden by liveness that can linger while background work winds + // down. Those states keep their ordinary group and attention treatment. + if (thread.interactionMode === "plan" && thread.hasActionableProposedPlan) { + return "plan_ready"; + } + + // A settled foreground turn can still own native subagents, workflows, or + // watch scripts. Keep it among agent-work rows until the authoritative + // server projection clears instead of prematurely dropping it into Ready. + if (thread.backgroundLiveness === "working" || thread.backgroundLiveness === "monitoring") { + return thread.interactionMode === "plan" ? "planning" : "implementing"; + } + + return thread.interactionMode === "plan" ? "plan_ready" : "ready"; +} + +/** + * Keep a thread in its last rendered lifecycle group while live execution + * authority is temporarily unavailable. The underlying execution snapshot is + * still cleared on disconnect; this only stabilizes sidebar presentation until + * a fresh execution frame arrives. + */ +export function resolvePhaseSidebarDisplayPhase( + currentPhase: PhaseSidebarPhaseId, + _previousPhase: PhaseSidebarPhaseId | null, +): PhaseSidebarPhaseId { + return currentPhase; +} + +export function derivePhaseSidebarRepositoryKey(project: Project): string { + return deriveLogicalProjectKey(project, { groupingMode: "repository" }); +} + +export function buildPhaseSidebarRepositoryOptions( + projects: ReadonlyArray, +): ReadonlyArray { + const grouped = new Map(); + for (const project of projects) { + const key = derivePhaseSidebarRepositoryKey(project); + const members = grouped.get(key); + if (members) members.push(project); + else grouped.set(key, [project]); + } + + return [...grouped.entries()] + .map(([key, members]) => { + const sortedMembers = members.slice().sort((left, right) => + `${left.environmentId}:${left.id}`.localeCompare(`${right.environmentId}:${right.id}`), + ); + const nicknames = [...new Set(sortedMembers.map((project) => project.title))].sort( + (left, right) => left.localeCompare(right), + ); + const canonicalLabels = [ + ...new Set( + sortedMembers.flatMap((project) => { + const identity = project.repositoryIdentity; + if (!identity) return []; + return [identity.displayName, identity.name].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + }), + ), + ].sort((left, right) => left.localeCompare(right)); + const label = + nicknames.length === 1 + ? nicknames[0]! + : (canonicalLabels[0] ?? nicknames[0] ?? "Unknown repository"); + const searchText = [ + ...nicknames, + ...canonicalLabels, + ...sortedMembers.flatMap((project) => { + const identity = project.repositoryIdentity; + return identity ? [identity.canonicalKey, identity.owner ?? ""] : []; + }), + ].join(" "); + return { key, label, searchText, project: sortedMembers[0]! }; + }) + .sort( + (left, right) => left.label.localeCompare(right.label) || left.key.localeCompare(right.key), + ); +} + +const KNOWN_PROVIDER_CODES: Readonly> = { + claudeAgent: "cc", + codex: "cx", + cursor: "cu", + grok: "gr", + opencode: "oc", +}; + +export function resolvePhaseSidebarProviderCode(providerKind: string): string { + const known = KNOWN_PROVIDER_CODES[providerKind]; + if (known) return known; + + const normalized = providerKind + .toLowerCase() + .replace(/[^a-z]+/g, " ") + .trim(); + if (!normalized) return "uk"; + const words = normalized.split(/\s+/); + if (words.length > 1) { + return `${words[0]?.[0] ?? "?"}${words[1]?.[0] ?? "?"}`; + } + return normalized.length === 1 ? normalized.repeat(2) : normalized.slice(0, 2); +} + +export function matchesPhaseSidebarFilters( + row: PhaseSidebarRow, + filters: PhaseSidebarFilters, +): boolean { + return ( + (filters.repositoryKeys.length === 0 || filters.repositoryKeys.includes(row.repositoryKey)) && + (filters.phaseIds.length === 0 || filters.phaseIds.includes(row.phaseId)) && + (filters.providerKinds.length === 0 || filters.providerKinds.includes(row.providerKind)) && + // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets. + (!filters.ownedByMe || row.isOwnedByMe) && + // Every selected person must be on the thread: selecting two people asks + // for their shared sessions, not the union of their work. + (filters.participantUserIds.length === 0 || + filters.participantUserIds.every((userId) => row.participantUserIds.includes(userId))) + // T3-CUSTOM(expbkt3): END + ); +} + +/** + * The rows this sidebar may render at all. Shared by the lifecycle groups and + * the parked shelves so a filter chip means the same thing everywhere. + */ +export function filterVisiblePhaseSidebarRows( + rows: ReadonlyArray, + filters: PhaseSidebarFilters, +): ReadonlyArray { + return rows.filter( + (row) => row.thread.archivedAt === null && matchesPhaseSidebarFilters(row, filters), + ); +} + +/** + * T3-CUSTOM(expbkt3): sort rank for a thread's priority. Unprioritised rows + * rank after P4 so an explicit P4 still outranks "no opinion". + */ +export function phaseSidebarPriorityRank(thread: ThreadShell): number { + return thread.priority ?? PHASE_SIDEBAR_UNPRIORITISED_RANK; +} + +export const PHASE_SIDEBAR_UNPRIORITISED_RANK = 5; + +/** T3-CUSTOM(expbkt3): render label for a priority value ("P0".."P4"). */ +export function formatThreadPriority(priority: number): string { + return `P${priority}`; +} + +/** T3-CUSTOM(expbkt3): the priority values offered in the row context menu. */ +export const PHASE_SIDEBAR_PRIORITY_CHOICES = [ + { value: 0, label: "P0 — Urgent" }, + { value: 1, label: "P1 — High" }, + { value: 2, label: "P2 — Medium" }, + { value: 3, label: "P3 — Low" }, + { value: 4, label: "P4 — Lowest" }, +] as const satisfies ReadonlyArray<{ readonly value: 0 | 1 | 2 | 3 | 4; readonly label: string }>; + +/** T3-CUSTOM(expbkt3): Which end of the time axis leads inside a lifecycle group. */ +export type PhaseSidebarSortDirection = "newest_first" | "oldest_first"; + +export interface PhaseSidebarSortPreferences { + readonly direction: PhaseSidebarSortDirection; + /** When true, P0 outranks every lower priority; ties fall through to time. */ + readonly priorityFirst: boolean; +} + +export const DEFAULT_PHASE_SIDEBAR_SORT: PhaseSidebarSortPreferences = { + direction: "newest_first", + priorityFirst: true, +}; + +export const PHASE_SIDEBAR_SORT_DIRECTION_LABELS: Record = { + newest_first: "Most recent on top", + oldest_first: "Oldest on top", +}; + +export function sanitizePhaseSidebarSort(value: unknown): PhaseSidebarSortPreferences { + if (!value || typeof value !== "object") return DEFAULT_PHASE_SIDEBAR_SORT; + const candidate = value as Partial>; + return { + direction: + candidate.direction === "oldest_first" || candidate.direction === "newest_first" + ? candidate.direction + : DEFAULT_PHASE_SIDEBAR_SORT.direction, + priorityFirst: + typeof candidate.priorityFirst === "boolean" + ? candidate.priorityFirst + : DEFAULT_PHASE_SIDEBAR_SORT.priorityFirst, + }; +} + +/** + * T3-CUSTOM(expbkt3): Ordering inside a lifecycle group is deliberately STRICT — + * it reads only the thread's priority, its sort timestamp, and stable tiebreaks. + * + * It used to also fold in `attentionPriority` and `isUnreadCompletion`. Both flip + * the moment you open a row, so simply reading a thread reordered the group under + * the pointer. Those states are already visible on the row (glint, unread dot) and + * hoisted into their own groups upstream, so ordering does not need to repeat them + * at the cost of a list that moves while you use it. + */ +export function comparePhaseSidebarRows( + left: PhaseSidebarRow, + right: PhaseSidebarRow, + sortOrder: SidebarThreadSortOrder, + sort: PhaseSidebarSortPreferences, +): number { + const priorityDelta = sort.priorityFirst + ? phaseSidebarPriorityRank(left.thread) - phaseSidebarPriorityRank(right.thread) + : 0; + const leftTime = getThreadSortTimestamp(left.thread, sortOrder); + const rightTime = getThreadSortTimestamp(right.thread, sortOrder); + const timeDelta = sort.direction === "oldest_first" ? leftTime - rightTime : rightTime - leftTime; + return ( + priorityDelta || + timeDelta || + left.thread.title.localeCompare(right.thread.title) || + String(left.thread.id).localeCompare(String(right.thread.id)) + ); +} + +export function buildPhaseSidebarGroups( + rows: ReadonlyArray, + filters: PhaseSidebarFilters, + sortOrder: SidebarThreadSortOrder, + sort: PhaseSidebarSortPreferences = DEFAULT_PHASE_SIDEBAR_SORT, +): ReadonlyArray { + const visibleRows = filterVisiblePhaseSidebarRows(rows, filters); + + return PHASE_SIDEBAR_PHASES.flatMap((phase) => { + const phaseRows = visibleRows + .filter((row) => row.phaseId === phase.id) + .sort((left, right) => comparePhaseSidebarRows(left, right, sortOrder, sort)); + return phaseRows.length > 0 ? [{ ...phase, rows: phaseRows }] : []; + }); +} + +function sanitizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return [ + ...new Set( + value.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ), + ), + ]; +} + +export function sanitizePhaseSidebarFilters(value: unknown): PhaseSidebarFilters { + if (!value || typeof value !== "object") return EMPTY_PHASE_SIDEBAR_FILTERS; + const candidate = value as Partial>; + return { + repositoryKeys: sanitizeStringArray(candidate.repositoryKeys), + phaseIds: sanitizeStringArray(candidate.phaseIds).filter( + (phaseId): phaseId is PhaseSidebarPhaseId => PHASE_ID_SET.has(phaseId), + ), + providerKinds: sanitizeStringArray(candidate.providerKinds), + // T3-CUSTOM(expbkt3): missing on blobs written before these facets existed, + // so both default off; storage stays v1. + ownedByMe: candidate.ownedByMe === true, + participantUserIds: sanitizeStringArray(candidate.participantUserIds), + }; +} + +export function reconcilePhaseSidebarFilters( + filters: PhaseSidebarFilters, + options: { + readonly repositoryKeys: ReadonlySet; + readonly providerKinds: ReadonlySet; + // False on single-user builds (no operator identity): a persisted + // ownership filter would otherwise hide every thread. + readonly assignmentAvailable: boolean; + // T3-CUSTOM(expbkt3): people still present in the directory. A teammate who + // leaves must not keep an invisible filter pinned over the sidebar. + readonly participantUserIds?: ReadonlySet; + }, +): PhaseSidebarFilters { + const knownParticipants = options.participantUserIds; + return { + repositoryKeys: filters.repositoryKeys.filter((key) => options.repositoryKeys.has(key)), + phaseIds: filters.phaseIds.filter((phaseId) => PHASE_ID_SET.has(phaseId)), + providerKinds: filters.providerKinds.filter((kind) => options.providerKinds.has(kind)), + ownedByMe: options.assignmentAvailable ? filters.ownedByMe : false, + participantUserIds: !options.assignmentAvailable + ? [] + : knownParticipants === undefined + ? filters.participantUserIds + : filters.participantUserIds.filter((userId) => knownParticipants.has(userId)), + }; +} + +export function buildPhaseSidebarFilterChips( + filters: PhaseSidebarFilters, + labels: { + readonly repositories: ReadonlyMap; + readonly providers: ReadonlyMap; + // T3-CUSTOM(expbkt3): display names for the co-participant facet. + readonly people?: ReadonlyMap; + }, +): ReadonlyArray { + const phaseLabels = new Map(PHASE_SIDEBAR_PHASES.map((phase) => [phase.id, phase.label])); + return [ + ...filters.repositoryKeys.map((value) => ({ + facet: "repository" as const, + value, + label: labels.repositories.get(value) ?? value, + })), + ...filters.phaseIds.map((value) => ({ + facet: "phase" as const, + value, + label: phaseLabels.get(value) ?? value, + })), + ...filters.providerKinds.map((value) => ({ + facet: "provider" as const, + value, + label: labels.providers.get(value) ?? value, + })), + // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant chips. + ...(filters.ownedByMe + ? [{ facet: "assignment" as const, value: "owned-by-me", label: "Started by me" }] + : []), + ...filters.participantUserIds.map((value) => ({ + facet: "person" as const, + value, + label: labels.people?.get(value) ?? "Teammate", + })), + // T3-CUSTOM(expbkt3): END + ]; +} + +export function flattenPhaseSidebarGroups( + groups: ReadonlyArray, +): ReadonlyArray { + return groups.flatMap((group) => group.rows); +} + +export function resolvePhaseSidebarTraversalTarget(input: { + readonly visibleThreadKeys: ReadonlyArray; + readonly currentThreadKey: string | null; + readonly direction: "previous" | "next"; +}): string | null { + if (input.visibleThreadKeys.length === 0) return null; + const currentIndex = input.currentThreadKey + ? input.visibleThreadKeys.indexOf(input.currentThreadKey) + : -1; + if (currentIndex === -1) { + return input.direction === "previous" + ? (input.visibleThreadKeys.at(-1) ?? null) + : (input.visibleThreadKeys[0] ?? null); + } + if (input.direction === "previous") { + return currentIndex > 0 ? (input.visibleThreadKeys[currentIndex - 1] ?? null) : null; + } + return currentIndex < input.visibleThreadKeys.length - 1 + ? (input.visibleThreadKeys[currentIndex + 1] ?? null) + : null; +} + +// --------------------------------------------------------------------------- +// T3-CUSTOM(expbkt3): Lifecycle counters. +// +// Moved here from apps/web/src/components/sidebar/sidebarSessionCounters.ts so +// the mobile home list can show the same running/idle summary the web sidebar +// chrome does. +// --------------------------------------------------------------------------- + +export interface SidebarSessionCounts { + readonly nonRunning: number; + readonly running: number; + readonly nextSnoozeWakeAt: string | null; +} + +export interface SidebarSessionCountOptions { + readonly now: string; + readonly snoozeSupported: (thread: ThreadShell) => boolean; +} + +export function threadNeedsHumanAttention(thread: ThreadShell): boolean { + return ( + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.hasActionableProposedPlan || + thread.execution?.turn?.state === "waiting-for-approval" || + thread.execution?.turn?.state === "waiting-for-input" || + thread.execution?.activity === "failed" || + thread.session?.status === "error" + ); +} + +export function threadIsRunning(thread: ThreadShell): boolean { + return ( + thread.execution?.activity === "active" || + thread.execution?.activity === "blocked" || + thread.execution?.activity === "stopping" || + thread.session?.status === "starting" || + thread.session?.status === "running" || + thread.backgroundLiveness === "working" || + thread.backgroundLiveness === "monitoring" + ); +} + +export function summarizeSidebarSessions( + threads: ReadonlyArray, + options: SidebarSessionCountOptions, +): SidebarSessionCounts { + let nonRunning = 0; + let running = 0; + let nextSnoozeWakeAt: string | null = null; + let nextSnoozeWakeAtMs = Number.POSITIVE_INFINITY; + + for (const thread of threads) { + if (thread.archivedAt !== null || thread.settledAt !== null) continue; + if (threadIsRunning(thread)) { + running += 1; + continue; + } + if (options.snoozeSupported(thread) && effectiveSnoozed(thread, { now: options.now })) { + const wakeAtMs = Date.parse(thread.snoozedUntil ?? ""); + if (wakeAtMs < nextSnoozeWakeAtMs) { + nextSnoozeWakeAt = thread.snoozedUntil ?? null; + nextSnoozeWakeAtMs = wakeAtMs; + } + continue; + } + nonRunning += 1; + } + + return { nonRunning, running, nextSnoozeWakeAt }; +} + +// --------------------------------------------------------------------------- +// T3-CUSTOM(expbkt3): Running-session emphasis. +// +// Moved from apps/web/src/components/sidebar/RunningSessionGlint.logic.ts. Web +// renders the emphasis as an animated glint; mobile renders it statically, so +// only the decision is shared, never the presentation. +// --------------------------------------------------------------------------- + +export function isRunningSessionPhase(phaseId: PhaseSidebarPhaseId): boolean { + return phaseId === "planning" || phaseId === "implementing"; +} + +/** Running emphasis belongs only to live lifecycle rows, never parked history. */ +export function shouldShowRunningSessionGlint( + phaseId: PhaseSidebarPhaseId, + section: PhaseSidebarSection, +): boolean { + return section === "active" && isRunningSessionPhase(phaseId); +} + +/** Place one quiet boundary before running work when idle groups are also visible. */ +export function runningSessionDividerPhase( + phaseIds: ReadonlyArray, +): PhaseSidebarPhaseId | null { + if (!phaseIds.some((phaseId) => !isRunningSessionPhase(phaseId))) return null; + return phaseIds.find(isRunningSessionPhase) ?? null; +} + +// --------------------------------------------------------------------------- +// T3-CUSTOM(expbkt3): "Move under session" candidates. +// +// Moved from apps/web/src/components/sidebar/MoveUnderSessionDialog.logic.ts. +// This is the client-side mirror of the server's cycle guard, and the two must +// not drift — so both clients run the same copy. +// --------------------------------------------------------------------------- + +export interface MoveUnderCandidate { + readonly thread: ThreadShell; + readonly label: string; + readonly repositoryLabel: string; +} + +/** + * Every thread reachable downwards from `threadId`, excluding itself. Bounded + * by the thread count: each id is enqueued at most once, so a corrupt cycle in + * the projection cannot make this loop forever. + */ +export function collectDescendantThreadIds( + threads: ReadonlyArray, + threadId: string, +): ReadonlySet { + const descendants = new Set(); + const queue: string[] = [threadId]; + while (queue.length > 0) { + const current = queue.pop() as string; + for (const thread of threads) { + if ((thread.parentThreadId ?? null) !== current) continue; + if (thread.id === threadId || descendants.has(thread.id)) continue; + descendants.add(thread.id); + queue.push(thread.id); + } + } + return descendants; +} + +/** + * Candidate parents for `subject`, newest first, filtered by `query`. + * + * Excluded: the thread itself, its descendants (the server would reject those + * as cycles, so offering them would only produce a confusing failure toast), + * archived threads, its current parent (already there), and — because lineage + * is a bare thread id resolved within one environment — anything from a + * different environment. + */ +export function resolveMoveUnderCandidates(input: { + readonly threads: ReadonlyArray; + readonly subject: ThreadShell; + readonly query: string; + readonly repositoryLabelFor: (thread: ThreadShell) => string; + readonly limit?: number; +}): ReadonlyArray { + const sameEnvironment = input.threads.filter( + (thread) => thread.environmentId === input.subject.environmentId, + ); + const blocked = collectDescendantThreadIds(sameEnvironment, input.subject.id); + const needle = input.query.trim().toLowerCase(); + + return sameEnvironment + .filter( + (thread) => + thread.id !== input.subject.id && + !blocked.has(thread.id) && + thread.archivedAt === null && + thread.id !== (input.subject.parentThreadId ?? null) && + (needle.length === 0 || thread.title.toLowerCase().includes(needle)), + ) + .sort( + (left, right) => + Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || + String(left.id).localeCompare(String(right.id)), + ) + .slice(0, input.limit ?? 50) + .map((thread) => ({ + thread, + label: thread.title, + repositoryLabel: input.repositoryLabelFor(thread), + })); +} + +// --------------------------------------------------------------------------- +// T3-CUSTOM(expbkt3): Unread tracking. +// +// Moved from apps/web/src/threadVisitTimestamp.ts. Both clients compare a +// thread's newest activity against the last time this device opened it; where +// that "last visited" map is stored is platform-specific, but the rule that +// decides what counts as newer is not. +// --------------------------------------------------------------------------- + +export interface ThreadVisitTimestampInput { + readonly threadUpdatedAt: string; + readonly latestTurnCompletedAt: string | null | undefined; +} + +export function resolveThreadVisitTimestamp(input: ThreadVisitTimestampInput): string { + const threadUpdatedAtMs = Date.parse(input.threadUpdatedAt); + const latestTurnCompletedAt = input.latestTurnCompletedAt; + const latestTurnCompletedAtMs = latestTurnCompletedAt + ? Date.parse(latestTurnCompletedAt) + : Number.NaN; + if ( + latestTurnCompletedAt != null && + Number.isFinite(latestTurnCompletedAtMs) && + (!Number.isFinite(threadUpdatedAtMs) || latestTurnCompletedAtMs > threadUpdatedAtMs) + ) { + return latestTurnCompletedAt; + } + return input.threadUpdatedAt; +} + +/** + * Whether a row should show the unread dot: the thread has newer activity than + * the last visit this device recorded. An unvisited thread is NOT unread — + * otherwise a fresh install marks the entire list. + */ +export function isThreadUnread(input: { + readonly threadUpdatedAt: string; + readonly latestTurnCompletedAt: string | null | undefined; + readonly lastVisitedAt: string | null | undefined; +}): boolean { + if (input.lastVisitedAt == null) return false; + const lastVisitedAtMs = Date.parse(input.lastVisitedAt); + if (Number.isNaN(lastVisitedAtMs)) return false; + const activityAtMs = Date.parse(resolveThreadVisitTimestamp(input)); + if (Number.isNaN(activityAtMs)) return false; + return activityAtMs > lastVisitedAtMs; +} diff --git a/packages/client-runtime/src/state/phaseSidebarTree.ts b/packages/client-runtime/src/state/phaseSidebarTree.ts new file mode 100644 index 000000000000..70b0a868c57a --- /dev/null +++ b/packages/client-runtime/src/state/phaseSidebarTree.ts @@ -0,0 +1,430 @@ +// T3-CUSTOM(expbkt3): session trees for the phase-grouped session list. +// +// Moved out of apps/web so the mobile thread list can nest the same way; the +// web sidebar keeps a re-export shim at +// apps/web/src/components/sidebar/PhaseSidebarTree.logic.ts. +// +// HERMES: this also runs under React Native. Sort a copy with `.sort()`, +// never `.toSorted()`. +// +// A session that fans work out — typically cross-repo, via the t3_create_session +// MCP tool — records the session that spawned it. This module turns that flat +// `parentThreadId` link into the nested rows the sidebar renders, and decides +// which lifecycle group a parent belongs in once its children are folded into it. +import { scopedThreadKey, scopeThreadRef } from "../environment/index.ts"; + +import { + matchesPhaseSidebarFilters, + resolvePhaseSidebarAttentionKind, + PHASE_SIDEBAR_PHASES, + type PhaseSidebarAttentionKind, + type PhaseSidebarFilters, + type PhaseSidebarPhaseDefinition, + type PhaseSidebarPhaseId, + type PhaseSidebarRow, +} from "./phaseSidebar.ts"; + +/** + * Indentation stops growing past this depth. Deep chains still nest logically — + * traversal, counts and the phase override all keep working — but the sidebar is + * ~260px wide, so past three levels the indent costs more title than it buys in + * legibility. + */ +export const PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH = 3; + +/** + * Backstop for a projection that already contains a cycle. The server rejects + * commands that would create one, but a client must never hang on bad data. + */ +export const PHASE_SIDEBAR_TREE_MAX_DEPTH = 16; + +/** + * A descendant counts as "busy" when its own phase says an agent is actively + * working. This is the single input to the parent's phase override. + */ +const BUSY_PHASE_IDS: ReadonlySet = new Set([ + "planning", + "implementing", +]); + +/** + * Most-blocking first. A subtree can hold several stuck sessions at once, and + * the parent has room for exactly one derived badge, so it reports the worst. + */ +const ATTENTION_RANK: ReadonlyArray = ["input", "approval", "error"]; + +/** + * A descendant needs a human when it is parked in the Needs Input phase or is + * flying an attention badge of its own — a pending approval does not change a + * session's phase, so both signals matter. + */ +function attentionKindOf(row: PhaseSidebarRow): PhaseSidebarAttentionKind | null { + const kind = resolvePhaseSidebarAttentionKind(row.thread); + if (kind !== null) return kind; + return row.phaseId === "needs_input" ? "input" : null; +} + +function moreUrgent( + left: PhaseSidebarAttentionKind | null, + right: PhaseSidebarAttentionKind | null, +): PhaseSidebarAttentionKind | null { + if (left === null) return right; + if (right === null) return left; + return ATTENTION_RANK.indexOf(left) <= ATTENTION_RANK.indexOf(right) ? left : right; +} + +export interface PhaseSidebarTreeNode { + readonly row: PhaseSidebarRow; + readonly key: string; + readonly children: ReadonlyArray; + /** 0 for a root row; used for indentation and for the aria tree semantics. */ + readonly depth: number; + /** Every descendant, not just direct children — this is the count the pill shows. */ + readonly descendantCount: number; + /** True when any descendant is planning or implementing (see BUSY_PHASE_IDS). */ + readonly hasBusyDescendant: boolean; + /** + * How many descendants finished a turn the user has not read yet, and how + * many have an agent working. Counts rather than the booleans above: a parent + * with a wide fan-out needs to know whether one child or nine are waiting to + * be read, and the number is the whole reason to open the subtree. + */ + readonly descendantUnreadCount: number; + readonly descendantRunningCount: number; + /** + * The most blocking thing any descendant is waiting on, or null. Drives both + * the parent's group placement and its derived badge: work buried in a + * collapsed subtree is invisible, so the parent has to raise its hand. + */ + readonly descendantAttention: PhaseSidebarAttentionKind | null; + /** + * Set only on a row whose recorded parent is not rendering in this section — + * archived, settled, filtered out, in another environment, or deleted. The row + * renders at the top level with this breadcrumb instead of silently losing its + * lineage. + */ + readonly orphanedFrom: { readonly key: string; readonly title: string } | null; +} + +export function phaseSidebarRowKey(row: PhaseSidebarRow): string { + return scopedThreadKey(scopeThreadRef(row.thread.environmentId, row.thread.id)); +} + +/** + * The parent link is a bare thread id: a session can only be created by a caller + * on the same server, so parent and child always share an environment. Scoping + * the lookup by the child's environment is therefore both correct and the only + * way to avoid colliding ids across connected environments. + */ +function parentKeyOf(row: PhaseSidebarRow): string | null { + const parentThreadId = row.thread.parentThreadId; + if (parentThreadId == null) return null; + return scopedThreadKey(scopeThreadRef(row.thread.environmentId, parentThreadId)); +} + +interface MutableNode { + readonly row: PhaseSidebarRow; + readonly key: string; + readonly children: MutableNode[]; + depth: number; + descendantCount: number; + hasBusyDescendant: boolean; + descendantUnreadCount: number; + descendantRunningCount: number; + descendantAttention: PhaseSidebarAttentionKind | null; + orphanedFrom: { readonly key: string; readonly title: string } | null; +} + +function isBusy(row: PhaseSidebarRow): boolean { + return BUSY_PHASE_IDS.has(row.phaseId); +} + +/** + * Resolve the row's effective parent, or null when it should render as a root. + * + * A row nests only if its parent is present in the SAME row set. That one rule + * absorbs every edge case — parent archived, settled, snoozed, filtered out, + * deleted, or in another environment — without special-casing any of them, and + * guarantees the result is a forest rooted in rows that actually render. + */ +function resolveParent( + node: MutableNode, + byKey: ReadonlyMap, +): MutableNode | null { + const parentKey = parentKeyOf(node.row); + if (parentKey === null) return null; + const parent = byKey.get(parentKey); + if (parent === undefined || parent.key === node.key) return null; + + // Walk to the root before accepting the link. A cycle here means the stored + // data is already corrupt; promoting the row to a root keeps the sidebar + // usable instead of dropping the row or looping forever. + const seen = new Set([node.key]); + let cursor: MutableNode | undefined = parent; + for (let depth = 0; cursor !== undefined && depth < PHASE_SIDEBAR_TREE_MAX_DEPTH; depth += 1) { + if (seen.has(cursor.key)) return null; + seen.add(cursor.key); + const nextKey = parentKeyOf(cursor.row); + cursor = nextKey === null ? undefined : byKey.get(nextKey); + } + return cursor === undefined ? parent : null; +} + +/** + * Bottom-up rollup of the two derived facts a parent row renders: how many + * sessions live under it, and whether any of them is doing work. + */ +function finalize(node: MutableNode, depth: number): void { + node.depth = depth; + let descendantCount = 0; + let hasBusyDescendant = false; + let descendantUnreadCount = 0; + let descendantRunningCount = 0; + let descendantAttention: PhaseSidebarAttentionKind | null = null; + for (const child of node.children) { + finalize(child, depth + 1); + descendantCount += 1 + child.descendantCount; + hasBusyDescendant = hasBusyDescendant || isBusy(child.row) || child.hasBusyDescendant; + descendantUnreadCount += (child.row.isUnreadCompletion ? 1 : 0) + child.descendantUnreadCount; + descendantRunningCount += (isBusy(child.row) ? 1 : 0) + child.descendantRunningCount; + descendantAttention = moreUrgent( + descendantAttention, + moreUrgent(attentionKindOf(child.row), child.descendantAttention), + ); + } + node.descendantCount = descendantCount; + node.hasBusyDescendant = hasBusyDescendant; + node.descendantUnreadCount = descendantUnreadCount; + node.descendantRunningCount = descendantRunningCount; + node.descendantAttention = descendantAttention; +} + +function freeze(node: MutableNode): PhaseSidebarTreeNode { + return { + row: node.row, + key: node.key, + children: node.children.map(freeze), + depth: node.depth, + descendantCount: node.descendantCount, + hasBusyDescendant: node.hasBusyDescendant, + descendantUnreadCount: node.descendantUnreadCount, + descendantRunningCount: node.descendantRunningCount, + descendantAttention: node.descendantAttention, + orphanedFrom: node.orphanedFrom, + }; +} + +/** + * Build the forest for one sidebar section (active / snoozed / settled). + * + * `compareSiblings` orders both the returned roots and every child list, so a + * subtree reads with the same ordering rules as the list it sits in. + * `titleForKey` resolves orphan breadcrumbs against the full thread set, not + * just this section, so "↳ Parent title" still names a settled or filtered + * parent. + */ +export function buildPhaseSidebarTree( + rows: ReadonlyArray, + options: { + readonly compareSiblings: (left: PhaseSidebarRow, right: PhaseSidebarRow) => number; + readonly titleForKey?: (key: string) => string | null; + }, +): ReadonlyArray { + const nodes: MutableNode[] = rows.map((row) => ({ + row, + key: phaseSidebarRowKey(row), + children: [], + depth: 0, + descendantCount: 0, + hasBusyDescendant: false, + descendantUnreadCount: 0, + descendantRunningCount: 0, + descendantAttention: null, + orphanedFrom: null, + })); + const byKey = new Map(nodes.map((node) => [node.key, node])); + + const roots: MutableNode[] = []; + for (const node of nodes) { + const parent = resolveParent(node, byKey); + if (parent === null) { + const parentKey = parentKeyOf(node.row); + if (parentKey !== null) { + const title = options.titleForKey?.(parentKey) ?? null; + if (title !== null) node.orphanedFrom = { key: parentKey, title }; + } + roots.push(node); + continue; + } + parent.children.push(node); + } + + const sortRecursively = (list: MutableNode[]): void => { + list.sort((left, right) => options.compareSiblings(left.row, right.row)); + for (const node of list) sortRecursively(node.children); + }; + sortRecursively(roots); + for (const root of roots) finalize(root, 0); + + return roots.map(freeze); +} + +/** + * The phase a ROOT row is grouped under. + * + * Precedence, most urgent first: + * + * 1. Anything in the subtree is waiting on a human → Needs Input + * 2. Anything in the subtree is doing work → Implementing + * 3. Otherwise → the row's own phase + * + * Attention outranks work because a collapsed subtree hides it completely: an + * approval sitting two levels down under a parent filed as "Implementing" is + * invisible until someone happens to expand the right row. Hoisting the parent + * costs one row of churn and is the whole reason the Needs Input group is worth + * scanning first. + */ +export function resolvePhaseSidebarTreePhase(node: PhaseSidebarTreeNode): PhaseSidebarPhaseId { + if (node.descendantAttention !== null) return "needs_input"; + return node.hasBusyDescendant ? "implementing" : node.row.phaseId; +} + +export function flattenPhaseSidebarTree( + nodes: ReadonlyArray, + isExpanded: (key: string) => boolean, +): ReadonlyArray { + const flattened: PhaseSidebarTreeNode[] = []; + const visit = (node: PhaseSidebarTreeNode): void => { + flattened.push(node); + if (node.children.length === 0 || !isExpanded(node.key)) return; + for (const child of node.children) visit(child); + }; + for (const node of nodes) visit(node); + return flattened; +} + +/** Every key in a subtree except its root — backs "Expand/Collapse all children". */ +export function collectPhaseSidebarSubtreeKeys(node: PhaseSidebarTreeNode): ReadonlyArray { + const keys: string[] = []; + const visit = (current: PhaseSidebarTreeNode): void => { + for (const child of current.children) { + keys.push(child.key); + visit(child); + } + }; + visit(node); + return keys; +} + +/** + * Keys of parents that must be force-expanded because a filter matched + * something inside them. Without this, filtering by repository would silently + * hide matches nested under a collapsed parent from another repository — the + * exact cross-repo case this feature exists to make visible. + */ +export function resolveForcedExpansionKeys( + nodes: ReadonlyArray, + matches: (row: PhaseSidebarRow) => boolean, +): ReadonlySet { + const forced = new Set(); + const visit = (node: PhaseSidebarTreeNode): boolean => { + let descendantMatched = false; + for (const child of node.children) { + descendantMatched = visit(child) || descendantMatched; + } + if (descendantMatched) forced.add(node.key); + return descendantMatched || matches(node.row); + }; + for (const node of nodes) visit(node); + return forced; +} + +/** Indentation in px for a nested row, capped so deep chains stay readable. */ +export function phaseSidebarTreeIndent(depth: number): number { + return Math.min(depth, PHASE_SIDEBAR_TREE_MAX_INDENT_DEPTH) * 14; +} + +export function phaseSidebarFiltersActive(filters: PhaseSidebarFilters): boolean { + return ( + filters.repositoryKeys.length > 0 || + filters.phaseIds.length > 0 || + filters.providerKinds.length > 0 || + // T3-CUSTOM(expbkt3): ownership and co-participant facets. + filters.participantUserIds.length > 0 || + filters.ownedByMe + ); +} + +export interface PhaseSidebarTreeGroup extends PhaseSidebarPhaseDefinition { + readonly nodes: ReadonlyArray; +} + +export interface PhaseSidebarTreeGroupsResult { + readonly groups: ReadonlyArray; + /** + * Parents the user did not open but that must render open anyway, because a + * filter matched something inside them. Transient — never written to the + * expansion store, so clearing the filter restores the user's own state. + */ + readonly forcedExpansionKeys: ReadonlySet; +} + +/** + * The full pipeline for one section: filter, nest, then group the roots. + * + * Filtering runs against the tree rather than the flat row list so a match is + * never hidden inside a collapsed parent that does not itself match. A row + * survives when it matches, or when anything in its subtree matches (its + * ancestors are carried along to keep the path renderable). + */ +export function buildPhaseSidebarTreeGroups(input: { + readonly rows: ReadonlyArray; + readonly filters: PhaseSidebarFilters; + readonly compareSiblings: (left: PhaseSidebarRow, right: PhaseSidebarRow) => number; + readonly titleForKey?: (key: string) => string | null; +}): PhaseSidebarTreeGroupsResult { + const candidates = input.rows.filter((row) => row.thread.archivedAt === null); + const matches = (row: PhaseSidebarRow) => matchesPhaseSidebarFilters(row, input.filters); + const filtersActive = phaseSidebarFiltersActive(input.filters); + + let survivingRows = candidates; + if (filtersActive) { + // Nest against the UNFILTERED set first, so ancestry is true lineage rather + // than an artefact of what the filter happened to leave behind. A row is + // kept when it matches; its ancestors come along to keep the path to it + // renderable. + const keep = new Set(); + const visit = (node: PhaseSidebarTreeNode, ancestorKeys: ReadonlyArray): void => { + if (matches(node.row)) { + keep.add(node.key); + for (const ancestorKey of ancestorKeys) keep.add(ancestorKey); + } + const nextAncestors = [...ancestorKeys, node.key]; + for (const child of node.children) visit(child, nextAncestors); + }; + for (const node of buildPhaseSidebarTree(candidates, { + compareSiblings: input.compareSiblings, + })) { + visit(node, []); + } + survivingRows = candidates.filter((row) => keep.has(phaseSidebarRowKey(row))); + } + + // Descendant counts and the busy rollup describe what actually renders. + const tree = buildPhaseSidebarTree(survivingRows, { + compareSiblings: input.compareSiblings, + ...(input.titleForKey ? { titleForKey: input.titleForKey } : {}), + }); + + const groups = PHASE_SIDEBAR_PHASES.flatMap((phase) => { + const nodes = tree.filter((node) => resolvePhaseSidebarTreePhase(node) === phase.id); + return nodes.length > 0 ? [{ ...phase, nodes }] : []; + }); + + return { + groups, + forcedExpansionKeys: filtersActive + ? resolveForcedExpansionKeys(tree, matches) + : new Set(), + }; +} diff --git a/packages/client-runtime/src/state/providerRateLimitsPresentation.ts b/packages/client-runtime/src/state/providerRateLimitsPresentation.ts new file mode 100644 index 000000000000..9b7db83e7d99 --- /dev/null +++ b/packages/client-runtime/src/state/providerRateLimitsPresentation.ts @@ -0,0 +1,383 @@ +// T3-CUSTOM(expbkt3): Provider rate-limit presentation, shared by web and mobile. +// +// Moved out of apps/web (SidebarProviderRateLimits.logic.ts, which keeps a +// re-export shim) so the mobile status strip renders the same numbers as the +// web sidebar chip. Pure: contracts and effect/DateTime only. +// +// HERMES: this also runs under React Native. Sort a copy with `.sort()`, +// never `.toSorted()`. +import { + defaultInstanceIdForDriver, + type EnvironmentId, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRateLimitSnapshot, + type ProviderRateLimitWindow, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +const STALE_AFTER_MS = 10 * 60 * 1_000; +const MINUTE_MS = 60 * 1_000; +const MINUTES_PER_DAY = 24 * 60; +const CODEX = ProviderDriverKind.make("codex"); +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const DISPLAY_ORDER = [CODEX, CLAUDE] as const; + +/** + * T3-CUSTOM(expbkt3): the headline reading is the weekly window only. + * + * It used to be `min()` across every window, which meant the short rolling + * window (Claude's five-hour, Codex's primary) almost always won and the + * sidebar silently reported a five-hour number under a weekly-looking meter. + * The rolling window is now surfaced separately, and only once it actually + * constrains you -- see ROLLING_CHIP_VISIBLE_BELOW_PERCENT. + */ +const ROLLING_CHIP_VISIBLE_BELOW_PERCENT = 50; + +export function selectProviderRateLimitEnvironmentId( + activeEnvironmentId: EnvironmentId | null, + primaryEnvironmentId: EnvironmentId | null, +): EnvironmentId | null { + return activeEnvironmentId ?? primaryEnvironmentId; +} + +export type ProviderRateLimitTone = "healthy" | "warning" | "danger" | "unknown"; +export type ProviderRateLimitFreshness = "fresh" | "stale" | "unknown" | "not-applicable" | "error"; + +export interface ProviderRateLimitHeaderProvider { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + readonly enabled: boolean; +} + +export interface ProviderRateLimitWindowView { + readonly window: ProviderRateLimitWindow; + readonly remainingPercent: number | null; + readonly status: "active" | "stale" | "awaiting-refresh"; +} + +/** + * The short rolling window (five-hour on Claude, primary on Codex), shown beside + * the weekly meter only while it is the tighter constraint. + */ +export interface ProviderRateLimitRollingView { + readonly remainingPercent: number; + readonly minutesUntilReset: number | null; + readonly resetsAtMs: number | null; + readonly tone: ProviderRateLimitTone; + /** Compact window length, e.g. `5h`. Null when the provider omits a duration. */ + readonly windowLabel: string | null; +} + +/** + * Minutes as the shortest readable unit: `47m`, `1h 8m`, `5h`. Used for both the + * window length and its reset countdown so the chip reads consistently. + */ +export function formatCompactMinutes(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`; +} + +/** + * A single unit, rounded up: `6d`, `18h`, `45m`. The weekly countdown sits in the + * sidebar permanently, so it trades the compound form's precision for a label + * that never grows past three characters. + */ +export function formatSingleUnitMinutes(minutes: number): string { + if (minutes >= MINUTES_PER_DAY) return `${Math.ceil(minutes / MINUTES_PER_DAY)}d`; + if (minutes >= 60) return `${Math.ceil(minutes / 60)}h`; + return `${Math.max(1, minutes)}m`; +} + +/** + * When {@link formatSingleUnitMinutes} would next print something different. + * Rounding up means the label changes as the remaining time crosses each whole + * unit, so the sidebar can wake exactly then instead of ticking every minute for + * a week. + */ +export function singleUnitBoundaryMs(resetsAtMs: number, minutesUntilReset: number): number { + const unitMinutes = + minutesUntilReset >= MINUTES_PER_DAY ? MINUTES_PER_DAY : minutesUntilReset >= 60 ? 60 : 1; + const wholeUnits = Math.ceil(minutesUntilReset / unitMinutes); + return resetsAtMs - (wholeUnits - 1) * unitMinutes * MINUTE_MS; +} + +export interface ProviderRateLimitRowView { + readonly driverKind: ProviderDriverKind; + readonly providerInstanceId: ProviderInstanceId; + readonly displayName: "Codex" | "Claude"; + readonly availability: ProviderRateLimitSnapshot["availability"]; + /** Weekly window only. Null when the provider reports no weekly quota. */ + readonly remainingPercent: number | null; + /** + * When the window behind {@link remainingPercent} refills. Always rendered, so + * the headline percentage is never read without knowing how long it has to last. + */ + readonly headlineMinutesUntilReset: number | null; + readonly headlineResetsAtMs: number | null; + readonly rolling: ProviderRateLimitRollingView | null; + readonly tone: ProviderRateLimitTone; + readonly freshness: ProviderRateLimitFreshness; + readonly observedAt: DateTime.Utc | null; + readonly lastRefreshFailed: boolean; + readonly source: "live" | "cache"; + readonly windows: ReadonlyArray; +} + +export function providerRateLimitTone(remainingPercent: number | null): ProviderRateLimitTone { + if (remainingPercent === null) return "unknown"; + if (remainingPercent >= 50) return "healthy"; + if (remainingPercent >= 20) return "warning"; + return "danger"; +} + +function roundedRemaining(usedPercent: number): number { + return Math.round(100 - usedPercent); +} + +function isSnapshotStale(snapshot: ProviderRateLimitSnapshot, now: number): boolean { + return ( + snapshot.observedAt !== null && + now - DateTime.toEpochMillis(snapshot.observedAt) > STALE_AFTER_MS + ); +} + +function windowView( + window: ProviderRateLimitWindow, + now: number, + stale: boolean, +): ProviderRateLimitWindowView { + if (window.resetsAt !== null && DateTime.toEpochMillis(window.resetsAt) <= now) { + return { + window, + remainingPercent: roundedRemaining(window.usedPercent), + status: "awaiting-refresh", + }; + } + if (stale) { + return { window, remainingPercent: roundedRemaining(window.usedPercent), status: "stale" }; + } + return { window, remainingPercent: roundedRemaining(window.usedPercent), status: "active" }; +} + +function minutesUntilReset(resetsAt: DateTime.Utc | null, now: number): number | null { + if (resetsAt === null) return null; + return Math.max(0, Math.ceil((DateTime.toEpochMillis(resetsAt) - now) / MINUTE_MS)); +} + +function displayName(driver: ProviderDriverKind): "Codex" | "Claude" { + return driver === CODEX ? "Codex" : "Claude"; +} + +function unknownRow( + driverKind: ProviderDriverKind, + providerInstanceId: ProviderInstanceId, +): ProviderRateLimitRowView { + return { + driverKind, + providerInstanceId, + displayName: displayName(driverKind), + availability: "unknown", + remainingPercent: null, + headlineMinutesUntilReset: null, + headlineResetsAtMs: null, + rolling: null, + tone: "unknown", + freshness: "unknown", + observedAt: null, + lastRefreshFailed: false, + source: "live", + windows: [], + }; +} + +function projectRow( + snapshot: ProviderRateLimitSnapshot, + now: number, + source: "live" | "cache", +): ProviderRateLimitRowView { + const stale = + snapshot.availability === "available" && + (source === "cache" || snapshot.lastRefreshFailed || isSnapshotStale(snapshot, now)); + const windows = snapshot.windows + .map((window) => windowView(window, now, stale)) + .sort( + (left, right) => + (left.remainingPercent ?? Number.POSITIVE_INFINITY) - + (right.remainingPercent ?? Number.POSITIVE_INFINITY), + ); + const activeRemainingValues = windows.flatMap((window) => + window.status !== "active" || window.remainingPercent === null ? [] : [window.remainingPercent], + ); + // `windows` is already sorted ascending by remaining, so the head of a pool is + // its lowest reading. + const lowestOf = ( + views: ReadonlyArray, + ): ProviderRateLimitWindowView | null => { + const known = views.filter((view) => view.remainingPercent !== null); + const active = known.filter((view) => view.status === "active"); + const pool = stale ? known : active.length > 0 ? active : known; + return pool[0] ?? null; + }; + const weeklyWindows = windows.filter(({ window }) => window.category === "weekly"); + // Providers that report no weekly quota keep the previous all-window reading + // rather than degrading the meter to an em dash. + const headline = lowestOf(weeklyWindows.length > 0 ? weeklyWindows : windows); + const remainingPercent = + snapshot.availability === "available" ? (headline?.remainingPercent ?? null) : null; + const headlineResetsAt = remainingPercent === null ? null : (headline?.window.resetsAt ?? null); + const rollingLowest = lowestOf(windows.filter(({ window }) => window.category === "rolling")); + const rollingRemaining = rollingLowest?.remainingPercent ?? null; + const rolling: ProviderRateLimitRollingView | null = + snapshot.availability === "available" && + rollingRemaining !== null && + rollingRemaining < ROLLING_CHIP_VISIBLE_BELOW_PERCENT + ? { + remainingPercent: rollingRemaining, + minutesUntilReset: minutesUntilReset(rollingLowest?.window.resetsAt ?? null, now), + resetsAtMs: + rollingLowest?.window.resetsAt == null + ? null + : DateTime.toEpochMillis(rollingLowest.window.resetsAt), + tone: providerRateLimitTone(rollingRemaining), + windowLabel: + rollingLowest?.window.windowDurationMinutes === undefined + ? null + : formatCompactMinutes(rollingLowest.window.windowDurationMinutes), + } + : null; + const hasOnlyExpiredWindows = + snapshot.availability === "available" && + windows.length > 0 && + activeRemainingValues.length === 0; + const freshness: ProviderRateLimitFreshness = + snapshot.availability === "not-applicable" + ? "not-applicable" + : snapshot.availability === "error" + ? "error" + : stale || hasOnlyExpiredWindows + ? "stale" + : snapshot.observedAt === null + ? "unknown" + : "fresh"; + + return { + driverKind: snapshot.driverKind, + providerInstanceId: snapshot.providerInstanceId, + displayName: displayName(snapshot.driverKind), + availability: snapshot.availability, + remainingPercent, + headlineMinutesUntilReset: minutesUntilReset(headlineResetsAt, now), + headlineResetsAtMs: headlineResetsAt === null ? null : DateTime.toEpochMillis(headlineResetsAt), + rolling: + rolling === null ? null : freshness === "fresh" ? rolling : { ...rolling, tone: "unknown" }, + tone: freshness === "fresh" ? providerRateLimitTone(remainingPercent) : "unknown", + freshness, + observedAt: snapshot.observedAt, + lastRefreshFailed: snapshot.lastRefreshFailed, + source, + windows, + }; +} + +function canUseLiveSnapshot(snapshot: ProviderRateLimitSnapshot): boolean { + return ( + snapshot.availability === "not-applicable" || + (snapshot.availability === "available" && + snapshot.observedAt !== null && + snapshot.windows.length > 0) + ); +} + +export function buildProviderRateLimitRows(input: { + readonly providers: ReadonlyArray; + readonly entries: ReadonlyArray; + readonly cachedEntries?: ReadonlyArray; + readonly now: number; +}): ReadonlyArray { + const entryById = new Map(input.entries.map((entry) => [entry.providerInstanceId, entry])); + const cachedEntryById = new Map( + (input.cachedEntries ?? []).map((entry) => [entry.providerInstanceId, entry]), + ); + const providerById = new Map(input.providers.map((provider) => [provider.instanceId, provider])); + + return DISPLAY_ORDER.flatMap((driverKind) => { + const defaultId = defaultInstanceIdForDriver(driverKind); + const provider = providerById.get(defaultId); + if (!provider?.enabled) return []; + const liveEntry = entryById.get(defaultId); + if (liveEntry !== undefined && canUseLiveSnapshot(liveEntry)) { + return [projectRow(liveEntry, input.now, "live")]; + } + const cachedEntry = cachedEntryById.get(defaultId); + if (cachedEntry !== undefined && cachedEntry.availability === "available") { + return [projectRow(cachedEntry, input.now, "cache")]; + } + return [ + liveEntry === undefined + ? unknownRow(driverKind, defaultId) + : projectRow(liveEntry, input.now, "live"), + ]; + }); +} + +export function summarizeProviderRateLimitRows( + rows: ReadonlyArray, +): string { + const readings = rows.map((row) => { + const rolling = + row.rolling === null + ? "" + : `, ${row.rolling.windowLabel ?? "rolling"} window ${ + row.rolling.remainingPercent + }% remaining${ + row.rolling.minutesUntilReset === null + ? "" + : ` and resets in ${formatCompactMinutes(row.rolling.minutesUntilReset)}` + }`; + const headlineReset = + row.headlineMinutesUntilReset === null + ? "" + : `, resets in ${formatSingleUnitMinutes(row.headlineMinutesUntilReset)}`; + return row.remainingPercent === null + ? `${row.displayName} unavailable${rolling}` + : `${row.displayName} ${row.remainingPercent}% weekly remaining${headlineReset}${ + row.source === "cache" ? ", cached" : row.freshness === "stale" ? ", stale" : "" + }${rolling}`; + }); + return `Provider usage limits: ${readings.join("; ")}`; +} + +export function providerRateLimitBoundaryTimes( + rows: ReadonlyArray, +): ReadonlyArray { + return rows.flatMap((row) => [ + ...(row.observedAt === null + ? [] + : [DateTime.toEpochMillis(row.observedAt) + STALE_AFTER_MS + 1]), + ...row.windows.flatMap(({ window }) => + window.resetsAt === null ? [] : [DateTime.toEpochMillis(window.resetsAt)], + ), + ...rollingMinuteBoundaries(row.rolling), + // The always-on weekly countdown moves a unit at a time, so one wake-up per + // unit is enough — a per-minute schedule would be ~10k timers for a week. + ...(row.headlineResetsAtMs === null || row.headlineMinutesUntilReset === null + ? [] + : [singleUnitBoundaryMs(row.headlineResetsAtMs, row.headlineMinutesUntilReset)]), + ]); +} + +/** + * While the rolling countdown is on screen it has to re-render every minute, so + * emit each remaining minute boundary before its reset. + */ +function rollingMinuteBoundaries( + rolling: ProviderRateLimitRollingView | null, +): ReadonlyArray { + const resetsAtMs = rolling?.resetsAtMs; + const minutes = rolling?.minutesUntilReset; + if (resetsAtMs == null || minutes == null) return []; + return Array.from({ length: minutes }, (_, index) => resetsAtMs - (index + 1) * MINUTE_MS); +} From 32e18d5bda2a127151d8c88d25e1cd6d116ec139 Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 15:25:59 +0000 Subject: [PATCH 3/7] feat(mobile): review proposed plans from the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile had no way to act on a proposed plan at all: the thread showed a "Plan Ready" pill and the only way forward was a desktop. This adds a native plan-review surface — read the plan, question specific lines, then approve or request changes. Web reviews plans in a Plate rich-text editor, which does not port to a phone. The useful phone operations are read, question, and decide, so this surface is read-plus-annotate and reviewer edits to the plan body stay a desktop affordance; `editedMarkdown` is always null on submit, which the contract already allows. Line selection deliberately copies the diff reviewer's gesture (tap to anchor, tap to extend, tap again to clear) so reviewers moving between the two surfaces do not learn two interactions. Anchoring is by quoted text, re-located against whichever version is on screen, so a quote the agent has since rewritten is still listed rather than silently dropped. Fork-cost notes: the two upstream files touched take one marked line each (an import plus one element in ThreadRouteScreen, a route pair in Stack.tsx), and the banner resolves its own visibility so no conditional leaks into the thread screen. planReviewMarkdown moved from apps/web into client-runtime rather than being copied, since it was already DOM-free and both clients need it. Verified: 31 new tests across the three pure modules, mobile tsc and lint clean, web and client-runtime tsc clean after the move. Model: Claude Opus 5 (1M context), harness: Claude Code in T3 Code. Co-Authored-By: Claude Opus 5 (1M context) --- apps/mobile/src/Stack.tsx | 27 ++ .../planreview/PlanReviewCommentSheet.tsx | 161 +++++++++ .../features/planreview/PlanReviewSheet.tsx | 337 ++++++++++++++++++ .../planreview/PlanReviewThreadBanner.tsx | 78 ++++ .../planreview/planReviewAvailability.test.ts | 133 +++++++ .../planreview/planReviewAvailability.ts | 62 ++++ .../planReviewDocumentModel.test.ts | 192 ++++++++++ .../planreview/planReviewDocumentModel.ts | 140 ++++++++ .../planreview/planReviewSelection.test.ts | 67 ++++ .../planreview/planReviewSelection.ts | 95 +++++ .../features/threads/ThreadRouteScreen.tsx | 9 + apps/mobile/src/state/planReview.ts | 9 + .../src/environment/ServerEnvironment.ts | 2 + .../planreview/PlanReviewEditor.tsx | 2 +- .../planreview/PlanReviewOutline.tsx | 2 +- .../components/planreview/PlanReviewPanel.tsx | 2 +- .../plate/planReviewCommentMarks.test.ts | 2 +- packages/client-runtime/package.json | 4 + .../src/state}/planReviewMarkdown.test.ts | 2 +- .../src/state}/planReviewMarkdown.ts | 8 +- packages/contracts/src/environment.ts | 5 + packages/shared/src/planReview.test.ts | 29 ++ packages/shared/src/planReview.ts | 13 + 23 files changed, 1372 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/planreview/PlanReviewCommentSheet.tsx create mode 100644 apps/mobile/src/features/planreview/PlanReviewSheet.tsx create mode 100644 apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx create mode 100644 apps/mobile/src/features/planreview/planReviewAvailability.test.ts create mode 100644 apps/mobile/src/features/planreview/planReviewAvailability.ts create mode 100644 apps/mobile/src/features/planreview/planReviewDocumentModel.test.ts create mode 100644 apps/mobile/src/features/planreview/planReviewDocumentModel.ts create mode 100644 apps/mobile/src/features/planreview/planReviewSelection.test.ts create mode 100644 apps/mobile/src/features/planreview/planReviewSelection.ts create mode 100644 apps/mobile/src/state/planReview.ts rename {apps/web/src/components/planreview => packages/client-runtime/src/state}/planReviewMarkdown.test.ts (99%) rename {apps/web/src/components/planreview => packages/client-runtime/src/state}/planReviewMarkdown.ts (92%) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index fd190cd72b6c..4ea43792c97b 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -25,6 +25,9 @@ import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayo import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; import { ReviewSheet } from "./features/review/ReviewSheet"; +// T3-CUSTOM(expbkt3): native plan review screens. +import { PlanReviewSheet } from "./features/planreview/PlanReviewSheet"; +import { PlanReviewCommentSheet } from "./features/planreview/PlanReviewCommentSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; @@ -498,6 +501,30 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: Platform.OS !== "android", }, }), + // T3-CUSTOM(expbkt3): BEGIN native plan review + ThreadPlanReview: createNativeStackScreen({ + screen: PlanReviewSheet, + linking: `${THREAD_LINKING_PREFIX}/plan-review`, + options: { + ...SOLID_HEADER_OPTIONS, + title: "Plan review", + }, + }), + ThreadPlanReviewComment: createNativeStackScreen({ + screen: PlanReviewCommentSheet, + linking: `${THREAD_LINKING_PREFIX}/plan-review-comment`, + options: { + // Same Android constraint as the diff-review composer: the keyboard + // cannot be hosted inside a formSheet there. + ...(Platform.OS === "android" + ? { presentation: "fullScreenModal" as const } + : FORM_SHEET_PRESENTATION_OPTIONS), + sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], + sheetGrabberVisible: Platform.OS !== "android", + headerShown: false, + }, + }), + // T3-CUSTOM(expbkt3): END native plan review ThreadFiles: createNativeStackScreen({ screen: ThreadFilesTreeScreen, linking: `${THREAD_LINKING_PREFIX}/files`, diff --git a/apps/mobile/src/features/planreview/PlanReviewCommentSheet.tsx b/apps/mobile/src/features/planreview/PlanReviewCommentSheet.tsx new file mode 100644 index 000000000000..6041d5f43e2e --- /dev/null +++ b/apps/mobile/src/features/planreview/PlanReviewCommentSheet.tsx @@ -0,0 +1,161 @@ +// T3-CUSTOM(expbkt3): composer for a plan-review discussion on mobile. +// +// A separate route rather than an inline sheet, matching the diff reviewer's +// comment composer: the keyboard needs the whole screen on a phone, and Android +// cannot host a keyboard-driven composer inside a formSheet. +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { nextPlanDiscussionId } from "@t3tools/client-runtime/state/planReviewMarkdown"; +import { useCallback, useMemo, useState } from "react"; +import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { KeyboardAvoidingView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { planReviewEnvironment } from "../../state/planReview"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { clearPlanReviewSelection, usePlanReviewSelection } from "./planReviewSelection"; +import { formatPlanReviewSelectionLabel } from "./planReviewSelection"; + +const QUOTE_PREVIEW_MAX_LINES = 6; + +type PlanReviewCommentSheetProps = StaticScreenProps<{ + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly documentId: string; +}>; + +export function PlanReviewCommentSheet(props: PlanReviewCommentSheetProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const selection = usePlanReviewSelection(); + const { environmentId, documentId } = props.route.params; + const [body, setBody] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const upsertDiscussion = useAtomCommand( + planReviewEnvironment.upsertDiscussion, + "plan review upsert discussion", + ); + + const quoteLines = useMemo( + () => (selection ? selection.quotedText.split("\n") : []), + [selection], + ); + const canSubmit = body.trim().length > 0 && selection !== null && !isSaving; + + const dismiss = useCallback(() => { + clearPlanReviewSelection(); + navigation.goBack(); + }, [navigation]); + + const handleSubmit = useCallback(() => { + if (selection === null || body.trim().length === 0) return; + setIsSaving(true); + setError(null); + void upsertDiscussion({ + environmentId, + input: { + documentId, + discussionId: nextPlanDiscussionId(), + quotedText: selection.quotedText, + bodyMarkdown: body.trim(), + }, + }) + .then((result) => { + if (result._tag === "Failure") { + // Keep the text: the reviewer's words are the expensive part. + setError("The comment could not be saved. Try again."); + return; + } + dismiss(); + }) + .finally(() => { + setIsSaving(false); + }); + }, [body, dismiss, documentId, environmentId, selection, upsertDiscussion]); + + return ( + + + + + Cancel + + Comment + + {isSaving ? ( + + ) : ( + + Save + + )} + + + + + {selection === null ? ( + + + The selection was lost. Go back and pick the lines again. + + + ) : ( + <> + + + {formatPlanReviewSelectionLabel(selection)} + + + {quoteLines.slice(0, QUOTE_PREVIEW_MAX_LINES).map((line, index) => ( + + {line.length > 0 ? line : " "} + + ))} + {quoteLines.length > QUOTE_PREVIEW_MAX_LINES ? ( + + +{quoteLines.length - QUOTE_PREVIEW_MAX_LINES} more lines + + ) : null} + + + + + + {error === null ? null : ( + + {error} + + )} + + )} + + + + ); +} diff --git a/apps/mobile/src/features/planreview/PlanReviewSheet.tsx b/apps/mobile/src/features/planreview/PlanReviewSheet.tsx new file mode 100644 index 000000000000..d8dc5ab7e2ce --- /dev/null +++ b/apps/mobile/src/features/planreview/PlanReviewSheet.tsx @@ -0,0 +1,337 @@ +// T3-CUSTOM(expbkt3): the mobile plan-review screen. +// +// Web reviews plans in a rich-text editor (Plate). That does not port: on a +// phone the useful operations are read the plan, question a specific line, and +// decide. So this surface is read-plus-annotate — tap lines to select, comment, +// then approve or request changes — and reviewer edits to the plan body stay a +// desktop affordance. `editedMarkdown` is therefore always null on submit here, +// which the contract already allows. +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { + EnvironmentId, + PlanReviewDecision, + PlanReviewSnapshotResult, + ThreadId, +} from "@t3tools/contracts"; +import { useCallback, useMemo, useState } from "react"; +import { ActivityIndicator, FlatList, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useEnvironmentQuery } from "../../state/query"; +import { planReviewEnvironment } from "../../state/planReview"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + buildPlanReviewView, + quotedTextForLineRange, + type PlanReviewLineRow, +} from "./planReviewDocumentModel"; +import { + formatPlanReviewSelectionLabel, + setPlanReviewSelection, + togglePlanReviewLine, + usePlanReviewSelection, +} from "./planReviewSelection"; + +type PlanReviewSheetProps = StaticScreenProps<{ + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly documentId: string; +}>; + +function PlanReviewLine(props: { + readonly row: PlanReviewLineRow; + readonly isSelected: boolean; + readonly onPress: (lineIndex: number) => void; +}) { + const { row, isSelected, onPress } = props; + const handlePress = useCallback(() => onPress(row.lineIndex), [onPress, row.lineIndex]); + + return ( + 0 && "bg-amber-500/10", + )} + onPress={handlePress} + > + + {row.lineIndex + 1} + + + {row.text.length > 0 ? row.text : " "} + + {row.discussionIds.length > 0 ? ( + + {row.discussionIds.length} + + ) : null} + + ); +} + +export function PlanReviewSheet(props: PlanReviewSheetProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const iconTint = String(useThemeColor("--color-icon")); + const { environmentId, threadId, documentId } = props.route.params; + const selection = usePlanReviewSelection(); + const [pendingDecision, setPendingDecision] = useState(null); + const [error, setError] = useState(null); + + const initial = useEnvironmentQuery( + planReviewEnvironment.review({ environmentId, input: { documentId } }), + ); + // The subscription supersedes the one-shot read as soon as it produces a + // frame, so the screen never renders stale state after the agent revises the + // plan or another client comments. Same precedence as the web panel. + const live = useEnvironmentQuery( + planReviewEnvironment.subscription({ environmentId, input: { documentId } }), + ); + const snapshot: PlanReviewSnapshotResult | null = live.data ?? initial.data ?? null; + const submit = useAtomCommand(planReviewEnvironment.submit, "plan review submit"); + + const view = useMemo( + () => (snapshot === null ? null : buildPlanReviewView(snapshot)), + [snapshot], + ); + + const handleLinePress = useCallback( + (lineIndex: number) => { + if (view === null) return; + setPlanReviewSelection( + togglePlanReviewLine({ + current: selection, + documentId, + lineIndex, + quoteFor: (startIndex, endIndex) => + quotedTextForLineRange(view.lines, startIndex, endIndex), + }), + ); + }, + [documentId, selection, view], + ); + + const handleOpenComposer = useCallback(() => { + navigation.navigate("ThreadPlanReviewComment", { environmentId, threadId, documentId }); + }, [documentId, environmentId, navigation, threadId]); + + const handleDecide = useCallback( + (decision: PlanReviewDecision) => { + setPendingDecision(decision); + setError(null); + void submit({ + environmentId, + input: { documentId, decision, globalComment: "", editedMarkdown: null }, + }) + .then((result) => { + if (result._tag === "Failure") { + setError("The decision could not be sent. Try again."); + return; + } + // The agent picks the turn up from here; the thread is where the + // reviewer watches it happen. + navigation.goBack(); + }) + .finally(() => { + setPendingDecision(null); + }); + }, + [documentId, environmentId, navigation, submit], + ); + + if (initial.isPending && view === null) { + return ( + + + + ); + } + + if (view === null) { + return ( + + + {initial.error ?? "This plan is no longer available."} + + + Try again + + + ); + } + + const isDecided = snapshot?.document.status !== "open"; + const isSubmitting = pendingDecision !== null; + + return ( + + + } + ListHeaderComponent={ + + + {snapshot?.document.title ?? "Plan"} + + + Revision {view.currentVersion?.revision ?? 0} + {view.unresolvedCount > 0 + ? ` · ${view.unresolvedCount} open comment${view.unresolvedCount === 1 ? "" : "s"}` + : ""} + + + Tap a line to select it, tap another to extend, then comment. + + + } + contentContainerStyle={{ paddingBottom: insets.bottom + 120 }} + data={view.lines} + initialNumToRender={40} + keyExtractor={(row) => String(row.lineIndex)} + renderItem={({ item }) => ( + = selection.startIndex && + item.lineIndex <= selection.endIndex + } + onPress={handleLinePress} + row={item} + /> + )} + windowSize={11} + /> + + {error === null ? null : ( + + + {error} + + + )} + + {selection === null ? ( + isDecided ? null : ( + + handleDecide("changes-requested")} + > + {pendingDecision === "changes-requested" ? ( + + ) : ( + Request changes + )} + + handleDecide("approved")} + > + {pendingDecision === "approved" ? ( + + ) : ( + Approve + )} + + + ) + ) : ( + + + {formatPlanReviewSelectionLabel(selection)} + + + + Comment + + + )} + + ); +} + +function PlanReviewDiscussionList(props: { + readonly environmentId: EnvironmentId; + readonly documentId: string; + readonly threads: ReturnType["threads"]; +}) { + const resolveDiscussion = useAtomCommand( + planReviewEnvironment.resolveDiscussion, + "plan review resolve discussion", + ); + + if (props.threads.length === 0) return null; + + return ( + + Comments + {props.threads.map((thread) => ( + + + + {thread.startIndex === null + ? "Anchor no longer in the plan" + : `Line ${thread.startIndex + 1}${ + thread.endIndex !== null && thread.endIndex !== thread.startIndex + ? `-${thread.endIndex + 1}` + : "" + }`} + + {thread.discussion.isResolved ? ( + · resolved + ) : null} + + + + {thread.discussion.quotedText} + + + {thread.comments.map((comment) => ( + + {comment.bodyMarkdown} + + ))} + { + void resolveDiscussion({ + environmentId: props.environmentId, + input: { + documentId: props.documentId, + discussionId: thread.discussion.discussionId, + isResolved: !thread.discussion.isResolved, + }, + }); + }} + > + + {thread.discussion.isResolved ? "Reopen" : "Resolve"} + + + + ))} + + ); +} diff --git a/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx b/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx new file mode 100644 index 000000000000..60342f419875 --- /dev/null +++ b/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx @@ -0,0 +1,78 @@ +// T3-CUSTOM(expbkt3): the thread screen's entry point into plan review. +// +// Self-contained on purpose. It resolves its own capability, document list and +// visibility, and renders null whenever plan review does not apply, so the seam +// inside the upstream thread screen stays a single element with no surrounding +// conditional. That keeps the next upstream merge cheap. +import { useNavigation } from "@react-navigation/native"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useCallback } from "react"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useServerConfigs } from "../../state/entities"; +import { planReviewEnvironment } from "../../state/planReview"; +import { useEnvironmentQuery } from "../../state/query"; +import { resolveOpenPlanReviewDocument, shouldOfferPlanReview } from "./planReviewAvailability"; + +export function PlanReviewThreadBanner(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly hasActionableProposedPlan: boolean; +}) { + const navigation = useNavigation(); + const iconTint = String(useThemeColor("--color-icon")); + const serverConfigs = useServerConfigs(); + const capabilities = serverConfigs.get(props.environmentId)?.environment.capabilities; + + // Only ask the server for documents once the two cheap gates pass; a thread + // with no plan awaiting review should cost no RPC at all. + const isPossible = capabilities?.planReview === true && props.hasActionableProposedPlan; + const documentsQuery = useEnvironmentQuery( + isPossible + ? planReviewEnvironment.list({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }) + : null, + ); + + const documents = documentsQuery.data?.documents ?? null; + const offered = shouldOfferPlanReview({ + capabilities, + hasActionableProposedPlan: props.hasActionableProposedPlan, + documents, + }); + const document = resolveOpenPlanReviewDocument(documents); + + const handleOpen = useCallback(() => { + if (document === null) return; + navigation.navigate("ThreadPlanReview", { + environmentId: props.environmentId, + threadId: props.threadId, + documentId: document.documentId, + }); + }, [document, navigation, props.environmentId, props.threadId]); + + if (!offered || document === null) return null; + + return ( + + + + + Plan ready for review + + + {document.title} + + + + + ); +} diff --git a/apps/mobile/src/features/planreview/planReviewAvailability.test.ts b/apps/mobile/src/features/planreview/planReviewAvailability.test.ts new file mode 100644 index 000000000000..71afadcdf978 --- /dev/null +++ b/apps/mobile/src/features/planreview/planReviewAvailability.test.ts @@ -0,0 +1,133 @@ +// T3-CUSTOM(expbkt3): fork-owned coverage for the mobile plan-review gates. +// +// These live in a fork-owned file deliberately: after an upstream merge they are +// the proof that the mobile plan-review entry point survived conflict +// resolution. +import { describe, expect, it } from "@effect/vitest"; +import type { ExecutionEnvironmentCapabilities, PlanReviewDocument } from "@t3tools/contracts"; + +import { + environmentSupportsPlanReview, + resolveOpenPlanReviewDocument, + shouldOfferPlanReview, +} from "./planReviewAvailability"; + +const capabilities = ( + overrides: Partial = {}, +): ExecutionEnvironmentCapabilities => + ({ + repositoryIdentity: false, + planReview: true, + ...overrides, + }) as ExecutionEnvironmentCapabilities; + +const document = (overrides: Partial = {}): PlanReviewDocument => + ({ + documentId: "doc-1", + threadId: "thread-1", + projectId: "project-1", + title: "Plan", + currentRevision: 1, + status: "open", + format: "md", + createdByUserId: null, + createdAt: "2026-08-31T00:00:00.000Z", + updatedAt: "2026-08-31T00:00:00.000Z", + ...overrides, + }) as PlanReviewDocument; + +describe("environmentSupportsPlanReview", () => { + it("requires the capability to be explicitly true", () => { + expect(environmentSupportsPlanReview(capabilities())).toBe(true); + expect(environmentSupportsPlanReview(capabilities({ planReview: false }))).toBe(false); + }); + + it("treats an upstream server, which omits the key entirely, as unsupported", () => { + expect(environmentSupportsPlanReview(capabilities({ planReview: undefined }))).toBe(false); + expect(environmentSupportsPlanReview(undefined)).toBe(false); + }); +}); + +describe("resolveOpenPlanReviewDocument", () => { + it("returns null when there is nothing to review", () => { + expect(resolveOpenPlanReviewDocument(null)).toBeNull(); + expect(resolveOpenPlanReviewDocument(undefined)).toBeNull(); + expect(resolveOpenPlanReviewDocument([])).toBeNull(); + }); + + it("ignores documents whose decision has already been made", () => { + const resolved = resolveOpenPlanReviewDocument([ + document({ documentId: "a", status: "approved" }), + document({ documentId: "b", status: "changes-requested" }), + document({ documentId: "c", status: "discarded" }), + ]); + expect(resolved).toBeNull(); + }); + + it("picks the open document even when resolved ones are newer", () => { + const resolved = resolveOpenPlanReviewDocument([ + document({ documentId: "old-open", updatedAt: "2026-08-01T00:00:00.000Z" }), + document({ + documentId: "new-approved", + status: "approved", + updatedAt: "2026-08-30T00:00:00.000Z", + }), + ]); + expect(resolved?.documentId).toBe("old-open"); + }); + + it("takes the most recently updated when more than one is open", () => { + const resolved = resolveOpenPlanReviewDocument([ + document({ documentId: "older", updatedAt: "2026-08-01T00:00:00.000Z" }), + document({ documentId: "newer", updatedAt: "2026-08-30T00:00:00.000Z" }), + ]); + expect(resolved?.documentId).toBe("newer"); + }); + + it("still returns an open document when its timestamp is unparseable", () => { + const resolved = resolveOpenPlanReviewDocument([document({ updatedAt: "not-a-date" })]); + expect(resolved?.documentId).toBe("doc-1"); + }); +}); + +describe("shouldOfferPlanReview", () => { + it("offers the CTA when every gate passes", () => { + expect( + shouldOfferPlanReview({ + capabilities: capabilities(), + hasActionableProposedPlan: true, + documents: [document()], + }), + ).toBe(true); + }); + + it("hides the CTA when the server cannot serve plan review", () => { + expect( + shouldOfferPlanReview({ + capabilities: capabilities({ planReview: undefined }), + hasActionableProposedPlan: true, + documents: [document()], + }), + ).toBe(false); + }); + + it("hides the CTA once the plan is no longer awaiting the user", () => { + expect( + shouldOfferPlanReview({ + capabilities: capabilities(), + hasActionableProposedPlan: false, + documents: [document()], + }), + ).toBe(false); + }); + + it("hides the CTA when ingest produced no open document", () => { + expect( + shouldOfferPlanReview({ + capabilities: capabilities(), + hasActionableProposedPlan: true, + documents: [document({ status: "approved" })], + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/planreview/planReviewAvailability.ts b/apps/mobile/src/features/planreview/planReviewAvailability.ts new file mode 100644 index 000000000000..c9dfc67d6462 --- /dev/null +++ b/apps/mobile/src/features/planreview/planReviewAvailability.ts @@ -0,0 +1,62 @@ +// T3-CUSTOM(expbkt3): when the mobile plan-review surface is offered at all. +// +// Three independent gates, kept pure and separate from the screen so each can +// be tested and so "why is the CTA hidden?" is answerable: +// +// 1. The server must advertise the `planReview` capability. Upstream servers, +// and fork servers from before plan review shipped, do not — calling +// planReview.* against them fails the RPC rather than degrading. +// 2. The thread must have an actionable proposed plan, i.e. one the agent is +// still waiting on. A plan already implemented is history. +// 3. A plan-review document must exist and still be open. The server creates +// one from every proposed plan, but a document that has been approved or +// discarded is read-only. +import type { ExecutionEnvironmentCapabilities, PlanReviewDocument } from "@t3tools/contracts"; + +export function environmentSupportsPlanReview( + capabilities: ExecutionEnvironmentCapabilities | undefined, +): boolean { + return capabilities?.planReview === true; +} + +/** + * The open document for a thread, or null. + * + * At most one document per thread should be open — the server appends revisions + * to the existing lineage rather than starting a new document — but this takes + * the most recently updated regardless, which is the one the agent is waiting + * on if that invariant ever slips. + */ +export function resolveOpenPlanReviewDocument( + documents: ReadonlyArray | null | undefined, +): PlanReviewDocument | null { + if (documents == null) return null; + let newest: PlanReviewDocument | null = null; + let newestMs = Number.NEGATIVE_INFINITY; + for (const document of documents) { + if (document.status !== "open") continue; + const parsed = Date.parse(document.updatedAt); + const rank = Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed; + if (newest === null || rank > newestMs) { + newest = document; + newestMs = rank; + } + } + return newest; +} + +/** + * Whether the thread screen should offer the "Review plan" call to action. + * + * `hasActionableProposedPlan` is the same signal the thread's "Plan Ready" + * status pill reads, so the CTA appears and disappears alongside it. + */ +export function shouldOfferPlanReview(input: { + readonly capabilities: ExecutionEnvironmentCapabilities | undefined; + readonly hasActionableProposedPlan: boolean; + readonly documents: ReadonlyArray | null | undefined; +}): boolean { + if (!environmentSupportsPlanReview(input.capabilities)) return false; + if (!input.hasActionableProposedPlan) return false; + return resolveOpenPlanReviewDocument(input.documents) !== null; +} diff --git a/apps/mobile/src/features/planreview/planReviewDocumentModel.test.ts b/apps/mobile/src/features/planreview/planReviewDocumentModel.test.ts new file mode 100644 index 000000000000..913b913c1d07 --- /dev/null +++ b/apps/mobile/src/features/planreview/planReviewDocumentModel.test.ts @@ -0,0 +1,192 @@ +// T3-CUSTOM(expbkt3): fork-owned coverage for the mobile plan-review model. +import { describe, expect, it } from "@effect/vitest"; +import type { + PlanReviewComment, + PlanReviewDiscussion, + PlanReviewSnapshotResult, + PlanReviewVersion, +} from "@t3tools/contracts"; + +import { + buildPlanReviewView, + quotedTextForLineRange, + resolveCurrentPlanVersion, +} from "./planReviewDocumentModel"; + +const PLAN = ["# Auth rewrite", "", "1. Add the migration", "2. Backfill the rows"].join("\n"); + +const version = (overrides: Partial = {}): PlanReviewVersion => + ({ + versionId: "v1", + documentId: "doc-1", + revision: 1, + authorKind: "agent", + authorUserId: null, + origin: "agent-proposed", + contentMarkdown: PLAN, + contentValueJson: null, + summary: null, + createdAt: "2026-08-31T00:00:00.000Z", + ...overrides, + }) as PlanReviewVersion; + +const discussion = (overrides: Partial = {}): PlanReviewDiscussion => + ({ + discussionId: "d-1", + documentId: "doc-1", + anchorVersionId: "v1", + quotedText: "1. Add the migration", + isResolved: false, + resolvedByUserId: null, + resolvedAt: null, + createdByUserId: null, + createdAt: "2026-08-31T00:00:00.000Z", + ...overrides, + }) as PlanReviewDiscussion; + +const comment = (overrides: Partial = {}): PlanReviewComment => + ({ + commentId: "c-1", + discussionId: "d-1", + authorUserId: null, + bodyMarkdown: "Split this.", + isEdited: false, + createdAt: "2026-08-31T00:00:00.000Z", + updatedAt: "2026-08-31T00:00:00.000Z", + ...overrides, + }) as PlanReviewComment; + +const snapshot = (overrides: Partial = {}): PlanReviewSnapshotResult => + ({ + document: { + documentId: "doc-1", + threadId: "thread-1", + projectId: "project-1", + title: "Auth rewrite", + currentRevision: 1, + status: "open", + format: "md", + createdByUserId: null, + createdAt: "2026-08-31T00:00:00.000Z", + updatedAt: "2026-08-31T00:00:00.000Z", + }, + versions: [version()], + draft: null, + discussions: [], + comments: [], + ...overrides, + }) as PlanReviewSnapshotResult; + +describe("resolveCurrentPlanVersion", () => { + it("picks the revision the document names as current", () => { + const resolved = resolveCurrentPlanVersion( + snapshot({ + document: { ...snapshot().document, currentRevision: 2 }, + versions: [ + version({ versionId: "v1", revision: 1 }), + version({ versionId: "v2", revision: 2 }), + ], + }), + ); + expect(resolved?.versionId).toBe("v2"); + }); + + it("falls back to the highest revision when the named one is missing", () => { + const resolved = resolveCurrentPlanVersion( + snapshot({ + document: { ...snapshot().document, currentRevision: 9 }, + versions: [ + version({ versionId: "v1", revision: 1 }), + version({ versionId: "v2", revision: 2 }), + ], + }), + ); + expect(resolved?.versionId).toBe("v2"); + }); + + it("returns null when there are no versions at all", () => { + expect(resolveCurrentPlanVersion(snapshot({ versions: [] }))).toBeNull(); + }); +}); + +describe("buildPlanReviewView", () => { + it("renders one row per markdown line", () => { + const view = buildPlanReviewView(snapshot()); + expect(view.lines.map((line) => line.text)).toEqual([ + "# Auth rewrite", + "", + "1. Add the migration", + "2. Backfill the rows", + ]); + }); + + it("anchors a discussion to the line its quote matches", () => { + const view = buildPlanReviewView(snapshot({ discussions: [discussion()] })); + expect(view.threads[0]?.startIndex).toBe(2); + expect(view.threads[0]?.endIndex).toBe(2); + expect(view.lines[2]?.discussionIds).toEqual(["d-1"]); + expect(view.lines[3]?.discussionIds).toEqual([]); + }); + + it("lists a discussion whose quote the agent has rewritten, without anchoring it", () => { + const view = buildPlanReviewView( + snapshot({ discussions: [discussion({ quotedText: "3. Something long gone" })] }), + ); + expect(view.threads).toHaveLength(1); + expect(view.threads[0]?.startIndex).toBeNull(); + expect(view.lines.every((line) => line.discussionIds.length === 0)).toBe(true); + }); + + it("does not decorate lines for a resolved discussion", () => { + const view = buildPlanReviewView(snapshot({ discussions: [discussion({ isResolved: true })] })); + expect(view.threads[0]?.startIndex).toBe(2); + expect(view.lines[2]?.discussionIds).toEqual([]); + expect(view.unresolvedCount).toBe(0); + }); + + it("groups comments under their discussion, oldest first", () => { + const view = buildPlanReviewView( + snapshot({ + discussions: [discussion()], + comments: [ + comment({ commentId: "c-2", createdAt: "2026-08-31T02:00:00.000Z" }), + comment({ commentId: "c-1", createdAt: "2026-08-31T01:00:00.000Z" }), + comment({ commentId: "other", discussionId: "d-other" }), + ], + }), + ); + expect(view.threads[0]?.comments.map((entry) => entry.commentId)).toEqual(["c-1", "c-2"]); + }); + + it("counts only unresolved discussions", () => { + const view = buildPlanReviewView( + snapshot({ + discussions: [ + discussion({ discussionId: "a" }), + discussion({ discussionId: "b", isResolved: true }), + ], + }), + ); + expect(view.unresolvedCount).toBe(1); + }); + + it("renders an empty plan rather than throwing when no version exists", () => { + const view = buildPlanReviewView(snapshot({ versions: [] })); + expect(view.currentVersion).toBeNull(); + expect(view.markdown).toBe(""); + }); +}); + +describe("quotedTextForLineRange", () => { + it("returns the selected source lines verbatim", () => { + const view = buildPlanReviewView(snapshot()); + expect(quotedTextForLineRange(view.lines, 2, 3)).toBe( + "1. Add the migration\n2. Backfill the rows", + ); + }); + + it("returns a single line for a single-line range", () => { + const view = buildPlanReviewView(snapshot()); + expect(quotedTextForLineRange(view.lines, 0, 0)).toBe("# Auth rewrite"); + }); +}); diff --git a/apps/mobile/src/features/planreview/planReviewDocumentModel.ts b/apps/mobile/src/features/planreview/planReviewDocumentModel.ts new file mode 100644 index 000000000000..192c98a55730 --- /dev/null +++ b/apps/mobile/src/features/planreview/planReviewDocumentModel.ts @@ -0,0 +1,140 @@ +// T3-CUSTOM(expbkt3): turns a plan-review snapshot into what the screen renders. +// +// Kept pure and separate from the view so the awkward parts — which version is +// current, where a discussion's quote sits now, which comments belong to it — +// are testable without a renderer. +// +// Anchoring is by quoted text, not line number: the server stores the excerpt a +// reviewer selected, and its position is re-derived against whichever version +// is on screen. A quote whose lines the agent has since rewritten simply stops +// locating, and the discussion is still listed as unanchored rather than +// silently dropped or pinned to the wrong paragraph. +import type { + PlanReviewComment, + PlanReviewDiscussion, + PlanReviewSnapshotResult, + PlanReviewVersion, +} from "@t3tools/contracts"; +import { locateQuotedLineRange } from "@t3tools/shared/planReview"; + +export interface PlanReviewLineRow { + /** 0-based index into the current version's markdown. */ + readonly lineIndex: number; + readonly text: string; + /** Discussion ids anchored to a range covering this line. */ + readonly discussionIds: ReadonlyArray; +} + +export interface PlanReviewDiscussionThread { + readonly discussion: PlanReviewDiscussion; + /** Oldest first, which is how a conversation reads. */ + readonly comments: ReadonlyArray; + /** Null when the quote no longer matches the version on screen. */ + readonly startIndex: number | null; + readonly endIndex: number | null; +} + +export interface PlanReviewView { + readonly markdown: string; + readonly lines: ReadonlyArray; + readonly currentVersion: PlanReviewVersion | null; + readonly threads: ReadonlyArray; + readonly unresolvedCount: number; +} + +/** + * The version the reviewer is looking at. + * + * Prefers the revision the document names as current; falls back to the highest + * revision present so a snapshot that arrives mid-write still renders something + * rather than an empty plan. + */ +export function resolveCurrentPlanVersion( + snapshot: Pick, +): PlanReviewVersion | null { + let fallback: PlanReviewVersion | null = null; + for (const version of snapshot.versions) { + if (version.revision === snapshot.document.currentRevision) return version; + if (fallback === null || version.revision > fallback.revision) fallback = version; + } + return fallback; +} + +function sortCommentsByCreation( + comments: ReadonlyArray, +): ReadonlyArray { + return [...comments].sort((left, right) => { + const leftMs = Date.parse(left.createdAt); + const rightMs = Date.parse(right.createdAt); + if (Number.isNaN(leftMs) || Number.isNaN(rightMs)) return 0; + return leftMs - rightMs; + }); +} + +/** Everything the plan-review screen renders, derived from one snapshot. */ +export function buildPlanReviewView(snapshot: PlanReviewSnapshotResult): PlanReviewView { + const currentVersion = resolveCurrentPlanVersion(snapshot); + const markdown = currentVersion?.contentMarkdown ?? ""; + const textLines = markdown.replaceAll("\r\n", "\n").split("\n"); + + const commentsByDiscussion = new Map(); + for (const comment of snapshot.comments) { + const bucket = commentsByDiscussion.get(comment.discussionId); + if (bucket) bucket.push(comment); + else commentsByDiscussion.set(comment.discussionId, [comment]); + } + + const threads: PlanReviewDiscussionThread[] = []; + const discussionIdsByLine = new Map(); + + for (const discussion of snapshot.discussions) { + const located = locateQuotedLineRange(markdown, discussion.quotedText); + threads.push({ + discussion, + comments: sortCommentsByCreation(commentsByDiscussion.get(discussion.discussionId) ?? []), + startIndex: located?.startIndex ?? null, + endIndex: located?.endIndex ?? null, + }); + + // Resolved discussions locate but do not decorate: the gutter should show + // what still needs attention, not the full history of the review. + if (located === null || discussion.isResolved) continue; + for (let line = located.startIndex; line <= located.endIndex; line += 1) { + const bucket = discussionIdsByLine.get(line); + if (bucket) bucket.push(discussion.discussionId); + else discussionIdsByLine.set(line, [discussion.discussionId]); + } + } + + const lines = textLines.map((text, lineIndex) => ({ + lineIndex, + text, + discussionIds: discussionIdsByLine.get(lineIndex) ?? [], + })); + + return { + markdown, + lines, + currentVersion, + threads, + unresolvedCount: snapshot.discussions.filter((discussion) => !discussion.isResolved).length, + }; +} + +/** + * The excerpt to store for a selected line range. + * + * The raw source lines are kept verbatim, because `locateQuotedLineRange` + * normalizes both sides when it matches and the reviewer should see back what + * they actually selected. + */ +export function quotedTextForLineRange( + lines: ReadonlyArray, + startIndex: number, + endIndex: number, +): string { + return lines + .slice(startIndex, endIndex + 1) + .map((line) => line.text) + .join("\n"); +} diff --git a/apps/mobile/src/features/planreview/planReviewSelection.test.ts b/apps/mobile/src/features/planreview/planReviewSelection.test.ts new file mode 100644 index 000000000000..764dd7e1cbae --- /dev/null +++ b/apps/mobile/src/features/planreview/planReviewSelection.test.ts @@ -0,0 +1,67 @@ +// T3-CUSTOM(expbkt3): fork-owned coverage for plan-review line selection. +import { describe, expect, it } from "@effect/vitest"; + +import { + formatPlanReviewSelectionLabel, + togglePlanReviewLine, + type PlanReviewSelection, +} from "./planReviewSelection"; + +const LINES = ["# Auth rewrite", "", "1. Add the migration", "2. Backfill the rows"]; +const quoteFor = (startIndex: number, endIndex: number) => + LINES.slice(startIndex, endIndex + 1).join("\n"); + +const toggle = (current: PlanReviewSelection | null, lineIndex: number) => + togglePlanReviewLine({ current, documentId: "doc-1", lineIndex, quoteFor }); + +describe("togglePlanReviewLine", () => { + it("anchors on the first tap", () => { + expect(toggle(null, 2)).toEqual({ + documentId: "doc-1", + startIndex: 2, + endIndex: 2, + quotedText: "1. Add the migration", + }); + }); + + it("extends downward on a second tap", () => { + const selection = toggle(toggle(null, 2), 3); + expect(selection).toMatchObject({ startIndex: 2, endIndex: 3 }); + expect(selection?.quotedText).toBe("1. Add the migration\n2. Backfill the rows"); + }); + + it("extends upward when the second tap is above the anchor", () => { + const selection = toggle(toggle(null, 3), 0); + expect(selection).toMatchObject({ startIndex: 0, endIndex: 3 }); + }); + + it("clears when the single selected line is tapped again", () => { + expect(toggle(toggle(null, 2), 2)).toBeNull(); + }); + + it("collapses onto a line tapped inside a multi-line selection", () => { + const range = toggle(toggle(null, 0), 3); + const collapsed = toggle(range, 2); + expect(collapsed).toMatchObject({ startIndex: 2, endIndex: 2 }); + }); + + it("restarts rather than extending across documents", () => { + const other = togglePlanReviewLine({ + current: toggle(null, 0), + documentId: "doc-2", + lineIndex: 3, + quoteFor, + }); + expect(other).toMatchObject({ documentId: "doc-2", startIndex: 3, endIndex: 3 }); + }); +}); + +describe("formatPlanReviewSelectionLabel", () => { + it("names a single line in 1-based terms", () => { + expect(formatPlanReviewSelectionLabel(toggle(null, 2)!)).toBe("Line 3"); + }); + + it("names a range in 1-based terms", () => { + expect(formatPlanReviewSelectionLabel(toggle(toggle(null, 2), 3)!)).toBe("Lines 3-4"); + }); +}); diff --git a/apps/mobile/src/features/planreview/planReviewSelection.ts b/apps/mobile/src/features/planreview/planReviewSelection.ts new file mode 100644 index 000000000000..02edbb341a7c --- /dev/null +++ b/apps/mobile/src/features/planreview/planReviewSelection.ts @@ -0,0 +1,95 @@ +// T3-CUSTOM(expbkt3): line-range selection for the mobile plan-review screen. +// +// Deliberately the same interaction as the diff reviewer in `features/review`: +// the first tap anchors, a second tap extends, and tapping the anchor again +// clears. Reviewers move between the two surfaces and should not have to learn +// two gestures. The store is module-level and read through +// `useSyncExternalStore` for the same reason it is in `reviewCommentSelection`: +// the composer is a separate route and cannot receive the selection as a prop. +import { useSyncExternalStore } from "react"; + +export interface PlanReviewSelection { + readonly documentId: string; + /** 0-based inclusive line indices into the version on screen. */ + readonly startIndex: number; + readonly endIndex: number; + readonly quotedText: string; +} + +let currentSelection: PlanReviewSelection | null = null; +const listeners = new Set<() => void>(); + +function emitChange() { + listeners.forEach((listener) => listener()); +} + +export function subscribePlanReviewSelection(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getPlanReviewSelection(): PlanReviewSelection | null { + return currentSelection; +} + +export function setPlanReviewSelection(selection: PlanReviewSelection | null) { + currentSelection = selection; + emitChange(); +} + +export function clearPlanReviewSelection() { + setPlanReviewSelection(null); +} + +export function usePlanReviewSelection(): PlanReviewSelection | null { + return useSyncExternalStore( + subscribePlanReviewSelection, + getPlanReviewSelection, + getPlanReviewSelection, + ); +} + +/** + * The selection after tapping `lineIndex`, or null when the tap clears it. + * + * Pure so the interaction is testable without a renderer. A tap in a different + * document replaces the selection rather than extending across documents. + */ +export function togglePlanReviewLine(input: { + readonly current: PlanReviewSelection | null; + readonly documentId: string; + readonly lineIndex: number; + readonly quoteFor: (startIndex: number, endIndex: number) => string; +}): PlanReviewSelection | null { + const { current, documentId, lineIndex, quoteFor } = input; + + const startFresh = (): PlanReviewSelection => ({ + documentId, + startIndex: lineIndex, + endIndex: lineIndex, + quotedText: quoteFor(lineIndex, lineIndex), + }); + + if (current === null || current.documentId !== documentId) return startFresh(); + + // Tapping the single selected line again clears, so a mis-tap costs one tap. + if (current.startIndex === lineIndex && current.endIndex === lineIndex) return null; + + // Tapping inside a multi-line selection collapses onto that line rather than + // doing nothing, which is how the reviewer narrows a range. + if (lineIndex >= current.startIndex && lineIndex <= current.endIndex) return startFresh(); + + const startIndex = Math.min(current.startIndex, lineIndex); + const endIndex = Math.max(current.endIndex, lineIndex); + return { documentId, startIndex, endIndex, quotedText: quoteFor(startIndex, endIndex) }; +} + +/** Label for the selection action bar. */ +export function formatPlanReviewSelectionLabel(selection: PlanReviewSelection): string { + const count = selection.endIndex - selection.startIndex + 1; + return count === 1 + ? `Line ${selection.startIndex + 1}` + : `Lines ${selection.startIndex + 1}-${selection.endIndex + 1}`; +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 3e09c0d1f8d0..d2bccfab273a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -39,6 +39,8 @@ import { import { useKnownTerminalSessions } from "../../state/use-terminal-session"; import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; +// T3-CUSTOM(expbkt3): plan review entry point. +import { PlanReviewThreadBanner } from "../planreview/PlanReviewThreadBanner"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { buildTerminalMenuSessions, @@ -798,6 +800,13 @@ function ThreadRouteContent( + {/* T3-CUSTOM(expbkt3): plan review entry point; renders null when N/A. */} + + = [ diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 73c544daf289..3e7691e7e9f5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -143,6 +143,10 @@ "types": "./src/state/planReview.ts", "default": "./src/state/planReview.ts" }, + "./state/planReviewMarkdown": { + "types": "./src/state/planReviewMarkdown.ts", + "default": "./src/state/planReviewMarkdown.ts" + }, "./state/agentUi": { "types": "./src/state/agentUi.ts", "default": "./src/state/agentUi.ts" diff --git a/apps/web/src/components/planreview/planReviewMarkdown.test.ts b/packages/client-runtime/src/state/planReviewMarkdown.test.ts similarity index 99% rename from apps/web/src/components/planreview/planReviewMarkdown.test.ts rename to packages/client-runtime/src/state/planReviewMarkdown.test.ts index 0dc8dac42280..1aa60a3894ea 100644 --- a/apps/web/src/components/planreview/planReviewMarkdown.test.ts +++ b/packages/client-runtime/src/state/planReviewMarkdown.test.ts @@ -5,7 +5,7 @@ import { hasPlanReviewEditorChange, parsePlanOutline, resolveSubmittedPlanMarkdown, -} from "./planReviewMarkdown"; +} from "./planReviewMarkdown.ts"; const CANONICAL_PLAN = [ "## Context", diff --git a/apps/web/src/components/planreview/planReviewMarkdown.ts b/packages/client-runtime/src/state/planReviewMarkdown.ts similarity index 92% rename from apps/web/src/components/planreview/planReviewMarkdown.ts rename to packages/client-runtime/src/state/planReviewMarkdown.ts index 0b1e24a27ee9..37a544b403d2 100644 --- a/apps/web/src/components/planreview/planReviewMarkdown.ts +++ b/packages/client-runtime/src/state/planReviewMarkdown.ts @@ -1,10 +1,10 @@ /** - * T3-CUSTOM(expbkt3): markdown helpers for the plan review editor. + * T3-CUSTOM(expbkt3): markdown helpers shared by every plan-review surface. * * Markdown is the canonical form: it is what the agent reads, what versions - * store, and what diffs are computed from. The Plate value is a working cache. - * The round trip itself lives in `PlanReviewEditor`, where the typed editor - * instance is in scope. + * store, and what diffs are computed from. Each client's editor value is a + * working cache over it — Plate on web, plain selectable lines on mobile — so + * everything here is string-level and holds no editor types. */ import { planReviewAnchorText } from "@t3tools/shared/planReview"; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 5a1be8fccb39..a607b2a342a5 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -85,6 +85,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadCreationDefaults: Schema.optionalKey(Schema.Boolean), /** T3-CUSTOM(expbkt3): accepted turns have durable desired-state and recovery. */ durableExecutionRecovery: Schema.optionalKey(Schema.Boolean), + /** T3-CUSTOM(expbkt3): server exposes the native plan-review document API + (planReview.* and subscribePlanReview). Absent on upstream servers and on + fork servers from before it shipped, so clients hide the plan-review + surface entirely rather than probing for it. */ + planReview: Schema.optionalKey(Schema.Boolean), /** Server persists a pull request reference on thread.meta.update. */ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on diff --git a/packages/shared/src/planReview.test.ts b/packages/shared/src/planReview.test.ts index 262bcd02074f..6cdde6175f5e 100644 --- a/packages/shared/src/planReview.test.ts +++ b/packages/shared/src/planReview.test.ts @@ -5,6 +5,7 @@ import { buildPlanReviewFeedbackPrompt, formatPlanReviewComment, locateQuotedLineRange, + planReviewDocumentIdFromSectionId, planReviewFence, } from "./planReview.ts"; @@ -385,3 +386,31 @@ describe("buildPlanReviewApprovalPrompt", () => { expect(prompt).toContain("(The full plan is repeated because this session compacted"); }); }); + +describe("planReviewDocumentIdFromSectionId", () => { + it("recovers the document id a plan comment carries", () => { + expect(planReviewDocumentIdFromSectionId("plan:doc-1")).toBe("doc-1"); + }); + + it("returns null for file and diff comments", () => { + expect(planReviewDocumentIdFromSectionId("src/app.ts")).toBeNull(); + expect(planReviewDocumentIdFromSectionId("")).toBeNull(); + }); + + it("returns null for a plan prefix with no id", () => { + expect(planReviewDocumentIdFromSectionId("plan:")).toBeNull(); + expect(planReviewDocumentIdFromSectionId("plan: ")).toBeNull(); + }); + + it("round-trips the section id formatPlanReviewComment writes", () => { + const block = formatPlanReviewComment("doc-42", "Auth rewrite", { + startIndex: 4, + endIndex: 4, + quotedText: "1. Add the migration", + body: "Do this first.", + authorLabel: null, + }); + const sectionId = /sectionId="([^"]+)"/.exec(block)?.[1] ?? ""; + expect(planReviewDocumentIdFromSectionId(sectionId)).toBe("doc-42"); + }); +}); diff --git a/packages/shared/src/planReview.ts b/packages/shared/src/planReview.ts index 5a8e46197347..8423da33ef64 100644 --- a/packages/shared/src/planReview.ts +++ b/packages/shared/src/planReview.ts @@ -62,6 +62,19 @@ export function isPlanReviewSectionId(sectionId: string): boolean { return sectionId.startsWith(PLAN_REVIEW_SECTION_ID_PREFIX); } +/** + * The plan-review document a `plan:` section id refers to. + * + * The inverse of the section id `formatPlanReviewComment` writes. Returns null + * for any other section id, so a caller can treat "not a plan comment" and + * "malformed plan comment" the same way. + */ +export function planReviewDocumentIdFromSectionId(sectionId: string): string | null { + if (!isPlanReviewSectionId(sectionId)) return null; + const documentId = sectionId.slice(PLAN_REVIEW_SECTION_ID_PREFIX.length).trim(); + return documentId.length > 0 ? documentId : null; +} + /** * Recovers the plan title from the synthetic `filePath` the block carries. * From 5a18572e0cbc8dd4b14bf6a75113ba199324464b Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 15:27:10 +0000 Subject: [PATCH 4/7] chore(fork): tighten the fork-marker baseline now Stack.tsx is fully marked Co-Authored-By: Claude Opus 5 (1M context) --- scripts/fork-marker-baseline.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/fork-marker-baseline.json b/scripts/fork-marker-baseline.json index 9494fd922def..295842597ce7 100644 --- a/scripts/fork-marker-baseline.json +++ b/scripts/fork-marker-baseline.json @@ -1,7 +1,6 @@ { "files": [ ".env.example", - "apps/mobile/src/Stack.tsx", "apps/mobile/src/features/home/homeThreadList.ts", "apps/mobile/src/features/review/ReviewSheet.tsx", "apps/mobile/src/features/settings/SettingsRouteScreen.tsx", From 2550e9e78fbfa352e702f66bfb0a9e9645b2508c Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 15:28:36 +0000 Subject: [PATCH 5/7] perf(mobile): scope the plan-review banner to its own environment config Reading the whole server-config map re-rendered the banner whenever any unrelated environment's config changed. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/planreview/PlanReviewThreadBanner.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx b/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx index 60342f419875..e72b794f6774 100644 --- a/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx +++ b/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx @@ -12,7 +12,7 @@ import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { useThemeColor } from "../../lib/useThemeColor"; -import { useServerConfigs } from "../../state/entities"; +import { useEnvironmentServerConfig } from "../../state/entities"; import { planReviewEnvironment } from "../../state/planReview"; import { useEnvironmentQuery } from "../../state/query"; import { resolveOpenPlanReviewDocument, shouldOfferPlanReview } from "./planReviewAvailability"; @@ -24,8 +24,11 @@ export function PlanReviewThreadBanner(props: { }) { const navigation = useNavigation(); const iconTint = String(useThemeColor("--color-icon")); - const serverConfigs = useServerConfigs(); - const capabilities = serverConfigs.get(props.environmentId)?.environment.capabilities; + // Per-environment rather than the whole configs map: this banner sits on the + // thread screen and should not re-render when an unrelated environment's + // config changes. + const serverConfig = useEnvironmentServerConfig(props.environmentId); + const capabilities = serverConfig?.environment.capabilities; // Only ask the server for documents once the two cheap gates pass; a thread // with no plan awaiting review should cost no RPC at all. From b301b0d9c5c58b1b9febf52b45d3ab38d97187aa Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 15:40:31 +0000 Subject: [PATCH 6/7] fix(mobile): stop the Expo manifest read breaking unrelated test suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught two failures that the local scoped runs could not: the mobile suite as a whole, and repo-wide formatting. `src/lib/connection.test.ts` failed to load at all with "__DEV__ is not defined". bkBuildManifest.ts imported expo-constants at module scope, and authClientMetadata imports that module, so every test reaching authClientMetadata pulled in expo-modules-core — which reads React Native's `__DEV__` global as an import side effect. vitest does not define it. That module's own doc comment already stated the intent ("so the version-formatting logic stays testable without pulling react-native into the unit test environment"); the split just did not go far enough to cover its own consumers. The manifest read is now function-scoped and fail-soft: outside a real Expo runtime there is no manifest, and "no manifest" already means "no SHA", which is what connection.test.ts asserts (plain "1.0.4", no +bk suffix). Also formats four files that were carrying pre-existing whitespace drift from commits made outside this worktree, which the staged-files pre-commit hook never saw. `vp fmt --check` is now clean across all 3350 files. Co-Authored-By: Claude Opus 5 (1M context) --- apps/mobile/app.config.bk.test.ts | 1 - apps/mobile/src/lib/bkBuildManifest.ts | 26 ++++++++++++++++--- .../src/state/phaseSidebar.test.ts | 4 +-- scripts/build-bk-mobile.test.ts | 10 ++----- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/apps/mobile/app.config.bk.test.ts b/apps/mobile/app.config.bk.test.ts index b7fc6774e669..2e5768b572d2 100644 --- a/apps/mobile/app.config.bk.test.ts +++ b/apps/mobile/app.config.bk.test.ts @@ -125,4 +125,3 @@ describe("applyBkMobileConfig", () => { ).toThrow(/positive integer/); }); }); - diff --git a/apps/mobile/src/lib/bkBuildManifest.ts b/apps/mobile/src/lib/bkBuildManifest.ts index 133250841c9b..810a5d9149f8 100644 --- a/apps/mobile/src/lib/bkBuildManifest.ts +++ b/apps/mobile/src/lib/bkBuildManifest.ts @@ -1,11 +1,29 @@ // T3-CUSTOM(expbkt3): The Expo-manifest half of the fork build identity. // // Split from bkBuildIdentity.ts so the version-formatting logic stays testable -// without pulling react-native into the unit test environment. -import Constants from "expo-constants"; - +// without pulling react-native into the unit test environment. That split was +// incomplete: `authClientMetadata` imports this module, so a module-scope +// `expo-constants` import put expo-modules-core on the import graph of every +// test reaching authClientMetadata, and expo-modules-core reads React Native's +// `__DEV__` global as a side effect — which vitest does not define. +// +// So the manifest read is both function-scoped and fail-soft. Outside a real +// Expo runtime there is no manifest to read, and "no manifest" already has a +// defined meaning here: no SHA, so `bkAppVersion` returns the plain version. import { readBkGitSha } from "./bkBuildIdentity"; +function expoConfigExtra(): unknown { + try { + const loaded = require("expo-constants") as { + readonly default?: { readonly expoConfig?: { readonly extra?: unknown } }; + readonly expoConfig?: { readonly extra?: unknown }; + }; + return (loaded.default ?? loaded).expoConfig?.extra; + } catch { + return null; + } +} + export function bkBuildGitSha(): string | null { - return readBkGitSha(Constants.expoConfig?.extra); + return readBkGitSha(expoConfigExtra()); } diff --git a/packages/client-runtime/src/state/phaseSidebar.test.ts b/packages/client-runtime/src/state/phaseSidebar.test.ts index 3da3c7893f36..f090b7fb830f 100644 --- a/packages/client-runtime/src/state/phaseSidebar.test.ts +++ b/packages/client-runtime/src/state/phaseSidebar.test.ts @@ -36,9 +36,7 @@ const now = "2026-01-01T00:00:00.000Z"; const environmentId = EnvironmentId.make("env-1"); const projectId = ProjectId.make("project-1"); -function makeExecution( - overrides: Partial = {}, -): ThreadExecutionSnapshot { +function makeExecution(overrides: Partial = {}): ThreadExecutionSnapshot { return { activity: "idle", canStop: false, diff --git a/scripts/build-bk-mobile.test.ts b/scripts/build-bk-mobile.test.ts index 4714e083651e..aa9c9222f8b3 100644 --- a/scripts/build-bk-mobile.test.ts +++ b/scripts/build-bk-mobile.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - assertKeylessBuild, - parseArgs, - xcodeSchemeName, -} from "./build-bk-mobile.ts"; +import { assertKeylessBuild, parseArgs, xcodeSchemeName } from "./build-bk-mobile.ts"; import { bkAppVersionString, bkArtifactFileName, @@ -69,9 +65,7 @@ describe("build identity", () => { describe("parseMobileAppVersion", () => { it("reads the version the mobile app reports to servers", () => { - expect( - parseMobileAppVersion('export const MOBILE_APP_VERSION = "1.0.4";\n'), - ).toBe("1.0.4"); + expect(parseMobileAppVersion('export const MOBILE_APP_VERSION = "1.0.4";\n')).toBe("1.0.4"); }); it("fails rather than guessing when the constant moves", () => { From c942439775592e2d1ab159fcd1e10ac5350da66c Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 15:45:02 +0000 Subject: [PATCH 7/7] fix(client-runtime): opt planReviewMarkdown out of the globalDate diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving this module out of apps/web and into client-runtime subjected it to the package's Effect diagnostics, which reject `Date.now()` in favour of Effect's Clock. `nextPlanDiscussionId` is a client-side id generator called straight from a React event handler; threading a Clock through it would add real plumbing for no benefit, and the wall clock is only there to stop ids colliding across a reload (a bare counter restarts at 1 and would make `upsertDiscussion` edit an existing discussion instead of creating one). So it takes the same file-scoped opt-out with a stated reason that threadSettled.ts already uses for UI-level time. Worth recording why CI caught this and my local runs did not: `vp run typecheck` uses tsgo with these diagnostics, while a bare `npx tsc --noEmit` does not. Re-verified with `vp run typecheck` in all four touched packages (client-runtime, shared, mobile, web) — clean. Co-Authored-By: Claude Opus 5 (1M context) --- packages/client-runtime/src/state/planReviewMarkdown.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client-runtime/src/state/planReviewMarkdown.ts b/packages/client-runtime/src/state/planReviewMarkdown.ts index 37a544b403d2..442b20aedf59 100644 --- a/packages/client-runtime/src/state/planReviewMarkdown.ts +++ b/packages/client-runtime/src/state/planReviewMarkdown.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off -- Client-side discussion ids are a UI concern; the wall clock only breaks collisions across reloads. /** * T3-CUSTOM(expbkt3): markdown helpers shared by every plan-review surface. *