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..2e5768b572d2 --- /dev/null +++ b/apps/mobile/app.config.bk.test.ts @@ -0,0 +1,127 @@ +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/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..e72b794f6774 --- /dev/null +++ b/apps/mobile/src/features/planreview/PlanReviewThreadBanner.tsx @@ -0,0 +1,81 @@ +// 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 { useEnvironmentServerConfig } 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")); + // 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. + 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. */} + + 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..810a5d9149f8 --- /dev/null +++ b/apps/mobile/src/lib/bkBuildManifest.ts @@ -0,0 +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. 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(expoConfigExtra()); +} diff --git a/apps/mobile/src/state/planReview.ts b/apps/mobile/src/state/planReview.ts new file mode 100644 index 000000000000..b89b902ed003 --- /dev/null +++ b/apps/mobile/src/state/planReview.ts @@ -0,0 +1,9 @@ +// T3-CUSTOM(expbkt3): mobile binding for the fork's native plan review. +// +// The atoms themselves are platform-agnostic and already live in +// client-runtime; web binds them the same way. Only the runtime differs. +import { createPlanReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/planReview"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const planReviewEnvironment = createPlanReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 11cab5973cf3..23357029037c 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -167,6 +167,8 @@ export const make = Effect.gen(function* () { threadCreationDefaults: true, // T3-CUSTOM(expbkt3): desired execution intent survives process restarts. durableExecutionRecovery: true, + // T3-CUSTOM(expbkt3): native plan-review documents and discussions. + planReview: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/web/src/components/planreview/PlanReviewEditor.tsx b/apps/web/src/components/planreview/PlanReviewEditor.tsx index 23879e219b94..b8c302b1552f 100644 --- a/apps/web/src/components/planreview/PlanReviewEditor.tsx +++ b/apps/web/src/components/planreview/PlanReviewEditor.tsx @@ -115,7 +115,7 @@ import { nextPlanDiscussionId, normalizeQuotedText, resolveSubmittedPlanMarkdown, -} from "./planReviewMarkdown"; +} from "@t3tools/client-runtime/state/planReviewMarkdown"; import { replacePlanReviewEditorValue } from "./planReviewEditorValue"; import { cn } from "../../lib/utils"; diff --git a/apps/web/src/components/planreview/PlanReviewOutline.tsx b/apps/web/src/components/planreview/PlanReviewOutline.tsx index 8abe99ca521a..8422a33da43d 100644 --- a/apps/web/src/components/planreview/PlanReviewOutline.tsx +++ b/apps/web/src/components/planreview/PlanReviewOutline.tsx @@ -12,7 +12,7 @@ */ import { memo } from "react"; -import type { PlanOutlineHeading } from "./planReviewMarkdown"; +import type { PlanOutlineHeading } from "@t3tools/client-runtime/state/planReviewMarkdown"; import { cn } from "../../lib/utils"; /** Indent per heading level, capped so a deep plan does not run out of column. */ diff --git a/apps/web/src/components/planreview/PlanReviewPanel.tsx b/apps/web/src/components/planreview/PlanReviewPanel.tsx index 14c1aa58c88c..4f302dbbe39c 100644 --- a/apps/web/src/components/planreview/PlanReviewPanel.tsx +++ b/apps/web/src/components/planreview/PlanReviewPanel.tsx @@ -29,7 +29,7 @@ import { countPlanOutlineComments, parsePlanOutline, type PlanOutlineHeading, -} from "./planReviewMarkdown"; +} from "@t3tools/client-runtime/state/planReviewMarkdown"; import { locateQuotedLineRange } from "@t3tools/shared/planReview"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; diff --git a/apps/web/src/components/planreview/plate/planReviewCommentMarks.test.ts b/apps/web/src/components/planreview/plate/planReviewCommentMarks.test.ts index f81346cb4324..53072a3e41a6 100644 --- a/apps/web/src/components/planreview/plate/planReviewCommentMarks.test.ts +++ b/apps/web/src/components/planreview/plate/planReviewCommentMarks.test.ts @@ -26,7 +26,7 @@ import { stripPlanReviewCommentMarks, type PlanReviewNodeLike, } from "./planReviewCommentMarks"; -import { hasPlanReviewEditorChange } from "../planReviewMarkdown"; +import { hasPlanReviewEditorChange } from "@t3tools/client-runtime/state/planReviewMarkdown"; /** A plan as Plate holds it: a heading, a paragraph, and a two-item list. */ const PLAN_VALUE: ReadonlyArray = [ 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 651ca4398388..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,401 +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 Mattermost conversation a session is bound to. - * - * Mattermost is self-hosted, so there is no canonical domain to validate - * against — anything that parses as an http(s) URL is accepted, and the label - * is derived only as far as the path reliably allows. Mattermost permalinks - * are `//pl/`, channels `//channels/`, and DMs - * `//messages/@user`; anything else degrades to the host, which is still - * more useful in a tooltip than the raw URL. - */ -export interface PhaseSidebarMattermostLink { - /** Tooltip text, e.g. "Mattermost · #co-x-tech". */ - readonly label: string; - readonly url: string; -} - -export function resolvePhaseSidebarMattermostLink( - manualUrl?: string | null, -): PhaseSidebarMattermostLink | null { - const trimmed = manualUrl?.trim(); - if (!trimmed) return null; - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - return null; - } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; - - const segments = parsed.pathname.split("/").filter((segment) => segment.length > 0); - const kind = segments[1]; - const name = segments[2]; - const detail = - kind === "channels" && name - ? `#${decodeURIComponent(name)}` - : kind === "messages" && name - ? decodeURIComponent(name) - : parsed.host; - return { label: `Mattermost · ${detail}`, url: parsed.toString() }; -} - -/** - * 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 the Mattermost link on thread.meta.update. */ - readonly mattermostLinkSupported?: 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 @@ -664,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. @@ -946,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/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/packages/client-runtime/package.json b/packages/client-runtime/package.json index 201407bd95cc..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" @@ -167,6 +171,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..f090b7fb830f --- /dev/null +++ b/packages/client-runtime/src/state/phaseSidebar.test.ts @@ -0,0 +1,327 @@ +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..c5c28342b8e3 --- /dev/null +++ b/packages/client-runtime/src/state/phaseSidebar.ts @@ -0,0 +1,1350 @@ +// 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 Mattermost conversation a session is bound to. + * + * Mattermost is self-hosted, so there is no canonical domain to validate + * against — anything that parses as an http(s) URL is accepted, and the label + * is derived only as far as the path reliably allows. Mattermost permalinks + * are `//pl/`, channels `//channels/`, and DMs + * `//messages/@user`; anything else degrades to the host, which is still + * more useful in a tooltip than the raw URL. + */ +export interface PhaseSidebarMattermostLink { + /** Tooltip text, e.g. "Mattermost · #co-x-tech". */ + readonly label: string; + readonly url: string; +} + +export function resolvePhaseSidebarMattermostLink( + manualUrl?: string | null, +): PhaseSidebarMattermostLink | null { + const trimmed = manualUrl?.trim(); + if (!trimmed) return null; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + + const segments = parsed.pathname.split("/").filter((segment) => segment.length > 0); + const kind = segments[1]; + const name = segments[2]; + const detail = + kind === "channels" && name + ? `#${decodeURIComponent(name)}` + : kind === "messages" && name + ? decodeURIComponent(name) + : parsed.host; + return { label: `Mattermost · ${detail}`, url: parsed.toString() }; +} + +/** + * 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 the Mattermost link on thread.meta.update. */ + readonly mattermostLinkSupported?: 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/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 89% rename from apps/web/src/components/planreview/planReviewMarkdown.ts rename to packages/client-runtime/src/state/planReviewMarkdown.ts index 0b1e24a27ee9..442b20aedf59 100644 --- a/apps/web/src/components/planreview/planReviewMarkdown.ts +++ b/packages/client-runtime/src/state/planReviewMarkdown.ts @@ -1,10 +1,11 @@ +// @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 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/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); +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index ebc9fa3d9e38..588a643be510 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -87,6 +87,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. * diff --git a/scripts/build-bk-mobile.test.ts b/scripts/build-bk-mobile.test.ts new file mode 100644 index 000000000000..aa9c9222f8b3 --- /dev/null +++ b/scripts/build-bk-mobile.test.ts @@ -0,0 +1,76 @@ +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/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", 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]!; +}