diff --git a/.github/workflows/activation-e2e.yml b/.github/workflows/activation-e2e.yml index cac7a48f..03170f5e 100644 --- a/.github/workflows/activation-e2e.yml +++ b/.github/workflows/activation-e2e.yml @@ -1,7 +1,7 @@ name: Activation E2E (Maestro) # Deterministic regression coverage for the activation flow (first open -> -# telemetry consent -> server URL entry -> connect -> send first message), +# server URL entry -> connect -> send first message), # including the connect-time-401 negative case tied to the 0%-7-day-retention # / GitHub issue #76 investigation. # diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 858801d6..ca70e7ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,15 +34,6 @@ jobs: build: runs-on: ubuntu-latest - env: - EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} - EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} - EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER: ${{ secrets.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - # No Sentry token (forks): skip the source-map upload, which otherwise fails the Gradle build. - SENTRY_DISABLE_AUTO_UPLOAD: ${{ secrets.SENTRY_AUTH_TOKEN == '' && 'true' || 'false' }} steps: - uses: actions/checkout@v6 @@ -73,20 +64,6 @@ jobs: - name: Install dependencies run: npm install --legacy-peer-deps - - name: Set Sentry release identifiers - # sentry.gradle (applied from android/app/build.gradle) defaults the - # upload's --release/--dist to `${applicationId}@${versionName}+${versionCode}`, - # which does NOT match the release/dist Sentry.init() reports at runtime - # (`opencode-mobile@${app.json version}`, see src/lib/sentry.ts). That - # mismatch made every uploaded source map land under a release Sentry - # never looks up, so symbolication silently failed. Pin the Gradle-side - # values to exactly what the app reports. - run: | - VERSION=$(node -p "require('./app.json').expo.version") - echo "SENTRY_RELEASE=opencode-mobile@${VERSION}" >> "$GITHUB_ENV" - echo "SENTRY_DIST=${VERSION}" >> "$GITHUB_ENV" - echo "Sentry release=opencode-mobile@${VERSION} dist=${VERSION}" - - name: Expo prebuild run: npx expo prebuild --platform android --no-install @@ -137,165 +114,8 @@ jobs: name: app-release path: android/app/build/outputs/apk/release/app-release.apk - # F-Droid rebuilds this app from source using the recipe in - # distribution/fdroid-submission/metadata.yml (Builds:), which applies - # fdroid/expo-application-patches and fdroid/expo-notifications-patches to - # strip Firebase Cloud Messaging / Play Install Referrer / Sentry before - # compiling. If we only ever publish the full-featured `app-release.apk` - # (built above, unpatched) and metadata.yml's `Binaries:` points at it, - # F-Droid's reproducible-build check compares its from-source (patched) - # rebuild against that (unpatched) reference binary and can never match — - # see issue #95. - # - # Fix: build a SECOND, additional artifact here — app-release-fdroid.apk — - # by applying the exact same patch set F-Droid's own recipe applies, then - # publish it as an extra GitHub Release asset. metadata.yml's `Binaries:` - # points at THIS asset, not app-release.apk. The main `build` job above is - # completely untouched: Play/GitHub/IzzyOnDroid users keep the full-featured - # binary (push notifications, Sentry crash reporting) unchanged. - # - # This job intentionally does NOT run `npx expo prebuild` — F-Droid's own - # Builds: recipe doesn't either; it patches the already-committed `android/` - # tree directly. Running prebuild here would regenerate android/app/build.gradle - # and could diverge from what F-Droid's build server produces from the same - # tracked source, defeating the byte-for-byte parity this job exists for. - # - # This job also intentionally receives NONE of the EXPO_PUBLIC_SENTRY_DSN / - # EXPO_PUBLIC_POSTHOG_KEY / EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER / SENTRY_* - # secrets that the main `build` job sets — F-Droid's isolated build - # environment has no access to this repo's secrets either, so baking any of - # them into the JS bundle here would itself be a source of reproducible-build - # divergence. - build-fdroid: - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: 20 - cache: npm - - # F-Droid's metadata.yml prebuild patch bumps the Kotlin/Java toolchain - # target from 17 to 21 (see the "Apply F-Droid source patches" step - # below). Install both so Gradle's toolchain auto-detection can resolve - # either, matching F-Droid's own multi-JDK build environment. - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 21 - - - name: Setup Android SDK - uses: android-actions/setup-android@v4 - - - name: Cache Gradle - uses: actions/cache@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - android/.gradle - key: ${{ runner.os }}-gradle-fdroid-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle-fdroid- - - - name: Install dependencies - run: npm install --legacy-peer-deps - - - name: Apply F-Droid source patches - # Mirrors distribution/fdroid-submission/metadata.yml `Builds:.prebuild` - # verbatim so this CI build and F-Droid's from-source rebuild patch the - # exact same lines/files. Keep these two in sync on any future change. - run: | - set -euxo pipefail - - # Drop the Sentry native gradle plugin apply (no SENTRY_* secrets - # are available in F-Droid's build environment either). - sed -i '/apply from.*sentry.gradle/d' android/app/build.gradle - - # F-Droid's build environment toolchains on JDK 21; bump the - # Kotlin/Java compile target used by RN's gradle plugin + expo-modules-core. - sed -i '/jvmToolchain\|JavaVersion/s/17/21/' \ - node_modules/@react-native/gradle-plugin/*/build.gradle.kts \ - node_modules/@react-native/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/JdkConfiguratorUtils.kt \ - node_modules/expo-modules-core/android/ExpoModulesCorePlugin.gradle - printf '\nkotlin.jvm.target.validation.mode=warning\n' >> android/gradle.properties - - # Strip Firebase Cloud Messaging from expo-notifications. - sed -i '/firebase/d' node_modules/expo-notifications/android/build.gradle - cp -a fdroid/expo-notifications-patches/. \ - node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/ - rm -f \ - node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/RemoteMessageSerializer.java \ - node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/triggers/FirebaseNotificationTrigger.kt - - # Strip Google Play Install Referrer (GMS-only API) from expo-application. - sed -i '/installreferrer/d' node_modules/expo-application/android/build.gradle - cp -a fdroid/expo-application-patches/. \ - node_modules/expo-application/android/src/main/java/expo/modules/application/ - - - name: Setup signing - run: | - if [[ -n "${{ secrets.KEYSTORE_BASE64 }}" ]]; then - echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore - echo "RELEASE_STORE_FILE=release.keystore" >> "$GITHUB_ENV" - echo "RELEASE_STORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV" - echo "RELEASE_KEY_ALIAS=${{ secrets.KEY_ALIAS }}" >> "$GITHUB_ENV" - echo "RELEASE_KEY_PASSWORD=${{ secrets.KEY_PASSWORD }}" >> "$GITHUB_ENV" - # Same production keystore as app-release.apk: F-Droid's - # AllowedAPKSigningKeys check requires this binary to carry the - # same signing certificate fingerprint. - echo "Signing: production keystore" - else - keytool -genkey -v -keystore android/app/debug.keystore -storepass android \ - -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 \ - -dname "CN=Android Debug,O=Android,C=US" - echo "Signing: debug keystore (non-release build)" - fi - - - name: Build F-Droid-flavored APK - working-directory: android - run: ./gradlew assembleRelease - - - name: Re-sign APK v1+v2 only (drop v3/v4 for fdroidserver compatibility) - if: ${{ env.RELEASE_STORE_FILE != '' }} - run: | - # Same rationale as publish-fdroid.yml: androguard (used by - # fdroidserver) crashes parsing a v2+v3 signature block pair. Force - # v1+v2-only here deterministically. - APK=android/app/build/outputs/apk/release/app-release.apk - APKSIGNER=$(ls "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1) - echo "Using $APKSIGNER" - "$APKSIGNER" sign \ - --ks android/app/release.keystore \ - --ks-pass "pass:${RELEASE_STORE_PASSWORD}" \ - --ks-key-alias "${RELEASE_KEY_ALIAS}" \ - --key-pass "pass:${RELEASE_KEY_PASSWORD}" \ - --v1-signing-enabled true \ - --v2-signing-enabled true \ - --v3-signing-enabled false \ - --v4-signing-enabled false \ - "$APK" - echo "=== signature schemes after re-sign ===" - "$APKSIGNER" verify -v "$APK" | grep -i "Verified using" || true - - - name: Rename artifact - run: cp android/app/build/outputs/apk/release/app-release.apk app-release-fdroid.apk - - - name: Upload F-Droid APK artifact - uses: actions/upload-artifact@v7 - with: - name: app-release-fdroid - path: app-release-fdroid.apk - release: - needs: [build, build-fdroid] + needs: [build] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: @@ -305,14 +125,8 @@ jobs: with: name: app-release - - uses: actions/download-artifact@v8 - with: - name: app-release-fdroid - - name: Create Release uses: softprops/action-gh-release@v2 with: - files: | - app-release.apk - app-release-fdroid.apk + files: app-release.apk generate_release_notes: true diff --git a/.github/workflows/sentry-noise-gate-report.yml b/.github/workflows/sentry-noise-gate-report.yml deleted file mode 100644 index b29e5f19..00000000 --- a/.github/workflows/sentry-noise-gate-report.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Sentry noise-gate report - -# Grades the AGE-105 noise gate against the number it promised, with install-base -# uptake folded in — see scripts/noise-gate-report.mjs for why a raw event count -# cannot grade a gate that ships inside an app binary. -# -# It lives in CI rather than on a laptop for one blunt reason: neither credential -# it needs (Sentry org read token, Play service account) exists outside GitHub -# Secrets, so an agent picking up this measurement locally is stuck. Dispatch this -# instead and read the run summary: -# -# gh workflow run "Sentry noise-gate report" -f post=2026-08-21T00:00:00Z..now -# gh run watch && gh run view # table is in the job summary -# -# The scheduled Monday run exists so the trend is recorded even if nobody asks. - -on: - workflow_dispatch: - inputs: - pre: - description: "Baseline window START..END (rate before the rollout reached devices)" - required: false - # Post-box-bot-fix, pre-mobile-rollout. The only clean baseline that - # measures mobile alone: AGE-55 silenced openclaw-box-bot at 06:19Z and - # v0.4.14 reached production at 14:22Z on the same day. - default: "2026-08-14T07:00:00Z..2026-08-14T14:00:00Z" - post: - description: "Measured window START..END (default: trailing 7d)" - required: false - default: "" - project: - description: "Sentry project slug" - required: false - default: "opencode-mobile" - schedule: - # Mondays 15:00 UTC. Weekly, not daily: a window under ~24h ranks sources but - # cannot certify a monthly rate, and Play vitals land with a multi-day lag. - - cron: "0 15 * * 1" - -permissions: - contents: read - -concurrency: - group: sentry-noise-gate-report-${{ github.ref }} - cancel-in-progress: false - -jobs: - report: - # Upstream-only: needs upstream secrets/accounts; skip on forks. - if: github.repository == 'dzianisv/opencode-mobile' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: 24 - - # The grading model is the part that can be wrong silently, so it is tested - # before it is trusted — in the same job that publishes the number. - - name: Test the grading model - run: node --test scripts/noise-gate-report.test.mjs - - - name: Report - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PRODUCT_INTELLIGENCE_TOKEN || secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG || 'vibetechnologies' }} - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }} - run: | - set -euo pipefail - args=(--pre "${{ inputs.pre || '2026-08-14T07:00:00Z..2026-08-14T14:00:00Z' }}") - args+=(--project "${{ inputs.project || 'opencode-mobile' }}") - if [ -n "${{ inputs.post }}" ]; then args+=(--post "${{ inputs.post }}"); fi - node scripts/noise-gate-report.mjs "${args[@]}" - - - name: Raw org volume (per project, per outcome, with client_discard reasons) - if: always() - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_PRODUCT_INTELLIGENCE_TOKEN || secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG || 'vibetechnologies' }} - run: | - set -euo pipefail - { - echo '' - echo '### Raw org volume' - echo '```' - node scripts/sentry-volume-report.mjs --by-reason --since-rollout || true - echo '```' - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.maestro/flows/activation-negative-401.yaml b/.maestro/flows/activation-negative-401.yaml index 3c0286f8..4207c964 100644 --- a/.maestro/flows/activation-negative-401.yaml +++ b/.maestro/flows/activation-negative-401.yaml @@ -1,4 +1,4 @@ -appId: cc.agentlabs.opencode +appId: it.kjeldahl.opencode name: Activation - negative path (connect-time 401 must surface a visible error) --- # Negative activation flow — regression test for the GitHub issue #76 failure @@ -38,12 +38,6 @@ name: Activation - negative path (connect-time 401 must surface a visible error) - launchApp: clearState: true -- assertVisible: - id: "telemetry-consent-card" -- tapOn: - id: "telemetry-decline-button" -- takeScreenshot: negative-S1_consent_dismissed - - assertVisible: text: "No Connection" - tapOn: diff --git a/.maestro/flows/activation-positive.yaml b/.maestro/flows/activation-positive.yaml index 42f03ed8..b5c06178 100644 --- a/.maestro/flows/activation-positive.yaml +++ b/.maestro/flows/activation-positive.yaml @@ -1,7 +1,7 @@ -appId: cc.agentlabs.opencode -name: Activation - positive path (consent -> connect -> send message) +appId: it.kjeldahl.opencode +name: Activation - positive path (connect -> send message) --- -# Positive activation flow: first launch -> telemetry consent -> quick connect +# Positive activation flow: first launch -> quick connect # to the mock opencode server (tests/fixtures/mock-opencode-server.ts, run # normally on the port below) -> connected indicator -> new session -> send a # message -> assert it sends (optimistic echo), WITHOUT waiting for the @@ -34,7 +34,7 @@ name: Activation - positive path (consent -> connect -> send message) # output in production; the bug they hit there was the 401-retry storm, not # a missing/undelivered reply). Asserting on the reply here would therefore # be asserting on a CI-harness limitation, not real app behavior — so this -# flow verifies everything reliably observable (consent, connect, session +# flow verifies everything reliably observable (connect, session # creation, message send) and stops short of the SSE round trip. If a real # fix for the underlying RN-Android streaming limitation ever lands (a native # SSE module, WebSocket transport, etc.), restore the extendedWaitUntil + @@ -43,12 +43,6 @@ name: Activation - positive path (consent -> connect -> send message) - launchApp: clearState: true -- takeScreenshot: positive-S1_launch_consent_modal -- assertVisible: - id: "telemetry-consent-card" -- tapOn: - id: "telemetry-decline-button" - - assertVisible: text: "No Connection" - takeScreenshot: positive-S2_empty_state diff --git a/.maestro/flows/all-sessions.yaml b/.maestro/flows/all-sessions.yaml index ec023cb2..7712c33f 100644 --- a/.maestro/flows/all-sessions.yaml +++ b/.maestro/flows/all-sessions.yaml @@ -1,4 +1,4 @@ -appId: cc.agentlabs.opencode +appId: it.kjeldahl.opencode name: All sessions across directories - list + open-across-project (regression #46/#48) --- # New-feature regression coverage for the "all sessions across all projects" @@ -31,11 +31,6 @@ name: All sessions across directories - list + open-across-project (regression # - launchApp: clearState: true -- assertVisible: - id: "telemetry-consent-card" -- tapOn: - id: "telemetry-decline-button" - - assertVisible: text: "No Connection" - tapOn: diff --git a/.maestro/flows/demo.yaml b/.maestro/flows/demo.yaml index 4ddbd97b..fc185834 100644 --- a/.maestro/flows/demo.yaml +++ b/.maestro/flows/demo.yaml @@ -1,4 +1,4 @@ -appId: cc.agentlabs.opencode +appId: it.kjeldahl.opencode name: Demo mode - offline scripted walkthrough (no server, zero-server activation) --- # Covers the "Try a demo" activation path added for the ~0% 7-day retention @@ -20,11 +20,6 @@ name: Demo mode - offline scripted walkthrough (no server, zero-server activatio - launchApp: clearState: true -- assertVisible: - id: "telemetry-consent-card" -- tapOn: - id: "telemetry-decline-button" - - assertVisible: text: "No Connection" - takeScreenshot: demo-S1_empty_state diff --git a/.maestro/flows/diff-scroll.yaml b/.maestro/flows/diff-scroll.yaml index a36e4133..5dc3549d 100644 --- a/.maestro/flows/diff-scroll.yaml +++ b/.maestro/flows/diff-scroll.yaml @@ -1,4 +1,4 @@ -appId: cc.agentlabs.opencode +appId: it.kjeldahl.opencode name: "DiffView + CodeBlock horizontal scroll - wide diff/code line reachable by swipe (closes #21)" --- # GitHub issue #21 ("QA: verify DiffView + CodeBlock horizontal-scroll @@ -39,11 +39,6 @@ name: "DiffView + CodeBlock horizontal scroll - wide diff/code line reachable by - launchApp: clearState: true -- assertVisible: - id: "telemetry-consent-card" -- tapOn: - id: "telemetry-decline-button" - - assertVisible: text: "No Connection" - tapOn: diff --git a/.maestro/flows/directory-picker.yaml b/.maestro/flows/directory-picker.yaml index 602310e7..04ffd212 100644 --- a/.maestro/flows/directory-picker.yaml +++ b/.maestro/flows/directory-picker.yaml @@ -1,4 +1,4 @@ -appId: cc.agentlabs.opencode +appId: it.kjeldahl.opencode name: Directory picker - browse server folders and create a session in one --- # New-feature regression coverage for DirectoryBrowserSheet @@ -19,11 +19,6 @@ name: Directory picker - browse server folders and create a session in one - launchApp: clearState: true -- assertVisible: - id: "telemetry-consent-card" -- tapOn: - id: "telemetry-decline-button" - - assertVisible: text: "No Connection" - tapOn: diff --git a/.maestro/flows/variant-picker.yaml b/.maestro/flows/variant-picker.yaml index 42c48920..341997a9 100644 --- a/.maestro/flows/variant-picker.yaml +++ b/.maestro/flows/variant-picker.yaml @@ -1,4 +1,4 @@ -appId: cc.agentlabs.opencode +appId: it.kjeldahl.opencode name: Variant picker - reasoning-effort chip renders, selects, and still sends --- # New-feature regression coverage for VariantPicker @@ -29,11 +29,6 @@ name: Variant picker - reasoning-effort chip renders, selects, and still sends - launchApp: clearState: true -- assertVisible: - id: "telemetry-consent-card" -- tapOn: - id: "telemetry-decline-button" - - assertVisible: text: "No Connection" - tapOn: diff --git a/android/app/build.gradle b/android/app/build.gradle index 79448afc..2c5fc3e4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -81,8 +81,6 @@ def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBu */ def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' -apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") - android { ndkVersion rootProject.ext.ndkVersion diff --git a/android/sentry.properties b/android/sentry.properties deleted file mode 100644 index 08085a8d..00000000 --- a/android/sentry.properties +++ /dev/null @@ -1,4 +0,0 @@ -defaults.url=https://sentry.io/ -defaults.org=vibetechnologies -defaults.project=opencode-mobile -# Using SENTRY_AUTH_TOKEN environment variable \ No newline at end of file diff --git a/app.json b/app.json index bb8e16bd..ee6872a8 100644 --- a/app.json +++ b/app.json @@ -50,13 +50,6 @@ "expo-router", "expo-secure-store", "expo-local-authentication", - [ - "@sentry/react-native/expo", - { - "organization": "vibetechnologies", - "project": "opencode-mobile" - } - ], [ "expo-notifications", { diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 7c69237e..49747e13 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -23,12 +23,14 @@ import { useSessions } from "../../src/stores/sessions" import { useConnections } from "../../src/stores/connections" import { useEvents } from "../../src/stores/events" import { useCatalog } from "../../src/stores/catalog" +import { useChanges, useHeadline } from "../../src/stores/changes" import type BottomSheet from "@gorhom/bottom-sheet" import type { Session, Project } from "../../src/lib/sdk" import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat" import { groupByDirectory } from "../../src/lib/session-grouping" import { UpdateBanner } from "../../src/components/UpdateBanner" import { nameOf } from "../../src/lib/path-utils" +import { newChatDir, newChatProjects, projectDir, projectName, tracksChanges } from "../../src/lib/projects" import { SETUP_GUIDE_URL } from "../../src/lib/links" function formatTime(timestamp: number, t: (key: string, opts?: Record) => string): string { @@ -72,6 +74,8 @@ function SessionItem({ ]) } + const changes = useHeadline(session.directory) + // Extract short directory name from session const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null @@ -91,11 +95,11 @@ function SessionItem({ {formatTime(session.time.updated, t)} - {/* summary is always present but files defaults to 0 until the - server populates it — only show the count when it's meaningful, - matching the SessionInfo panel's `summary.files > 0` guard (#55) */} - {session.summary && session.summary.files > 0 && - ` · ${t("sessionsList.filesCount", { count: session.summary.files })}`} + {/* Repo-level counts from the vcs diff (see stores/changes); + session.summary has been empty since opencode 1.15. */} + {changes && changes.files > 0 && ` · ${t("sessionsList.filesCount", { count: changes.files })} `} + {changes && changes.files > 0 && +{changes.additions} } + {changes && changes.files > 0 && -{changes.deletions}} {shortDir && ( @@ -147,14 +151,10 @@ function GroupHeader({ } // Get short directory name (last folder or project name) -function getShortPath( - project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined, -): string { +function getShortPath(project: Project | null | undefined): string { if (!project) return "" if (project.name) return project.name - if (!project.path?.absolute) return "" - const parts = project.path.absolute.split("/").filter(Boolean) - return parts[parts.length - 1] || project.path.absolute + return projectDir(project)?.split("/").filter(Boolean).pop() || projectDir(project) || "" } export default function SessionsScreen() { @@ -241,6 +241,10 @@ export default function SessionsScreen() { .catch(() => setServerProjects([])) }, [showNewSession, client]) + const projects = useMemo(() => newChatProjects(serverProjects), [serverProjects]) + const projectDirs = useMemo(() => new Set(projects.map((p) => projectDir(p))), [projects]) + const currentDir = projectDir(currentProject) || activeConnection?.directory + const handleSwitchDirectory = useCallback( async (dir?: string) => { await switchDirectory(dir) @@ -260,16 +264,26 @@ export default function SessionsScreen() { }, [client, loadSessions, refreshProject]), ) + // Change counts for the most recent repos in the list (stale entries only; + // pull-to-refresh forces). + const changeDirs = useMemo( + () => [...new Set(sessions.map((session) => session.directory).filter(Boolean))].slice(0, 10), + [sessions], + ) + useEffect(() => { + if (client) void useChanges.getState().prefetch(changeDirs) + }, [client, changeDirs]) + const onRefresh = useCallback(async () => { setRefreshing(true) try { - await Promise.all([loadSessions(), refreshProject()]) + await Promise.all([loadSessions(), refreshProject(), useChanges.getState().prefetch(changeDirs, true)]) } catch (err) { console.error("Refresh failed:", err) } finally { setRefreshing(false) } - }, [loadSessions, refreshProject]) + }, [loadSessions, refreshProject, changeDirs]) const handleRename = useCallback((session: Session) => { setRenameText(session.title || "") @@ -425,7 +439,13 @@ export default function SessionsScreen() { }, []) const onFabPress = () => { - // Quick create in current project + // Quick create: the connection's default directory (set by workspace + // links), else the last project a chat was started in. + const dir = newChatDir(activeConnection?.directory, recentDirectories) + if (dir && dir !== activeConnection?.directory) { + onCreateInDirectory(dir) + return + } onCreateSession() } @@ -607,32 +627,75 @@ export default function SessionsScreen() { - {/* Current directory — tapping creates session immediately */} - - {t("sessionsList.newSessionModal.currentProjectLabel")} - - onCreateInDirectory()} - disabled={isCreating} - > - - - {currentProject?.path?.absolute || activeConnection?.directory || t("sessionsList.newSessionModal.serverDefault")} - - - + {/* Server projects first: git repos (change tracking) before the rest */} + {projects.length > 0 && ( + <> + + {t("sessionsList.newSessionModal.serverProjectsLabel")} + + {projects.map((p) => { + const dir = projectDir(p) + const isCurrent = dir === currentDir + return ( + onCreateInDirectory(dir)} + disabled={isCreating} + testID={`new-chat-project-${p.id}`} + > + + + + {projectName(p)} + + + {tracksChanges(p) ? dir : `${dir} · ${t("sessionsList.newSessionModal.noChangeTracking")}`} + + + {isCurrent && } + + ) + })} + + )} + + {/* Current directory, when it is not one of the projects above */} + {!projectDirs.has(currentDir ?? "") && ( + <> + 0 && { marginTop: 16 }]}> + {t("sessionsList.newSessionModal.currentProjectLabel")} + + onCreateInDirectory()} + disabled={isCreating} + > + + + {currentDir || t("sessionsList.newSessionModal.serverDefault")} + + + + + )} {/* Recent projects */} - {recentDirectories.length > 0 && ( + {recentDirectories.some((dir) => !projectDirs.has(dir)) && ( <> {t("sessionsList.newSessionModal.recentProjectsLabel")} - {recentDirectories.map((dir) => { + {recentDirectories.filter((dir) => !projectDirs.has(dir)).map((dir) => { const short = dir.split("/").filter(Boolean).pop() || dir - const isCurrent = - dir === (currentProject?.path?.absolute || activeConnection?.directory) + const isCurrent = dir === currentDir return ( )} - {/* Server-known projects (excluding current) */} - {serverProjects.filter((p) => p.path?.absolute !== currentProject?.path?.absolute).length > 0 && ( - <> - - {t("sessionsList.newSessionModal.serverProjectsLabel")} - - {serverProjects - .filter((p) => p.path?.absolute !== currentProject?.path?.absolute) - .map((p) => { - const short = p.name || p.path?.absolute?.split("/").filter(Boolean).pop() || p.id - return ( - onCreateInDirectory(p.path?.absolute)} - disabled={isCreating} - > - - - - {short} - - {p.path?.absolute && ( - - {p.path.absolute} - - )} - - - ) - })} - - )} - {/* Browse the server's filesystem instead of typing a path */} - openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create") + openBrowser(projectDir(currentProject) || activeConnection?.directory || null, "create") } disabled={isCreating} testID="browse-folders-button" @@ -862,7 +891,7 @@ export default function SessionsScreen() { isDark={isDark} onSwitch={handleSwitchDirectory} onBrowse={() => - openBrowser(activeConnection?.directory || currentProject?.path?.absolute || null, "switch") + openBrowser(activeConnection?.directory || projectDir(currentProject) || null, "switch") } /> @@ -990,6 +1019,8 @@ const styles = StyleSheet.create({ fontSize: 13, color: "#666666", }, + changesAdd: { color: "#16a34a", fontWeight: "600" }, + changesDel: { color: "#dc2626", fontWeight: "600" }, sessionMetaRow: { flexDirection: "row", alignItems: "center", diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index af3a4559..a20030a6 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -21,8 +21,6 @@ import { granted as notificationsGranted, } from "../../src/lib/notifications" import type { Category } from "../../src/lib/notifications" -import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry" -import { PRIVACY_POLICY_URL } from "../../src/lib/links" import { CURRENT_VERSION, checkForUpdate, type AvailableUpdate } from "../../src/lib/update-check" import type { LocalePreference } from "../../src/lib/i18n/locale-resolve" @@ -78,7 +76,6 @@ export default function SettingsScreen() { const { settings, hasBiometrics, updateSettings, lock } = useAuth() const { notifications, setNotification, locale, setLocale } = useSettings() const [osGranted, setOsGranted] = useState(null) - const [telemetryUpdating, setTelemetryUpdating] = useState(false) // Settings is where a user goes to ask "what am I running?". Answer it, and if // a newer build exists say so here too — the banner on the sessions list is @@ -97,26 +94,6 @@ export default function SettingsScreen() { } }, []) - // Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false. - // We initialise local state from in-memory value; updates call setTelemetryConsent(). - const [crashReporting, setCrashReporting] = useState(hasTelemetryConsent() ?? false) - - const handleCrashReportingToggle = useCallback( - async (value: boolean) => { - setTelemetryUpdating(true) - try { - await setTelemetryConsent(value) - setCrashReporting(value) - } catch { - setCrashReporting(hasTelemetryConsent() ?? false) - Alert.alert(t("settings.alerts.privacyNotSavedTitle"), t("settings.alerts.privacyNotSavedMessage")) - } finally { - setTelemetryUpdating(false) - } - }, - [t], - ) - // Check OS permission state on first toggle attempt const handleToggle = useCallback( async (category: Category, enabled: boolean) => { @@ -231,31 +208,6 @@ export default function SettingsScreen() { )} - - - } - /> - Linking.openURL(PRIVACY_POLICY_URL)} - right={} - /> - - ("loading") - useEffect(() => { + installCrashLogging() initAuth() loadConnections() useSettings.getState().load() @@ -52,29 +46,6 @@ function RootLayout() { else router.push("/") }) - // Load telemetry consent — initialise Sentry only if previously granted - loadTelemetryConsent() - .then((state) => { - if (state === "granted") { - import("../src/lib/sentry").then(({ initSentry }) => { - initSentry() - addBreadcrumb({ category: "app.lifecycle", message: "app started" }) - }) - initAnalytics() - trackAppOpened() - setConsentState("decided") - } else if (state === "denied") { - addBreadcrumb({ category: "app.lifecycle", message: "app started (telemetry off)" }) - setConsentState("decided") - } else { - setConsentState("unknown") - } - }) - .catch(() => { - // SecureStore unavailable — show modal so user can decide - setConsentState("unknown") - }) - return unsubNotifications }, []) @@ -96,30 +67,6 @@ function RootLayout() { return () => sub.remove() }, []) - // Retry any waitlist signup that couldn't reach the server when the user - // tapped Join (AGE-87). Runs at cold start and on every foreground, which is - // the cheapest reliable proxy for "connectivity may have come back" — it is a - // no-op (single storage read, no network) when the queue is empty, and it - // replaces the old silent mailto: fallback that lost 20 of 21 signups. - useEffect(() => { - const flush = () => { - void flushPendingSignups() - .then((outcome) => { - if (outcome.synced.length > 0) { - addBreadcrumb({ category: "waitlist", message: `retried ${outcome.synced.length} queued signup(s)` }) - } - }) - .catch(() => { - // Best effort: the entry stays queued for the next foreground. - }) - } - flush() - const sub = AppState.addEventListener("change", (next) => { - if (next === "active") flush() - }) - return () => sub.remove() - }, []) - // Connect/disconnect SSE and load catalog when client changes useEffect(() => { if (client && !sseStarted.current) { @@ -150,7 +97,7 @@ function RootLayout() { } }, [client]) - const isLoading = authLoading || connectionsLoading || consentState === "loading" + const isLoading = authLoading || connectionsLoading if (isLoading) { return ( @@ -213,21 +160,9 @@ function RootLayout() { - {/* Telemetry consent modal — shown once on first launch */} - { - await setTelemetryConsent(true) - setConsentState("decided") - }} - onDecline={async () => { - await setTelemetryConsent(false) - setConsentState("decided") - }} - /> ) } -export default wrap(RootLayout) +export default RootLayout diff --git a/app/changes/[id].tsx b/app/changes/[id].tsx new file mode 100644 index 00000000..5828ea7e --- /dev/null +++ b/app/changes/[id].tsx @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, RefreshControl, ActivityIndicator } from "react-native" +import { Stack, router, useLocalSearchParams } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" +import { useChanges, useOnTurnEnd, infoKey, turnKey, vcsKey } from "../../src/stores/changes" +import { useSessions } from "../../src/stores/sessions" +import { branchOffered, lastUserMessageID, sortDiffs, splitPath, totals, type ChangeTab } from "../../src/lib/changes" +import type { FileDiff } from "../../src/lib/sdk" + +const TABS: ChangeTab[] = ["branch", "git", "turn"] + +export default function ChangesScreen() { + const params = useLocalSearchParams<{ id: string; directory?: string; tab?: ChangeTab }>() + const id = params.id + const isDark = useColorScheme() === "dark" + const { t } = useTranslation() + + // The chat's own folder. Never fall back to no directory: the server would + // answer for its default folder, i.e. silently the wrong repo. + const directory = useSessions((s) => (s.currentSession?.id === id ? s.currentSession.directory : undefined)) || params.directory || undefined + const messageID = useSessions((s) => (s.currentSession?.id === id ? lastUserMessageID(s.messages) : undefined)) + const info = useChanges((s) => (directory ? s.info[infoKey(directory)] : undefined)) + const { loadInfo, loadVcs, loadTurn } = useChanges() + + // Branch is offered only off the default branch (same rule as the web app). + const tabs = branchOffered(info) ? TABS : TABS.filter((item) => item !== "branch") + const [picked, setPicked] = useState(params.tab) + const tab = picked && tabs.includes(picked) ? picked : tabs[0] + + const key = tab === "turn" ? turnKey(id) : directory ? vcsKey(directory, tab) : "" + const entry = useChanges((s) => s.entries[key]) + + const load = useCallback( + (which: ChangeTab, force?: boolean) => { + if (!directory) return Promise.resolve() + if (which === "turn") return loadTurn(id, directory, messageID, force) + return loadVcs(directory, which, force) + }, + [id, directory, messageID, loadTurn, loadVcs], + ) + + useEffect(() => { + if (directory) void loadInfo(directory) + }, [directory, loadInfo]) + + useEffect(() => { + void load(tab) + }, [tab, load]) + + useOnTurnEnd(id, () => { + if (directory) void loadInfo(directory) + for (const which of tabs) void load(which, true) + }) + + const diffs = useMemo(() => sortDiffs(entry?.diffs ?? []), [entry?.diffs]) + const sum = totals(diffs) + const emptyText = t(`changes.empty.${tab}`) + + return ( + + + + + {tabs.map((item) => ( + setPicked(item)} + testID={`changes-tab-${item}`} + > + {t(`changes.tabs.${item}`)} + + ))} + + + + + {tab === "branch" && info?.branch && info.default_branch + ? `${t("changes.branchVs", { branch: info.branch, base: info.default_branch })} · ` + : tab === "git" && info?.branch + ? `${info.branch} · ` + : ""} + {t("changes.summary", { count: sum.files })} + + +{sum.additions} + -{sum.deletions} + + + {entry?.error && ( + + {t("changes.error", { message: entry.error })} + + )} + + item.file} + refreshControl={ void load(tab, true)} />} + renderItem={({ item }) => ( + router.push({ pathname: "/changes/file", params: { key, file: item.file } })} + /> + )} + ListEmptyComponent={ + !directory ? ( + {t("changes.noDirectory")} + ) : entry?.loading || !entry ? ( + + ) : entry.error ? null : ( + {emptyText} + ) + } + contentContainerStyle={s.list} + testID="changes-list" + /> + + ) +} + +function FileRow({ diff, isDark, onPress }: { diff: FileDiff; isDark: boolean; onPress: () => void }) { + const { dir, name } = splitPath(diff.file) + const icon = diff.status === "added" ? "add-circle-outline" : diff.status === "deleted" ? "remove-circle-outline" : "document-text-outline" + const color = diff.status === "added" ? "#16a34a" : diff.status === "deleted" ? "#dc2626" : isDark ? "#a3a3a3" : "#666666" + return ( + + + + + {name} + + {dir ? ( + + {dir} + + ) : null} + + +{diff.additions} + -{diff.deletions} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#ffffff" }, + containerDark: { backgroundColor: "#0a0a0a" }, + tabs: { flexDirection: "row", borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: "#e5e5e5" }, + tabsDark: { borderBottomColor: "#262626" }, + tab: { flex: 1, alignItems: "center", paddingVertical: 12, borderBottomWidth: 2, borderBottomColor: "transparent" }, + tabActive: { borderBottomColor: "#3b82f6" }, + tabText: { fontSize: 14, fontWeight: "500", color: "#0a0a0a" }, + tabTextActive: { color: "#3b82f6", fontWeight: "600" }, + textDark: { color: "#ffffff" }, + metaDark: { color: "#888888" }, + summary: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 16, paddingVertical: 10 }, + summaryText: { flex: 1, fontSize: 13, color: "#666666" }, + add: { fontSize: 12, fontWeight: "700", color: "#16a34a" }, + del: { fontSize: 12, fontWeight: "700", color: "#dc2626" }, + error: { color: "#ef4444", fontSize: 13, paddingHorizontal: 16, paddingBottom: 8 }, + list: { paddingBottom: 32, flexGrow: 1 }, + loading: { marginTop: 32 }, + empty: { textAlign: "center", marginTop: 32, paddingHorizontal: 24, fontSize: 14, color: "#666666" }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#eeeeee", + }, + rowDark: { borderBottomColor: "#1f1f1f" }, + rowText: { flex: 1 }, + name: { fontSize: 14, fontWeight: "500", color: "#0a0a0a" }, + dir: { fontSize: 12, color: "#888888", marginTop: 2 }, +}) diff --git a/app/changes/file.tsx b/app/changes/file.tsx new file mode 100644 index 00000000..444768f5 --- /dev/null +++ b/app/changes/file.tsx @@ -0,0 +1,106 @@ +import { useMemo } from "react" +import { View, Text, FlatList, StyleSheet, TouchableOpacity, useColorScheme } from "react-native" +import { Stack, router, useLocalSearchParams } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" +import { useChanges } from "../../src/stores/changes" +import { parsePatch, sortDiffs, splitPath } from "../../src/lib/changes" +import { DiffRow } from "../../src/components/chat" + +// Fullscreen diff of one file from the Changes view. Long lines wrap; the +// patch comes from the Changes store entry (`key`) the file list was showing; +// the header arrows step through that list in the same order. +export default function ChangeFileScreen() { + const { key, file } = useLocalSearchParams<{ key: string; file: string }>() + const isDark = useColorScheme() === "dark" + const { t } = useTranslation() + const diffs = useChanges((s) => s.entries[key]?.diffs) + const files = useMemo(() => sortDiffs(diffs ?? []), [diffs]) + const index = files.findIndex((item) => item.file === file) + const diff = index === -1 ? undefined : files[index] + const go = (step: number) => { + const next = files[index + step] + if (next) router.setParams({ file: next.file }) + } + const color = (enabled: boolean) => (enabled ? (isDark ? "#ffffff" : "#0a0a0a") : isDark ? "#444444" : "#cccccc") + const lines = useMemo(() => parsePatch(diff?.patch), [diff?.patch]) + const { dir, name } = splitPath(file ?? "") + + return ( + + + files.length > 1 ? ( + + go(-1)} disabled={index <= 0} hitSlop={8} testID="change-file-prev"> + 0)} /> + + + {index + 1}/{files.length} + + go(1)} + disabled={index === -1 || index >= files.length - 1} + hitSlop={8} + testID="change-file-next" + > + + + + ) : undefined, + }} + /> + + + {dir} + {name} + + {diff && ( + <> + {diff.status && {t(`changes.status.${diff.status}`)}} + +{diff.additions} + -{diff.deletions} + + )} + + String(index)} + renderItem={({ item }) => } + initialNumToRender={60} + windowSize={11} + ListEmptyComponent={{t("changes.noPatch")}} + contentContainerStyle={s.list} + testID="change-file-diff" + /> + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#f8f8f8" }, + containerDark: { backgroundColor: "#1a1a1a" }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: "#ffffff", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#e5e5e5", + }, + headerDark: { backgroundColor: "#0a0a0a", borderBottomColor: "#262626" }, + path: { flex: 1, fontSize: 12, color: "#666666" }, + nav: { flexDirection: "row", alignItems: "center", gap: 12 }, + count: { fontSize: 13, color: "#666666", minWidth: 32, textAlign: "center" }, + metaDark: { color: "#888888" }, + status: { fontSize: 12, color: "#666666" }, + add: { fontSize: 12, fontWeight: "700", color: "#16a34a" }, + del: { fontSize: 12, fontWeight: "700", color: "#dc2626" }, + list: { paddingVertical: 4, paddingBottom: 32 }, + empty: { textAlign: "center", marginTop: 32, paddingHorizontal: 24, fontSize: 14, color: "#666666" }, +}) diff --git a/app/connect.tsx b/app/connect.tsx new file mode 100644 index 00000000..8087d397 --- /dev/null +++ b/app/connect.tsx @@ -0,0 +1,216 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + StyleSheet, + useColorScheme, + ActivityIndicator, +} from "react-native" +import { Stack, router, useLocalSearchParams } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" +import { useConnections } from "../src/stores/connections" +import { connectLinkFromParams, findConnection, type ConnectLink } from "../src/lib/connect-link" +import { useKeyboardHeight } from "../src/lib/use-keyboard-height" + +// Target of opencode://connect links (see src/lib/connect-link.ts). A known +// server is activated without questions; a new one asks for its password once, +// which then lives in SecureStore like any other connection's. +export default function ConnectScreen() { + const params = useLocalSearchParams() + const link = useMemo(() => connectLinkFromParams(params), [JSON.stringify(params)]) + const isDark = useColorScheme() === "dark" + const { t } = useTranslation() + const keyboardHeight = useKeyboardHeight() + + const existing = useConnections((s) => (link ? findConnection(s.connections, link.url) : undefined)) + const [name, setName] = useState(link?.name ?? "") + const [user, setUser] = useState(link?.user ?? "opencode") + const [password, setPassword] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const started = useRef(false) + + // Known server: switch to it right away. + useEffect(() => { + if (!link || !existing || started.current) return + started.current = true + void activateExisting(existing.id, link) + }, [link, existing]) + + const onConnect = async () => { + if (!link || busy) return + // The store update below makes `existing` match; don't let the effect + // above activate (and navigate) a second time. + started.current = true + setBusy(true) + setError(null) + const store = useConnections.getState() + const connection = { + name: name.trim() || link.name || hostOf(link.url), + type: "tunnel" as const, + url: link.url, + username: user.trim() || undefined, + directory: link.dir, + } + const result = await store.testConnection({ id: "", ...connection }, "onboarding", password || undefined) + if (!result.ok) { + started.current = false + setBusy(false) + setError(result.error || t("connect.failed")) + return + } + const id = await store.addConnection(connection, password || undefined) + await useConnections.getState().setActiveConnection(id) + open(link) + } + + if (!link) { + return ( + + + + {t("connect.invalid")} + router.replace("/")}> + {t("connect.home")} + + + ) + } + + if (existing) { + return ( + + + + {t("connect.opening", { name: existing.name })} + + ) + } + + return ( + + + + {t("connect.newIntro")} + + {t("connect.server")} + + {link.url} + + {link.dir && ( + <> + {t("connect.directory")} + + {link.dir} + + + )} + + {t("connect.name")} + + + {t("connect.user")} + + + {t("connect.password")} + + + {error && {error}} + + + {busy ? ( + + ) : ( + {t("connect.submit")} + )} + + + + ) +} + +async function activateExisting(id: string, link: ConnectLink) { + const store = useConnections.getState() + const current = store.connections.find((c) => c.id === id) + if (link.dir && current?.directory !== link.dir) await store.updateConnection(id, { directory: link.dir }) + if (useConnections.getState().activeConnection?.id !== id) await useConnections.getState().setActiveConnection(id) + open(link) +} + +// Sessions list, then the chat when the link names one. +function open(link: ConnectLink) { + router.replace("/") + if (!link.session) return + router.push({ pathname: "/session/[id]", params: { id: link.session, ...(link.dir ? { directory: link.dir } : {}) } }) +} + +function hostOf(url: string): string { + return url.replace(/^https?:\/\//, "").split("/")[0] +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#ffffff" }, + containerDark: { backgroundColor: "#0a0a0a" }, + center: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16, padding: 24, backgroundColor: "#ffffff" }, + content: { padding: 16, paddingBottom: 32 }, + intro: { fontSize: 15, color: "#0a0a0a", marginBottom: 8 }, + hint: { fontSize: 14, color: "#666666", textAlign: "center" }, + label: { fontSize: 13, fontWeight: "600", color: "#666666", marginTop: 16, marginBottom: 6 }, + value: { fontSize: 14, color: "#0a0a0a" }, + textDark: { color: "#ffffff" }, + metaDark: { color: "#888888" }, + input: { + borderWidth: 1, + borderColor: "#e5e5e5", + borderRadius: 8, + padding: 12, + fontSize: 16, + color: "#0a0a0a", + backgroundColor: "#ffffff", + }, + inputDark: { borderColor: "#333333", color: "#ffffff", backgroundColor: "#1a1a1a" }, + error: { color: "#ef4444", fontSize: 13, marginTop: 12 }, + button: { + marginTop: 24, + backgroundColor: "#0a0a0a", + borderRadius: 8, + padding: 14, + alignItems: "center", + alignSelf: "stretch", + }, + buttonDark: { backgroundColor: "#ffffff" }, + buttonDisabled: { opacity: 0.6 }, + buttonText: { color: "#ffffff", fontSize: 16, fontWeight: "600" }, + buttonTextDark: { color: "#0a0a0a" }, +}) diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 49701d21..8b3b8ef0 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -20,6 +20,7 @@ import { probeConnection, shareReport } from "../../src/lib/diagnostics" import { captureDiagnostic } from "../../src/lib/sentry" import { parseUrl } from "../../src/lib/diagnostics-classify" import { buildAuth } from "../../src/lib/auth" +import { useKeyboardHeight } from "../../src/lib/use-keyboard-height" // labelKey (not literal text): this is a module-level constant evaluated // before i18next is guaranteed ready, so the label is resolved with t() at @@ -41,6 +42,7 @@ export default function EditConnectionScreen() { const { t } = useTranslation() const { connections, updateConnection, removeConnection, testConnection } = useConnections() + const keyboardHeight = useKeyboardHeight() const connection = connections.find((c) => c.id === id) @@ -181,8 +183,9 @@ export default function EditConnectionScreen() { } return ( + @@ -316,6 +319,7 @@ export default function EditConnectionScreen() { + ) } @@ -327,6 +331,9 @@ const styles = StyleSheet.create({ containerDark: { backgroundColor: "#0a0a0a", }, + scroll: { + flex: 1, + }, center: { justifyContent: "center", alignItems: "center", diff --git a/app/connection/add.tsx b/app/connection/add.tsx index b8616086..22d58873 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useState } from "react" import { View, Text, @@ -9,7 +9,6 @@ import { useColorScheme, ActivityIndicator, Alert, - Linking, } from "react-native" import { router } from "expo-router" import { Ionicons } from "@expo/vector-icons" @@ -20,21 +19,15 @@ import { probeConnection, shareReport } from "../../src/lib/diagnostics" import { captureDiagnostic } from "../../src/lib/sentry" import { parseUrl } from "../../src/lib/diagnostics-classify" import { buildAuth } from "../../src/lib/auth" +import { useKeyboardHeight } from "../../src/lib/use-keyboard-height" import { AnalyticsEvent, track } from "../../src/lib/analytics" -import { submitWaitlistSignup, buildWaitlistMailtoUrl, needsManualEscapeHatch, type QueuedSignup } from "../../src/lib/waitlist" -import { flushPendingSignups, queuePendingSignup, readPendingSignups, dropPendingSignup } from "../../src/lib/waitlist-queue-storage" -import appJson from "../../app.json" - -// Same read as sentry.ts: app.json is the single source of the user-visible -// version (package.json/gradle are kept in parity by `npm run check:versions`). -const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" - export default function AddConnectionScreen() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" const { t } = useTranslation() const { addConnection, testConnection } = useConnections() + const keyboardHeight = useKeyboardHeight() const [mode, setMode] = useState<"quick" | "advanced">("quick") const [type, setType] = useState("local") @@ -46,36 +39,6 @@ export default function AddConnectionScreen() { const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [isConnecting, setIsConnecting] = useState(false) - const [waitlistEmail, setWaitlistEmail] = useState("") - // "queued" = the POST failed but the signup is persisted on-device and will - // be retried on the next foreground/connectivity (AGE-87). It is NOT "sent". - const [waitlistState, setWaitlistState] = useState<"idle" | "submitting" | "joined" | "queued">("idle") - const [pendingSignup, setPendingSignup] = useState(null) - - // Retry anything left over from a previous session as soon as this screen - // opens (the root layout also flushes on every foreground), then reflect the - // real state back to the user instead of pretending nothing is pending. - useEffect(() => { - let cancelled = false - void (async () => { - const outcome = await flushPendingSignups().catch(() => null) - if (cancelled) return - const pending = outcome ? outcome.pending : await readPendingSignups().catch(() => []) - if (cancelled) return - if (outcome && outcome.synced.length > 0 && pending.length === 0) { - setWaitlistState("joined") - return - } - if (pending.length > 0) { - setPendingSignup(pending[pending.length - 1]) - setWaitlistState((current) => (current === "idle" ? "queued" : current)) - } - })() - return () => { - cancelled = true - } - }, []) - const buildUrl = () => { if (mode === "advanced") return url.trim() const raw = ip.trim() @@ -244,79 +207,12 @@ export default function AddConnectionScreen() { ) } - const handleJoinWaitlist = async () => { - if (waitlistState === "submitting") return - const attemptedEmail = waitlistEmail - setWaitlistState("submitting") - const result = await submitWaitlistSignup(attemptedEmail) - if (result.ok) { - // Clear any earlier queued attempt for the same address so the flush - // doesn't re-post it. - void dropPendingSignup(result.email) - setPendingSignup(null) - setWaitlistState("joined") - return - } - - if (!result.retryable) { - // The server rejected this address; queueing it would retry forever. - setWaitlistState(pendingSignup ? "queued" : "idle") - Alert.alert(t("connection.add.waitlist.alertTitle"), result.error) - return - } - - // Offline / timeout / 5xx: persist and retry later instead of dumping the - // user into a mail composer they may never send (AGE-87). - const entry = await queuePendingSignup(result.email, result.error) - if (entry) { - setPendingSignup(entry) - setWaitlistState("queued") - return - } - - // Storage refused the write — we cannot promise to finish this later, so - // offer the manual email path explicitly rather than claiming success. - setWaitlistState("idle") - Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.queueFailedMessage"), [ - { text: t("common.cancel"), style: "cancel" }, - { text: t("connection.add.waitlist.emailUsButton"), onPress: () => void openWaitlistMailto(result.email) }, - ]) - } - - // Last-resort, user-initiated only. Never opened automatically. - const openWaitlistMailto = async (email: string) => { - try { - await Linking.openURL(buildWaitlistMailtoUrl(email, APP_VERSION)) - } catch { - Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.noMailAppMessage")) - } - } - - // Explicit "Retry" from the queued state — same code path the foreground - // flush uses, so there is only one retry implementation. - const handleRetryQueued = async () => { - setWaitlistState("submitting") - const outcome = await flushPendingSignups().catch(() => null) - if (outcome && outcome.pending.length === 0 && outcome.synced.length > 0) { - setPendingSignup(null) - setWaitlistState("joined") - return - } - if (outcome && outcome.pending.length === 0) { - // Nothing left pending and nothing synced: the address was rejected. - setPendingSignup(null) - setWaitlistState("idle") - return - } - if (outcome) setPendingSignup(outcome.pending[outcome.pending.length - 1]) - setWaitlistState("queued") - } - // Quick connect mode - simplified if (mode === "quick") { return ( + @@ -431,83 +327,6 @@ export default function AddConnectionScreen() { - {/* OpenCode Connect — Coming Soon */} - - - - - - {t("connection.add.quick.connectCardTitle")} - - - {t("connection.add.quick.connectCardBadge")} - - - - - {t("connection.add.quick.connectCardDesc")} - - {waitlistState === "joined" ? ( - - - - {t("connection.add.waitlist.successText")} - - - ) : waitlistState === "queued" ? ( - - - - - {t("connection.add.waitlist.queuedText")} - - - {needsManualEscapeHatch(pendingSignup) && ( - void openWaitlistMailto(pendingSignup?.email ?? waitlistEmail)} - testID="waitlist-email-us" - > - {t("connection.add.waitlist.emailUsLink")} - - )} - void handleRetryQueued()} testID="waitlist-retry"> - {t("common.retry")} - - - ) : ( - <> - - - {waitlistState === "submitting" ? ( - - ) : ( - <> - - {t("connection.add.waitlist.joinButton")} - - )} - - - )} - - {/* Advanced mode link */} setMode("advanced")}> @@ -516,13 +335,15 @@ export default function AddConnectionScreen() { + ) } // Advanced mode - full options return ( + @@ -658,6 +479,7 @@ export default function AddConnectionScreen() { )} + ) } @@ -669,6 +491,9 @@ const styles = StyleSheet.create({ containerDark: { backgroundColor: "#0a0a0a", }, + scroll: { + flex: 1, + }, content: { padding: 16, paddingBottom: 32, @@ -861,85 +686,4 @@ const styles = StyleSheet.create({ marginTop: 32, marginBottom: 8, }, - connectCard: { - backgroundColor: "#f0f0ff", - borderRadius: 12, - padding: 16, - marginTop: 24, - borderWidth: 1, - borderColor: "#c7d2fe", - }, - connectCardDark: { - backgroundColor: "#1e1b4b", - borderColor: "#3730a3", - }, - connectCardHeader: { - flexDirection: "row", - alignItems: "center", - gap: 12, - marginBottom: 8, - }, - connectCardTitles: { - flexDirection: "row", - alignItems: "center", - gap: 8, - flexWrap: "wrap", - }, - connectCardTitle: { - fontSize: 16, - fontWeight: "700", - color: "#0a0a0a", - }, - connectCardBadge: { - backgroundColor: "#6366f1", - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 4, - }, - connectCardBadgeText: { - color: "#ffffff", - fontSize: 11, - fontWeight: "600", - }, - connectCardDesc: { - fontSize: 13, - color: "#666666", - lineHeight: 20, - }, - waitlistButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 8, - padding: 12, - borderRadius: 8, - backgroundColor: "#6366f1", - marginTop: 12, - }, - waitlistButtonText: { - fontSize: 15, - fontWeight: "600", - color: "#ffffff", - }, - waitlistSuccess: { - flexDirection: "row", - alignItems: "center", - gap: 8, - marginTop: 12, - }, - waitlistSuccessText: { - flex: 1, - fontSize: 13, - color: "#0a0a0a", - lineHeight: 20, - }, - waitlistEscapeHatch: { - marginTop: 8, - paddingVertical: 4, - }, - waitlistEscapeHatchText: { - fontSize: 13, - fontWeight: "600", - color: "#6366f1", - }, }) diff --git a/app/demo.tsx b/app/demo.tsx index 6a590304..8ab03c0f 100644 --- a/app/demo.tsx +++ b/app/demo.tsx @@ -85,13 +85,6 @@ export default function DemoScreen() { > {t("demo.connectButton")} - - {t("demo.hostedCtaLink")} - Linking.openURL(SETUP_GUIDE_URL)} @@ -147,8 +140,6 @@ const s = StyleSheet.create({ alignItems: "center", }, connectButtonText: { color: "#ffffff", fontWeight: "600", fontSize: 15 }, - hostedCtaLink: { marginTop: 14 }, - hostedCtaLinkText: { fontSize: 14, fontWeight: "600", color: "#8b5cf6", textAlign: "center" }, setupGuideLink: { marginTop: 14 }, setupGuideLinkText: { fontSize: 14, fontWeight: "600", color: "#6366f1" }, }) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 97f885f0..40356ac4 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -8,9 +8,11 @@ import { StyleSheet, useColorScheme, KeyboardAvoidingView, + Keyboard, Platform, ActivityIndicator, Alert, + useWindowDimensions, } from "react-native" import { useLocalSearchParams, Stack, useRouter, useFocusEffect } from "expo-router" import { Ionicons } from "@expo/vector-icons" @@ -39,6 +41,9 @@ import { useConnections } from "../../src/stores/connections" import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" +import { keyboardPadding, keyboardVerticalOffset } from "../../src/lib/keyboard-offset" +import { composerMaxHeight } from "../../src/lib/session-layout" +import { useChanges, useHeadline, useOnTurnEnd } from "../../src/stores/changes" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -77,14 +82,34 @@ export default function SessionScreen() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" const insets = useSafeAreaInsets() + const { height: windowHeight, fontScale } = useWindowDimensions() const { t } = useTranslation() const flatListRef = useRef(null) const modelSheetRef = useRef(null) const variantSheetRef = useRef(null) - const [input, setInput] = useState("") + const [input, setInputState] = useState(() => (id ? useSessions.getState().drafts[id] || "" : "")) + const inputRef = useRef(input) + inputRef.current = input const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) + const [keyboard, setKeyboard] = useState(() => Keyboard.metrics() ?? null) + const keyboardHeight = keyboard?.height ?? 0 + + useEffect(() => { + if (Platform.OS !== "android") return + const show = Keyboard.addListener("keyboardDidShow", (event) => setKeyboard(event.endCoordinates)) + const hide = Keyboard.addListener("keyboardDidHide", () => setKeyboard(null)) + // Close the render-to-effect race: listeners are attached first, then the + // current native metrics become the source of truth. + setKeyboard(Keyboard.metrics() ?? null) + return () => { + show.remove() + hide.remove() + } + }, []) + + const inputMaxHeight = composerMaxHeight(Platform.OS, windowHeight, keyboard?.screenY ?? null, insets.top, fontScale) const { currentSession, @@ -94,6 +119,8 @@ export default function SessionScreen() { loadingMore, hasMore, selectSession, + setDraft, + clearDraft, sendMessage, abortSession, loadOlderMessages, @@ -101,6 +128,16 @@ export default function SessionScreen() { unrevertSession, } = useSessions() + const setInput = useCallback( + (value: string | ((current: string) => string)) => { + const next = typeof value === "function" ? value(inputRef.current) : value + inputRef.current = next + setInputState(next) + if (id) setDraft(id, next) + }, + [id, setDraft], + ) + // Derive sending state for this specific session const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id])) @@ -131,6 +168,23 @@ export default function SessionScreen() { const questions = useEvents((s) => (sessionID ? s.questions[sessionID] : undefined)) || [] const shortDir = getShortDir(currentSession?.directory) + + // Branch/uncommitted change counts for this chat's repo; refreshed on focus + // and whenever the agent finishes a turn. + const changesDir = (currentSession?.id === id ? currentSession.directory : undefined) || directory + const changes = useHeadline(changesDir) + const refreshChanges = useCallback( + (force?: boolean) => { + if (changesDir) void useChanges.getState().prefetch([changesDir], force) + }, + [changesDir], + ) + useFocusEffect(useCallback(() => refreshChanges(), [refreshChanges])) + useOnTurnEnd(id, () => refreshChanges(true)) + const openChanges = useCallback(() => { + if (!id) return + router.push({ pathname: "/changes/[id]", params: { id, directory: changesDir ?? "" } }) + }, [id, changesDir, router]) const [showScrollButton, setShowScrollButton] = useState(false) // SSE reconnect banner @@ -194,9 +248,6 @@ export default function SessionScreen() { // handleMessageLongPress's deps — kept as a plain ref assignment (not // state) so the callback below stays referentially stable across // keystrokes for MessageBubble's custom memo comparator. - const inputRef = useRef(input) - inputRef.current = input - const applyRevertResult = useCallback((result: Awaited>) => { if (!result.ok) { if (result.reason === "unsupported") { @@ -265,6 +316,9 @@ export default function SessionScreen() { useFocusEffect( useCallback(() => { if (!id) return + const draft = useSessions.getState().drafts[id] || "" + inputRef.current = draft + setInputState(draft) selectSession(id, directory).then(() => { // Re-fetch pending permissions/questions from the server to recover from // missed SSE events or failed optimistic removals @@ -272,7 +326,7 @@ export default function SessionScreen() { const c = directory ? (connState.clientForDirectory(directory) ?? connState.client) : connState.client if (c) refreshPending(c, id) }) - }, [id, directory]), + }, [id, directory, selectSession]), ) // Sync model chip from latest assistant message @@ -417,6 +471,7 @@ export default function SessionScreen() { const text = input.trim() const files = [...attachments] setInput("") + if (id) clearDraft(id) setAttachments([]) // Server slash commands (no attachments for commands) @@ -579,6 +634,20 @@ export default function SessionScreen() { {shortDir} )} + + + {changes && changes.files > 0 && ( + <> + +{changes.additions} + -{changes.deletions} + + )} + setShowInfo((v) => !v)} hitSlop={8}> @@ -1044,6 +1120,17 @@ const s = StyleSheet.create({ borderRadius: 6, }, dirBadgeDark: { backgroundColor: "#1a1a1a" }, + changesChip: { + flexDirection: "row", + alignItems: "center", + gap: 4, + backgroundColor: "#f5f5f5", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + }, + changesAdd: { fontSize: 12, fontWeight: "600", color: "#16a34a" }, + changesDel: { fontSize: 12, fontWeight: "600", color: "#dc2626" }, dirText: { fontSize: 12, color: "#666666", fontWeight: "500" }, dirTextDark: { color: "#888888" }, diff --git a/docs/forge.md b/docs/forge.md new file mode 100644 index 00000000..d0930d51 --- /dev/null +++ b/docs/forge.md @@ -0,0 +1,28 @@ +# OpenCode forge (fork notes) + +App ID `it.kjeldahl.opencode`, name "OpenCode forge"; installs next to the store app. +No telemetry: Sentry, PostHog, Chatwoot and the waitlist are removed. The update +banner checks `kjeldahl/opencode-mobile` releases, so tag `vX.Y.Z` = `app.json` version. + +## Workspace links (hub "Open in app") + +``` +opencode://connect?url=&name=&user=opencode&dir=[&session=] +intent://connect?url=...&name=...&user=...&dir=...#Intent;scheme=opencode;package=it.kjeldahl.opencode;end +``` + +- URL-encode every value. Never put the password in the link. +- Use the `intent://` form in web pages: Chrome opens it reliably and it targets this + app even when the store app (same `opencode` scheme) is installed. +- Known `url`: the app activates that connection, makes `dir` its default directory, + opens Sessions (or the chat when `session` is given). +- New `url`: the app asks for the password once (stored in SecureStore), tests, saves. + +## Changes view + +Per chat: Branch (`/vcs/diff?mode=branch`), Uncommitted (`/vcs/diff?mode=git`), +This turn (`/session//diff?messageID=`). Every request carries the +chat's own `directory` (`session.directory`); without it the server answers for its +default folder. Branch is offered only when `/vcs` reports `branch != default_branch`. +Session list and chat header counts: branch diff when offered, else uncommitted; +never `session.summary`. diff --git a/metro.config.js b/metro.config.js index ba13333a..36a0531d 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,22 +1,7 @@ -// Metro bundler config. -// -// Wraps Expo's default Metro config with Sentry's config so the JS bundle -// gets a Debug ID embedded at build time (see -// https://docs.sentry.io/platforms/react-native/manual-setup/metro/). -// -// Why this matters: without this, Metro/Hermes produce bundles + source maps -// with NO debug ID. `android/sentry.gradle` (applied from -// android/app/build.gradle) then falls back to associating the uploaded -// source map with Sentry purely by `--release`/`--dist` string matching -// (see its `has-sourcemap-debugid.js` check). That legacy path is fragile — -// it silently breaks if the release/dist Gradle computes for the upload -// ever drifts from the release/dist the app reports at runtime via -// `Sentry.init()` (src/lib/sentry.ts). Debug ID matching sidesteps that -// entire class of bug: the bundle and its source map are linked by an -// embedded ID, independent of any release/dist string. -const { getSentryExpoConfig } = require("@sentry/react-native/metro") +// Metro bundler config: Expo's defaults. +const { getDefaultConfig } = require("expo/metro-config") /** @type {import('expo/metro-config').MetroConfig} */ -const config = getSentryExpoConfig(__dirname) +const config = getDefaultConfig(__dirname) module.exports = config diff --git a/package-lock.json b/package-lock.json index 7a846b5c..f5ee1e04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "@opencode-ai/mobile", - "version": "0.4.11", + "version": "0.4.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencode-ai/mobile", - "version": "0.4.11", + "version": "0.4.15", "dependencies": { "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.0.14", - "@sentry/react-native": "~6.22.0", "@tanstack/react-query": "^5.62.0", "expo": "^54.0.0", "expo-clipboard": "8.0.8", @@ -30,7 +29,6 @@ "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", "i18next": "^26.3.6", - "posthog-react-native": "^4.57.0", "react": "19.1.0", "react-i18next": "^17.0.10", "react-native": "0.81.5", @@ -2227,21 +2225,6 @@ "react-native": "*" } }, - "node_modules/@posthog/core": { - "version": "1.43.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.43.1.tgz", - "integrity": "sha512-hGM8f5sp3we6Em/RQHXbmyYm554hUx9+9jhf92ZQgDS4/xW72KHyZiO9wcFye+qAx2cJAOhOCDIznmO1FV8IbA==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.396.0" - } - }, - "node_modules/@posthog/types": { - "version": "1.396.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.396.0.tgz", - "integrity": "sha512-S0izvq+Hqvz2GPoYJO4x7fAtlCSHNN+JiugpBmQRdG7RrYW7kZ+GipmbTItjPSKuXoJx4KbEnxBvr6NHhoZV4w==", - "license": "MIT" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2818,349 +2801,6 @@ "nanoid": "^3.3.11" } }, - "node_modules/@sentry-internal/browser-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz", - "integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==", - "license": "MIT", - "dependencies": { - "@sentry/core": "8.55.0" - }, - "engines": { - "node": ">=14.18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.55.0.tgz", - "integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==", - "license": "MIT", - "dependencies": { - "@sentry/core": "8.55.0" - }, - "engines": { - "node": ">=14.18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.55.0.tgz", - "integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "8.55.0", - "@sentry/core": "8.55.0" - }, - "engines": { - "node": ">=14.18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz", - "integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==", - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "8.55.0", - "@sentry/core": "8.55.0" - }, - "engines": { - "node": ">=14.18" - } - }, - "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.2.0.tgz", - "integrity": "sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@sentry/browser": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.55.0.tgz", - "integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "8.55.0", - "@sentry-internal/feedback": "8.55.0", - "@sentry-internal/replay": "8.55.0", - "@sentry-internal/replay-canvas": "8.55.0", - "@sentry/core": "8.55.0" - }, - "engines": { - "node": ">=14.18" - } - }, - "node_modules/@sentry/cli": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.53.0.tgz", - "integrity": "sha512-n2ZNb+5Z6AZKQSI0SusQ7ZzFL637mfw3Xh4C3PEyVSn9LiF683fX0TTq8OeGmNZQS4maYfS95IFD+XpydU0dEA==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.7", - "progress": "^2.0.3", - "proxy-from-env": "^1.1.0", - "which": "^2.0.2" - }, - "bin": { - "sentry-cli": "bin/sentry-cli" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@sentry/cli-darwin": "2.53.0", - "@sentry/cli-linux-arm": "2.53.0", - "@sentry/cli-linux-arm64": "2.53.0", - "@sentry/cli-linux-i686": "2.53.0", - "@sentry/cli-linux-x64": "2.53.0", - "@sentry/cli-win32-arm64": "2.53.0", - "@sentry/cli-win32-i686": "2.53.0", - "@sentry/cli-win32-x64": "2.53.0" - } - }, - "node_modules/@sentry/cli-darwin": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.53.0.tgz", - "integrity": "sha512-NNPfpILMwKgpHiyJubHHuauMKltkrgLQ5tvMdxNpxY60jBNdo5VJtpESp4XmXlnidzV4j1z61V4ozU6ttDgt5Q==", - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-arm": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.53.0.tgz", - "integrity": "sha512-NdRzQ15Ht83qG0/Lyu11ciy/Hu/oXbbtJUgwzACc7bWvHQA8xEwTsehWexqn1529Kfc5EjuZ0Wmj3MHmp+jOWw==", - "cpu": [ - "arm" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-arm64": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.53.0.tgz", - "integrity": "sha512-xY/CZ1dVazsSCvTXzKpAgXaRqfljVfdrFaYZRUaRPf1ZJRGa3dcrivoOhSIeG/p5NdYtMvslMPY9Gm2MT0M83A==", - "cpu": [ - "arm64" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-i686": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.53.0.tgz", - "integrity": "sha512-0REmBibGAB4jtqt9S6JEsFF4QybzcXHPcHtJjgMi5T0ueh952uG9wLzjSxQErCsxTKF+fL8oG0Oz5yKBuCwCCQ==", - "cpu": [ - "x86", - "ia32" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-x64": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.53.0.tgz", - "integrity": "sha512-9UGJL+Vy5N/YL1EWPZ/dyXLkShlNaDNrzxx4G7mTS9ywjg+BIuemo6rnN7w43K1NOjObTVO6zY0FwumJ1pCyLg==", - "cpu": [ - "x64" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-arm64": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.53.0.tgz", - "integrity": "sha512-G1kjOjrjMBY20rQcJV2GA8KQE74ufmROCDb2GXYRfjvb1fKAsm4Oh8N5+Tqi7xEHdjQoLPkE4CNW0aH68JSUDQ==", - "cpu": [ - "arm64" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-i686": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.53.0.tgz", - "integrity": "sha512-qbGTZUzesuUaPtY9rPXdNfwLqOZKXrJRC1zUFn52hdo6B+Dmv0m/AHwRVFHZP53Tg1NCa8bDei2K/uzRN0dUZw==", - "cpu": [ - "x86", - "ia32" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-x64": { - "version": "2.53.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.53.0.tgz", - "integrity": "sha512-1TXYxYHtwgUq5KAJt3erRzzUtPqg7BlH9T7MdSPHjJatkrr/kwZqnVe2H6Arr/5NH891vOlIeSPHBdgJUAD69g==", - "cpu": [ - "x64" - ], - "license": "BSD-3-Clause", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/@sentry/cli/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@sentry/core": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.0.tgz", - "integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==", - "license": "MIT", - "engines": { - "node": ">=14.18" - } - }, - "node_modules/@sentry/react": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-8.55.0.tgz", - "integrity": "sha512-/qNBvFLpvSa/Rmia0jpKfJdy16d4YZaAnH/TuKLAtm0BWlsPQzbXCU4h8C5Hsst0Do0zG613MEtEmWpWrVOqWA==", - "license": "MIT", - "dependencies": { - "@sentry/browser": "8.55.0", - "@sentry/core": "8.55.0", - "hoist-non-react-statics": "^3.3.2" - }, - "engines": { - "node": ">=14.18" - }, - "peerDependencies": { - "react": "^16.14.0 || 17.x || 18.x || 19.x" - } - }, - "node_modules/@sentry/react-native": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-6.22.0.tgz", - "integrity": "sha512-Xt8ZuYa7YsMTG7hFU8awfNpVWijH6kWdyqykML6qVohBmP2OS57oSAd4fAdCmbtaIbHs8Drux+uPtb/SdOy2Vg==", - "license": "MIT", - "dependencies": { - "@sentry/babel-plugin-component-annotate": "4.2.0", - "@sentry/browser": "8.55.0", - "@sentry/cli": "2.53.0", - "@sentry/core": "8.55.0", - "@sentry/react": "8.55.0", - "@sentry/types": "8.55.0", - "@sentry/utils": "8.55.0" - }, - "bin": { - "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" - }, - "peerDependencies": { - "expo": ">=49.0.0", - "react": ">=17.0.0", - "react-native": ">=0.65.0" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "node_modules/@sentry/types": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.55.0.tgz", - "integrity": "sha512-6LRT0+r6NWQ+RtllrUW2yQfodST0cJnkOmdpHA75vONgBUhpKwiJ4H7AmgfoTET8w29pU6AnntaGOe0LJbOmog==", - "license": "MIT", - "dependencies": { - "@sentry/core": "8.55.0" - }, - "engines": { - "node": ">=14.18" - } - }, - "node_modules/@sentry/utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.55.0.tgz", - "integrity": "sha512-cYcl39+xcOivBpN9d8ZKbALl+DxZKo/8H0nueJZ0PO4JA+MJGhSm6oHakXxLPaiMoNLTX7yor8ndnQIuFg+vmQ==", - "license": "MIT", - "dependencies": { - "@sentry/core": "8.55.0" - }, - "engines": { - "node": ">=14.18" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -7585,26 +7225,6 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -8139,72 +7759,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/posthog-react-native": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.57.0.tgz", - "integrity": "sha512-HcTk59aanBZmtC1gE3ermZL8nxA+5ID2SnvPD8jU0LyXM110faxCcm7VIMjvpSdd2hqxCzz5/kUm4b/brmFI7Q==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.43.0", - "@posthog/types": "^1.396.0" - }, - "peerDependencies": { - "@posthog/react-native-plugin": ">= 2.2.0", - "@react-native-async-storage/async-storage": ">=1.0.0", - "@react-navigation/native": ">= 5.0.0", - "expo-application": ">= 4.0.0", - "expo-device": ">= 4.0.0", - "expo-file-system": ">= 13.0.0", - "expo-localization": ">= 11.0.0", - "posthog-react-native-session-replay": ">= 1.6.0", - "react-native-device-info": ">= 10.0.0", - "react-native-localize": ">= 3.0.0", - "react-native-navigation": ">= 6.0.0", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-svg": ">= 15.0.0" - }, - "peerDependenciesMeta": { - "@posthog/react-native-plugin": { - "optional": true - }, - "@react-native-async-storage/async-storage": { - "optional": true - }, - "@react-navigation/native": { - "optional": true - }, - "expo-application": { - "optional": true - }, - "expo-device": { - "optional": true - }, - "expo-file-system": { - "optional": true - }, - "expo-localization": { - "optional": true - }, - "posthog-react-native-session-replay": { - "optional": true - }, - "react-native-device-info": { - "optional": true - }, - "react-native-localize": { - "optional": true - }, - "react-native-navigation": { - "optional": true - }, - "react-native-safe-area-context": { - "optional": true - }, - "react-native-svg": { - "optional": true - } - } - }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -8289,12 +7843,6 @@ "node": ">= 6" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9809,12 +9357,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -10334,16 +9876,6 @@ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -10358,12 +9890,6 @@ "node": ">=10" } }, - "node_modules/whatwg-url/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index b24a7338..09bc0342 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "@gorhom/bottom-sheet": "5.2.8", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.0.14", - "@sentry/react-native": "~6.22.0", "@tanstack/react-query": "^5.62.0", "expo": "^54.0.0", "expo-clipboard": "8.0.8", @@ -35,7 +34,6 @@ "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", "i18next": "^26.3.6", - "posthog-react-native": "^4.57.0", "react": "19.1.0", "react-i18next": "^17.0.10", "react-native": "0.81.5", diff --git a/scripts/noise-gate-report.mjs b/scripts/noise-gate-report.mjs deleted file mode 100644 index 26c69f3a..00000000 --- a/scripts/noise-gate-report.mjs +++ /dev/null @@ -1,398 +0,0 @@ -#!/usr/bin/env node -/** - * Did the Sentry noise gate actually cut the volume? — the honest version. - * - * WHY THIS EXISTS - * --------------- - * `scripts/sentry-volume-report.mjs` answers "how many events/month is the org - * consuming". That number alone CANNOT grade the gate (AGE-105), because the gate - * ships inside an app binary and only runs on devices that installed it. - * - * On 2026-08-14 the gate went to Play production (versionCode 151). Replaying 90d - * of this project's real events through the gate drops 96.9% of them, which - * predicts ~87 events/month — but ONLY once every user is on a gated build. Play - * uptake takes days-to-weeks and never reaches 100%. - * - * So a naive read on 2026-08-17 ("still 2,000/month, the gate failed") is a - * measurement error, not a finding: if a third of the install base has upgraded, - * the honest expectation is ~2/3 of the baseline, and 2,000 is a PASS. The - * reverse mistake is just as easy — a drop caused by fewer users opening the app - * over a weekend, read as gate efficacy. - * - * This script folds Play's version share into the comparison: - * - * expected_post = baseline_rate x (1 - gated_share x efficacy) - * - * and grades the measured post rate against THAT, not against the endpoint. It - * also reports the endpoint (`fullUptakeProjection`) so the answer to "will this - * clear the 3,500/month org gate when uptake completes" is separate from "is it - * on track today". - * - * NOT A SUBSTITUTE FOR GATE LIVENESS. `client_discard/before_send > 0` proves the - * gate is running on real devices and is independent of install share — it shows - * up first. This script surfaces it and refuses to grade a window without it. - * - * USAGE - * SENTRY_AUTH_TOKEN=... GOOGLE_PLAY_SERVICE_ACCOUNT_JSON=... \ - * node scripts/noise-gate-report.mjs \ - * --pre 2026-08-14T07:00:00Z..2026-08-14T14:00:00Z \ - * --post 2026-08-17T00:00:00Z..now - * - * --json machine-readable - * --no-play skip Play (grades at gated_share=0, i.e. "no credit for uptake") - * --project SLUG default opencode-mobile - * - * Neither credential lives on a laptop. Run it through - * `.github/workflows/sentry-noise-gate-report.yml` (workflow_dispatch), which has - * both as repo secrets and writes the table to the run summary. - * - * Exit code is 0 for any *readable* result — an unmet target is a finding to - * report, not a crash. Non-zero only when the data cannot be obtained. - */ - -import { execFileSync } from "node:child_process" -import { readFile } from "node:fs/promises" -import { fileURLToPath } from "node:url" -import { dirname, join } from "node:path" - -import { gateRollout, parseRolloutHistory } from "./sentry-volume-report.mjs" - -/** Rollout instant of the first gated production build, or null if unrecorded. - * Never guessed: an unrecorded rollout must surface as UNGRADED, not as a - * window silently assumed to be post-gate. */ -async function gateRolloutAt() { - try { - const md = await readFile(new URL("../docs/playstore.md", import.meta.url), "utf8") - return gateRollout(parseRolloutHistory(md))?.at ?? null - } catch { - return null - } -} - -const HERE = dirname(fileURLToPath(import.meta.url)) -const MONTH_HOURS = 730 - -/** - * First Play versionCode containing the gate. v0.4.14 = internal 150 / production - * 151 (publish-play-store.yml sets versionCode = github.run_number + 100, so the - * gradle number is meaningless here). Anything >= this has the gate. - */ -export const GATE_FIRST_VERSION_CODE = Number(process.env.GATE_FIRST_VERSION_CODE || 150) - -/** - * Share of this project's real events the gate hard-drops, measured — not guessed - * — by replaying the 90d census (648 events, 11 issues) of `opencode-mobile` - * through the gate's own precedence in `src/lib/sentry-noise.test.ts`. 96.9%. - * Deliberately conservative: it counts only the transport drop-list, ignoring the - * dedup/rate-cap layers, which can only remove more. - */ -export const GATE_EFFICACY = Number(process.env.GATE_EFFICACY || 0.969) - -/** Org-wide monthly budget from AGE-71, and this project's own target from AGE-105. */ -export const ORG_MONTHLY_GATE = 3500 -export const PROJECT_MONTHLY_TARGET = 1500 - -/** - * Tolerance on the expectation. The inputs are two independent samples (Sentry - * hours, Play daily vitals) of a diurnal process, so demanding equality would - * make every read a failure. 25% above expectation is the line. - */ -export const TOLERANCE = Number(process.env.NOISE_GATE_TOLERANCE || 0.25) - -/** - * The whole point of the script, isolated and pure so it can be tested without a - * network: what SHOULD the post-deploy rate be, given how much of the install - * base is actually running the gate? - * - * @param {object} p - * @param {number} p.baselinePerHour submitted events/hour before the rollout - * @param {number} p.actualPerHour submitted events/hour in the measured window - * @param {number} p.gatedShare 0..1 share of active users on a gated build - * @param {number} [p.efficacy] share of events the gate drops on a gated device - * @param {boolean} [p.gateLive] did client_discard/before_send appear at all - */ -export function grade({ - baselinePerHour, - actualPerHour, - gatedShare, - efficacy = GATE_EFFICACY, - gateLive = true, - postPhase = "post", -}) { - const share = Math.min(Math.max(gatedShare ?? 0, 0), 1) - const expectedPerHour = baselinePerHour * (1 - share * efficacy) - const ceilingPerHour = expectedPerHour * (1 + TOLERANCE) - const fullUptakePerHour = baselinePerHour * (1 - efficacy) - const observedReduction = baselinePerHour > 0 ? 1 - actualPerHour / baselinePerHour : 0 - // What the gate must be doing on gated devices for the observed number to hold. - // Inverts the model instead of trusting the constant: reduction = share x eff. - const impliedEfficacy = share > 0 ? Math.min(Math.max(observedReduction / share, 0), 1) : null - - let verdict - let because - if (postPhase && postPhase !== "post") { - // A window that starts before the gate reached production contains devices - // that could not possibly have run it, so it dilutes the rate toward - // baseline and grades the gate as worse than it is. - verdict = "UNGRADED" - because = - `the post window is [${postPhase}] — it does not lie entirely after the gate's production rollout, ` + - "so it mixes gated and ungated devices. Re-run with a post window starting at the rollout instant." - } else if (!gateLive) { - verdict = "UNGRADED" - because = - "client_discard/before_send is 0 in this window — no device ran the gate, so nothing here measures it. " + - "Check the release actually reached users before reading the rate." - } else if (share === 0) { - verdict = "UNGRADED" - because = "Play reports 0% of active users on a gated build; the rate cannot be attributed to the gate yet." - } else if (actualPerHour <= ceilingPerHour) { - verdict = "ON_TRACK" - because = - `measured ${actualPerHour.toFixed(2)}/h is at or below the ${ceilingPerHour.toFixed(2)}/h ceiling implied by ` + - `${(share * 100).toFixed(1)}% uptake (expected ${expectedPerHour.toFixed(2)}/h +${(TOLERANCE * 100).toFixed(0)}%).` - } else { - verdict = "OFF_TRACK" - because = - `measured ${actualPerHour.toFixed(2)}/h exceeds the ${ceilingPerHour.toFixed(2)}/h ceiling for ` + - `${(share * 100).toFixed(1)}% uptake. Either the gate is not dropping what the replay said it would, or a new ` + - `noise class appeared. Name it — do not widen the drop-list blindly.` - } - - return { - verdict, - because, - postPhase, - gatedShare: share, - efficacy, - baselinePerMonth: baselinePerHour * MONTH_HOURS, - actualPerMonth: actualPerHour * MONTH_HOURS, - expectedPerMonth: expectedPerHour * MONTH_HOURS, - ceilingPerMonth: ceilingPerHour * MONTH_HOURS, - fullUptakeProjection: fullUptakePerHour * MONTH_HOURS, - observedReduction, - impliedEfficacy, - meetsProjectTargetAtFullUptake: fullUptakePerHour * MONTH_HOURS < PROJECT_MONTHLY_TARGET, - } -} - -/** - * Same question one level up: does the ORG clear its 3,500/month gate? - * - * `orgPerHour`/`projectPerHour` must come from the SAME window, and preferably the - * longest one available: the other projects here emit ~0.7 events/hour, so a 1-2h - * window routinely contains zero of them and would flatter the org total to "this - * project only". - */ -export function orgOutlook({ orgPerHour, projectPerHour, projectFullUptakePerMonth }) { - const otherPerMonth = Math.max(orgPerHour - projectPerHour, 0) * MONTH_HOURS - const projectedOrgPerMonth = otherPerMonth + projectFullUptakePerMonth - return { - orgPerMonthNow: orgPerHour * MONTH_HOURS, - otherProjectsPerMonth: otherPerMonth, - projectedOrgPerMonthAtFullUptake: projectedOrgPerMonth, - clearsOrgGate: projectedOrgPerMonth < ORG_MONTHLY_GATE, - headroom: ORG_MONTHLY_GATE - projectedOrgPerMonth, - } -} - -/** Share of active users on a build that contains the gate. */ -export function gatedShareFromPlay(playJson, firstGatedVersionCode = GATE_FIRST_VERSION_CODE) { - const versions = playJson?.versions ?? [] - const total = versions.reduce((s, v) => s + (v.users || 0), 0) - const gated = versions - .filter((v) => Number(v.versionCode) >= firstGatedVersionCode) - .reduce((s, v) => s + (v.users || 0), 0) - return { - gated, - total, - share: total > 0 ? gated / total : 0, - window: playJson?.window ?? null, - } -} - -function runJson(script, args) { - const out = execFileSync(process.execPath, [join(HERE, script), ...args], { - encoding: "utf8", - maxBuffer: 32 * 1024 * 1024, - stdio: ["ignore", "pipe", "pipe"], - }) - return JSON.parse(out) -} - -function parseArgs(argv) { - const out = { project: "opencode-mobile", play: true } - for (let i = 0; i < argv.length; i++) { - const a = argv[i] - if (a === "--pre") out.pre = argv[++i] - else if (a === "--post") out.post = argv[++i] - else if (a === "--project") out.project = argv[++i] - else if (a === "--json") out.json = true - else if (a === "--no-play") out.play = false - } - return out -} - -/** Documented pre-rollout baseline window (docs/analytics.md): starts after the - * AGE-55 box-bot fix landed (2026-08-14 06:19Z) and ends before the gate - * rollout (14:22Z). It is deliberately NOT "7 days before the rollout" — that - * spans the box-bot regime and would import ~22k/mo of dead volume into the - * org background estimate, making the outlook miss the gate by a mile for a - * reason that was already fixed. */ -export const BASELINE_WINDOW = "2026-08-14T07:00:00Z..2026-08-14T14:00:00Z" - -/** Fill in windows the caller did not pin, anchored on the rollout instant. - * - * The old default (`post = now-7d..now`) straddles the 2026-08-14 14:22Z - * rollout on every run before 08-21, so the grader's own default window mixed - * gated and ungated devices and diluted the measured rate toward baseline — - * i.e. it was biased toward reporting the gate as ineffective. The window may - * never start before the gate reached production. - */ -export function resolveWindows(args, rolloutAt, now = new Date()) { - const out = { ...args } - if (!out.pre) out.pre = BASELINE_WINDOW - if (!out.post) { - if (!rolloutAt) throw new Error("--post START..END is required (no rollout instant in docs/playstore.md)") - const weekAgo = new Date(now.getTime() - 7 * 86_400_000) - const start = weekAgo > rolloutAt ? weekAgo : rolloutAt - out.post = `${start.toISOString()}..now` - } - return out -} - -function pick(report, windowName, project) { - const w = report.windows.find((x) => x.name === windowName) - if (!w) throw new Error(`sentry-volume-report returned no window named ${windowName}`) - const p = w.projects.find((x) => x.project === project) - return { - hours: w.hours, - phase: w.phase ?? null, - orgPerHour: w.orgSubmitted / w.hours, - perHour: p ? p.submittedPerHour : 0, - submitted: p ? p.submitted : 0, - gateDropped: p ? p.gateDropped : 0, - backoffDropped: p ? p.backoffDropped : 0, - } -} - -const money = (n) => Math.round(n).toLocaleString("en-US") - -async function main() { - const args = resolveWindows(parseArgs(process.argv.slice(2)), await gateRolloutAt()) - - const volume = runJson("sentry-volume-report.mjs", [ - "--json", - "--window", - `pre=${args.pre}`, - "--window", - `post=${args.post}`, - ]) - const pre = pick(volume, "pre", args.project) - const post = pick(volume, "post", args.project) - - let play = null - let shareInfo = { gated: 0, total: 0, share: 0, window: null } - if (args.play) { - play = runJson("play-version-share.mjs", ["--json"]) - shareInfo = gatedShareFromPlay(play) - } - - const g = grade({ - baselinePerHour: pre.perHour, - actualPerHour: post.perHour, - gatedShare: shareInfo.share, - gateLive: post.gateDropped > 0, - postPhase: post.phase, - }) - // Other projects are a background rate, not something this gate changes, so - // estimate them from whichever window is long enough to contain any of them. - const bg = post.hours >= pre.hours ? post : pre - const org = orgOutlook({ - orgPerHour: bg.orgPerHour, - projectPerHour: bg.perHour, - projectFullUptakePerMonth: g.fullUptakeProjection, - }) - org.backgroundFrom = bg === post ? "post" : "pre" - org.backgroundHours = bg.hours - - const result = { - project: args.project, - generatedAt: new Date().toISOString(), - windows: { - pre: { ...pre, spec: args.pre }, - post: { ...post, spec: args.post }, - }, - uptake: shareInfo, - grade: g, - org, - } - - if (args.json) { - console.log(JSON.stringify(result, null, 2)) - return - } - - const lines = [] - lines.push(`## Noise-gate report — ${args.project}`) - lines.push("") - lines.push(`**${g.verdict}** — ${g.because}`) - lines.push("") - lines.push("| | events/month |") - lines.push("| --- | ---: |") - lines.push(`| baseline (pre, ${pre.hours}h) | ${money(g.baselinePerMonth)} |`) - lines.push(`| measured (post, ${post.hours}h) | ${money(g.actualPerMonth)} |`) - lines.push(`| expected at ${(g.gatedShare * 100).toFixed(1)}% uptake | ${money(g.expectedPerMonth)} |`) - lines.push(`| ceiling (+${(TOLERANCE * 100).toFixed(0)}%) | ${money(g.ceilingPerMonth)} |`) - lines.push(`| projection at 100% uptake | ${money(g.fullUptakeProjection)} |`) - lines.push(`| project target (AGE-105) | ${money(PROJECT_MONTHLY_TARGET)} |`) - lines.push("") - lines.push( - `Uptake: **${shareInfo.gated} of ${shareInfo.total}** active users on versionCode >= ${GATE_FIRST_VERSION_CODE}` + - ` = **${(shareInfo.share * 100).toFixed(1)}%**` + - (shareInfo.window ? ` (Play vitals ${shareInfo.window.start} → ${shareInfo.window.end})` : " (Play skipped)"), - ) - lines.push( - `Gate liveness: client_discard/before_send = **${post.gateDropped}** in the post window` + - ` (ratelimit_backoff ${post.backoffDropped} — that one is the org over quota, not us).`, - ) - if (g.impliedEfficacy !== null) { - lines.push( - `Implied on-device efficacy: **${(g.impliedEfficacy * 100).toFixed(1)}%** ` + - `(the replay predicted ${(g.efficacy * 100).toFixed(1)}%).`, - ) - } - lines.push("") - lines.push( - `Org outlook: other projects ${money(org.otherProjectsPerMonth)}/mo (from the ${org.backgroundFrom} window, ` + - `${org.backgroundHours}h) + this project at full uptake ${money(result.grade.fullUptakeProjection)}/mo = ` + - `**${money(org.projectedOrgPerMonthAtFullUptake)}/mo** vs the ${money(ORG_MONTHLY_GATE)}/mo org gate → ` + - `**${org.clearsOrgGate ? "clears" : "MISSES"}** (headroom ${money(org.headroom)}).`, - ) - lines.push("") - if (post.hours < 24) { - lines.push( - `⚠ The measured window is ${post.hours}h. Under ~24h a window can rank sources but cannot certify a monthly ` + - "rate — diurnal load is real. Treat the numbers above as directional.", - ) - lines.push("") - } - lines.push( - "Read `submitted` (accepted + rate_limited), never `accepted`: while the org is over quota, `accepted` is ~0 for " + - "every project and a broken org is indistinguishable from a fixed one.", - ) - - const text = lines.join("\n") - console.log(text) - if (process.env.GITHUB_STEP_SUMMARY) { - const { appendFileSync } = await import("node:fs") - appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`) - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((err) => { - console.error(`noise-gate-report: ${err.message}`) - process.exit(1) - }) -} diff --git a/scripts/noise-gate-report.test.mjs b/scripts/noise-gate-report.test.mjs deleted file mode 100644 index 9f949623..00000000 --- a/scripts/noise-gate-report.test.mjs +++ /dev/null @@ -1,189 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { - GATE_EFFICACY, - ORG_MONTHLY_GATE, - gatedShareFromPlay, - BASELINE_WINDOW, - resolveWindows, - grade, - orgOutlook, -} from "./noise-gate-report.mjs" - -const MONTH_HOURS = 730 -const BASELINE = 4.71 // measured pre-rollout rate for opencode-mobile, 2026-08-14 07:00-14:00Z - -test("partial uptake is not gate failure — the mistake this script exists to prevent", () => { - // A third of users upgraded. A naive read ("still ~2,200/mo, target is 1,500") - // would call this a failure; with 33% uptake it is exactly on model. - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: BASELINE * (1 - 0.33 * GATE_EFFICACY), - gatedShare: 0.33, - }) - assert.equal(g.verdict, "ON_TRACK") - assert.ok(g.actualPerMonth > 2000, "the raw number is still far above the 1,500/mo target") - assert.ok(Math.abs(g.impliedEfficacy - GATE_EFFICACY) < 0.01) -}) - -test("full uptake at the replayed efficacy lands under the project target", () => { - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: BASELINE * (1 - GATE_EFFICACY), - gatedShare: 1, - }) - assert.equal(g.verdict, "ON_TRACK") - assert.ok(g.meetsProjectTargetAtFullUptake) - assert.ok(g.fullUptakeProjection < 150, `expected ~107/mo, got ${g.fullUptakeProjection}`) -}) - -test("a real regression still fails even when uptake is low enough to excuse a lot", () => { - // 10% uptake excuses almost nothing; volume that did not move at all is fine, - // but volume that GREW is a finding. - const flat = grade({ - baselinePerHour: BASELINE, - actualPerHour: BASELINE, - gatedShare: 0.1, - }) - assert.equal(flat.verdict, "ON_TRACK", "flat volume at 10% uptake is within tolerance") - const worse = grade({ - baselinePerHour: BASELINE, - actualPerHour: BASELINE * 1.6, - gatedShare: 0.1, - }) - assert.equal(worse.verdict, "OFF_TRACK") - assert.match(worse.because, /new\s+noise class|not dropping/) -}) - -test("a gate that does nothing on device is caught once uptake is high", () => { - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: BASELINE, - gatedShare: 0.9, - }) - assert.equal(g.verdict, "OFF_TRACK") - assert.equal(g.impliedEfficacy, 0, "0% of the drop happened, so implied efficacy is 0") -}) - -test("no before_send discards means the window measures nothing — refuse to grade it", () => { - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: 0.01, - gatedShare: 0.9, - gateLive: false, - }) - assert.equal(g.verdict, "UNGRADED") - assert.match(g.because, /before_send/) -}) - -test("zero uptake is ungraded, not a pass — a quiet weekend is not efficacy", () => { - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: 0.2, - gatedShare: 0, - }) - assert.equal(g.verdict, "UNGRADED") - assert.equal(g.impliedEfficacy, null) -}) - -test("uptake share counts only builds that contain the gate", () => { - const play = { - versions: [ - { versionCode: 151, users: 30 }, - { versionCode: 150, users: 10 }, - { versionCode: 149, users: 50 }, - { versionCode: 146, users: 10 }, - ], - window: { start: "2026-08-10", end: "2026-08-16" }, - } - const s = gatedShareFromPlay(play) - assert.equal(s.gated, 40) - assert.equal(s.total, 100) - assert.equal(s.share, 0.4) -}) - -test("no Play rows at all reads as 0% uptake, never as a divide-by-zero pass", () => { - const s = gatedShareFromPlay({ versions: [] }) - assert.equal(s.share, 0) - assert.equal(grade({ baselinePerHour: BASELINE, actualPerHour: 0, gatedShare: s.share }).verdict, "UNGRADED") -}) - -test("org outlook subtracts this project before projecting it forward", () => { - // org 5.43/h of which mobile is 4.71/h -> other projects 0.72/h ~= 526/mo - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: BASELINE, - gatedShare: 0.5, - }) - const o = orgOutlook({ - orgPerHour: 5.43, - projectPerHour: BASELINE, - projectFullUptakePerMonth: g.fullUptakeProjection, - }) - assert.ok(Math.abs(o.otherProjectsPerMonth - 0.72 * MONTH_HOURS) < 1) - assert.ok(o.clearsOrgGate) - assert.ok(o.projectedOrgPerMonthAtFullUptake < ORG_MONTHLY_GATE) -}) - -test("org gate can still be missed by other projects even with a perfect mobile gate", () => { - const o = orgOutlook({ - orgPerHour: 6, - projectPerHour: 0.5, - projectFullUptakePerMonth: 100, - }) - assert.equal(o.clearsOrgGate, false) - assert.ok(o.headroom < 0) -}) - -test("a post window that straddles the rollout is UNGRADED, not a failing grade", () => { - // The old default post window (now-7d..now) started before the 08-14 14:22Z - // rollout on every run until 08-21, mixing devices that could not have run - // the gate into the "after" rate — which biases the verdict toward failure. - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: BASELINE * 0.9, - gatedShare: 0.4, - postPhase: "mixed", - }) - assert.equal(g.verdict, "UNGRADED") - assert.match(g.because, /mixes gated and ungated/) -}) - -test("an unrecorded rollout is UNGRADED — never assumed to be post-gate", () => { - const g = grade({ - baselinePerHour: BASELINE, - actualPerHour: 0.1, - gatedShare: 0.9, - postPhase: "unknown", - }) - assert.equal(g.verdict, "UNGRADED", "a great-looking number from an unattributable window is not a pass") -}) - -test("resolveWindows never starts the post window before the gate reached production", () => { - const rollout = new Date("2026-08-14T14:22:00Z") - // Run three days in: a naive 7d lookback would reach back to 08-10, four days - // before any device could have had the gate. - const w = resolveWindows({}, rollout, new Date("2026-08-17T00:00:00Z")) - assert.equal(w.post, `${rollout.toISOString()}..now`) - - // Well after rollout, a 7d lookback is entirely post-gate and is preferred: - // a longer window is what makes the other projects' background rate visible. - const later = resolveWindows({}, rollout, new Date("2026-09-01T00:00:00Z")) - assert.equal(later.post, "2026-08-25T00:00:00.000Z..now") -}) - -test("no rollout on record and no explicit post window is an error, not a default", () => { - assert.throws(() => resolveWindows({}, null), /--post/) -}) - -test("the default baseline excludes the box-bot era, which would swamp the org outlook", () => { - // A "7 days before the rollout" baseline spans the AGE-55 box-bot fix - // (2026-08-14 06:19Z) and drags ~22k/mo of already-fixed volume into the - // background estimate, so the org outlook MISSES for a dead reason. - const w = resolveWindows({}, new Date("2026-08-14T14:22:00Z"), new Date("2026-08-17T00:00:00Z")) - assert.equal(w.pre, BASELINE_WINDOW) - const [start, end] = BASELINE_WINDOW.split("..").map((s) => new Date(s)) - assert.ok(start >= new Date("2026-08-14T06:19:00Z"), "baseline must start after the box-bot fix") - assert.ok(end <= new Date("2026-08-14T14:22:00Z"), "baseline must end before the gate rollout") -}) diff --git a/scripts/run-e2e-flows.sh b/scripts/run-e2e-flows.sh index 6f4a40e4..9815b4d5 100755 --- a/scripts/run-e2e-flows.sh +++ b/scripts/run-e2e-flows.sh @@ -11,7 +11,7 @@ set -uo pipefail ROOT="$(pwd)" # capture BEFORE any cd, so diag paths are absolute APK="android/app/build/outputs/apk/release/app-release.apk" -# CORE = the activation coverage issue #90 verified green (consent -> connect +# CORE = the activation coverage issue #90 verified green (connect # -> send, and the connect-time-401 visible-error case). A failure here fails # the job — this is the suite's actual regression gate. # diff --git a/src/components/TelemetryConsentModal.tsx b/src/components/TelemetryConsentModal.tsx deleted file mode 100644 index 8267ad01..00000000 --- a/src/components/TelemetryConsentModal.tsx +++ /dev/null @@ -1,254 +0,0 @@ -/** - * First-launch consent modal for crash reporting AND activation analytics. - * - * A single "Allow" decision gates both Sentry (crash reports) and PostHog - * (anonymous usage analytics) — see src/lib/telemetry.ts. There is no - * separate toggle for analytics, so this modal must disclose both. - * - * Shows once when the user hasn't yet made a consent decision. - * Matches the app's existing Settings screen visual conventions. - */ - -import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Modal, Linking } from "react-native" -import { Ionicons } from "@expo/vector-icons" -import { useTranslation } from "react-i18next" -import { PRIVACY_POLICY_URL } from "../lib/links" - -interface Props { - visible: boolean - onAllow: () => void - onDecline: () => void -} - -export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { - const colorScheme = useColorScheme() - const isDark = colorScheme === "dark" - const { t } = useTranslation() - - return ( - - - - {/* Icon */} - - - - - {/* Title */} - {t("telemetryConsent.title")} - - {/* Body */} - {t("telemetryConsent.body")} - - {/* Detail bullets */} - - - - - - - - - {/* Privacy policy link */} - Linking.openURL(PRIVACY_POLICY_URL)}> - {t("telemetryConsent.privacyLink")} - - - {/* Actions */} - - - - {t("telemetryConsent.declineButton")} - - - - {t("telemetryConsent.allowButton")} - - - - {t("telemetryConsent.footnote")} - - - - ) -} - -function BulletRow({ - icon, - text, - isDark, - positive, -}: { - icon: "checkmark-circle" | "close-circle" - text: string - isDark: boolean - positive: boolean -}) { - return ( - - - {text} - - ) -} - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.6)", - justifyContent: "center", - alignItems: "center", - padding: 24, - }, - card: { - backgroundColor: "#ffffff", - borderRadius: 20, - padding: 28, - width: "100%", - maxWidth: 440, - shadowColor: "#000", - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.24, - shadowRadius: 16, - elevation: 10, - }, - cardDark: { - backgroundColor: "#1a1a1a", - }, - iconWrap: { - width: 72, - height: 72, - borderRadius: 18, - backgroundColor: "#eff6ff", - justifyContent: "center", - alignItems: "center", - alignSelf: "center", - marginBottom: 16, - }, - iconWrapDark: { - backgroundColor: "#1e293b", - }, - title: { - fontSize: 22, - fontWeight: "700", - color: "#0a0a0a", - textAlign: "center", - marginBottom: 12, - }, - textDark: { - color: "#f8fafc", - }, - body: { - fontSize: 15, - color: "#374151", - textAlign: "center", - lineHeight: 22, - marginBottom: 20, - }, - bodyDark: { - color: "#94a3b8", - }, - bullets: { - marginBottom: 16, - }, - bulletRow: { - flexDirection: "row", - alignItems: "flex-start", - marginBottom: 8, - }, - bulletIcon: { - marginTop: 2, - marginRight: 10, - flexShrink: 0, - }, - bulletText: { - fontSize: 14, - color: "#374151", - flex: 1, - lineHeight: 20, - }, - privacyLink: { - color: "#3b82f6", - fontSize: 14, - textAlign: "center", - marginBottom: 24, - textDecorationLine: "underline", - }, - actions: { - flexDirection: "row", - gap: 12, - marginBottom: 16, - }, - btn: { - flex: 1, - paddingVertical: 14, - borderRadius: 12, - alignItems: "center", - justifyContent: "center", - }, - btnDecline: { - backgroundColor: "#f1f5f9", - }, - btnDeclineDark: { - backgroundColor: "#2a2a2a", - }, - btnDeclineText: { - color: "#374151", - fontWeight: "600", - fontSize: 16, - }, - btnDeclineTextDark: { - color: "#94a3b8", - }, - btnAllow: { - backgroundColor: "#3b82f6", - }, - btnAllowText: { - color: "#ffffff", - fontWeight: "700", - fontSize: 16, - }, - footnote: { - fontSize: 12, - color: "#9ca3af", - textAlign: "center", - }, - footnoteDark: { - color: "#64748b", - }, -}) diff --git a/src/components/chat/DiffView.tsx b/src/components/chat/DiffView.tsx index 00c66c09..c24a3382 100644 --- a/src/components/chat/DiffView.tsx +++ b/src/components/chat/DiffView.tsx @@ -1,6 +1,6 @@ import { View, Text, StyleSheet, Platform, ScrollView } from "react-native" import { WIDE_CONTENT_SCROLL_CONFIG } from "../../lib/scroll-config" -import { computeDiff } from "./diff-compute" +import { computeDiff, type DiffLine } from "./diff-compute" const mono = Platform.OS === "ios" ? "Menlo" : "monospace" @@ -20,29 +20,7 @@ export function DiffView({ before, after, isDark }: Props) { {lines.map((line, idx) => ( - - - {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "} - - - {line.text} - - + ))} @@ -50,6 +28,39 @@ export function DiffView({ before, after, isDark }: Props) { ) } +// One diff line. `wrap` breaks long lines instead of relying on a horizontal +// scroll (the fullscreen Changes view). +export function DiffRow({ line, isDark, wrap }: { line: DiffLine; isDark: boolean; wrap?: boolean }) { + const hunk = line.type === "context" && line.text.startsWith("@@") + return ( + + + {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "} + + + {line.text} + + + ) +} + const s = StyleSheet.create({ container: { borderRadius: 6, @@ -87,4 +98,8 @@ const s = StyleSheet.create({ textDark: { color: "#e5e5e5" }, addText: { color: "#16a34a" }, removeText: { color: "#dc2626" }, + hunk: { backgroundColor: "#eef2ff" }, + hunkDark: { backgroundColor: "#1e1b4b" }, + hunkText: { color: "#6366f1" }, + wrap: { flex: 1 }, }) diff --git a/src/components/chat/QuestionPrompt.tsx b/src/components/chat/QuestionPrompt.tsx index d6aaeea4..dd499caf 100644 --- a/src/components/chat/QuestionPrompt.tsx +++ b/src/components/chat/QuestionPrompt.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from "react" -import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native" +import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" +import { QUESTION_BODY_SCROLL_PROPS } from "../../lib/session-layout" interface QuestionOption { label: string @@ -86,9 +87,8 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { {q.header || t("chat.questionPrompt.headerFallback")} - {q.question} - - + + {q.question} {q.options.map((opt) => { const selected = (answers[current] || []).includes(opt.label) return ( @@ -129,7 +129,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { {t("chat.questionPrompt.customAnswerLabel")} ))} - + @@ -158,6 +158,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { const s = StyleSheet.create({ card: { + flexShrink: 1, margin: 12, padding: 16, backgroundColor: "#f5f3ff", @@ -169,10 +170,11 @@ const s = StyleSheet.create({ header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, title: { fontSize: 15, fontWeight: "600", color: "#6d28d9" }, textWhite: { color: "#ffffff" }, - question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a", marginBottom: 12 }, + body: { flexShrink: 1 }, + question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a" }, metaDark: { color: "#666666" }, - options: { gap: 8 }, + options: { gap: 8, paddingBottom: 1 }, option: { padding: 12, borderRadius: 8, diff --git a/src/components/chat/SessionInfo.tsx b/src/components/chat/SessionInfo.tsx index bb80b53b..78735e5e 100644 --- a/src/components/chat/SessionInfo.tsx +++ b/src/components/chat/SessionInfo.tsx @@ -4,6 +4,7 @@ import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" import type { Message, Session } from "../../lib/sdk" import type { Provider } from "../../stores/catalog" +import type { ChangeTotals } from "../../lib/changes" interface Props { session: Session | null @@ -16,6 +17,10 @@ interface Props { onLoadAll: () => void onScrollToTop: () => void onClose: () => void + // Branch/uncommitted totals from the vcs diff (session.summary is empty + // since opencode 1.15). + changes?: ChangeTotals | null + onChangesPress?: () => void } function compact(n: number): string { @@ -51,6 +56,8 @@ export function SessionInfo({ onLoadAll, onScrollToTop, onClose, + changes, + onChangesPress, }: Props) { const { t } = useTranslation() // Match TUI: last assistant message tokens (context window), cumulative cost @@ -89,7 +96,6 @@ export function SessionInfo({ const hasTokens = stats.total > 0 const hasCost = stats.cost > 0 - const summary = session?.summary const created = session?.time.created const updated = session?.time.updated @@ -164,13 +170,15 @@ export function SessionInfo({ value={String(messages.length) + (hasMore ? "+" : "")} isDark={isDark} /> - {summary && summary.files > 0 && ( - + {changes && changes.files > 0 && ( + + + )} {session?.share?.url && ( diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index 4df6472f..c04c0d74 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -1,6 +1,6 @@ export { MessageBubble } from "./MessageBubble" export { ToolCallCard } from "./ToolCallCard" -export { DiffView } from "./DiffView" +export { DiffView, DiffRow } from "./DiffView" export { ReasoningBlock } from "./ReasoningBlock" export { PermissionPrompt } from "./PermissionPrompt" export { QuestionPrompt } from "./QuestionPrompt" diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 47523941..89b9d62d 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,71 +1,8 @@ -// Centralised PostHog wrapper for activation-funnel analytics. -// -// Mirrors sentry.ts's shape and guarantees: -// 1. Strict no-op when no API key is configured (dev/CI builds need no secrets). -// 2. Strict no-op when the user has not granted telemetry consent — this -// module never calls PostHog.init/capture on its own; it is only ever -// driven by ./telemetry.ts, which gates BOTH Sentry and analytics behind -// the exact same "opencode_telemetry_consent" flag. -// 3. On consent REVOCATION, buffered-but-unsent events are DROPPED, not -// flushed: PostHog's shutdown() normally drains the queue over the -// network, and optOut() only blocks NEW captures (already-queued events -// would still be sent by the next flush). So ConsentGatedPostHog -// overrides the client's public fetch() transport; once revoked it -// answers every SDK request with a synthetic 200 without touching the -// network. shutdown() then "drains" the queue into that stub — clearing -// the persisted queue and stopping timers — while zero bytes leave the -// device. -// 4. No PII in event properties: never pass server URLs, tokens, prompts, -// or file contents. Only coarse, enumerated event names + small typed -// properties (booleans, enums, counts). -// -// Chosen SDK: PostHog (posthog-react-native), self-instantiated (no -// PostHogProvider / autocapture) so the app controls exactly what is sent — -// same "explicit event, no magic" posture as sentry.ts. - -import PostHog from "posthog-react-native" -import * as SecureStore from "expo-secure-store" -import { log } from "./logbuffer" +// Analytics stub. This fork sends no telemetry: upstream's PostHog wrapper is +// replaced by a no-op `track` so call sites stay unchanged. export { classifyConnectionError, type ConnectionErrorClass } from "./analytics-classify" -const API_KEY = process.env.EXPO_PUBLIC_POSTHOG_KEY -// EU by default (GDPR-friendly region for opencode's mostly-EU/self-hosted user base). -// Override with EXPO_PUBLIC_POSTHOG_HOST for a self-hosted instance. -const HOST = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com" - -const FIRST_OPEN_KEY = "opencode_analytics_first_open_done" - -// When true (set on consent revocation), the transport answers with a -// synthetic 200 instead of hitting the network, so queued events are -// discarded rather than uploaded. Reset when a new client is created. -let dropNetwork = false - -/** PostHog client whose transport is consent-gated: after revocation every - * request short-circuits to a fake success so nothing reaches the network. - * (Types derived from the base class to avoid importing the transitive - * @posthog/core package directly.) */ -class ConsentGatedPostHog extends PostHog { - fetch(url: string, options: Parameters[1]): ReturnType { - if (dropNetwork) { - return Promise.resolve({ - status: 200, - text: async () => "", - json: async () => ({ status: 1 }), - }) - } - return super.fetch(url, options) - } -} - -let client: ConsentGatedPostHog | null = null -let enabled = false -// app_opened must fire at most once per JS session, whichever path enables -// analytics first (cold start with prior consent, or the consent modal / -// Settings toggle mid-session). Also prevents a revoke -> re-grant in the -// same session from double-counting. -let appOpenedTracked = false - /** Activation-funnel events. Keep this list in 1:1 sync with the funnel steps * tracked in the product analytics dashboard. */ export enum AnalyticsEvent { @@ -105,84 +42,6 @@ export enum AnalyticsEvent { * post-activation noise. */ export type ConnectionTestSource = "onboarding" | "edit_test" | "sse" -export function initAnalytics() { - if (enabled) return - if (!API_KEY) { - log.info("analytics", "no API key configured — analytics disabled") - return - } - try { - dropNetwork = false - client = new ConsentGatedPostHog(API_KEY, { - host: HOST, - // We call track() explicitly at each funnel step — no implicit capture. - captureAppLifecycleEvents: false, - }) - // A previous revoke persisted the SDK-level opt-out flag; clear it so the - // re-granted client can enqueue again. No-op on a fresh install. - void client.optIn() - enabled = true - log.info("analytics", "initialized", `host=${HOST}`) - } catch (e) { - log.warn("analytics", "init failed", String(e)) - } -} - -/** Consent revoked: block new captures, DROP anything buffered (see header - * note 3 — the gated fetch turns shutdown's drain into a no-network discard), - * and tear the client down. */ -export async function shutdownAnalytics() { - if (!enabled || !client) return - enabled = false - const c = client - client = null - dropNetwork = true - try { - // Persist SDK-level opt-out first so even a re-created client (without - // consent) could not capture, then let shutdown clear queue + timers. - await c.optOut() - await c.shutdown() - } catch (e) { - log.warn("analytics", "shutdown failed", String(e)) - } - log.info("analytics", "disabled by user — buffered events dropped") -} - -export function analyticsEnabled(): boolean { - return enabled -} - -/** Flat, JSON-safe event properties — keep it to primitives so nothing - * accidentally nests an object that could carry a URL/token. */ export type AnalyticsProps = Record -/** No-op unless consent has been granted (initAnalytics() was called) and a - * key is configured. Never throws. */ -export function track(event: AnalyticsEvent, props?: AnalyticsProps) { - if (!enabled || !client) return - try { - client.capture(event, props) - } catch (e) { - log.warn("analytics", "capture failed", String(e)) - } -} - -/** Fire AppOpened with `is_first_open`, at most once per JS session. - * Called both from app start (consent already granted) and from the - * consent-grant transition (modal "Allow" / Settings toggle) — the session - * guard makes whichever happens first win. The "seen before" flag is only - * ever read/written once consent is granted (this function is itself a - * no-op without consent), so nothing is recorded locally pre-consent. */ -export async function trackAppOpened() { - if (!enabled || appOpenedTracked) return - appOpenedTracked = true - let isFirstOpen = false - try { - const seen = await SecureStore.getItemAsync(FIRST_OPEN_KEY) - isFirstOpen = !seen - if (isFirstOpen) await SecureStore.setItemAsync(FIRST_OPEN_KEY, "1") - } catch { - // SecureStore unavailable — still fire the event, just without the flag. - } - track(AnalyticsEvent.AppOpened, { is_first_open: isFirstOpen }) -} +export function track(_event: AnalyticsEvent, _props?: AnalyticsProps) {} diff --git a/src/lib/changes.test.ts b/src/lib/changes.test.ts new file mode 100644 index 00000000..ce0f73ae --- /dev/null +++ b/src/lib/changes.test.ts @@ -0,0 +1,91 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { branchOffered, headline, lastUserMessageID, parsePatch, sortDiffs, splitPath, totals } from "./changes.ts" + +const diff = (file: string, additions: number, deletions: number) => ({ file, additions, deletions }) + +test("totals sums files and line counts", () => { + assert.deepEqual(totals([diff("a", 3, 1), diff("b", 2, 0)]), { files: 2, additions: 5, deletions: 1 }) + assert.deepEqual(totals(undefined), { files: 0, additions: 0, deletions: 0 }) +}) + +test("branch is offered only off the default branch", () => { + assert.equal(branchOffered({ branch: "feat", default_branch: "main" }), true) + assert.equal(branchOffered({ branch: "main", default_branch: "main" }), false) + assert.equal(branchOffered({ branch: "feat" }), false) + assert.equal(branchOffered(null), false) +}) + +test("headline counts the branch off the default branch, else uncommitted", () => { + const branch = [diff("a", 1, 1)] + const git = [diff("b", 9, 9), diff("c", 1, 0)] + assert.deepEqual(headline({ branch: "feat", default_branch: "main" }, branch, git), { files: 1, additions: 1, deletions: 1 }) + assert.deepEqual(headline({ branch: "main", default_branch: "main" }, branch, git), { files: 2, additions: 10, deletions: 9 }) + assert.deepEqual(headline(undefined, undefined, undefined), { files: 0, additions: 0, deletions: 0 }) +}) + +test("sortDiffs orders by path without mutating", () => { + const input = [diff("src/b.ts", 0, 0), diff("README.md", 0, 0), diff("src/a.ts", 0, 0)] + assert.deepEqual(sortDiffs(input).map((d) => d.file), ["README.md", "src/a.ts", "src/b.ts"]) + assert.equal(input[0].file, "src/b.ts") +}) + +test("lastUserMessageID skips assistant and optimistic messages", () => { + const messages = [ + { id: "msg_1", role: "user" }, + { id: "msg_2", role: "assistant" }, + { id: "msg_3", role: "user" }, + { id: "msg_4", role: "assistant" }, + { id: "temp-5", role: "user" }, + ] + assert.equal(lastUserMessageID(messages), "msg_3") + assert.equal(lastUserMessageID([{ id: "msg_1", role: "assistant" }]), undefined) +}) + +test("splitPath separates directory and name", () => { + assert.deepEqual(splitPath("src/lib/a.ts"), { dir: "src/lib/", name: "a.ts" }) + assert.deepEqual(splitPath("a.ts"), { dir: "", name: "a.ts" }) +}) + +test("parsePatch reads a git diff, dropping file headers", () => { + const patch = [ + "diff --git a/src/a.ts b/src/a.ts", + "index 1111111..2222222 100644", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1,3 +1,3 @@", + " keep", + "-old", + "+new", + "\\ No newline at end of file", + ].join("\n") + assert.deepEqual(parsePatch(patch), [ + { type: "context", text: "@@ -1,3 +1,3 @@" }, + { type: "context", text: "keep" }, + { type: "remove", text: "old" }, + { type: "add", text: "new" }, + ]) +}) + +test("parsePatch reads jsdiff output and keeps removed lines that look like headers", () => { + const patch = [ + "Index: a.md", + "===================================================================", + "--- a.md", + "+++ a.md", + "@@ -1,2 +1,1 @@", + "--- a yaml rule", + "+++ added", + "", + ].join("\n") + assert.deepEqual(parsePatch(patch), [ + { type: "context", text: "@@ -1,2 +1,1 @@" }, + { type: "remove", text: "-- a yaml rule" }, + { type: "add", text: "++ added" }, + ]) +}) + +test("parsePatch handles missing patches", () => { + assert.deepEqual(parsePatch(undefined), []) + assert.deepEqual(parsePatch(""), []) +}) diff --git a/src/lib/changes.ts b/src/lib/changes.ts new file mode 100644 index 00000000..f8ce0f5c --- /dev/null +++ b/src/lib/changes.ts @@ -0,0 +1,98 @@ +// Pure helpers for the Changes view (no React Native imports, so node:test can +// cover them). Patch parsing follows chliny's patch-compute from upstream +// PR #192 (MIT). +import type { DiffLine } from "../components/chat/diff-compute" +import type { FileDiff, VcsInfo } from "./sdk" + +export type ChangeTab = "branch" | "git" | "turn" + +export interface ChangeTotals { + files: number + additions: number + deletions: number +} + +export function totals(diffs: FileDiff[] | undefined): ChangeTotals { + const list = diffs ?? [] + return { + files: list.length, + additions: list.reduce((sum, diff) => sum + (diff.additions || 0), 0), + deletions: list.reduce((sum, diff) => sum + (diff.deletions || 0), 0), + } +} + +// "Branch" (vs the default branch) only means something off the default +// branch; on it the branch diff is empty. Same rule as the web app. +export function branchOffered(info: VcsInfo | null | undefined): boolean { + return !!info?.branch && !!info.default_branch && info.branch !== info.default_branch +} + +// Counts for the session list and chat header: everything on the branch, or +// the uncommitted edits when there is no branch to compare. +export function headline(info: VcsInfo | null | undefined, branch: FileDiff[] | undefined, git: FileDiff[] | undefined): ChangeTotals { + return branchOffered(info) ? totals(branch) : totals(git) +} + +export function sortDiffs(diffs: FileDiff[]): FileDiff[] { + return [...diffs].sort((a, b) => a.file.localeCompare(b.file)) +} + +// Last user message, i.e. the start of the current turn. Temp (optimistic) +// ids are skipped: the server does not know them yet. +export function lastUserMessageID(messages: Array<{ id: string; role: string }>): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role === "user" && !message.id.startsWith("temp-")) return message.id + } + return undefined +} + +export function splitPath(file: string): { dir: string; name: string } { + const index = file.lastIndexOf("/") + if (index === -1) return { dir: "", name: file } + return { dir: file.slice(0, index + 1), name: file.slice(index + 1) } +} + +const MAX_PATCH_LINES = 5000 + +// Unified diff (git or jsdiff output) to the DiffView line model. File +// headers (diff --git, index, ---/+++, Index:, ====) are dropped; hunk headers +// stay as context lines so the jumps between hunks remain visible. +export function parsePatch(patch: string | undefined): DiffLine[] { + if (!patch) return [] + const source = patch.split(/\r?\n/) + const lines: DiffLine[] = [] + let inHunk = false + for (const text of source) { + if (lines.length >= MAX_PATCH_LINES) { + lines.push({ type: "context", text: `… patch too large to display in full (${source.length} lines)` }) + break + } + if (text.startsWith("@@")) { + lines.push({ type: "context", text }) + inHunk = true + continue + } + if (!inHunk) continue + if (text.startsWith("diff --git ")) { + inHunk = false + continue + } + if (text.startsWith("\\")) continue + if (text.startsWith("+")) { + lines.push({ type: "add", text: text.slice(1) }) + continue + } + if (text.startsWith("-")) { + lines.push({ type: "remove", text: text.slice(1) }) + continue + } + if (text.startsWith(" ")) { + lines.push({ type: "context", text: text.slice(1) }) + continue + } + if (text === "") continue + inHunk = false + } + return lines +} diff --git a/src/lib/chatwoot.test.ts b/src/lib/chatwoot.test.ts deleted file mode 100644 index 50299a98..00000000 --- a/src/lib/chatwoot.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { test, beforeEach, afterEach } from "node:test" -import assert from "node:assert/strict" -import { chatwootConfigured, sendSupportReport } from "./chatwoot.ts" - -const INBOX = "inbox-token-abc" -const BASE = "https://support.example.com" - -const savedEnv = { ...process.env } - -beforeEach(() => { - process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER = INBOX - process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL = BASE -}) - -afterEach(() => { - process.env = { ...savedEnv } -}) - -interface Call { - url: string - body: Record -} - -// Sequenced fetch mock: each entry answers one call, in order. -function mockFetch(responses: Array<{ status: number; json?: unknown }>) { - const calls: Call[] = [] - const fetchFn = (async (url: RequestInfo | URL, init?: RequestInit) => { - calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }) - const next = responses[calls.length - 1] ?? { status: 500 } - return { - status: next.status, - json: async () => { - if (next.json === undefined) throw new Error("no body") - return next.json - }, - } as Response - }) as typeof fetch - return { fetchFn, calls } -} - -test("chatwootConfigured false without inbox identifier", () => { - delete process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER - assert.equal(chatwootConfigured(), false) -}) - -test("sendSupportReport returns false when unconfigured, makes no calls", async () => { - delete process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER - const { fetchFn, calls } = mockFetch([]) - assert.equal(await sendSupportReport("hello", { fetchFn }), false) - assert.equal(calls.length, 0) -}) - -test("happy path: contact -> conversation -> message, source_id saved", async () => { - const { fetchFn, calls } = mockFetch([ - { status: 200, json: { source_id: "src-1" } }, - { status: 200, json: { id: 42 } }, - { status: 200, json: {} }, - ]) - let saved: string | null = null - const ok = await sendSupportReport("report body", { - fetchFn, - loadSourceId: async () => null, - saveSourceId: async (id) => { - saved = id - }, - }) - assert.equal(ok, true) - assert.equal(saved, "src-1") - assert.deepEqual( - calls.map((c) => c.url), - [ - `${BASE}/public/api/v1/inboxes/${INBOX}/contacts`, - `${BASE}/public/api/v1/inboxes/${INBOX}/contacts/src-1/conversations`, - `${BASE}/public/api/v1/inboxes/${INBOX}/contacts/src-1/conversations/42/messages`, - ], - ) - assert.equal(calls[2].body.content, "report body") -}) - -test("stored source_id skips contact creation", async () => { - const { fetchFn, calls } = mockFetch([ - { status: 200, json: { id: 7 } }, - { status: 200, json: {} }, - ]) - const ok = await sendSupportReport("hi", { fetchFn, loadSourceId: async () => "src-old" }) - assert.equal(ok, true) - assert.equal(calls.length, 2) - assert.match(calls[0].url, /contacts\/src-old\/conversations$/) -}) - -test("stale source_id (404) recreates contact once and retries", async () => { - const { fetchFn, calls } = mockFetch([ - { status: 404, json: { error: "resource could not be found" } }, - { status: 200, json: { source_id: "src-new" } }, - { status: 200, json: { id: 9 } }, - { status: 200, json: {} }, - ]) - let saved: string | null = null - const ok = await sendSupportReport("hi", { - fetchFn, - loadSourceId: async () => "src-stale", - saveSourceId: async (id) => { - saved = id - }, - }) - assert.equal(ok, true) - assert.equal(saved, "src-new") - assert.equal(calls.length, 4) - assert.match(calls[3].url, /contacts\/src-new\/conversations\/9\/messages$/) -}) - -test("contact create failure throws", async () => { - const { fetchFn } = mockFetch([{ status: 500, json: { error: "boom" } }]) - await assert.rejects(() => sendSupportReport("hi", { fetchFn, loadSourceId: async () => null }), /http 500/) -}) - -test("non-404 conversation failure does not recreate contact", async () => { - const { fetchFn, calls } = mockFetch([{ status: 500 }]) - await assert.rejects(() => sendSupportReport("hi", { fetchFn, loadSourceId: async () => "src-x" }), /http 500/) - assert.equal(calls.length, 1) -}) - -test("base url trailing slash is normalised", async () => { - process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL = `${BASE}///` - const { fetchFn, calls } = mockFetch([ - { status: 200, json: { id: 1 } }, - { status: 200, json: {} }, - ]) - await sendSupportReport("hi", { fetchFn, loadSourceId: async () => "s" }) - assert.equal(calls[0].url, `${BASE}/public/api/v1/inboxes/${INBOX}/contacts/s/conversations`) -}) diff --git a/src/lib/chatwoot.ts b/src/lib/chatwoot.ts deleted file mode 100644 index 75b8c02f..00000000 --- a/src/lib/chatwoot.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Chatwoot support-feedback client using the PUBLIC client API -// (/public/api/v1/inboxes/{inbox_identifier}/...), which is designed for -// untrusted clients: the only value shipped in the binary is the inbox -// identifier. Never put an account-level api_access_token in this app — -// that token can read/write the whole support account. -// -// Contacts are created anonymously (no identifier), so Chatwoot's HMAC -// identifier-validation does not apply. The returned contact source_id is -// persisted via injected storage hooks so repeat reports from the same -// install land on the same contact. -// -// This module is dependency-injected and imports no React Native / Expo -// packages so it runs under `node --test`. - -const REQUEST_TIMEOUT_MS = 15_000 - -export interface ChatwootDeps { - fetchFn?: typeof fetch - loadSourceId?: () => Promise - saveSourceId?: (id: string) => Promise -} - -interface ChatwootConfig { - baseUrl: string - inboxIdentifier: string -} - -export function getChatwootConfig(): ChatwootConfig | null { - const inboxIdentifier = process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER - if (!inboxIdentifier) return null - const baseUrl = (process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL || "https://support.agentlabs.cc").replace(/\/+$/, "") - return { baseUrl, inboxIdentifier } -} - -export function chatwootConfigured(): boolean { - return getChatwootConfig() !== null -} - -async function post( - fetchFn: typeof fetch, - url: string, - body: Record, -): Promise<{ status: number; json: Record | null }> { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) - try { - const res = await fetchFn(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - signal: controller.signal, - }) - let json: Record | null = null - try { - json = (await res.json()) as Record - } catch { - // non-JSON error body - } - return { status: res.status, json } - } finally { - clearTimeout(timer) - } -} - -async function createContact(fetchFn: typeof fetch, cfg: ChatwootConfig): Promise { - const { status, json } = await post(fetchFn, `${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts`, { - name: "OpenCode Mobile user", - }) - const sourceId = json?.source_id - if (status >= 400 || typeof sourceId !== "string" || !sourceId) { - throw new Error(`chatwoot contact create failed (http ${status})`) - } - return sourceId -} - -async function createConversation(fetchFn: typeof fetch, cfg: ChatwootConfig, sourceId: string): Promise { - const { status, json } = await post( - fetchFn, - `${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts/${encodeURIComponent(sourceId)}/conversations`, - {}, - ) - const id = json?.id - if (status >= 400 || typeof id !== "number") { - const err = new Error(`chatwoot conversation create failed (http ${status})`) - ;(err as Error & { status?: number }).status = status - throw err - } - return id -} - -async function postMessage( - fetchFn: typeof fetch, - cfg: ChatwootConfig, - sourceId: string, - conversationId: number, - content: string, -): Promise { - const { status } = await post( - fetchFn, - `${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts/${encodeURIComponent(sourceId)}/conversations/${conversationId}/messages`, - { content }, - ) - if (status >= 400) throw new Error(`chatwoot message post failed (http ${status})`) -} - -/** - * Send an already-scrubbed report to the support inbox as a new conversation. - * Returns true on success, false when Chatwoot is not configured. - * Throws on network/API failure — callers decide whether that is fatal - * (the in-app share path treats it as best-effort). - * - * Consent is NOT checked here; this module has no access to the consent - * store. Callers MUST gate on telemetry consent before invoking. - */ -export async function sendSupportReport(content: string, deps: ChatwootDeps = {}): Promise { - const cfg = getChatwootConfig() - if (!cfg) return false - const fetchFn = deps.fetchFn ?? fetch - - let sourceId = (await deps.loadSourceId?.().catch(() => null)) ?? null - let created = false - if (!sourceId) { - sourceId = await createContact(fetchFn, cfg) - created = true - } - - let conversationId: number - try { - conversationId = await createConversation(fetchFn, cfg, sourceId) - } catch (error) { - const status = (error as { status?: number }).status - // A persisted source_id can go stale (contact deleted server-side). - // Recreate the contact once and retry. - if (created || status !== 404) throw error - sourceId = await createContact(fetchFn, cfg) - created = true - conversationId = await createConversation(fetchFn, cfg, sourceId) - } - - await postMessage(fetchFn, cfg, sourceId, conversationId, content) - if (created) await deps.saveSourceId?.(sourceId).catch(() => undefined) - return true -} diff --git a/src/lib/connect-link.test.ts b/src/lib/connect-link.test.ts new file mode 100644 index 00000000..8547f3c7 --- /dev/null +++ b/src/lib/connect-link.test.ts @@ -0,0 +1,55 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { connectLinkFromParams, findConnection, normalizeServerUrl, parseConnectLink } from "./connect-link.ts" + +const url = encodeURIComponent("https://Ruby-DCB.tail1234.ts.net/ws/alpha/") + +test("parses the opencode:// form", () => { + assert.deepEqual(parseConnectLink(`opencode://connect?url=${url}&name=alpha&user=opencode&dir=%2Fhome%2Fwork%2Frepo%2F&session=ses_1`), { + url: "https://ruby-dcb.tail1234.ts.net/ws/alpha", + name: "alpha", + user: "opencode", + dir: "/home/work/repo", + session: "ses_1", + }) +}) + +test("parses the intent:// form only when it targets the opencode scheme", () => { + const link = `intent://connect?url=${url}&name=alpha#Intent;scheme=opencode;package=it.kjeldahl.opencode;end` + assert.equal(parseConnectLink(link)?.url, "https://ruby-dcb.tail1234.ts.net/ws/alpha") + assert.equal(parseConnectLink(`intent://connect?url=${url}#Intent;scheme=https;end`), null) +}) + +test("rejects links without a usable http(s) url", () => { + assert.equal(parseConnectLink("opencode://connect?name=alpha"), null) + assert.equal(parseConnectLink("opencode://connect?url=ftp%3A%2F%2Fhost"), null) + assert.equal(parseConnectLink("opencode://session/abc"), null) +}) + +test("ignores a password parameter", () => { + const link = parseConnectLink(`opencode://connect?url=${url}&password=secret`) + assert.ok(link) + assert.equal(Object.values(link).includes("secret"), false) +}) + +test("reads expo-router params, empty values as absent", () => { + assert.deepEqual(connectLinkFromParams({ url: "http://10.0.0.2:4096", name: "", dir: ["/repo", "/other"] }), { + url: "http://10.0.0.2:4096", + name: undefined, + user: undefined, + dir: "/repo", + session: undefined, + }) +}) + +test("normalizeServerUrl drops trailing slash, query and fragment", () => { + assert.equal(normalizeServerUrl("HTTP://Host:4096/"), "http://host:4096") + assert.equal(normalizeServerUrl("https://host/a/b/?x=1#y"), "https://host/a/b") + assert.equal(normalizeServerUrl("host:4096"), null) +}) + +test("findConnection matches stored urls after normalising", () => { + const list = [{ id: "a", url: "http://host:4096/" }, { id: "b", url: "https://other" }] + assert.equal(findConnection(list, "http://host:4096")?.id, "a") + assert.equal(findConnection(list, "https://none"), undefined) +}) diff --git a/src/lib/connect-link.ts b/src/lib/connect-link.ts new file mode 100644 index 00000000..c84337d2 --- /dev/null +++ b/src/lib/connect-link.ts @@ -0,0 +1,63 @@ +// Workspace links from the forge hub: +// opencode://connect?url=&name=&user=opencode&dir=[&session=] +// Chrome opens the intent:// form reliably (and only in this app, via package=): +// intent://connect?url=...#Intent;scheme=opencode;package=it.kjeldahl.opencode;end +// Android hands the app the opencode:// form either way; the intent form is +// accepted here too so a pasted or forwarded link still works. The password is +// never part of a link. + +export interface ConnectLink { + url: string + name?: string + user?: string + dir?: string + session?: string +} + +export const APP_PACKAGE = "it.kjeldahl.opencode" + +type Params = Record + +function first(value: string | string[] | undefined): string | undefined { + const raw = Array.isArray(value) ? value[0] : value + const trimmed = raw?.trim() + return trimmed ? trimmed : undefined +} + +// Base URL normalised for comparison and storage: lower-case scheme and host, +// no trailing slash, no query or fragment. Null for anything but http(s). +export function normalizeServerUrl(raw: string | undefined): string | null { + const match = raw?.trim().match(/^(https?):\/\/([^/?#]+)([^?#]*)/i) + if (!match) return null + const path = match[3].replace(/\/+$/, "") + return `${match[1].toLowerCase()}://${match[2].toLowerCase()}${path}` +} + +// From expo-router search params (the /connect route). +export function connectLinkFromParams(params: Params): ConnectLink | null { + const url = normalizeServerUrl(first(params.url)) + if (!url) return null + return { + url, + name: first(params.name), + user: first(params.user), + dir: first(params.dir)?.replace(/\/+$/, "") || undefined, + session: first(params.session), + } +} + +// From a raw link string (opencode:// or intent://). +export function parseConnectLink(link: string): ConnectLink | null { + const match = link.trim().match(/^(opencode|intent):\/\/connect\/?\?([^#]*)(#.*)?$/i) + if (!match) return null + if (match[1].toLowerCase() === "intent" && !/;scheme=opencode;/i.test(match[3] ?? "")) return null + const params: Params = {} + for (const [key, value] of new URLSearchParams(match[2])) { + if (params[key] === undefined) params[key] = value + } + return connectLinkFromParams(params) +} + +export function findConnection(connections: T[], url: string): T | undefined { + return connections.find((connection) => normalizeServerUrl(connection.url) === url) +} diff --git a/src/lib/diagnostics-classify.test.ts b/src/lib/diagnostics-classify.test.ts index 16dee493..273dd8a7 100644 --- a/src/lib/diagnostics-classify.test.ts +++ b/src/lib/diagnostics-classify.test.ts @@ -135,7 +135,7 @@ test("root reachable but internet probe down still classifies as health-failed, test("health probe 401 -> auth-failed, and the summary tells the user to fix credentials", () => { const r = classify( okUrl, - probe({ ok: false, error: "HTTP 401", status: 401 }), // health + probe({ ok: false, error: "HTTP 401", status: 401, authProvided: true }), // health probe({ ok: true }), // internet probe({ ok: true, status: 401 }), // root reachable ) @@ -146,8 +146,45 @@ test("health probe 401 -> auth-failed, and the summary tells the user to fix cre assert.doesNotMatch(r.summary, /actually works/i) }) +// The probe only sends Authorization when a password is filled in. A 401 with +// NO credentials sent says nothing about the saved password — the user may +// just not have typed it yet. That must not be reported as "the server +// rejected your credentials", or they re-type a good password in a loop. +test("health probe 401 with no credentials sent -> auth-required, not auth-failed", () => { + const r = classify( + okUrl, + probe({ ok: false, error: "HTTP 401", status: 401 }), // health, authProvided unset + probe({ ok: true }), + probe({ ok: true, status: 401 }), + ) + assert.equal(r.classification, "auth-required") + assert.match(r.summary, /requires authentication/i) + assert.match(r.summary, /no credentials/i) + assert.doesNotMatch(r.summary, /rejected your credentials/i) +}) + +test("health probe 403 with no credentials sent -> auth-required too", () => { + const r = classify( + okUrl, + probe({ ok: false, status: 403 }), + probe({ ok: true }), + probe({ ok: true, status: 403 }), + ) + assert.equal(r.classification, "auth-required") +}) + +test("explicit authProvided: false is treated the same as unset", () => { + const r = classify( + okUrl, + probe({ ok: false, status: 401, authProvided: false }), + probe({ ok: true }), + probe({ ok: true, status: 401 }), + ) + assert.equal(r.classification, "auth-required") +}) + test("health probe 403 -> auth-failed too", () => { - const r = classify(okUrl, probe({ ok: false, status: 403 }), probe({ ok: true }), probe({ ok: true, status: 403 })) + const r = classify(okUrl, probe({ ok: false, status: 403, authProvided: true }), probe({ ok: true }), probe({ ok: true, status: 403 })) assert.equal(r.classification, "auth-failed") assert.match(r.summary, /403/) }) @@ -155,7 +192,7 @@ test("health probe 403 -> auth-failed too", () => { test("auth-failed wins even when the server root probe is unreachable", () => { // A 401 from /global/health already proves the server answered us; the // root probe's outcome must not downgrade this to server-unreachable. - const r = classify(okUrl, probe({ ok: false, status: 401 }), probe({ ok: true }), probe({ ok: false })) + const r = classify(okUrl, probe({ ok: false, status: 401, authProvided: true }), probe({ ok: true }), probe({ ok: false })) assert.equal(r.classification, "auth-failed") }) diff --git a/src/lib/diagnostics-classify.ts b/src/lib/diagnostics-classify.ts index ff1be6d3..2cc7e936 100644 --- a/src/lib/diagnostics-classify.ts +++ b/src/lib/diagnostics-classify.ts @@ -7,6 +7,7 @@ export type Classification = | "malformed-url" | "no-internet" | "server-unreachable" + | "auth-required" | "auth-failed" | "health-failed" | "tls-error" @@ -21,6 +22,7 @@ export interface ProbeAttempt { durationMs: number error?: string errorCause?: string + authProvided?: boolean } export interface ParsedUrl { @@ -67,6 +69,21 @@ export function classify( // as ok:true and told the user "connection actually works now" while the // password was wrong. `requireOk` (v0.4.8) stopped the lie; this makes the // remaining message actionable so the user stops retrying blind. + // 401/403 with no credentials sent is a different problem from rejected + // credentials: the probe never authenticated, so the server's answer says + // nothing about the saved password. This happens when the user runs the + // diagnostic before typing the password (Quick Connect, empty field) — + // telling them "the server rejected your credentials" sends them + // re-typing a password that may be perfectly fine. + if ((health.status === 401 || health.status === 403) && !health.authProvided) { + return { + classification: "auth-required", + summary: + `The server is running and requires authentication (HTTP ${health.status}), ` + + `but this probe sent no credentials. Enter the server password — and the username too, ` + + `if the server sets OPENCODE_SERVER_USERNAME (it defaults to "opencode") — then retry.`, + } + } if (health.status === 401 || health.status === 403) { return { classification: "auth-failed", diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 1cac275b..35036e05 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -4,12 +4,9 @@ import { Platform, Share } from "react-native" import * as Clipboard from "expo-clipboard" import * as Device from "expo-device" -import * as SecureStore from "expo-secure-store" import appJson from "../../app.json" import { log, formatLogLines } from "./logbuffer" import { type Classification, type ProbeAttempt, type ParsedUrl, parseUrl, classify } from "./diagnostics-classify" -import { chatwootConfigured, sendSupportReport } from "./chatwoot" -import { hasTelemetryConsent, loadTelemetryConsent } from "./telemetry" import { redactHostAndUrls } from "./scrub" export type { Classification, ProbeAttempt } from "./diagnostics-classify" @@ -90,6 +87,7 @@ export async function probeConnection(url: string, auth?: { username: string; pa timedFetch("server-root", `${base}/`, { headers }, { requireOk: false }), timedFetch("internet", INTERNET_CHECK_URL), ]) + health.authProvided = Boolean(auth?.password) } else { const skipped: ProbeAttempt = { name: "health", target: url, ok: false, durationMs: 0, error: "skipped: malformed url" } health = skipped @@ -185,34 +183,10 @@ export function formatReportForSupport(report: DiagnosticReport): string { return redactHostAndUrls(formatReport(report), [report.host, ...seenHosts]) } -const CHATWOOT_SOURCE_KEY = "opencode_chatwoot_source_id" - -// Best-effort delivery of the scrubbed report to the Chatwoot support inbox. -// Only runs when the user has granted telemetry consent (same flag that -// gates Sentry/analytics) and the inbox is configured for this build. -async function sendReportToSupport(report: DiagnosticReport): Promise { - // Consent may not be loaded yet if a crash happens very early in startup — - // resolve it from the store rather than silently dropping the report. - if (hasTelemetryConsent() === null) await loadTelemetryConsent() - if (hasTelemetryConsent() !== true || !chatwootConfigured()) return - try { - await sendSupportReport(formatReportForSupport(report), { - loadSourceId: () => SecureStore.getItemAsync(CHATWOOT_SOURCE_KEY), - saveSourceId: (id) => SecureStore.setItemAsync(CHATWOOT_SOURCE_KEY, id), - }) - log.info("diag", "report delivered to support inbox") - } catch (e) { - log.warn("diag", "support delivery failed", String(e)) - } -} - // Copy the report to the clipboard and open the native share sheet. -// Works fully offline (unlike the Sentry auto-upload). When telemetry -// consent is granted, a scrubbed copy is also delivered to the support -// inbox so reports reach us even if the user cancels the share sheet. +// Works fully offline; nothing is sent anywhere unless the user shares it. export async function shareReport(report: DiagnosticReport): Promise { const text = formatReport(report) - void sendReportToSupport(report) try { await Clipboard.setStringAsync(text) } catch { diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 13519f23..984b972f 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -12,7 +12,6 @@ "sections": { "security": "Security", "notifications": "Notifications", - "privacy": "Privacy", "about": "About" }, "security": { @@ -33,16 +32,6 @@ "notifications": { "disabledNotice": "Notifications are disabled at the system level. Enable them in Settings to receive alerts." }, - "privacy": { - "crashReporting": { - "label": "Crash Reports & Usage Analytics", - "description": "Share anonymous crash reports (Sentry) and usage analytics (PostHog) to help improve OpenCode. Diagnostic reports you share are also delivered to our support inbox. No code or prompts are included." - }, - "privacyPolicy": { - "label": "Privacy Policy", - "description": "What data we collect and how" - } - }, "about": { "version": "Version", "github": { @@ -66,8 +55,6 @@ "tagline": "Connect to your AI coding assistant from anywhere" }, "alerts": { - "privacyNotSavedTitle": "Privacy Setting Not Saved", - "privacyNotSavedMessage": "Crash reporting is off for this session, but your choice could not be saved. Please try again.", "notificationsDisabledTitle": "Notifications Disabled", "notificationsDisabledMessage": "Enable notifications for OpenCode in your device settings to receive alerts." } @@ -172,21 +159,8 @@ "helpProtocolMiddle": " for local and Tailscale addresses unless TLS is configured, then use ", "helpProtocolSuffix": ".", "helpRunningPrefix": "Make sure OpenCode is running:", - "connectCardTitle": "OpenCode Connect", - "connectCardBadge": "Coming Soon", - "connectCardDesc": "Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from anywhere.", "advancedLink": "Advanced options (tunnels, cloud, auth)" }, - "waitlist": { - "successText": "You're on the list — we'll email you when OpenCode Connect is ready.", - "joinButton": "Join Waitlist", - "alertTitle": "Join Waitlist", - "queuedText": "Saved on this device — we'll finish signing you up as soon as you're back online. You don't have to do anything.", - "emailUsLink": "Still not working? Email us instead", - "emailUsButton": "Email us", - "queueFailedMessage": "We couldn't reach the signup service or save your request on this device. You can email us instead and we'll add you by hand.", - "noMailAppMessage": "No email app is available. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\"." - }, "advanced": { "backToQuick": "Simple mode", "urlHintPrefix": "Local/Tailscale examples: ", @@ -285,7 +259,8 @@ "browseFoldersHint": "Explore the server's filesystem", "enterPathLabel": "Enter Path Manually", "createInButton": "Create in {{dir}}", - "createSessionButton": "Create Session" + "createSessionButton": "Create Session", + "noChangeTracking": "no change tracking" }, "renameModal": { "title": "Rename Session" @@ -325,23 +300,6 @@ "shareButton": "Share Report", "tryAgainButton": "Try Again" }, - "telemetryConsent": { - "title": "Help improve OpenCode?", - "body": "Share anonymous crash reports and usage analytics to help us find and fix bugs faster. Diagnostic reports you share are also delivered to our support inbox. No code, prompts, or server addresses are ever included.", - "bullets": { - "deviceInfo": "Device model, OS version, app version", - "stackTraces": "Stack traces of crashes (no variable values)", - "usageEvents": "Anonymous usage events (app opened, connection attempts, messages sent) — sent to PostHog EU", - "noCode": "Your code, prompts, or chat messages", - "noServerUrls": "Server URLs or authentication tokens" - }, - "privacyLink": "Read our full privacy policy", - "declineA11yLabel": "No thanks, decline crash reporting and analytics", - "declineButton": "No thanks", - "allowA11yLabel": "Allow anonymous crash reports and usage analytics", - "allowButton": "Allow", - "footnote": "You can change this at any time in Settings → Privacy." - }, "authGate": { "title": "OpenCode Locked", "subtitle": "Authenticate to access your sessions", @@ -442,8 +400,7 @@ "ctaTitle": "Like what you see?", "ctaSubtitle": "This was a scripted demo. Connect your own opencode server to do this for real.", "connectButton": "Connect your own server", - "setupGuideLink": "How to set up a server", - "hostedCtaLink": "No server? Join the OpenCode Connect waitlist — hosted, no setup" + "setupGuideLink": "How to set up a server" }, "update": { "available": "Update available", @@ -451,5 +408,42 @@ "action": "Get it", "dismiss": "Not now", "upToDate": "Up to date" + }, + "changes": { + "title": "Changes", + "tabs": { + "branch": "Branch", + "git": "Uncommitted", + "turn": "This turn" + }, + "branchVs": "{{branch}} vs {{base}}", + "summary": "{{count}} files", + "empty": { + "branch": "No changes on this branch.", + "git": "No uncommitted changes.", + "turn": "No changes in the last turn." + }, + "error": "Could not load changes: {{message}}", + "noPatch": "No diff available for this file (binary or too large).", + "status": { + "added": "added", + "deleted": "deleted", + "modified": "modified" + }, + "noDirectory": "This chat has no folder, so there are no changes to show." + }, + "connect": { + "title": "Open workspace", + "newIntro": "Add this workspace. The password is asked once and stored on this phone.", + "server": "Server", + "directory": "Repository", + "name": "Name", + "user": "Username", + "password": "Password", + "submit": "Connect", + "failed": "Could not connect.", + "opening": "Opening {{name}}…", + "invalid": "This link has no valid server URL.", + "home": "Go to sessions" } } diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 9ba906f7..1bcefb8f 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -12,7 +12,6 @@ "sections": { "security": "安全", "notifications": "通知", - "privacy": "隐私", "about": "关于" }, "security": { @@ -33,16 +32,6 @@ "notifications": { "disabledNotice": "通知已在系统层面关闭。请在系统设置中启用通知以接收提醒。" }, - "privacy": { - "crashReporting": { - "label": "崩溃报告与使用分析", - "description": "分享匿名崩溃报告(Sentry)和使用分析(PostHog),帮助改进 OpenCode。您分享的诊断报告也会发送至我们的支持收件箱。不包含代码或提示词内容。" - }, - "privacyPolicy": { - "label": "隐私政策", - "description": "我们收集哪些数据及收集方式" - } - }, "about": { "version": "版本", "github": { @@ -66,8 +55,6 @@ "tagline": "随时随地连接您的 AI 编程助手" }, "alerts": { - "privacyNotSavedTitle": "隐私设置未保存", - "privacyNotSavedMessage": "本次会话已关闭崩溃报告,但您的选择未能保存。请重试。", "notificationsDisabledTitle": "通知已关闭", "notificationsDisabledMessage": "请在设备设置中为 OpenCode 启用通知以接收提醒。" } @@ -172,21 +159,8 @@ "helpProtocolMiddle": ",除非已配置 TLS,此时请使用 ", "helpProtocolSuffix": "。", "helpRunningPrefix": "请确保 OpenCode 正在运行:", - "connectCardTitle": "OpenCode Connect", - "connectCardBadge": "即将上线", - "connectCardDesc": "将手机桥接到您的 opencode 服务器 — 无需配置隧道或防火墙。一键连接,随时随地。", "advancedLink": "高级选项(隧道、云端、身份验证)" }, - "waitlist": { - "successText": "您已加入候补名单 — OpenCode Connect 准备就绪时我们会通过邮件通知您。", - "joinButton": "加入候补名单", - "alertTitle": "加入候补名单", - "queuedText": "已保存在此设备上 — 联网后我们会自动完成注册,你无需再做任何操作。", - "emailUsLink": "仍然无法注册?改为给我们发邮件", - "emailUsButton": "发送邮件", - "queueFailedMessage": "无法连接注册服务,也无法在此设备上保存你的请求。你可以改为给我们发邮件,我们会手动将你加入名单。", - "noMailAppMessage": "没有可用的邮件应用。请发送邮件至 support@agentlabs.cc,主题为 \"OpenCode Connect Waitlist\"。" - }, "advanced": { "backToQuick": "简易模式", "urlHintPrefix": "本地/Tailscale 示例:", @@ -285,7 +259,8 @@ "browseFoldersHint": "浏览服务器的文件系统", "enterPathLabel": "手动输入路径", "createInButton": "在 {{dir}} 中创建", - "createSessionButton": "创建会话" + "createSessionButton": "创建会话", + "noChangeTracking": "无更改跟踪" }, "renameModal": { "title": "重命名会话" @@ -325,23 +300,6 @@ "shareButton": "分享报告", "tryAgainButton": "重试" }, - "telemetryConsent": { - "title": "帮助改进 OpenCode?", - "body": "分享匿名崩溃报告和使用分析,帮助我们更快地发现并修复问题。您分享的诊断报告也会发送至我们的支持收件箱。绝不包含代码、提示词或服务器地址。", - "bullets": { - "deviceInfo": "设备型号、系统版本、应用版本", - "stackTraces": "崩溃堆栈跟踪(不含变量值)", - "usageEvents": "匿名使用事件(打开应用、连接尝试、发送消息)— 发送至 PostHog EU", - "noCode": "您的代码、提示词或聊天消息", - "noServerUrls": "服务器地址或身份验证令牌" - }, - "privacyLink": "阅读完整隐私政策", - "declineA11yLabel": "不用了,拒绝崩溃报告和使用分析", - "declineButton": "不用了", - "allowA11yLabel": "允许匿名崩溃报告和使用分析", - "allowButton": "允许", - "footnote": "您可以随时在设置 → 隐私中更改此设置。" - }, "authGate": { "title": "OpenCode 已锁定", "subtitle": "请验证身份以访问您的会话", @@ -442,8 +400,7 @@ "ctaTitle": "喜欢这个体验吗?", "ctaSubtitle": "这是一个脚本化的演示。连接您自己的 opencode 服务器即可真正体验。", "connectButton": "连接您自己的服务器", - "setupGuideLink": "如何设置服务器", - "hostedCtaLink": "没有服务器?加入 OpenCode Connect 候补名单 — 托管,无需搭建" + "setupGuideLink": "如何设置服务器" }, "update": { "available": "有可用更新", @@ -451,5 +408,42 @@ "action": "去获取", "dismiss": "暂不", "upToDate": "已是最新" + }, + "changes": { + "title": "更改", + "tabs": { + "branch": "分支", + "git": "未提交", + "turn": "本轮" + }, + "branchVs": "{{branch}} 对比 {{base}}", + "summary": "{{count}} 个文件", + "empty": { + "branch": "此分支没有更改。", + "git": "没有未提交的更改。", + "turn": "上一轮没有更改。" + }, + "error": "无法加载更改:{{message}}", + "noPatch": "此文件没有可显示的差异(二进制或过大)。", + "status": { + "added": "新增", + "deleted": "删除", + "modified": "修改" + }, + "noDirectory": "此会话没有文件夹,无法显示更改。" + }, + "connect": { + "title": "打开工作区", + "newIntro": "添加此工作区。密码只需输入一次,并保存在本机。", + "server": "服务器", + "directory": "仓库", + "name": "名称", + "user": "用户名", + "password": "密码", + "submit": "连接", + "failed": "无法连接。", + "opening": "正在打开 {{name}}…", + "invalid": "此链接没有有效的服务器地址。", + "home": "前往会话" } } diff --git a/src/lib/keyboard-offset.test.ts b/src/lib/keyboard-offset.test.ts new file mode 100644 index 00000000..8341d6ab --- /dev/null +++ b/src/lib/keyboard-offset.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + IOS_KEYBOARD_VERTICAL_OFFSET, + keyboardPadding, + keyboardVerticalOffset, +} from "./keyboard-offset.ts" + +test("Android uses the IME-reported height as bottom padding", () => { + assert.equal(keyboardPadding("android", 351.28), 351.28) +}) + +test("Android hide and invalid metrics produce zero padding", () => { + assert.equal(keyboardPadding("android", 0), 0) + assert.equal(keyboardPadding("android", -20), 0) +}) + +test("iOS does not receive Android's explicit keyboard padding", () => { + assert.equal(keyboardPadding("ios", 351.28), 0) +}) + +test("iOS keeps its existing empirical offset", () => { + assert.equal(keyboardVerticalOffset("ios", 0), IOS_KEYBOARD_VERTICAL_OFFSET) + assert.equal(keyboardVerticalOffset("ios", 61.29), IOS_KEYBOARD_VERTICAL_OFFSET) +}) + +test("Android does not use KeyboardAvoidingView's vertical offset", () => { + assert.equal(keyboardVerticalOffset("android", 61.293), 0) +}) + +test("Pixel 11 Pro measurement maps directly to the required padding", () => { + const keyboardHeight = 1168 / (1280 / 393) + assert.equal(keyboardPadding("android", keyboardHeight), keyboardHeight) +}) diff --git a/src/lib/keyboard-offset.ts b/src/lib/keyboard-offset.ts new file mode 100644 index 00000000..18230c4e --- /dev/null +++ b/src/lib/keyboard-offset.ts @@ -0,0 +1,15 @@ +// iOS continues to use KeyboardAvoidingView's established offset. Android +// applies the IME-reported height directly because RN 0.81's Android hide +// event path can retain keyboardVerticalOffset as stale bottom padding. + +export const IOS_KEYBOARD_VERTICAL_OFFSET = 90 + +export function keyboardPadding(platform: string, keyboardHeight: number): number { + if (platform !== "android") return 0 + return Math.max(0, keyboardHeight) +} + +export function keyboardVerticalOffset(platform: string, _insetTop: number): number { + if (platform === "ios") return IOS_KEYBOARD_VERTICAL_OFFSET + return 0 +} diff --git a/src/lib/persisted-keys.test.ts b/src/lib/persisted-keys.test.ts index 2e1a07b0..17403b43 100644 --- a/src/lib/persisted-keys.test.ts +++ b/src/lib/persisted-keys.test.ts @@ -32,9 +32,6 @@ const ALLOWED_PERSISTED_KEYS = new Map([ ["AUTH_SETTINGS_KEY", "biometric/app-lock preference"], ["COUNT_KEY", "store-review: launch counter"], ["ASKED_KEY", "store-review: already-prompted flag"], - ["FIRST_OPEN_KEY", "analytics: first-open flag"], - ["CONSENT_KEY", "telemetry consent decision"], - ["CHATWOOT_SOURCE_KEY", "support contact id issued by Chatwoot"], ]) const SRC = path.join(import.meta.dirname, "..") diff --git a/src/lib/projects.test.ts b/src/lib/projects.test.ts new file mode 100644 index 00000000..3111c873 --- /dev/null +++ b/src/lib/projects.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { newChatDir, newChatProjects, projectDir, projectName, tracksChanges } from "./projects.ts" + +const project = (id: string, worktree: string, extra: Record = {}) => ({ id, worktree, ...extra }) + +test("projectDir reads worktree, falls back to the legacy path shape", () => { + assert.equal(projectDir(project("a", "/home/u/repo/")), "/home/u/repo") + assert.equal(projectDir({ id: "b", path: { cwd: "", root: "", absolute: "/old" } }), "/old") + assert.equal(projectDir(project("g", "/")), undefined) + assert.equal(projectDir(null), undefined) +}) + +test("projectName prefers the name, then the last path segment", () => { + assert.equal(projectName(project("a", "/home/u/repo", { name: "Repo" })), "Repo") + assert.equal(projectName(project("a", "/home/u/repo")), "repo") + assert.equal(projectName({ id: "x" }), "x") +}) + +test("newChatProjects drops the global project and lists git repos first", () => { + const list = [ + project("global", "/"), + project("work", "/home/u/work", { time: { created: 0, updated: 9 } }), + project("old", "/home/u/old", { vcs: "git", time: { created: 0, updated: 1 } }), + project("new", "/home/u/new", { vcs: "git", time: { created: 0, updated: 5 } }), + ] + assert.deepEqual(newChatProjects(list).map((p) => p.id), ["new", "old", "work"]) + assert.equal(tracksChanges(list[1]), false) +}) + +test("newChatDir: connection default, then last used, then server default", () => { + assert.equal(newChatDir("/repo", ["/recent"]), "/repo") + assert.equal(newChatDir(undefined, ["/recent", "/older"]), "/recent") + assert.equal(newChatDir(undefined, []), undefined) +}) diff --git a/src/lib/projects.ts b/src/lib/projects.ts new file mode 100644 index 00000000..20467b9c --- /dev/null +++ b/src/lib/projects.ts @@ -0,0 +1,39 @@ +// Project helpers for the new-chat sheet (pure; see projects.test.ts). +import type { Project } from "./sdk" + +// The project's directory. Undefined for the server's catch-all "global" +// project (worktree "/"), which is not a place to start a chat. +export function projectDir(project: Project | null | undefined): string | undefined { + const dir = (project?.worktree || project?.path?.absolute)?.replace(/\/+$/, "") + return dir || undefined +} + +export function projectName(project: Project): string { + const dir = projectDir(project) + return project.name || dir?.split("/").filter(Boolean).pop() || project.id +} + +// Without git the server tracks no changes (no Changes view, no counts). +export function tracksChanges(project: Project): boolean { + return project.vcs === "git" +} + +// Projects a new chat can start in: git repos first (most recently updated +// first), then the rest. +export function newChatProjects(projects: Project[]): Project[] { + return projects + .filter((project) => !!projectDir(project)) + .sort((a, b) => { + const git = Number(tracksChanges(b)) - Number(tracksChanges(a)) + if (git !== 0) return git + const time = (b.time?.updated ?? 0) - (a.time?.updated ?? 0) + if (time !== 0) return time + return projectName(a).localeCompare(projectName(b)) + }) +} + +// Plain "New chat": the connection's default directory, else the last project +// a chat was started in, else the server's own default (undefined). +export function newChatDir(connectionDir: string | undefined, recent: string[]): string | undefined { + return connectionDir || recent[0] || undefined +} diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index 89ffb68c..43796ba0 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -75,6 +75,24 @@ export interface Message { finish?: string } +// One file's change, as returned by /vcs/diff and /session/:id/diff. +export interface FileDiff { + file: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +// "git": working tree vs HEAD (uncommitted). "branch": working tree vs the +// merge-base with the default branch (everything on this branch). +export type VcsMode = "git" | "branch" + +export interface VcsInfo { + branch?: string + default_branch?: string +} + // API returns messages with parts embedded export interface MessageWithParts { info: Message @@ -145,10 +163,16 @@ export interface Command { hints: string[] } +// Server shape is { id, worktree, vcs?, name?, time }. `path` is the shape this +// client used to assume; kept optional for older servers. Read the directory +// with projectDir() from ./projects. export interface Project { id: string name?: string - path: { + worktree?: string + vcs?: "git" + time?: { created: number; updated: number; initialized?: number } + path?: { cwd: string root: string absolute: string @@ -337,6 +361,21 @@ export function createClient(config: ClientConfig) { }, }, + // Version control state of this client's directory. The directory also + // goes in the query: a request without one is answered (200) for the + // server's default folder, i.e. silently the wrong repo. + vcs: { + get: () => { + const query = config.directory ? `?${new URLSearchParams({ directory: config.directory }).toString()}` : "" + return request(config, `/vcs${query}`) + }, + diff: (mode: VcsMode) => { + const query = new URLSearchParams({ mode }) + if (config.directory) query.set("directory", config.directory) + return request(config, `/vcs/diff?${query.toString()}`) + }, + }, + path: { get: () => request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"), @@ -449,8 +488,11 @@ export function createClient(config: ClientConfig) { abort: (sessionID: string) => request(config, `/session/${sessionID}/abort`, { method: "POST" }), diff: (sessionID: string, messageID?: string) => { - const qs = messageID ? `?messageID=${messageID}` : "" - return request(config, `/session/${sessionID}/diff${qs}`) + const query = new URLSearchParams() + if (messageID) query.set("messageID", messageID) + if (config.directory) query.set("directory", config.directory) + const qs = query.toString() + return request(config, `/session/${sessionID}/diff${qs ? `?${qs}` : ""}`) }, // Marks messageID (and everything after it) as pending revert. The diff --git a/src/lib/sentry-noise-production.test.ts b/src/lib/sentry-noise-production.test.ts deleted file mode 100644 index b80bdb5f..00000000 --- a/src/lib/sentry-noise-production.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -// Replay of REAL production Sentry data through the AGE-105 noise gate. -// -// `sentry-noise.test.ts` proves the gate behaves as specified. This file proves -// the specification was aimed at the right targets — that the patterns match the -// event signatures `opencode-mobile` actually emits, at the volumes it actually -// emits them, and therefore that the gate lands under the org quota gate. -// -// Why freeze a census instead of querying Sentry in a test: the numbers below -// are the *justification* for the pattern lists. If someone later edits -// TRANSPORT_NOISE_PATTERNS or ALWAYS_SEND_PATTERNS and the projected volume -// leaves budget — or a genuine crash class stops being allowlisted — that is a -// regression, and it should fail here rather than on next month's Sentry bill. -// A live query would move under our feet and could never fail. -// -// Census: every issue in the `opencode-mobile` Sentry project over the 90 days -// ending 2026-08-14, sorted by event count, taken from -// GET /organizations/vibetechnologies/issues/?statsPeriod=90d&sort=freq. -// Reproduce with `node scripts/sentry-volume-report.mjs` + the issues endpoint. -// -// Volume context measured the same day (see docs/analytics.md): -// * `opencode-mobile` submitted = 2,812 events / 30d (~2,750/month) -// * org error quota 5,000/month, self-imposed gate 3,500/month (AGE-71) -// * AGE-105 target for this project: under 1,500/month -// -// NOTE: server-side levers were tested and are NOT available on this Sentry -// plan — per-key rate limits accept a PUT with HTTP 200 and silently discard -// the value, custom inbound filters are absent, spike protection returns 403. -// This client-side gate is the only control that exists, so its coverage is the -// whole safety margin. - -import { test } from "node:test" -import assert from "node:assert/strict" -import { eventText, isAlwaysSend, isTransportNoise } from "./sentry-noise.ts" - -type Observed = { - /** Sentry short id, so a failure is traceable to the real issue. */ - id: string - /** exception type as Sentry grouped it */ - type: string - /** exception value, verbatim (truncated exactly as Sentry stores it) */ - value: string - /** events in the 90d window */ - count: number - /** what the gate must decide, and why it is the correct decision */ - expect: "drop" | "always-send" | "capped" -} - -/** Frozen 90d production census (648 events across 11 issues). */ -const PRODUCTION_90D: Observed[] = [ - // --- client-side network conditions: already shown to the user as connection - // UI and already trended in PostHog as connection_failed{error_class}. --- - { id: "OPENCODE-MOBILE-3", type: "Error", value: "connect timeout", count: 462, expect: "drop" }, - { id: "OPENCODE-MOBILE-4", type: "Error", value: "connect server-unreachable", count: 157, expect: "drop" }, - { id: "OPENCODE-MOBILE-B", type: "Error", value: "Network request failed", count: 6, expect: "drop" }, - { id: "OPENCODE-MOBILE-7", type: "Error", value: "Request timed out after 30000ms", count: 3, expect: "drop" }, - - // --- genuine crash classes: must survive whatever the quota says. --- - { - id: "OPENCODE-MOBILE-A", - type: "Error", - value: - "Call to function 'ExponentImagePicker.launchImageLibraryAsync' has been rejected.\n→ Caused by: java.lang.IllegalStateException: Attempting to launch an unregistered ActivityResultLauncher with contract expo.modules.imagepicker.contracts.ImageLibraryC", - count: 5, - expect: "always-send", - }, - { - id: "OPENCODE-MOBILE-5", - type: "OutOfMemoryError", - value: "Failed to allocate a 126790640 byte allocation with 12451840 free bytes", - count: 4, - expect: "always-send", - }, - { - id: "OPENCODE-MOBILE-6", - type: "OutOfMemoryError", - value: "Failed to allocate a 8208 byte allocation with 1261584 free bytes", - count: 1, - expect: "always-send", - }, - { - id: "OPENCODE-MOBILE-8", - type: "ApplicationNotResponding", - value: "ANR", - count: 1, - expect: "always-send", - }, - { - id: "OPENCODE-MOBILE-9", - type: "IllegalStateException", - value: "The specified child already has a parent. You must call removeView() on the child's parent first.", - count: 1, - expect: "always-send", - }, - { - id: "OPENCODE-MOBILE-2", - type: "IllegalStateException", - value: "The specified child already has a parent. You must call removeView() on the child's parent first.", - count: 1, - expect: "always-send", - }, - - // --- reportable, but a storm of it must collapse: AGE-107 traced 498 of - // these to one user re-tapping Connect with a wrong password. Dedup + rate - // cap handle it; a hard drop would hide real server-side 401 regressions. --- - { id: "OPENCODE-MOBILE-1", type: "Error", value: "API Error: 401 - ", count: 7, expect: "capped" }, -] - -const asEvent = (o: Observed) => ({ - exception: { values: [{ type: o.type, value: o.value, mechanism: { handled: true } }] }, -}) - -function classify(o: Observed): Observed["expect"] { - const text = eventText(asEvent(o)) - // Mirrors NoiseGate.admit() precedence: allowlist wins over the drop-list. - if (isAlwaysSend(text)) return "always-send" - if (isTransportNoise(text)) return "drop" - return "capped" -} - -test("production replay: every observed issue is classified as intended", () => { - for (const o of PRODUCTION_90D) { - assert.equal( - classify(o), - o.expect, - `${o.id} (${o.count} events, ${o.type}: ${o.value.slice(0, 60)}) should be ${o.expect}`, - ) - } -}) - -test("production replay: no genuine crash class is ever filtered", () => { - // The failure mode this guards: hitting the volume target by silencing real - // crashes. A zero here would make the quota win meaningless. - const crashes = PRODUCTION_90D.filter((o) => o.expect === "always-send") - assert.ok(crashes.length >= 6, "census should still contain the observed crash classes") - for (const o of crashes) { - assert.equal(isAlwaysSend(eventText(asEvent(o))), true, `${o.id} must be allowlisted`) - assert.equal(classify(o), "always-send", `${o.id} must not be reachable by the drop-list`) - } -}) - -test("production replay: gate drops >=95% of observed volume", () => { - let dropped = 0 - let total = 0 - for (const o of PRODUCTION_90D) { - total += o.count - if (classify(o) === "drop") dropped += o.count - } - assert.equal(total, 648, "census total changed — re-derive the projections below") - const share = dropped / total - assert.ok(share >= 0.95, `expected >=95% of volume dropped, got ${(share * 100).toFixed(1)}%`) -}) - -test("production replay: projected monthly volume clears the AGE-105 target", () => { - const SUBMITTED_PER_MONTH = 2750 // measured 2026-08-14, pre-v0.4.14-uptake - const TARGET_PER_MONTH = 1500 // AGE-105 "done means" - let survivingShare = 0 - let total = 0 - for (const o of PRODUCTION_90D) { - total += o.count - if (classify(o) !== "drop") survivingShare += o.count - } - // Upper bound: assumes dedup + the hourly cap never fire, which they will. - const projected = SUBMITTED_PER_MONTH * (survivingShare / total) - assert.ok( - projected < TARGET_PER_MONTH, - `projected ${Math.round(projected)}/month must stay under ${TARGET_PER_MONTH}/month`, - ) -}) diff --git a/src/lib/sentry-noise.test.ts b/src/lib/sentry-noise.test.ts deleted file mode 100644 index a5f5eaec..00000000 --- a/src/lib/sentry-noise.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { test } from "node:test" -import assert from "node:assert/strict" -import { - DEFAULT_GATE_LIMITS, - NoiseGate, - eventText, - fingerprint, - isAlwaysSend, - isFatalEvent, - isTransportNoise, -} from "./sentry-noise.ts" - -const T0 = 1_786_700_000_000 // fixed clock; every test drives time explicitly -const MIN = 60_000 -const HOUR = 60 * MIN - -// --- pattern lists ------------------------------------------------------- - -test("isTransportNoise: the three top AGE-105 noise producers are noise", () => { - assert.equal(isTransportNoise("connect timeout"), true) - assert.equal(isTransportNoise("connect server-unreachable"), true) - assert.equal(isTransportNoise("Network request failed"), true) - assert.equal(isTransportNoise("connect no-internet"), true) - assert.equal(isTransportNoise("Request timed out after 10000ms"), true) - assert.equal(isTransportNoise("fetch error: ECONNREFUSED"), true) -}) - -test("isTransportNoise: server-side and app-side failures are NOT noise", () => { - // health-failed means the box answered but is unhealthy — that is actionable. - assert.equal(isTransportNoise("connect health-failed"), false) - assert.equal(isTransportNoise("connect tls-error"), false) - assert.equal(isTransportNoise("API Error: 500 - boom"), false) - assert.equal(isTransportNoise("TypeError: undefined is not a function"), false) - assert.equal(isTransportNoise(""), false) -}) - -test("isTransportNoise: AGE-107 — a rejected credential is user config, not an app defect", () => { - // The server answered 401/403. Nothing on our side can fix a wrong password; - // it is already shown in the connection screen and already trended in - // PostHog as connection_failed{error_class:"unauthorized"}. - assert.equal(isTransportNoise("connect auth-failed"), true) - // ...but a genuine 401 from anywhere else still reports — only the - // classified diagnostic is dropped. - assert.equal(isTransportNoise("API Error: 401 - "), false) -}) - -test("isAlwaysSend: genuine crash classes bypass the gate", () => { - assert.equal(isAlwaysSend("OutOfMemoryError (okio.Buffer.readByteArray)"), true) - assert.equal(isAlwaysSend("IllegalStateException: no activity"), true) - assert.equal(isAlwaysSend("NullPointerException"), true) - assert.equal(isAlwaysSend("SIGSEGV"), true) - assert.equal(isAlwaysSend("connect timeout"), false) -}) - -// --- event flattening ---------------------------------------------------- - -test("eventText: prefers the exception type+value, falls back to message", () => { - assert.equal( - eventText({ exception: { values: [{ type: "Error", value: "connect timeout" }] } }), - "connect timeout", - ) - assert.equal( - eventText({ exception: { values: [{ type: "OutOfMemoryError", value: "Failed to allocate" }] } }), - "OutOfMemoryError: Failed to allocate", - ) - assert.equal(eventText({ message: "plain message" }), "plain message") - assert.equal(eventText({}), "") -}) - -test("isFatalEvent: fatal level or unhandled mechanism is fatal", () => { - assert.equal(isFatalEvent({ level: "fatal" }), true) - assert.equal( - isFatalEvent({ exception: { values: [{ value: "boom", mechanism: { handled: false } }] } }), - true, - ) - assert.equal( - isFatalEvent({ level: "error", exception: { values: [{ value: "boom", mechanism: { handled: true } }] } }), - false, - ) -}) - -// --- fingerprinting ------------------------------------------------------ - -test("fingerprint: the 401 retry loop collapses to a single key", () => { - const a = fingerprint('API Error: 401 - {"error":"token expired at 1786700000"}') - const b = fingerprint('API Error: 401 - {"error":"token expired at 1786700931"}') - const c = fingerprint("API Error: 401 - Unauthorized") - assert.equal(a, b) - assert.equal(a, c) -}) - -test("fingerprint: different statuses stay distinct", () => { - assert.notEqual(fingerprint("API Error: 401 - x"), fingerprint("API Error: 403 - x")) - assert.notEqual(fingerprint("API Error: 401 - x"), fingerprint("API Error: 500 - x")) -}) - -test("fingerprint: ids/urls/paths do not fragment one error into many", () => { - const a = fingerprint("Failed to load session 0f8c7a2b-1111-4d3e-9aaa-1234567890ab from https://box.example/v1/x") - const b = fingerprint("Failed to load session 7bb1c0de-2222-4d3e-9aaa-abcdef123456 from https://other.example/v1/y") - assert.equal(a, b) -}) - -test("fingerprint: genuinely different errors stay different", () => { - assert.notEqual(fingerprint("TypeError: x is not a function"), fingerprint("connect timeout")) -}) - -test("fingerprint: bounded length", () => { - assert.ok(fingerprint("z".repeat(500)).length <= 100) -}) - -// --- gate behaviour ------------------------------------------------------ - -test("gate: transport noise is dropped every time, forever", () => { - const gate = new NoiseGate() - for (let i = 0; i < 200; i++) { - const d = gate.admit("connect timeout", {}, T0 + i * MIN) - assert.equal(d.send, false) - assert.equal(d.reason, "transport-noise") - } - assert.equal(gate.takeDroppedCount(), 200) -}) - -test("gate: a 401 retry loop reports once per cooldown, not 498 times", () => { - const gate = new NoiseGate() - let sent = 0 - // 498 events over 3 hours — the exact shape of the AGE-105 single-user loop. - for (let i = 0; i < 498; i++) { - const at = T0 + Math.floor((i * 3 * HOUR) / 498) - if (gate.admit(`API Error: 401 - attempt ${i}`, {}, at).send) sent++ - } - assert.equal(sent, 1) - // ...and it re-opens once the 6h cooldown has elapsed. - assert.equal(gate.admit("API Error: 401 - later", {}, T0 + 6 * HOUR + MIN).send, true) -}) - -test("gate: cooldown boundary is exclusive-then-inclusive", () => { - const gate = new NoiseGate() - assert.equal(gate.admit("TypeError: boom", {}, T0).send, true) - assert.equal(gate.admit("TypeError: boom", {}, T0 + DEFAULT_GATE_LIMITS.cooldownMs - 1).send, false) - assert.equal(gate.admit("TypeError: boom", {}, T0 + DEFAULT_GATE_LIMITS.cooldownMs).send, true) -}) - -test("gate: at most maxNewPerHour distinct new issues open per hour", () => { - const gate = new NoiseGate() - let sent = 0 - for (let i = 0; i < 20; i++) { - if (gate.admit(`Distinct failure ${String.fromCharCode(97 + i)}`, {}, T0 + i * MIN).send) sent++ - } - assert.equal(sent, DEFAULT_GATE_LIMITS.maxNewPerHour) - // The window is rolling, so an hour later new issues can open again. - assert.equal(gate.admit("Distinct failure zz", {}, T0 + HOUR + MIN).send, true) -}) - -test("gate: total per hour never exceeds maxPerHour even across cooldowns", () => { - const gate = new NoiseGate({ cooldownMs: 0, maxNewPerHour: 1000 }) - let sent = 0 - for (let i = 0; i < 100; i++) { - if (gate.admit(`Failure ${i % 30}`, {}, T0 + i * 30_000).send) sent++ - } - // 100 events spread over 50 minutes — all inside one rolling hour. - assert.equal(sent, DEFAULT_GATE_LIMITS.maxPerHour) -}) - -test("gate: real crashes are never dropped, whatever the quota state", () => { - const gate = new NoiseGate() - // Exhaust every budget with ordinary errors first. - for (let i = 0; i < 50; i++) gate.admit(`Ordinary failure ${i}`, {}, T0 + i * MIN) - - const oom = gate.admit("OutOfMemoryError (okio.Buffer.readByteArray)", {}, T0 + 51 * MIN) - assert.equal(oom.send, true) - assert.equal(oom.reason, "always-send") - - // Repeats of a crash loop also survive — a relaunch-crash loop is a real signal. - const again = gate.admit("OutOfMemoryError (okio.Buffer.readByteArray)", {}, T0 + 52 * MIN) - assert.equal(again.send, true) - - const fatal = gate.admit("Some unhandled native failure", { fatal: true }, T0 + 53 * MIN) - assert.equal(fatal.send, true) - assert.equal(fatal.reason, "always-send") -}) - -test("gate: fingerprint state stays bounded (oldest evicted)", () => { - // Distinct, digit-free labels: fingerprint() maps every digit to '#', so - // numbered labels would all collapse into one key. - const label = (i: number) => - `failure ${i - .toString(26) - .split("") - .map((c) => String.fromCharCode(97 + parseInt(c, 26))) - .join("")}` - const gate = new NoiseGate({ maxNewPerHour: 1e6, maxPerHour: 1e6, maxTrackedFingerprints: 10 }) - for (let i = 0; i < 50; i++) gate.admit(label(i), {}, T0 + i) - - // The oldest fingerprints were evicted, so they are allowed to report again - // (bounded memory beats perfect dedup on a mobile client). - assert.equal(gate.admit(label(0), {}, T0 + MIN).send, true) - // A recently-seen one is still deduped. - assert.equal(gate.admit(label(49), {}, T0 + MIN).send, false) -}) - -test("gate: the AGE-105 mixed hour lands far under the old volume", () => { - const gate = new NoiseGate() - let sent = 0 - const at = (i: number) => T0 + i * 1000 - let i = 0 - // The observed mix, compressed into one hour. - for (let n = 0; n < 462; n++) if (gate.admit("connect timeout", {}, at(i++)).send) sent++ - for (let n = 0; n < 498; n++) if (gate.admit(`API Error: 401 - ${n}`, {}, at(i++)).send) sent++ - for (let n = 0; n < 157; n++) if (gate.admit("connect server-unreachable", {}, at(i++)).send) sent++ - for (let n = 0; n < 5; n++) if (gate.admit("Network request failed", {}, at(i++)).send) sent++ - for (let n = 0; n < 4; n++) if (gate.admit("OutOfMemoryError (okio.Buffer)", {}, at(i++)).send) sent++ - - // 1126 raw events -> 1 auth report + 4 real crashes. - assert.equal(sent, 5) -}) diff --git a/src/lib/sentry-noise.ts b/src/lib/sentry-noise.ts deleted file mode 100644 index cccdc227..00000000 --- a/src/lib/sentry-noise.ts +++ /dev/null @@ -1,252 +0,0 @@ -// Client-side Sentry noise gate (AGE-105). -// -// Why this exists: `opencode-mobile` became the #1 source of Sentry error -// volume in the org (~4,500 events/month against a 3,500/month org gate). -// Breaking that number down, the top three issues (~1,100 events) were NOT app -// defects: -// -// 462 events / 104 users Error: connect timeout -// 498 events / 1 user Error: API Error: 401 - … -// 157 events / 33 users Error: connect server-unreachable -// -// The `connect …` ones are `captureDiagnostic()` reports of client-side network -// conditions (user's LAN/VPN/self-hosted box is down). They are already shown to -// the user as UI state AND already counted, without PII, as the PostHog -// `connection_failed` event with an `error_class` property -// (see analytics-classify.ts + stores/connections.ts), so dropping them from -// Sentry loses no trend visibility — it just stops paying per-event for a graph -// we already have. -// -// The 401 storm was NOT an automated retry loop (AGE-107 traced it): it was one -// user manually re-tapping Connect for two months because v0.4.4's probe scored -// any HTTP response as a success and told them "connection actually works now" -// while their password was wrong. A wrong password is user config, not an app -// defect — it is already surfaced in the connection screen (now as -// `connect auth-failed`) and already trended in PostHog as -// `connection_failed{error_class:"unauthorized"}`, so it is dropped here too. -// -// Three layers, cheapest first (`admit()` applies them in order): -// 1. ALWAYS-SEND allowlist — genuine crash classes (OOM/ANR/native/fatal) -// bypass every limit below. Quota is worthless if it silences real crashes. -// 2. TRANSPORT drop-list — hard drop for client-side network conditions. -// Hard, not sampled: this gate runs per-install, so even "1 per device per -// day" multiplies by the install base back into thousands per month. -// 3. Dedup + rate cap — per-fingerprint cooldown plus new-fingerprint/hour and -// total/hour ceilings, mirroring the `openclaw-box-bot` shim (AGE-55) that -// took that project from 50.7 events/h to 0. This is what turns a retry -// loop into one report, and caps the blast radius of any future regression. -// -// This module is pure and dependency-free (no @sentry/react-native, no RN) so it -// runs under plain `node --test`, same convention as analytics-classify.ts / -// diagnostics-classify.ts / api-error.ts. - -/** Minimal structural shape of a Sentry event — avoids importing the SDK here - * so this module stays testable under plain node. */ -export type NoiseEventLike = { - level?: string - message?: string - exception?: { values?: Array<{ type?: string; value?: string; mechanism?: { handled?: boolean } }> } -} - -/** Client-side network and credential conditions. Unactionable server-side, - * already surfaced to the user as connection UI, and already trended in - * PostHog as `connection_failed{error_class}`. Dropped outright. */ -export const TRANSPORT_NOISE_PATTERNS: RegExp[] = [ - // captureDiagnostic() → new Error(`connect ${classification}`) - // `auth-failed` = the server answered 401/403: wrong password, user config. - // `health-failed` and `tls-error` are deliberately NOT here — a box that - // answers but is unhealthy, or a broken cert, is actionable. - /^connect (?:timeout|server-unreachable|no-internet|malformed-url|auth-failed)$/i, - // RN fetch failures surfacing through the global handler / rejection hook. - /network request failed/i, - /request timed out after/i, - /\b(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH)\b/, - /aborted due to timeout/i, -] - -/** Real crash classes that must never be filtered, whatever the quota says. - * Kept deliberately narrow — everything not listed here is still *reported*, - * just deduped/rate-capped rather than dropped. */ -export const ALWAYS_SEND_PATTERNS: RegExp[] = [ - /OutOfMemoryError/i, - /\bANR\b/, - /Application Not Responding/i, - /IllegalStateException/, - /NullPointerException/, - /SIGSEGV|SIGABRT|SIGBUS|EXC_BAD_ACCESS/, - /native crash/i, -] - -export type NoiseReason = - | "always-send" - | "ok" - | "transport-noise" - | "cooldown" - | "new-fingerprint-cap" - | "hourly-cap" - -export type NoiseDecision = { send: boolean; reason: NoiseReason; fingerprint: string } - -export type NoiseGateLimits = { - /** Per-fingerprint silence window after one report is sent. */ - cooldownMs: number - /** Max *distinct* fingerprints allowed to open a new report per rolling hour. */ - maxNewPerHour: number - /** Absolute ceiling of events sent per rolling hour, allowlist excluded. */ - maxPerHour: number - /** Bound on retained fingerprint state (oldest evicted first). */ - maxTrackedFingerprints: number -} - -const HOUR_MS = 60 * 60 * 1000 - -/** Mirrors the box-bot shim: cooldown=6h, max_new/h=6, max/h=10. */ -export const DEFAULT_GATE_LIMITS: NoiseGateLimits = { - cooldownMs: 6 * HOUR_MS, - maxNewPerHour: 6, - maxPerHour: 10, - maxTrackedFingerprints: 200, -} - -/** Flatten an event to the text the pattern lists match against. */ -export function eventText(event: NoiseEventLike): string { - const values = event.exception?.values ?? [] - const parts: string[] = [] - for (const ex of values) { - const type = ex.type && ex.type !== "Error" ? `${ex.type}: ` : "" - if (ex.value || ex.type) parts.push(`${type}${ex.value ?? ""}`.trim()) - } - if (!parts.length && event.message) parts.push(event.message) - return parts.join(" | ").trim() -} - -/** True for events that must bypass every limit: allowlisted crash classes, - * fatal level, or an unhandled native mechanism. */ -export function isFatalEvent(event: NoiseEventLike): boolean { - if (event.level === "fatal") return true - return (event.exception?.values ?? []).some((ex) => ex.mechanism?.handled === false) -} - -export function isTransportNoise(text: string): boolean { - if (!text) return false - return TRANSPORT_NOISE_PATTERNS.some((p) => p.test(text)) -} - -export function isAlwaysSend(text: string): boolean { - if (!text) return false - return ALWAYS_SEND_PATTERNS.some((p) => p.test(text)) -} - -/** Collapse an error message to a stable dedup key. - * - * The point is that `API Error: 401 - {"error":"token expired at 17867…"}` - * fired 498 times by one token-refresh loop must map to ONE key. So: keep the - * first line, cut the variable tail off `API Error: - `, blank - * out URLs/paths/hex/uuids/numbers, then truncate. */ -export function fingerprint(text: string): string { - let s = (text || "").split("\n")[0].trim() - // HTTP status is the one number worth keeping: 401 and 500 are different bugs. - const apiErr = s.match(/API Error:\s*(\d{3})\b/i) - if (apiErr) return `api error: ${apiErr[1]}` - s = s - .replace(/|/gi, "") - .replace(/https?:\/\/\S+/gi, "") - .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "") - .replace(/\b[0-9a-f]{12,}\b/gi, "") - .replace(/\/[^\s"']*/g, "") - .replace(/\d+/g, "#") - .replace(/\s+/g, " ") - .trim() - .toLowerCase() - return s.slice(0, 100) -} - -/** - * Dedup + rate limiter. Deterministic: every method takes an explicit `now` - * (defaulting to `Date.now()`), so tests drive time instead of sleeping. - * State is in-memory and per app process — a restart legitimately re-opens the - * cooldown, since a crash-restart loop is itself worth one report per launch. - */ -export class NoiseGate { - private readonly limits: NoiseGateLimits - /** fingerprint -> last time it was SENT */ - private readonly lastSent = new Map() - /** timestamps of events sent in the current rolling hour */ - private sentTimes: number[] = [] - /** timestamps at which a *previously unseen* fingerprint was opened */ - private newFingerprintTimes: number[] = [] - private dropped = 0 - - constructor(limits: Partial = {}) { - this.limits = { ...DEFAULT_GATE_LIMITS, ...limits } - } - - /** Number of events dropped since the last `takeDroppedCount()`. */ - takeDroppedCount(): number { - const n = this.dropped - this.dropped = 0 - return n - } - - admit(text: string, opts: { fatal?: boolean } = {}, now: number = Date.now()): NoiseDecision { - const fp = fingerprint(text) - - if (opts.fatal || isAlwaysSend(text)) { - // Still recorded so the hourly window reflects reality, but never blocked. - this.touch(fp, now) - this.sentTimes.push(now) - this.prune(now) - return { send: true, reason: "always-send", fingerprint: fp } - } - - if (isTransportNoise(text)) return this.drop("transport-noise", fp) - - this.prune(now) - - const last = this.lastSent.get(fp) - if (last !== undefined && now - last < this.limits.cooldownMs) { - return this.drop("cooldown", fp) - } - if (this.sentTimes.length >= this.limits.maxPerHour) { - return this.drop("hourly-cap", fp) - } - if (last === undefined && this.newFingerprintTimes.length >= this.limits.maxNewPerHour) { - return this.drop("new-fingerprint-cap", fp) - } - - if (last === undefined) this.newFingerprintTimes.push(now) - this.touch(fp, now) - this.sentTimes.push(now) - this.evictIfNeeded() - return { send: true, reason: "ok", fingerprint: fp } - } - - /** Re-insert so Map iteration order stays least-recently-sent first. */ - private touch(fp: string, now: number) { - this.lastSent.delete(fp) - this.lastSent.set(fp, now) - } - - private drop(reason: NoiseReason, fp: string): NoiseDecision { - this.dropped++ - return { send: false, reason, fingerprint: fp } - } - - private prune(now: number) { - const hourAgo = now - HOUR_MS - this.sentTimes = this.sentTimes.filter((t) => t > hourAgo) - this.newFingerprintTimes = this.newFingerprintTimes.filter((t) => t > hourAgo) - const cutoff = now - this.limits.cooldownMs - for (const [fp, t] of this.lastSent) if (t <= cutoff) this.lastSent.delete(fp) - } - - private evictIfNeeded() { - while (this.lastSent.size > this.limits.maxTrackedFingerprints) { - // Map preserves insertion order and we re-set on each send, so the first - // key is the least recently sent. - const oldest = this.lastSent.keys().next() - if (oldest.done) break - this.lastSent.delete(oldest.value) - } - } -} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index bb3c218b..15495cc4 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -1,101 +1,46 @@ -// Centralised Sentry wrapper. The goals here: -// 1. Capture every *unexpected* error: React render crashes, uncaught JS -// exceptions from the RN bridge, unhandled promise rejections, native -// crashes (handled by the Sentry RN SDK automatically). -// 2. Stay a strict no-op when no DSN is configured so dev/CI builds need no -// secrets and offline behaviour is unchanged. -// 3. Scrub URLs (basic-auth + query string) from every outgoing event so -// server addresses or tokens never leak to Sentry. -// 4. Provide small `addBreadcrumb` / `captureException` helpers so call sites -// get rich context without importing the Sentry SDK directly. -// 5. Keep event volume inside the org Sentry quota (AGE-105): a noise gate in -// `beforeSend` drops client-side network conditions and collapses retry -// loops, while genuine crashes pass through untouched. The decision logic -// lives in ./sentry-noise.ts so it is unit-testable without the RN SDK. +// Local-only crash logging. This fork sends no telemetry: the upstream Sentry +// wrapper is replaced by these no-op-or-log helpers so call sites stay the same. +// Uncaught errors still land in the in-memory log buffer, and therefore in any +// diagnostic report the user chooses to share. -import * as Sentry from "@sentry/react-native" -import appJson from "../../app.json" import { log } from "./logbuffer" -import { NoiseGate, eventText, isFatalEvent, isTransportNoise } from "./sentry-noise" import type { DiagnosticReport } from "./diagnostics" -const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN -const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" +export type Breadcrumb = { + category: string + message: string + level?: "debug" | "info" | "warning" | "error" + data?: Record +} + +export function addBreadcrumb(_crumb: Breadcrumb) {} -let enabled = false -let handlersInstalled = false +export function captureException( + err: unknown, + _context?: { tags?: Record; extra?: Record; level?: string }, +) { + log.error("crash", "captureException", toError(err).message) +} -// One gate per app process. Drops are counted and attached to the next event -// that does get through, so the quota saving stays visible in Sentry itself. -const noiseGate = new NoiseGate() +export function captureDiagnostic(report: DiagnosticReport) { + log.info("diag", "connect failed", report.classification) +} -export function initSentry() { - if (enabled) return - if (!DSN) { - log.info("sentry", "no DSN configured — telemetry disabled") - installGlobalHandlers() - return - } +function toError(value: unknown): Error { + if (value instanceof Error) return value + if (typeof value === "string") return new Error(value) try { - Sentry.init({ - dsn: DSN, - release: `opencode-mobile@${APP_VERSION}`, - dist: APP_VERSION, - // Performance tracing off by default; only error + crash capture. - tracesSampleRate: 0, - enableAutoSessionTracking: true, - // Don't ship default PII (IP, cookies). We attach what we want explicitly. - sendDefaultPii: false, - // Auto-capture uncaught JS exceptions AND unhandled promise rejections. - // The SDK enables these by default but we keep them on explicitly so a - // future config refactor can't silently drop coverage. - enableNative: true, - enableNativeCrashHandling: true, - enableAutoPerformanceTracing: false, - attachStacktrace: true, - maxBreadcrumbs: 100, - // Final pre-send stage: drop non-actionable noise (AGE-105), then strip - // URLs everywhere they could appear. - beforeSend(event) { - const filtered = applyNoiseGate(event) - return filtered ? scrubEvent(filtered) : null - }, - beforeBreadcrumb(crumb) { - // Console output can contain malformed server payloads, prompts, or code. - if (crumb.category === "console") return null - if (crumb.data && typeof crumb.data === "object") { - crumb.data = redactObject(crumb.data as Record) - } - if (typeof crumb.message === "string") crumb.message = redactString(crumb.message) - return crumb - }, - }) - enabled = true - Sentry.setTag("app.version", APP_VERSION) - log.info("sentry", "initialized", `release=opencode-mobile@${APP_VERSION}`) - } catch (e) { - log.warn("sentry", "init failed", String(e)) + return new Error(JSON.stringify(value)) + } catch { + return new Error(String(value)) } - installGlobalHandlers() } -export async function disableSentry() { - if (!enabled) return - enabled = false - await Sentry.close() - log.info("sentry", "disabled by user") -} +let installed = false -// Install belt-and-braces global handlers. The Sentry RN SDK already wires -// these via its ReactNativeErrorHandlers integration, but we layer our own on -// top so: -// * Errors still land in the in-memory log buffer (and therefore in any -// shared diagnostic report) even when Sentry is disabled. -// * Telemetry-disabled builds still leave a breadcrumb that something blew -// up, which is invaluable when triaging a user-shared report offline. -function installGlobalHandlers() { - if (handlersInstalled) return - handlersInstalled = true +export function installCrashLogging() { + if (installed) return + installed = true type GlobalErrorUtils = { getGlobalHandler?: () => (err: unknown, isFatal?: boolean) => void @@ -107,198 +52,18 @@ function installGlobalHandlers() { errorUtils.setGlobalHandler((err: unknown, isFatal?: boolean) => { const error = toError(err) log.error("crash", isFatal ? "FATAL" : "non-fatal", error.message, error.stack ?? "") - if (enabled) { - Sentry.captureException(error, (scope) => { - scope.setLevel(isFatal ? "fatal" : "error") - scope.setTag("crash.source", "js-global") - scope.setTag("crash.fatal", String(Boolean(isFatal))) - return scope - }) - } previous?.(err, isFatal) }) } - // Hermes/RN expose `onunhandledrejection` on the global object. type GlobalRejection = { onunhandledrejection?: (event: { reason?: unknown; promise?: unknown }) => void } const g = globalThis as unknown as GlobalRejection - const prevRej = g.onunhandledrejection + const prev = g.onunhandledrejection g.onunhandledrejection = (event) => { const error = toError(event?.reason) log.error("crash", "unhandled-rejection", error.message, error.stack ?? "") - if (enabled) { - Sentry.captureException(error, (scope) => { - scope.setLevel("error") - scope.setTag("crash.source", "promise-rejection") - return scope - }) - } - prevRej?.(event) - } -} - -function toError(value: unknown): Error { - if (value instanceof Error) return value - if (typeof value === "string") return new Error(value) - try { - return new Error(JSON.stringify(value)) - } catch { - return new Error(String(value)) - } -} - -// --- Noise gate (pure logic lives in ./sentry-noise for testability) ------ - -/** Returns the event to send, or null to drop it. Exported for the RN-side - * integration test; the pure rules are tested in sentry-noise.test.ts. */ -export function applyNoiseGate(event: T): T | null { - const text = eventText(event) - const decision = noiseGate.admit(text, { fatal: isFatalEvent(event) }) - if (!decision.send) { - log.info("sentry", "dropped event", decision.reason) - return null - } - const dropped = noiseGate.takeDroppedCount() - if (dropped > 0) { - event.tags = { ...event.tags, "noise.dropped_since_last": String(dropped) } - } - return event -} - -// --- Scrubbing (pure functions live in ./scrub for testability) ---------- - -export { scrubUrl } from "./scrub" - -function scrubEvent(event: T): T { - if (event.request?.url) event.request.url = "" - if (event.message) event.message = redactString(event.message) - if (event.exception?.values) { - for (const ex of event.exception.values) { - if (ex.value) ex.value = redactString(ex.value) - } - } - if (event.breadcrumbs) { - event.breadcrumbs = event.breadcrumbs.filter((crumb) => crumb.category !== "console") - for (const crumb of event.breadcrumbs) { - if (typeof crumb.message === "string") crumb.message = redactString(crumb.message) - if (crumb.data && typeof crumb.data === "object") { - crumb.data = redactObject(crumb.data as Record) - } - } - } - return event -} - -function redactString(value: string): string { - return value.replace(/https?:\/\/[^\s)\]}"']+/gi, "") -} - -function redactObject(value: Record): Record { - const redacted: Record = {} - for (const [key, item] of Object.entries(value)) { - if ( - /^(?:id|.*Id|.*ID|url|host|hostname|port|address|server|serverUrl|target|endpoint|authorization|auth|token|password|secret|apiKey|username|cookie)$/i.test( - key, - ) - ) { - redacted[key] = "" - continue - } - if (typeof item === "string") { - redacted[key] = redactString(item) - continue - } - if (Array.isArray(item)) { - redacted[key] = item.map((entry) => - typeof entry === "string" - ? redactString(entry) - : entry && typeof entry === "object" - ? redactObject(entry as Record) - : entry, - ) - continue - } - if (item && typeof item === "object") { - redacted[key] = redactObject(item as Record) - continue - } - redacted[key] = item + prev?.(event) } - return redacted } - -// --- Helpers exposed to the rest of the app ------------------------------ - -export type Breadcrumb = { - category: string - message: string - level?: "debug" | "info" | "warning" | "error" - data?: Record -} - -export function addBreadcrumb(crumb: Breadcrumb) { - if (!enabled) return - Sentry.addBreadcrumb({ - category: crumb.category, - message: crumb.message, - level: crumb.level ?? "info", - data: crumb.data, - timestamp: Date.now() / 1000, - }) -} - -export function captureException( - err: unknown, - context?: { tags?: Record; extra?: Record; level?: Sentry.SeverityLevel }, -) { - const error = toError(err) - log.error("sentry", "captureException", error.message) - if (!enabled) return - Sentry.withScope((scope) => { - if (context?.level) scope.setLevel(context.level) - if (context?.tags) for (const [k, v] of Object.entries(context.tags)) scope.setTag(k, v) - if (context?.extra) for (const [k, v] of Object.entries(context.extra)) scope.setExtra(k, v) - Sentry.captureException(error) - }) -} - -export function captureDiagnostic(report: DiagnosticReport) { - log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)") - if (!enabled) return - // Client-side network conditions (timeout / unreachable / no internet) and - // wrong credentials (auth-failed) are already shown to the user and already - // trended in PostHog as `connection_failed{error_class}`; they were the - // single largest consumer of the org Sentry quota (AGE-105 / AGE-107). Skip - // them here so we don't even build the event. `health-failed` / `tls-error` - // are genuinely actionable and still go. - if (isTransportNoise(`connect ${report.classification}`)) { - log.info("sentry", "skipped diagnostic upload (client-side network condition)", report.classification) - return - } - Sentry.withScope((scope) => { - scope.setTag("connect.classification", report.classification) - scope.setTag("connect.scheme", report.scheme ?? "n/a") - scope.setContext("connection", { - targetType: report.isHostname ? "hostname" : "ip-address", - }) - scope.setContext("probes", { - attempts: report.attempts.map((a) => ({ - name: a.name, - ok: a.ok, - status: a.status, - durationMs: a.durationMs, - })), - }) - scope.setContext("device", report.device) - Sentry.captureException(new Error(`connect ${report.classification}`)) - }) -} - -// React error boundaries are implemented as our own class component -// (see src/components/ErrorBoundary.tsx) so we can render a useful -// "Share diagnostic" fallback. We still expose Sentry.wrap as `wrap` -// for callers that just want auto-capture without a custom fallback. -export const wrap = Sentry.wrap -export const sentryEnabled = () => enabled diff --git a/src/lib/session-layout.test.ts b/src/lib/session-layout.test.ts new file mode 100644 index 00000000..4db66b78 --- /dev/null +++ b/src/lib/session-layout.test.ts @@ -0,0 +1,36 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + COMPOSER_MAX_HEIGHT, + COMPOSER_MIN_HEIGHT, + QUESTION_BODY_SCROLL_PROPS, + composerMaxHeight, +} from "./session-layout.ts" + +test("composer keeps its established cap while the Android IME is hidden", () => { + assert.equal(composerMaxHeight("android", 768, null, 39), COMPOSER_MAX_HEIGHT) + assert.equal(composerMaxHeight("ios", 768, 377, 39), COMPOSER_MAX_HEIGHT) +}) + +test("composer keeps multiple lines visible on compact Android IME geometry", () => { + assert.equal(composerMaxHeight("android", 768, 377, 39), COMPOSER_MIN_HEIGHT) + assert.equal(composerMaxHeight("android", 480, 250, 24), COMPOSER_MIN_HEIGHT) +}) + +test("composer preserves multiple draft lines at Android accessibility text scaling", () => { + assert.equal(composerMaxHeight("android", 768, 377, 39, 1.5), COMPOSER_MIN_HEIGHT) + assert.equal(composerMaxHeight("android", 768, 377, 39, 2), 100) +}) + +test("composer responds to keyboard resize without exceeding the established cap", () => { + assert.equal(composerMaxHeight("android", 900, 700, 24), COMPOSER_MAX_HEIGHT) + assert.equal(composerMaxHeight("android", 700, 380, 24), 106) +}) + +test("question body uses the only vertical overflow owner and retains navigation taps", () => { + assert.deepEqual(QUESTION_BODY_SCROLL_PROPS, { + keyboardShouldPersistTaps: "handled", + nestedScrollEnabled: true, + showsVerticalScrollIndicator: true, + }) +}) diff --git a/src/lib/session-layout.ts b/src/lib/session-layout.ts new file mode 100644 index 00000000..833f0025 --- /dev/null +++ b/src/lib/session-layout.ts @@ -0,0 +1,27 @@ +// Android keyboard offset/padding lives in ./keyboard-offset (#201). +export const COMPOSER_MAX_HEIGHT = 120 +export const COMPOSER_MIN_HEIGHT = 88 +const COMPOSER_LINE_HEIGHT = 20 +const COMPOSER_VERTICAL_PADDING = 20 + +export function composerMaxHeight( + platform: string, + windowHeight: number, + keyboardY: number | null, + insetTop: number, + fontScale = 1, +): number { + if (platform !== "android" || keyboardY === null) return COMPOSER_MAX_HEIGHT + + // Keep multiple scaled draft lines usable on compact IME-resized windows while + // reserving room for the question/transcript surface and composer controls. + const visibleHeight = Math.max(0, Math.min(windowHeight, keyboardY) - Math.max(0, insetTop)) + const scaledMinimum = Math.ceil(COMPOSER_LINE_HEIGHT * Math.max(1, fontScale) * 2 + COMPOSER_VERTICAL_PADDING) + return Math.min(COMPOSER_MAX_HEIGHT, Math.max(COMPOSER_MIN_HEIGHT, scaledMinimum, visibleHeight - 250)) +} + +export const QUESTION_BODY_SCROLL_PROPS = { + keyboardShouldPersistTaps: "handled", + nestedScrollEnabled: true, + showsVerticalScrollIndicator: true, +} as const diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts deleted file mode 100644 index fdd335bc..00000000 --- a/src/lib/telemetry.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Telemetry consent + initialisation gate. - * - * Wraps sentry.ts AND analytics.ts so that initSentry()/initAnalytics() are - * only called when the user has explicitly opted in. Both crash reporting - * and activation-funnel analytics share this single consent flag — there is - * no separate toggle for analytics. Consent state is persisted in - * expo-secure-store so it survives app restarts. - * - * Usage: - * import { loadTelemetryConsent, setTelemetryConsent, hasTelemetryConsent } from './telemetry' - * - * // On app start — call BEFORE trying to initialise Sentry/analytics. - * const state = await loadTelemetryConsent() // 'granted' | 'denied' | 'unknown' - * if (state === 'granted') { initSentry(); initAnalytics() } - * - * // After the user taps "Allow" in the consent modal: - * await setTelemetryConsent(true) // persists + calls initSentry()/initAnalytics() if not already done - * - * // Check in Settings screen: - * const current = hasTelemetryConsent() // boolean | null (null = not yet decided) - */ - -import * as SecureStore from "expo-secure-store" -import { disableSentry, initSentry, sentryEnabled } from "./sentry" -import { initAnalytics, shutdownAnalytics, analyticsEnabled, trackAppOpened } from "./analytics" - -const CONSENT_KEY = "opencode_telemetry_consent" - -export type ConsentState = "granted" | "denied" | "unknown" - -let _resolved: boolean | null = null // null = unknown, true = granted, false = denied -let transition = Promise.resolve() - -/** - * Load persisted consent from SecureStore. - * Returns 'unknown' if the user has never been asked. - */ -export async function loadTelemetryConsent(): Promise { - try { - const stored = await SecureStore.getItemAsync(CONSENT_KEY) - if (stored === "granted") { - _resolved = true - return "granted" - } - if (stored === "denied") { - _resolved = false - return "denied" - } - // No stored value — first launch - _resolved = null - return "unknown" - } catch { - _resolved = false - return "denied" - } -} - -/** - * Returns the in-memory resolved state (set by loadTelemetryConsent or setTelemetryConsent). - * null = consent decision not yet loaded. - * true = granted. - * false = denied. - */ -export function hasTelemetryConsent(): boolean | null { - return _resolved -} - -/** - * Persist the user's consent decision and, if granted and Sentry is not yet - * running, initialise it immediately. - */ -export function setTelemetryConsent(granted: boolean): Promise { - const next = transition.then(() => applyTelemetryConsent(granted)) - transition = next.catch(() => undefined) - return next -} - -async function applyTelemetryConsent(granted: boolean): Promise { - if (granted) { - await SecureStore.setItemAsync(CONSENT_KEY, "granted") - _resolved = true - if (!sentryEnabled()) initSentry() - if (!analyticsEnabled()) initAnalytics() - // First-ever session reaches here via the consent modal's "Allow" (app - // start skipped init because consent was still unknown), so app_opened - // must also fire on the grant transition — otherwise the true first - // session emits nothing and session 2 gets mislabeled is_first_open. - // trackAppOpened() is internally once-per-session, so a mid-session - // revoke -> re-grant cannot double-count. - void trackAppOpened() - return - } - - _resolved = false - await disableSentry() - await shutdownAnalytics() - try { - await SecureStore.setItemAsync(CONSENT_KEY, "denied") - } catch (error) { - await SecureStore.deleteItemAsync(CONSENT_KEY) - throw error - } -} diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts index 70005a7c..ca32b62c 100644 --- a/src/lib/update-check.ts +++ b/src/lib/update-check.ts @@ -38,11 +38,11 @@ import { export type { AvailableUpdate } -/** Same source Sentry uses for `release`, so the two always agree. */ +/** The version in app.json; release tags (vX.Y.Z) must match it. */ export const CURRENT_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" -const RELEASES_API = "https://api.github.com/repos/dzianisv/opencode-mobile/releases/latest" -const RELEASES_PAGE = "https://github.com/dzianisv/opencode-mobile/releases/latest" +const RELEASES_API = "https://api.github.com/repos/kjeldahl/opencode-mobile/releases/latest" +const RELEASES_PAGE = "https://github.com/kjeldahl/opencode-mobile/releases/latest" const TIMEOUT_MS = 8000 const storage: UpdateStorage = { diff --git a/src/lib/use-keyboard-height.ts b/src/lib/use-keyboard-height.ts new file mode 100644 index 00000000..d6c218a5 --- /dev/null +++ b/src/lib/use-keyboard-height.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react" +import { Keyboard, Platform } from "react-native" + +// Android IME height, 0 elsewhere. Under edge-to-edge Android no longer resizes +// the window for the keyboard, so screens pad themselves by this amount. Same +// listener pattern as the chat composer (#201): keyboardDidHide resets to 0, so +// no stale padding survives the keyboard closing. +export function useKeyboardHeight(): number { + const [height, setHeight] = useState(() => (Platform.OS === "android" ? (Keyboard.metrics()?.height ?? 0) : 0)) + + useEffect(() => { + if (Platform.OS !== "android") return + const show = Keyboard.addListener("keyboardDidShow", (event) => setHeight(event.endCoordinates.height)) + const hide = Keyboard.addListener("keyboardDidHide", () => setHeight(0)) + setHeight(Keyboard.metrics()?.height ?? 0) + return () => { + show.remove() + hide.remove() + } + }, []) + + return height +} diff --git a/src/lib/waitlist-queue-storage.ts b/src/lib/waitlist-queue-storage.ts deleted file mode 100644 index d9e16be6..00000000 --- a/src/lib/waitlist-queue-storage.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Production wiring for the waitlist retry queue (AGE-87). -// -// Split out from waitlist-queue.ts so the queue logic stays importable by -// `node --test` (no react-native / AsyncStorage native module). This file is -// the only place that touches device storage. -// -// AsyncStorage (not SecureStore) on purpose: a pending waitlist email is not a -// credential, and this queue must survive a keychain that refuses to unlock — -// the whole point is that the signup is never silently lost. - -import AsyncStorage from "@react-native-async-storage/async-storage" -import { flushQueue, loadQueue, enqueueSignup, removeFromQueue, type FlushOutcome, type QueuedSignup, type QueueStorage } from "./waitlist" - -const storage: QueueStorage = { - getItem: (key) => AsyncStorage.getItem(key), - setItem: (key, value) => AsyncStorage.setItem(key, value), - removeItem: (key) => AsyncStorage.removeItem(key), -} - -export function queuePendingSignup(email: string, error?: string): Promise { - return enqueueSignup(storage, email, { error }) -} - -export function readPendingSignups(): Promise { - return loadQueue(storage) -} - -export function dropPendingSignup(email: string): Promise { - return removeFromQueue(storage, email) -} - -/** Best-effort retry of every pending signup. Safe to call on every foreground. */ -export function flushPendingSignups(): Promise { - return flushQueue(storage) -} diff --git a/src/lib/waitlist-queue.test.ts b/src/lib/waitlist-queue.test.ts deleted file mode 100644 index f1d5940c..00000000 --- a/src/lib/waitlist-queue.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { test } from "node:test" -import assert from "node:assert/strict" -import { - WAITLIST_QUEUE_KEY, - WAITLIST_QUEUE_MAX, - WAITLIST_QUEUE_TTL_MS, - enqueueSignup, - flushQueue, - loadQueue, - needsManualEscapeHatch, - pruneQueue, - removeFromQueue, - type QueueStorage, - type QueuedSignup, - type WaitlistResult, -} from "./waitlist.ts" - -// In-memory stand-in for AsyncStorage. `fail` makes every write throw, which is -// how a device with full/locked storage behaves. -function memoryStorage(initial?: string, opts: { failWrites?: boolean } = {}) { - const map = new Map() - if (initial !== undefined) map.set(WAITLIST_QUEUE_KEY, initial) - const storage: QueueStorage & { map: Map } = { - map, - async getItem(key) { - return map.get(key) ?? null - }, - async setItem(key, value) { - if (opts.failWrites) throw new Error("storage full") - map.set(key, value) - }, - async removeItem(key) { - if (opts.failWrites) throw new Error("storage full") - map.delete(key) - }, - } - return storage -} - -function stored(storage: { map: Map }): QueuedSignup[] { - const raw = storage.map.get(WAITLIST_QUEUE_KEY) - return raw ? (JSON.parse(raw) as QueuedSignup[]) : [] -} - -const ok = (email: string): WaitlistResult => ({ ok: true, email }) -const offline = (email: string): WaitlistResult => ({ ok: false, email, retryable: true, error: "Network request failed" }) -const rejected = (email: string): WaitlistResult => ({ ok: false, email, retryable: false, error: "Enter a valid email address." }) - -// --- enqueue --- - -test("enqueue persists a normalized signup with attempt 1", async () => { - const storage = memoryStorage() - const entry = await enqueueSignup(storage, " Dev@Example.COM ", { now: 1_000, error: "Network request failed" }) - assert.deepEqual(entry, { - email: "dev@example.com", - queuedAt: 1_000, - attempts: 1, - lastAttemptAt: 1_000, - lastError: "Network request failed", - }) - assert.deepEqual(stored(storage), [entry]) -}) - -test("enqueue dedupes by email and bumps attempts instead of growing the queue", async () => { - const storage = memoryStorage() - await enqueueSignup(storage, "dev@example.com", { now: 1_000 }) - const second = await enqueueSignup(storage, "DEV@example.com", { now: 2_000 }) - assert.equal(stored(storage).length, 1) - assert.equal(second?.attempts, 2) - assert.equal(second?.queuedAt, 1_000, "original queue time is preserved") - assert.equal(second?.lastAttemptAt, 2_000) -}) - -test("enqueue caps the queue at WAITLIST_QUEUE_MAX, keeping the newest", async () => { - const storage = memoryStorage() - for (let i = 0; i < WAITLIST_QUEUE_MAX + 3; i++) { - await enqueueSignup(storage, `user${i}@example.com`, { now: 1_000 + i }) - } - const queue = stored(storage) - assert.equal(queue.length, WAITLIST_QUEUE_MAX) - assert.equal(queue[queue.length - 1].email, `user${WAITLIST_QUEUE_MAX + 2}@example.com`) -}) - -test("enqueue refuses an invalid email — nothing is stored, caller must not claim success", async () => { - const storage = memoryStorage() - assert.equal(await enqueueSignup(storage, "not-an-email"), null) - assert.equal(storage.map.size, 0) -}) - -test("enqueue returns null when storage refuses the write (never lie about saving)", async () => { - const storage = memoryStorage(undefined, { failWrites: true }) - assert.equal(await enqueueSignup(storage, "dev@example.com", { now: 1 }), null) -}) - -// --- load resilience: a corrupt blob must not brick the screen --- - -test("load tolerates missing, corrupt, non-array and foreign-shaped payloads", async () => { - assert.deepEqual(await loadQueue(memoryStorage()), []) - assert.deepEqual(await loadQueue(memoryStorage("{not json")), []) - assert.deepEqual(await loadQueue(memoryStorage('{"email":"a@b.co"}')), []) - assert.deepEqual(await loadQueue(memoryStorage('[{"nope":1},null,3]')), []) - assert.deepEqual( - await loadQueue(memoryStorage('[{"email":"NOT normalized","queuedAt":1,"attempts":1}]')), - [], - "entries that were never normalized are dropped rather than replayed as garbage", - ) -}) - -test("load backfills lastAttemptAt for entries written before that field existed", async () => { - const storage = memoryStorage('[{"email":"dev@example.com","queuedAt":42,"attempts":1}]') - assert.deepEqual(await loadQueue(storage), [ - { email: "dev@example.com", queuedAt: 42, attempts: 1, lastAttemptAt: 42 }, - ]) -}) - -test("load survives a storage read that throws", async () => { - const storage: QueueStorage = { - async getItem() { - throw new Error("AsyncStorage unavailable") - }, - async setItem() {}, - async removeItem() {}, - } - assert.deepEqual(await loadQueue(storage), []) -}) - -// --- TTL --- - -test("entries older than the TTL are pruned", () => { - const fresh: QueuedSignup = { email: "a@b.co", queuedAt: 1_000, attempts: 1, lastAttemptAt: 1_000 } - const stale: QueuedSignup = { email: "c@d.co", queuedAt: 0, attempts: 9, lastAttemptAt: 0 } - assert.deepEqual(pruneQueue([fresh, stale], WAITLIST_QUEUE_TTL_MS + 500), [fresh]) -}) - -// --- flush: the AGE-87 acceptance test --- - -test("offline signup reaches the server on the next flush, with no mail client involved", async () => { - const storage = memoryStorage() - - // 1. User taps "Join Waitlist" on a plane: the POST fails, we queue it. - const failure = offline("dev@example.com") - assert.equal(failure.ok, false) - const queued = await enqueueSignup(storage, "dev@example.com", { now: 1_000, error: failure.error }) - assert.equal(queued?.attempts, 1) - - // 2. Still offline on the next foreground: kept, attempts bumped. - const attempted: string[] = [] - const stillOffline = await flushQueue(storage, { - now: 2_000, - submit: async (email) => { - attempted.push(email) - return offline(email) - }, - }) - assert.deepEqual(stillOffline.synced, []) - assert.deepEqual(stillOffline.pending.map((e) => e.attempts), [2]) - assert.deepEqual(stored(storage).map((e) => e.email), ["dev@example.com"]) - - // 3. Device reconnects: the queued signup lands in Brevo list 4 and the - // queue empties. No mailto: URL was ever built. - const reconnected = await flushQueue(storage, { - now: 3_000, - submit: async (email) => { - attempted.push(email) - return ok(email) - }, - }) - assert.deepEqual(reconnected.synced, ["dev@example.com"]) - assert.deepEqual(reconnected.pending, []) - assert.equal(storage.map.has(WAITLIST_QUEUE_KEY), false, "queue key is removed once empty") - assert.deepEqual(attempted, ["dev@example.com", "dev@example.com"]) -}) - -test("flush drops entries the server permanently rejects (4xx) instead of retrying forever", async () => { - const storage = memoryStorage() - await enqueueSignup(storage, "dev@example.com", { now: 1 }) - const outcome = await flushQueue(storage, { now: 2, submit: async (email) => rejected(email) }) - assert.deepEqual(outcome.rejected, ["dev@example.com"]) - assert.deepEqual(outcome.pending, []) - assert.deepEqual(stored(storage), []) -}) - -test("flush keeps the other entries when one submit throws", async () => { - const storage = memoryStorage() - await enqueueSignup(storage, "a@example.com", { now: 1 }) - await enqueueSignup(storage, "b@example.com", { now: 2 }) - const outcome = await flushQueue(storage, { - now: 3, - submit: async (email) => { - if (email === "a@example.com") throw new TypeError("boom") - return ok(email) - }, - }) - assert.deepEqual(outcome.synced, ["b@example.com"]) - assert.deepEqual(outcome.pending.map((e) => e.email), ["a@example.com"]) - assert.match(outcome.pending[0].lastError ?? "", /boom/) -}) - -test("flush prunes expired entries without submitting them", async () => { - const storage = memoryStorage() - await enqueueSignup(storage, "old@example.com", { now: 0 }) - let called = 0 - const outcome = await flushQueue(storage, { - now: WAITLIST_QUEUE_TTL_MS + 1, - submit: async (email) => { - called++ - return ok(email) - }, - }) - assert.equal(called, 0) - assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] }) - assert.deepEqual(stored(storage), []) -}) - -test("flush on an empty queue is a no-op", async () => { - const storage = memoryStorage() - const outcome = await flushQueue(storage, { submit: async () => assert.fail("must not submit") }) - assert.deepEqual(outcome, { synced: [], rejected: [], pending: [] }) -}) - -// --- manual escape hatch policy --- - -test("the manual email escape hatch appears only after repeated failures", async () => { - const storage = memoryStorage() - let entry = await enqueueSignup(storage, "dev@example.com", { now: 1 }) - assert.equal(needsManualEscapeHatch(entry), false) - entry = await enqueueSignup(storage, "dev@example.com", { now: 2 }) - assert.equal(needsManualEscapeHatch(entry), false) - entry = await enqueueSignup(storage, "dev@example.com", { now: 3 }) - assert.equal(needsManualEscapeHatch(entry), true, "3rd failed attempt -> offer 'still not working? email us'") - assert.equal(needsManualEscapeHatch(null), false) -}) - -// --- explicit removal --- - -test("removeFromQueue drops just that email", async () => { - const storage = memoryStorage() - await enqueueSignup(storage, "a@example.com", { now: 1 }) - await enqueueSignup(storage, "b@example.com", { now: 2 }) - await removeFromQueue(storage, "a@example.com") - assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"]) - await removeFromQueue(storage, "missing@example.com") // no-op, no throw - assert.deepEqual(stored(storage).map((e) => e.email), ["b@example.com"]) -}) diff --git a/src/lib/waitlist.test.ts b/src/lib/waitlist.test.ts deleted file mode 100644 index a42a70e2..00000000 --- a/src/lib/waitlist.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { test } from "node:test" -import assert from "node:assert/strict" -import { - WAITLIST_ENDPOINT, - WAITLIST_SOURCE, - normalizeWaitlistEmail, - buildWaitlistPayload, - buildWaitlistMailtoUrl, - isRetryableFailure, - submitWaitlistSignup, -} from "./waitlist.ts" - -// --- normalizeWaitlistEmail: mirror of the server's 400 rule --- - -test("normalize trims and lowercases like the server", () => { - assert.equal(normalizeWaitlistEmail(" Dev@Example.COM "), "dev@example.com") -}) - -test("normalize rejects what the server would 400 on", () => { - assert.equal(normalizeWaitlistEmail(""), null) - assert.equal(normalizeWaitlistEmail(" "), null) - assert.equal(normalizeWaitlistEmail("not-an-email"), null) - assert.equal(normalizeWaitlistEmail("a b@example.com"), null) - assert.equal(normalizeWaitlistEmail("no-tld@host"), null) - assert.equal(normalizeWaitlistEmail(`${"x".repeat(250)}@a.com`), null) // > 254 chars -}) - -// --- payload: the source tag is the whole point of #87 --- - -test("payload tags the signup with the opencode-connect source", () => { - assert.deepEqual(buildWaitlistPayload("dev@example.com"), { - email: "dev@example.com", - source: WAITLIST_SOURCE, - }) -}) - -// --- mailto URL: last-resort escape hatch only (AGE-87), byte-compatible --- - -test("mailto escape hatch preserves subject and embeds the email", () => { - const url = buildWaitlistMailtoUrl("dev@example.com") - assert.ok(url.startsWith("mailto:support@agentlabs.cc?")) - assert.ok(url.includes("subject=OpenCode%20Connect%20Waitlist")) - assert.ok(url.includes(encodeURIComponent("Email: dev@example.com"))) -}) - -test("mailto escape hatch works without an email", () => { - const url = buildWaitlistMailtoUrl("") - assert.ok(url.includes("body=Sign%20me%20up!")) - assert.ok(!url.includes("Email")) -}) - -// AGE-100: the reconciler has to be able to say which build produced a mailto -// signup. Pre-v0.4.8 sideloads (no signup API, unreachable by any release) -// send no App: line; a current build sending one means the retry queue leaked -// and that is a new defect, not the known stale cohort. -test("mailto escape hatch stamps the app version when given one", () => { - const url = buildWaitlistMailtoUrl("dev@example.com", "0.4.13") - assert.ok(url.includes(encodeURIComponent("App: OpenCode Mobile v0.4.13"))) - assert.ok(url.includes(encodeURIComponent("Email: dev@example.com"))) -}) - -test("mailto escape hatch omits the version line when the version is unknown", () => { - const url = buildWaitlistMailtoUrl("dev@example.com") - assert.ok(!url.includes("App%3A")) -}) - -// --- retry decision --- - -// 502 named explicitly: the server returns it when Brevo itself fails, and the -// signup must survive that by being queued and retried (AGE-87), not mailed. -test("retryable: transport failures and 5xx (incl. 502 Brevo failure) -> retry, 4xx -> fix input", () => { - assert.equal(isRetryableFailure({ kind: "network-error" }), true) - assert.equal(isRetryableFailure({ kind: "http", status: 500 }), true) - assert.equal(isRetryableFailure({ kind: "http", status: 502 }), true) - assert.equal(isRetryableFailure({ kind: "http", status: 503 }), true) - assert.equal(isRetryableFailure({ kind: "http", status: 400 }), false) - assert.equal(isRetryableFailure({ kind: "http", status: 429 }), false) -}) - -// --- submitWaitlistSignup with injected fetch --- - -type FetchCall = { url: string; init: { method: string; headers: Record; body: string; signal: AbortSignal } } - -function fakeFetch(response: { ok: boolean; status: number; body?: unknown }, calls: FetchCall[] = []) { - return async (url: string, init: FetchCall["init"]) => { - calls.push({ url, init }) - return { ok: response.ok, status: response.status, json: async () => response.body ?? null } - } -} - -test("submit posts the tagged payload to the beta-signup endpoint", async () => { - const calls: FetchCall[] = [] - const result = await submitWaitlistSignup(" Dev@Example.com ", { fetchFn: fakeFetch({ ok: true, status: 200, body: { ok: true } }, calls) }) - assert.deepEqual(result, { ok: true, email: "dev@example.com" }) - assert.equal(calls.length, 1) - assert.equal(calls[0].url, WAITLIST_ENDPOINT) - assert.equal(calls[0].init.method, "POST") - assert.equal(calls[0].init.headers["content-type"], "application/json") - assert.deepEqual(JSON.parse(calls[0].init.body), { email: "dev@example.com", source: WAITLIST_SOURCE }) - assert.ok(calls[0].init.signal instanceof AbortSignal) -}) - -test("submit rejects an invalid email locally without hitting the network", async () => { - const calls: FetchCall[] = [] - const result = await submitWaitlistSignup("nope", { fetchFn: fakeFetch({ ok: true, status: 200 }, calls) }) - assert.equal(calls.length, 0) - assert.deepEqual(result, { ok: false, email: "nope", retryable: false, error: "Enter a valid email address." }) -}) - -test("submit surfaces the server's 400 message and marks it non-retryable", async () => { - const result = await submitWaitlistSignup("dev@example.com", { - fetchFn: fakeFetch({ ok: false, status: 400, body: { error: "Enter a valid email address." } }), - }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: false, error: "Enter a valid email address." }) -}) - -test("submit marks 5xx retryable (server broken, keep the signup alive)", async () => { - const result = await submitWaitlistSignup("dev@example.com", { - fetchFn: fakeFetch({ ok: false, status: 503, body: { error: "The waitlist is temporarily unavailable. Please try again later." } }), - }) - assert.equal(result.ok, false) - if (!result.ok) { - assert.equal(result.retryable, true) - assert.equal(result.error, "The waitlist is temporarily unavailable. Please try again later.") - } -}) - -test("submit marks a rejected fetch retryable (offline)", async () => { - const result = await submitWaitlistSignup("dev@example.com", { - fetchFn: async () => { - throw new TypeError("Network request failed") - }, - }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Network request failed" }) -}) - -test("submit aborts after timeoutMs and marks it retryable", async () => { - const result = await submitWaitlistSignup("dev@example.com", { - timeoutMs: 20, - fetchFn: (_url, init) => - new Promise((_resolve, reject) => { - init.signal.addEventListener("abort", () => { - const err = new Error("Aborted") - err.name = "AbortError" - reject(err) - }) - }), - }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "timeout after 20ms" }) -}) - -test("submit tolerates a non-JSON error body", async () => { - const result = await submitWaitlistSignup("dev@example.com", { - fetchFn: async () => ({ - ok: false, - status: 502, - json: async () => { - throw new SyntaxError("Unexpected token") - }, - }), - }) - assert.deepEqual(result, { ok: false, email: "dev@example.com", retryable: true, error: "Signup failed (HTTP 502)." }) -}) diff --git a/src/lib/waitlist.ts b/src/lib/waitlist.ts deleted file mode 100644 index 11f9cb00..00000000 --- a/src/lib/waitlist.ts +++ /dev/null @@ -1,349 +0,0 @@ -// OpenCode Connect waitlist signup: pure payload/fallback logic. -// -// Kept free of react-native imports (Linking, Alert) so it's unit-testable -// with plain `node --test`, the same split used for buildAuth() in auth.ts -// and shouldRequestReview() in store-review-policy.ts. The screen injects -// nothing in production (global fetch is used); tests inject a fake fetch. -// -// Backend: the OpenCodeMobileSite beta-signup route (VibeBrowserProductPage -// repo, OpenCodeMobileSite/app/api/beta-signup/route.ts) validates `email` -// and adds it to a Brevo list. It ignores unknown body fields today, so the -// `source` tag we send is forward-compatible: harmless now, attributable as -// soon as the route starts reading it. - -export const WAITLIST_ENDPOINT = "https://opencode.agentlabs.cc/api/beta-signup" -export const WAITLIST_SOURCE = "opencode-connect-waitlist" -export const WAITLIST_TIMEOUT_MS = 8_000 -export const WAITLIST_FALLBACK_EMAIL = "support@agentlabs.cc" - -// Mirrors the server-side pattern in brevo-contact.ts so we reject locally -// exactly what the server would 400 on, instead of burning a round trip. -const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - -/** Trim/lowercase like the server does; null when the server would 400. */ -export function normalizeWaitlistEmail(raw: string): string | null { - const email = raw.trim().toLowerCase() - if (!email || email.length > 254 || !emailPattern.test(email)) return null - return email -} - -export function buildWaitlistPayload(email: string): { email: string; source: string } { - return { email, source: WAITLIST_SOURCE } -} - -/** - * Last-resort, USER-INITIATED escape hatch (AGE-87). Never open this - * automatically: a failed signup is queued locally and retried - * (waitlist-queue.ts). This URL is only offered after retries keep failing, - * behind an explicit "still not working? email us" tap. - * - * `appVersion` stamps the build into the mail body (AGE-100). Without it the - * hourly reconciler that heals these mails - * (VibeBrowserProductPage/scripts/reconcile-waitlist-mailto.js) cannot tell a - * ~436-device pre-v0.4.8 sideload — which has no signup API at all and can - * never be fixed by shipping code — from a current build that still fell back, - * which would be a live defect. Absence of the line means "pre-v0.4.13 build". - */ -export function buildWaitlistMailtoUrl(email: string, appVersion?: string): string { - const subject = encodeURIComponent("OpenCode Connect Waitlist") - const lines = ["Sign me up!"] - if (email) lines.push("", `Email: ${email}`) - if (appVersion) lines.push("", `App: OpenCode Mobile v${appVersion}`) - const body = encodeURIComponent(lines.join("\n")) - return `mailto:${WAITLIST_FALLBACK_EMAIL}?subject=${subject}&body=${body}` -} - -export type WaitlistResult = - /** Signup persisted server-side. */ - | { ok: true; email: string } - /** Signup not persisted. `retryable` decides the UX: true -> the same - * request can succeed later, so queue it and retry on reconnect; - * false -> the input (or server validation) is wrong, ask the user to fix - * their email instead of queueing garbage forever. */ - | { ok: false; email: string; retryable: boolean; error: string } - -/** - * Retry decision, isolated for testability: - * - transport failure (offline, DNS, timeout) -> retry when connectivity returns - * - 5xx -> server broken through no fault of the user's -> retry - * - 4xx -> the server rejected this email; repeating it wouldn't help - */ -export function isRetryableFailure(outcome: { kind: "network-error" } | { kind: "http"; status: number }): boolean { - if (outcome.kind === "network-error") return true - return outcome.status >= 500 -} - -type FetchLike = ( - url: string, - init: { method: string; headers: Record; body: string; signal: AbortSignal }, -) => Promise<{ ok: boolean; status: number; json(): Promise }> - -export interface WaitlistDeps { - fetchFn?: FetchLike - timeoutMs?: number -} - -function serverError(body: unknown): string | undefined { - if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") { - return body.error - } - return undefined -} - -export async function submitWaitlistSignup(rawEmail: string, deps: WaitlistDeps = {}): Promise { - const email = normalizeWaitlistEmail(rawEmail) - if (email === null) { - return { ok: false, email: rawEmail.trim(), retryable: false, error: "Enter a valid email address." } - } - - const fetchFn = deps.fetchFn ?? (fetch as unknown as FetchLike) - const timeoutMs = deps.timeoutMs ?? WAITLIST_TIMEOUT_MS - - // Same timeout pattern as timedFetch() in diagnostics.ts. - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - try { - const res = await fetchFn(WAITLIST_ENDPOINT, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(buildWaitlistPayload(email)), - signal: controller.signal, - }) - if (res.ok) return { ok: true, email } - const message = serverError(await res.json().catch(() => null)) - return { - ok: false, - email, - retryable: isRetryableFailure({ kind: "http", status: res.status }), - error: message || `Signup failed (HTTP ${res.status}).`, - } - } catch (error: unknown) { - const err = error as { name?: string; message?: string } - const aborted = err?.name === "AbortError" - return { - ok: false, - email, - retryable: isRetryableFailure({ kind: "network-error" }), - error: aborted ? `timeout after ${timeoutMs}ms` : err?.message || String(error), - } - } finally { - clearTimeout(timer) - } -} - -// --------------------------------------------------------------------------- -// Durable retry queue (AGE-87) -// --------------------------------------------------------------------------- -// -// Before this, a signup that hit a network error / 8s timeout / 5xx was handed -// straight to a `mailto:` composer. That is lossy by design: it depends on the -// user actually pressing send in their mail client, and on us reconciling the -// support inbox into Brevo list 4 forever (AGE-61's hourly reconciler). 20 of -// 21 signups were lost that way before that reconciler existed. -// -// Instead we persist the pending signup on the device and retry it on the next -// foreground / connectivity. `mailto:` survives only as a last-resort, -// USER-INITIATED escape hatch once retries are clearly not working. -// -// Lives in this file (rather than its own module) so it stays a dependency-free -// leaf that `node --test` can run directly, the same constraint that keeps -// react-native imports out of here. Storage and the clock are injected; the -// production AsyncStorage adapter is waitlist-queue-storage.ts. - -export const WAITLIST_QUEUE_KEY = "opencode.waitlist.pending.v1" - -/** Cap the queue so a broken device can't grow storage without bound. */ -export const WAITLIST_QUEUE_MAX = 5 - -/** After this many failed attempts the UI offers the manual email escape hatch. */ -export const WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL = 3 - -/** Entries older than this are dropped: the address is stale, the user moved on. */ -export const WAITLIST_QUEUE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days - -export interface QueuedSignup { - /** Already normalized (trimmed/lowercased) by normalizeWaitlistEmail. */ - email: string - /** Epoch ms of the first attempt. */ - queuedAt: number - /** Number of failed submit attempts so far (>= 1 — enqueue follows a failure). */ - attempts: number - /** Epoch ms of the last attempt. */ - lastAttemptAt: number - /** Last transport/server error, for diagnostics only. */ - lastError?: string -} - -/** - * Minimal storage port. AsyncStorage satisfies this structurally, so does a - * plain Map in tests. Deliberately not typed against AsyncStorage so this file - * stays importable by `node --test`. - */ -export interface QueueStorage { - getItem(key: string): Promise - setItem(key: string, value: string): Promise - removeItem(key: string): Promise -} - -function isQueuedSignup(value: unknown): value is QueuedSignup { - if (typeof value !== "object" || value === null) return false - const entry = value as Record - return ( - typeof entry.email === "string" && - normalizeWaitlistEmail(entry.email) === entry.email && - typeof entry.queuedAt === "number" && - Number.isFinite(entry.queuedAt) && - typeof entry.attempts === "number" && - Number.isFinite(entry.attempts) - ) -} - -/** Reads the queue, tolerating absent/corrupt/foreign JSON (never throws). */ -export async function loadQueue(storage: QueueStorage): Promise { - let raw: string | null = null - try { - raw = await storage.getItem(WAITLIST_QUEUE_KEY) - } catch { - return [] - } - if (!raw) return [] - try { - const parsed: unknown = JSON.parse(raw) - if (!Array.isArray(parsed)) return [] - return parsed.filter(isQueuedSignup).map((entry) => ({ - ...entry, - lastAttemptAt: typeof entry.lastAttemptAt === "number" ? entry.lastAttemptAt : entry.queuedAt, - })) - } catch { - return [] - } -} - -async function saveQueue(storage: QueueStorage, queue: QueuedSignup[]): Promise { - try { - if (queue.length === 0) await storage.removeItem(WAITLIST_QUEUE_KEY) - else await storage.setItem(WAITLIST_QUEUE_KEY, JSON.stringify(queue)) - } catch { - // Storage unavailable: the retry is lost, but the caller already told the - // user the truth ("saved, we'll finish signing you up") only when this - // resolves. enqueueSignup() surfaces the failure via its return value. - throw new Error("waitlist queue storage unavailable") - } -} - -/** Drops entries past the TTL. Exported for the test that pins the policy. */ -export function pruneQueue(queue: QueuedSignup[], now: number): QueuedSignup[] { - return queue.filter((entry) => now - entry.queuedAt < WAITLIST_QUEUE_TTL_MS) -} - -/** - * Persists a failed signup for later retry. Dedupes by email (an impatient - * user tapping twice must not produce two entries) and keeps the newest - * WAITLIST_QUEUE_MAX entries. - * - * Returns the stored entry, or null when the email is invalid or storage - * refused the write — in that case the caller must NOT tell the user we saved it. - */ -export async function enqueueSignup( - storage: QueueStorage, - rawEmail: string, - options: { now?: number; error?: string } = {}, -): Promise { - const email = normalizeWaitlistEmail(rawEmail) - if (email === null) return null - const now = options.now ?? Date.now() - - const existing = pruneQueue(await loadQueue(storage), now) - const previous = existing.find((entry) => entry.email === email) - const entry: QueuedSignup = { - email, - queuedAt: previous?.queuedAt ?? now, - attempts: (previous?.attempts ?? 0) + 1, - lastAttemptAt: now, - ...(options.error ? { lastError: options.error } : {}), - } - const next = [...existing.filter((e) => e.email !== email), entry].slice(-WAITLIST_QUEUE_MAX) - try { - await saveQueue(storage, next) - } catch { - return null - } - return entry -} - -export async function removeFromQueue(storage: QueueStorage, email: string): Promise { - const queue = await loadQueue(storage) - const next = queue.filter((entry) => entry.email !== email) - if (next.length === queue.length) return - try { - await saveQueue(storage, next) - } catch { - // best effort - } -} - -export interface FlushOutcome { - /** Entries that reached the server (Brevo list 4). */ - synced: string[] - /** Entries the server permanently rejected (4xx) — dropped, retrying can't help. */ - rejected: string[] - /** Entries still pending after this flush. */ - pending: QueuedSignup[] -} - -export type SubmitFn = (email: string) => Promise - -/** - * Retries every pending signup once. Called on app foreground and when the - * waitlist screen mounts. - * - * - ok -> drop (it's in Brevo now) - * - 4xx -> drop (the server will never accept this address) - * - network/5xx -> keep, bump attempts (retry next foreground) - * - * Never throws: a flush is a background best-effort action. - */ -export async function flushQueue( - storage: QueueStorage, - options: { submit?: SubmitFn; now?: number } = {}, -): Promise { - const now = options.now ?? Date.now() - const submit = options.submit ?? ((email: string) => submitWaitlistSignup(email)) - - const queue = pruneQueue(await loadQueue(storage), now) - const synced: string[] = [] - const rejected: string[] = [] - const pending: QueuedSignup[] = [] - - for (const entry of queue) { - let result: WaitlistResult - try { - result = await submit(entry.email) - } catch (error: unknown) { - pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: String(error) }) - continue - } - if (result.ok) { - synced.push(entry.email) - } else if (!result.retryable) { - rejected.push(entry.email) - } else { - pending.push({ ...entry, attempts: entry.attempts + 1, lastAttemptAt: now, lastError: result.error }) - } - } - - try { - await saveQueue(storage, pending) - } catch { - // Storage went away mid-flush; the in-memory outcome is still accurate. - } - return { synced, rejected, pending } -} - -/** - * True once an entry has failed enough times that we should stop implying - * "we'll handle it" and offer the manual email escape hatch instead. - */ -export function needsManualEscapeHatch(entry: QueuedSignup | null | undefined): boolean { - return !!entry && entry.attempts >= WAITLIST_QUEUE_ATTEMPTS_BEFORE_MANUAL -} diff --git a/src/stores/changes.ts b/src/stores/changes.ts new file mode 100644 index 00000000..d0fbc763 --- /dev/null +++ b/src/stores/changes.ts @@ -0,0 +1,122 @@ +import { useEffect, useMemo, useRef } from "react" +import { create } from "zustand" +import { useConnections } from "./connections" +import { useEvents } from "./events" +import type { FileDiff, VcsInfo, VcsMode } from "../lib/sdk" +import { branchOffered, headline, type ChangeTotals } from "../lib/changes" + +// Diffs for the Changes view and the change counts in the session list and +// chat header. Branch/uncommitted diffs are per directory (every chat in a +// repo shares them); "this turn" is per session. Every request carries the +// chat's own directory: without one the server answers for its default folder. + +export interface ChangeEntry { + diffs?: FileDiff[] + error?: string + loading: boolean + at: number +} + +const STALE_MS = 60_000 + +interface ChangesState { + entries: Record + // /vcs per directory (branch, default_branch); null when it failed. + info: Record + loadInfo: (directory: string) => Promise + loadVcs: (directory: string, mode: VcsMode, force?: boolean) => Promise + loadTurn: (sessionID: string, directory: string, messageID: string | undefined, force?: boolean) => Promise + // /vcs, then uncommitted (and branch, when off the default branch) diffs + // for each directory, skipping fresh entries. + prefetch: (directories: string[], force?: boolean) => Promise +} + +function base(): string { + return useConnections.getState().clientBase?.baseUrl ?? "" +} + +export function vcsKey(directory: string, mode: VcsMode): string { + return `${base()}|${mode}|${directory}` +} + +export function infoKey(directory: string): string { + return `${base()}|${directory}` +} + +export function turnKey(sessionID: string): string { + return `${base()}|turn|${sessionID}` +} + +export const useChanges = create((set, get) => { + const load = async (key: string, force: boolean | undefined, fetch: () => Promise | null) => { + const current = get().entries[key] + if (current?.loading) return + if (!force && current && !current.error && Date.now() - current.at < STALE_MS) return + const request = fetch() + if (!request) return + set((state) => ({ entries: { ...state.entries, [key]: { ...current, loading: true, at: current?.at ?? 0 } } })) + const result = await request.then( + (diffs) => ({ diffs: Array.isArray(diffs) ? diffs : [], error: undefined }), + (err: unknown) => ({ diffs: current?.diffs, error: err instanceof Error ? err.message : String(err) }), + ) + set((state) => ({ entries: { ...state.entries, [key]: { ...result, loading: false, at: Date.now() } } })) + } + + return { + entries: {}, + info: {}, + + loadInfo: async (directory) => { + const client = useConnections.getState().clientForDirectory(directory) + if (!client) return null + const info = await client.vcs.get().catch(() => null) + set((state) => ({ info: { ...state.info, [infoKey(directory)]: info } })) + return info + }, + + loadVcs: (directory, mode, force) => + load(vcsKey(directory, mode), force, () => { + const client = useConnections.getState().clientForDirectory(directory) + return client ? client.vcs.diff(mode) : null + }), + + loadTurn: (sessionID, directory, messageID, force) => + load(turnKey(sessionID), force, () => { + const client = useConnections.getState().clientForDirectory(directory) + return client ? client.session.diff(sessionID, messageID) : null + }), + + prefetch: async (directories, force) => { + const unique = [...new Set(directories.filter(Boolean))] + await Promise.all( + unique.map(async (directory) => { + const info = await get().loadInfo(directory) + await Promise.all([ + get().loadVcs(directory, "git", force), + branchOffered(info) ? get().loadVcs(directory, "branch", force) : undefined, + ]) + }), + ) + }, + } +}) + +export function useHeadline(directory: string | undefined): ChangeTotals | null { + const info = useChanges((state) => (directory ? state.info[infoKey(directory)] : undefined)) + const branch = useChanges((state) => (directory ? state.entries[vcsKey(directory, "branch")]?.diffs : undefined)) + const git = useChanges((state) => (directory ? state.entries[vcsKey(directory, "git")]?.diffs : undefined)) + return useMemo(() => (branch || git ? headline(info, branch, git) : null), [info, branch, git]) +} + +// Calls `run` when the session finishes a turn (busy/retry -> idle). +export function useOnTurnEnd(sessionID: string | undefined, run: () => void) { + const status = useEvents((state) => (sessionID ? state.sessionStatus[sessionID]?.type : undefined)) + const previous = useRef(status) + const latest = useRef(run) + latest.current = run + useEffect(() => { + const before = previous.current + previous.current = status + if (status === "idle" && before !== undefined && before !== "idle") latest.current() + }, [status]) +} diff --git a/src/stores/connections.ts b/src/stores/connections.ts index 2fb33de5..f9b6d8ab 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -38,7 +38,8 @@ interface ConnectionsState { // Actions loadConnections: () => Promise - addConnection: (connection: Omit, password?: string) => Promise + // Resolves to the new connection id. + addConnection: (connection: Omit, password?: string) => Promise removeConnection: (id: string) => Promise setActiveConnection: (id: string) => Promise // `source` distinguishes the activation funnel (onboarding) from the edit @@ -183,6 +184,7 @@ export const useConnections = create((set, get) => ({ } set({ connections, activeConnection, client, clientBase: base, currentProject: project, serverHome }) + return id }, removeConnection: async (id) => { diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index ccd21f33..26372bf2 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -33,6 +33,8 @@ interface SessionsState { isLoading: boolean // Per-session optimistic sending flag — bridging gap between user tap and SSE busy sending: Record + // Unsent composer text, isolated by session ID. + drafts: Record loadingMore: boolean hasMore: boolean error: string | null @@ -40,6 +42,8 @@ interface SessionsState { // Actions loadSessions: () => Promise selectSession: (sessionID: string, directory?: string) => Promise + setDraft: (sessionID: string, text: string) => void + clearDraft: (sessionID: string) => void loadOlderMessages: () => Promise createSession: (title?: string) => Promise deleteSession: (sessionID: string) => Promise @@ -93,10 +97,22 @@ export const useSessions = create((set, get) => ({ parts: {}, isLoading: false, sending: {}, + drafts: {}, loadingMore: false, hasMore: false, error: null, + setDraft: (sessionID, text) => + set((state) => ({ drafts: { ...state.drafts, [sessionID]: text } })), + + clearDraft: (sessionID) => + set((state) => { + if (!(sessionID in state.drafts)) return state + const drafts = { ...state.drafts } + delete drafts[sessionID] + return { drafts } + }), + loadSessions: async () => { const connState = useConnections.getState() // Use a directory-less client so the server returns sessions from ALL projects, @@ -245,6 +261,7 @@ export const useSessions = create((set, get) => ({ await client.session.delete(sessionID) set((state) => ({ sessions: state.sessions.filter((s) => s.id !== sessionID), + drafts: Object.fromEntries(Object.entries(state.drafts).filter(([id]) => id !== sessionID)), currentSession: state.currentSession?.id === sessionID ? null : state.currentSession, messages: state.currentSession?.id === sessionID ? [] : state.messages, parts: state.currentSession?.id === sessionID ? {} : state.parts,