diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f3e3dcfd8ce..98c1c3b2022 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -66,7 +66,7 @@ Use these client origins: - Android Emulator: `http://10.0.2.2:` - Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin -Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. +Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. ## Start or reuse Metro safely diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 51f519cef61..aed9065f3ab 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -910,6 +910,15 @@ those imports to make a conflict go away, because nothing in the fork's copy references them any more. + Same spirit, opposite direction: Sidebar.logic.ts keeps upstream's + searchSidebarThreadsByTitle (and its tests) even though nothing in the + fork's copy of SidebarV2 renders it. Upstream's inline sidebar thread + search (#4769) lives in the chrome this customization replaced, so the + 2026-08-02 sync deliberately did not adopt its UI; the pure helper stays + so a future port into SidebarV2ChromeRows is a design decision, not an + archaeology dig. It is an orphan on purpose β€” do not "clean it up", and + do not count its tests as coverage of a rendered path. + The thread list pays for its own scroll gutter. scrollbar-gutter:stable reserves the scrollbar inside the list's padding box, so the symmetric px-2 from Figma 113:3718 spends 8px of air on the left and 8 plus the scrollbar @@ -1132,16 +1141,15 @@ index.css. Routing through --fork-font-* keeps the values scoped under the fork marker while the fallbacks leave an unmarked build on DM Sans. The terminal's font logic lives in custom/terminalFont.ts rather than in - the drawer: xterm takes its font from a constructor option instead of the - cascade, so --font-mono has to be resolved by hand and the cell grid - re-measured once the webfont lands β€” xterm sizes columns at open(), and a - webfont can land after that. The drawer keeps two one-line call sites, and - the font-load probe is derived from the resolved stack rather than - hardcoded, so it cannot go stale on a face swap and an unmarked build - names a local system face and fetches nothing. The re-fit must also - propagate to the PTY via resizeTerminal β€” nothing in the drawer subscribes - to onResize, and a re-fit that corrects only xterm's local grid leaves the - PTY wrapping to the stale, fallback-measured width. + the drawer: the ghostty surface takes its font as a creation option + instead of from the cascade, so --font-mono has to be resolved by hand at + the mount site. The drawer keeps one one-line call site passing + font.family. Unlike the xterm era there is no fork refit shim: the + surface itself waits on document.fonts before measuring the cell grid and + re-measures on loadingdone, so the webfont-landing-late case is upstream's + to handle now. In an unmarked build the cascade read returns upstream's + stack and no Geist byte is fetched; the module's fallback only engages + when the read comes back empty. Geist Mono is listed AHEAD of SF Mono: upstream puts SF Mono first, which means on macOS its bundled mono webfont never renders at all. body / pre, code are re-declared under @@ -1167,6 +1175,10 @@ watch: - apps/web/src/index.css - apps/web/src/components/ThreadTerminalDrawer.tsx + # The fork deleted its own refit shim because the surface waits on + # document.fonts before measuring and re-measures on loadingdone. If + # upstream stops, the terminal silently measures against fallback metrics. + - apps/web/src/terminal/ghostty/surface.ts # No fence, but load-bearing: ships the resolved --font-sans / --font-mono # into the previewed page β€” see FORK-CUSTOMIZATION-DECISIONS.md. - apps/web/src/browser/annotationTheme.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 07438b251c5..9bc321dac0d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -30,6 +30,7 @@ body: - apps/web - apps/server - apps/desktop + - apps/mobile - packages/contracts or packages/shared - Build, CI, or release tooling - Docs diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 53aab5166a5..3c9424fb322 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -30,6 +30,7 @@ body: - apps/web - apps/server - apps/desktop + - apps/mobile - packages/contracts or packages/shared - Build, CI, or release tooling - Docs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fea7cafa3e..4e4d233377a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,10 @@ on: - custom # fork:end ci-on-custom +concurrency: + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: check: name: Check @@ -22,6 +26,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -75,6 +84,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -104,13 +118,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... - name: Install mobile native static analysis tools run: brew bundle install --file apps/mobile/Brewfile @@ -127,13 +148,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 0b297e3ddea..c28225659b8 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -41,13 +41,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=t3code-relay... - name: Deploy production relay stage id: deploy diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 32e45fef54e..d53602f8f5e 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,13 +2,18 @@ name: Mobile EAS Preview on: pull_request: - types: [opened, reopened, synchronize, labeled, unlabeled] + types: [opened, reopened, synchronize, labeled] jobs: preview: name: EAS Preview - if: contains(github.event.pull_request.labels.*.name, 'πŸš€ Mobile Continuous Deployment') + if: | + contains(github.event.pull_request.labels.*.name, 'πŸš€ Mobile Continuous Deployment') && + (github.event.action != 'labeled' || github.event.label.name == 'πŸš€ Mobile Continuous Deployment') runs-on: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read pull-requests: write @@ -34,6 +39,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + # No sparse-checkout here: it makes actions/checkout fetch with + # --filter=blob:none, and eas-cli archives the project via + # `git clone --depth 1 file://`, which fails (exit 128) + # when the partial clone can't serve the unfetched blobs. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -41,7 +50,9 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 685df85e57c..2e61de6039e 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -57,6 +57,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + # No sparse-checkout here: it makes actions/checkout fetch with + # --filter=blob:none, and eas-cli archives the project via + # `git clone --depth 1 file://`, which fails (exit 128) + # when the partial clone can't serve the unfetched blobs. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -64,7 +68,9 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 36dfb61f73f..3eaaf508e31 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -37,13 +37,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | @@ -77,13 +86,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2db1451009..da6c0da248d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - id: check name: Compare HEAD to last nightly tag @@ -89,6 +93,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -197,6 +205,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -274,14 +286,19 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=t3... - name: Build node-pty linux-x64 prebuild shell: bash @@ -364,14 +381,21 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/desktop... + - --filter=t3... + - --filter=@t3tools/scripts... - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -662,6 +686,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -737,6 +765,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -858,6 +890,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -972,6 +1008,10 @@ jobs: fetch-depth: 0 token: ${{ steps.app_token.outputs.token }} persist-credentials: true + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - id: app_bot name: Resolve GitHub App bot identity @@ -1042,6 +1082,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 8e5c7a86126..86a8677f06a 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -54,7 +54,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setAboutPanelOptions.push(options); }), setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 385e694338d..0be55d633e6 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -45,6 +45,27 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; +export const resolveUserDataPath = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const legacyPath = environment.path.join( + environment.appDataDirectory, + environment.legacyUserDataDirName, + ); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathResolutionError({ + legacyPath, + cause, + }), + ), + ); + return legacyPathExists + ? legacyPath + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); +}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -90,24 +111,11 @@ export const make = Effect.gen(function* () { return commitHash; }); - const resolveUserDataPath = Effect.gen(function* () { - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), - ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); - }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); + const userDataPath = resolveUserDataPath.pipe( + Effect.provide( + yield* Effect.context(), + ), + ); const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; @@ -136,7 +144,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.appIdentity.configure")); return DesktopAppIdentity.of({ - resolveUserDataPath, + resolveUserDataPath: userDataPath, configure, }); }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 96d1e8cf465..ef0c6db9eac 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -33,17 +33,38 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true) => { +const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, + appDataDirectory: "/tmp/app-data", + userDataDirName: isDevelopment ? "t3code-dev" : "t3code", + legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + const electronApp = { + setPath: (name: string, value: string) => + Effect.sync(() => { + events.push(`setPath:${name}:${value}`); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + return DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), + Layer.succeed(ElectronApp.ElectronApp, electronApp), + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), + ), + ), ); }; @@ -66,11 +87,15 @@ describe("DesktopClerk", () => { it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); + const events: string[] = []; storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup }); + createClerkBridgeMock.mockImplementation(() => { + events.push("createClerkBridge"); + return { cleanup, isPrimaryInstance: true }; + }); return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events))); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -82,6 +107,10 @@ describe("DesktopClerk", () => { ], ]); assert.equal(cleanup.mock.calls.length, 1); + // The bridge acquires Electron's single-instance lock at creation, and + // the lock both lives in and creates the userData directory β€” so the + // real path must be set before the bridge exists. + assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]); storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); @@ -135,11 +164,67 @@ describe("DesktopClerk", () => { }); }); + it.effect("registers the second-instance handler in the primary instance", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const quit = vi.fn(); + const registeredEvents: string[] = []; + const electronApp = { + quit: Effect.sync(quit), + on: (eventName: string) => + Effect.sync(() => { + registeredEvents.push(eventName); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; + + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + + assert.isTrue(Exit.isSuccess(exit)); + assert.equal(quit.mock.calls.length, 0); + assert.deepEqual(registeredEvents, ["second-instance"]); + }).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + + it.effect("quits and interrupts startup in a secondary instance", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: false }); + const quit = vi.fn(); + const registeredEvents: string[] = []; + const electronApp = { + quit: Effect.sync(quit), + on: (eventName: string) => + Effect.sync(() => { + registeredEvents.push(eventName); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; + + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); + + assert.isTrue(Exit.hasInterrupts(exit)); + assert.equal(quit.mock.calls.length, 1); + assert.deepEqual(registeredEvents, []); + }).pipe( + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }); + it.each([ { isDevelopment: true, scheme: "t3code-dev" }, { isDevelopment: false, scheme: "t3code" }, ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn() }; + const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue(bridge); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0548f96fc55..af4d53b2b05 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -14,6 +14,7 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -111,6 +112,17 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + + // Electron scopes the single-instance lock to the userData directory and + // creates that directory when the lock is acquired. The SDK bridge takes + // the lock at creation, so userData must already point at the real + // directory here β€” under the default productName-derived path, acquiring + // the lock would create "T3 Code (Alpha)" and make the legacy-install + // detection in resolveUserDataPath match on fresh installs. + const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + // fork:begin fork-clerk-launch-resilience β€” see .fork/customizations.yaml#fork-clerk-launch-resilience // A build with no baked Clerk publishable key cannot sign in, so the // bridge is guaranteed dead weight β€” and a live hazard: createClerkBridge @@ -123,15 +135,17 @@ export const make = Effect.gen(function* () { // including loud initialization AND cleanup failures, which must stay // fatal there rather than hide behind a warning β€” except its redundant // scheme re-registration, which createDesktopClerkBridge above suppresses - // so a post-"ready" layer build cannot die on it. The singleton-lock - // behavior in configure below is bridge-independent either way. + // so a post-"ready" layer build cannot die on it. The bridge now also + // carries the single-instance lock (acquired at creation), so the keyless + // path takes the lock directly in configure below. + let bridge: ReturnType | undefined; if (desktopClerkFrontendApiHostname === undefined) { yield* Effect.logWarning( "No Clerk publishable key in this build; skipping the Clerk bridge (cloud sign-in unavailable).", ); } else { // fork:end fork-clerk-launch-resilience - yield* Effect.acquireRelease( + bridge = yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), catch: (cause) => @@ -163,7 +177,20 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - if (!(yield* electronApp.requestSingleInstanceLock)) { + // The SDK bridge holds Electron's single-instance lock (acquired at + // bridge creation) so OAuth deep-link callbacks on Windows/Linux are + // forwarded to the running app. In a secondary instance the bridge has + // already begun quitting the app; app.quit() is asynchronous, so stop + // bootstrap here before whenReady can fire. + // fork:begin fork-clerk-launch-resilience β€” keyless builds have no bridge + // With the bridge skipped nothing has taken the lock, so take it + // directly; the optional read keeps plain-Node unit imports safe, where + // the electron shim exposes no app object. + const isPrimaryInstance = bridge + ? bridge.isPrimaryInstance + : (Electron.app?.requestSingleInstanceLock() ?? true); + // fork:end fork-clerk-launch-resilience + if (!isPrimaryInstance) { yield* electronApp.quit; return yield* Effect.interrupt; } diff --git a/apps/desktop/src/app/DesktopClerkForkRegistrarSuppression.test.ts b/apps/desktop/src/app/DesktopClerkForkRegistrarSuppression.test.ts index 7aef53fb1bb..e388f4e608b 100644 --- a/apps/desktop/src/app/DesktopClerkForkRegistrarSuppression.test.ts +++ b/apps/desktop/src/app/DesktopClerkForkRegistrarSuppression.test.ts @@ -55,7 +55,7 @@ describe("DesktopClerk keyed-build registrar suppression", () => { }); it("suppresses the bridge's scheme re-registration, then restores the registrar", () => { - const bridge = { cleanup: vi.fn() }; + const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; createClerkBridgeMock.mockImplementation(() => { // What @clerk/electron does unconditionally when given a renderer. // Post-"ready" this throws in a real boot; suppression makes it a diff --git a/apps/desktop/src/app/DesktopClerkForkSkip.test.ts b/apps/desktop/src/app/DesktopClerkForkSkip.test.ts index e56a7259fa8..62a07df937a 100644 --- a/apps/desktop/src/app/DesktopClerkForkSkip.test.ts +++ b/apps/desktop/src/app/DesktopClerkForkSkip.test.ts @@ -7,20 +7,34 @@ * a keyless build has nothing to win. Unlike DesktopClerk.test.ts, this file * deliberately does NOT define __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, so the * module under test resolves no hostname and takes the skip path. + * + * Upstream's bridge acquires Electron's single-instance lock at creation and + * configure reads bridge.isPrimaryInstance; with the bridge skipped nothing + * has taken the lock, so the fork takes it directly off Electron.app in + * configure. The configure tests below pin that behavior on both outcomes β€” + * a suite that only checked the service exists would pass with every keyless + * launch silently admitting a second instance. */ import { assert, describe, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; -import { vi } from "vite-plus/test"; +import { beforeEach, vi } from "vite-plus/test"; -const { createClerkBridgeMock, storageMock } = vi.hoisted(() => ({ +const { createClerkBridgeMock, requestSingleInstanceLockMock, storageMock } = vi.hoisted(() => ({ createClerkBridgeMock: vi.fn(), + requestSingleInstanceLockMock: vi.fn(), storageMock: vi.fn(), })); +vi.mock("electron", () => ({ + app: { requestSingleInstanceLock: requestSingleInstanceLockMock }, +})); + vi.mock("@clerk/electron", () => ({ createClerkBridge: createClerkBridgeMock, })); @@ -29,21 +43,78 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = () => { +interface ConfigureProbe { + readonly registeredEvents: string[]; + readonly quits: () => number; + readonly electronApp: ElectronApp.ElectronApp["Service"]; +} + +function configureProbe(): ConfigureProbe { + const registeredEvents: string[] = []; + let quits = 0; + const electronApp = { + setPath: () => Effect.void, + quit: Effect.sync(() => { + quits += 1; + }), + on: (eventName: string) => + Effect.sync(() => { + registeredEvents.push(eventName); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + return { registeredEvents, quits: () => quits, electronApp }; +} + +const makeDesktopClerkLayer = (electronApp: ElectronApp.ElectronApp["Service"]) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment: false, + appDataDirectory: "/tmp/app-data", + userDataDirName: "t3code", + legacyUserDataDirName: "T3 Code (Alpha)", + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); return DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), + Layer.succeed(ElectronApp.ElectronApp, electronApp), + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), + ), + ), ); }; +const runConfigure = (probe: ConfigureProbe) => + Effect.gen(function* () { + const context = yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(probe.electronApp))); + const service = Context.get(context, DesktopClerk.DesktopClerk); + return yield* Effect.exit( + Effect.scoped( + service.configure.pipe( + Effect.provideService(ElectronApp.ElectronApp, probe.electronApp), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as unknown as ElectronWindow.ElectronWindow["Service"], + ), + ), + ), + ); + }); + describe("DesktopClerk keyless-build skip", () => { + beforeEach(() => { + createClerkBridgeMock.mockReset(); + requestSingleInstanceLockMock.mockReset(); + storageMock.mockReset(); + }); + it.effect("skips the bridge, warns, and still provides the service", () => { const messages: unknown[] = []; const logger = Logger.make(({ message }) => { @@ -51,9 +122,10 @@ describe("DesktopClerk keyless-build skip", () => { }); return Effect.gen(function* () { - const context = yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())).pipe( - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), - ); + const probe = configureProbe(); + const context = yield* Effect.scoped( + Layer.build(makeDesktopClerkLayer(probe.electronApp)), + ).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); // The bridge was never attempted β€” the skip is deterministic, not a // survived failure. @@ -64,10 +136,43 @@ describe("DesktopClerk keyless-build skip", () => { assert.isTrue( messages.some((message) => String(message).includes("skipping the Clerk bridge")), ); - // The service is still provided; its bridge-independent configure - // (single-instance lock) must keep working on the degraded path. const service = Context.get(context, DesktopClerk.DesktopClerk); assert.isDefined(service.configure); }); }); + + it.effect("takes the lock directly and keeps the primary instance running", () => + Effect.gen(function* () { + requestSingleInstanceLockMock.mockReturnValue(true); + const probe = configureProbe(); + + const exit = yield* runConfigure(probe); + + // No bridge holds the lock on this path, so configure must have asked + // Electron for it β€” a `?? true` fallback that never asks would admit + // every second instance. + assert.equal(requestSingleInstanceLockMock.mock.calls.length, 1); + assert.isTrue(Exit.isSuccess(exit)); + assert.equal(probe.quits(), 0); + // Deep-link forwarding survives the degraded path: the primary still + // listens for second instances. + assert.deepEqual(probe.registeredEvents, ["second-instance"]); + }), + ); + + it.effect("quits and interrupts bootstrap when another instance holds the lock", () => + Effect.gen(function* () { + requestSingleInstanceLockMock.mockReturnValue(false); + const probe = configureProbe(); + + const exit = yield* runConfigure(probe); + + assert.equal(requestSingleInstanceLockMock.mock.calls.length, 1); + // The secondary instance stops bootstrap before whenReady can fire: + // quit, then interrupt β€” and never registers the handler. + assert.equal(probe.quits(), 1); + assert.isTrue(Exit.hasInterrupts(exit)); + assert.deepEqual(probe.registeredEvents, []); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index e5ce72f8e48..978e000a7f5 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -29,7 +29,6 @@ describe("DesktopLifecycle", () => { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 077b343959c..ac14f56ad1a 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -14,7 +14,6 @@ const { quitMock, relaunchMock, removeListenerMock, - requestSingleInstanceLockMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -35,7 +34,6 @@ const { quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), - requestSingleInstanceLockMock: vi.fn(() => true), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -67,7 +65,6 @@ vi.mock("electron", () => ({ quit: quitMock, relaunch: relaunchMock, removeListener: removeListenerMock, - requestSingleInstanceLock: requestSingleInstanceLockMock, runningUnderARM64Translation: false, setAboutPanelOptions: setAboutPanelOptionsMock, setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 5f8052f902d..73323617195 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -56,7 +56,6 @@ export class ElectronApp extends Context.Service< options: Electron.AboutPanelOptionsOptions, ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; - readonly requestSingleInstanceLock: Effect.Effect; readonly getAppMetrics: Effect.Effect>; readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; readonly setAsDefaultProtocolClient: ( @@ -153,7 +152,6 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.app.setAppUserModelId(id); }), - requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), isDefaultProtocolClient: (protocol) => Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e30000427..2db85dafc4d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["script-src"], [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..9d5a47806b3 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat const scriptSources = [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 475be3da151..36cdcb50b6b 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -34,7 +34,6 @@ function makeElectronAppLayer( setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.sync(() => { onMetricsRead(); return metrics; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 34fc4447146..f04a49f82af 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -39,7 +39,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4a0c761f2c6..9a51373cfd0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -161,7 +161,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "0.1.0", + version: "1.0.1", runtimeVersion: { // Fingerprint (not appVersion) so an OTA only reaches binaries whose native // project β€” native deps, config plugins, AND patches/ β€” matches the update. @@ -181,6 +181,9 @@ const config: ExpoConfig = { ios: { icon: variant.assets.iosIcon, supportsTablet: true, + // Multitasking-capable iPad apps cannot rotate programmatically, so the + // showcase capture build requires full screen (see infoPlist below). + requireFullScreen: process.env.T3_SHOWCASE_CAPTURE_BUILD === "1", bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, @@ -197,6 +200,21 @@ const config: ExpoConfig = { NSLocalNetworkUsageDescription: "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", ITSAppUsesNonExemptEncryption: false, + // The App Store screenshot harness rotates the iPad interface from + // inside the app (CI denies osascript the Accessibility access that + // Simulator menu scripting needs), and iPadOS ignores programmatic + // orientation requests for multitasking-capable apps β€” so the capture + // build opts out of multitasking and declares landscape support. + ...(process.env.T3_SHOWCASE_CAPTURE_BUILD === "1" + ? { + "UISupportedInterfaceOrientations~ipad": [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ], + } + : {}), }, }, android: { @@ -275,10 +293,12 @@ const config: ExpoConfig = { "expo-camera", { cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", + microphonePermission: false, barcodeScannerEnabled: true, recordAudioAndroid: false, }, ], + ["expo-image-picker", { photosPermission: false, microphonePermission: false }], [ "expo-splash-screen", { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 7781b164a2c..23cf4720d8c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,5 +1,6 @@ import ExpoModulesCore import Security +import UIKit public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { @@ -32,6 +33,50 @@ public final class T3NativeControlsModule: Module { return arguments[flagIndex + 1] } + Function("getShowcaseOrientation") { () -> String? in + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseOrientation"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + // Rotates the interface without Simulator menu UI scripting, which CI + // runners cannot perform (osascript is denied Accessibility access there). + AsyncFunction("applyShowcaseOrientation") { (orientation: String) in + guard #available(iOS 16.0, *) else { return } + let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait + for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { + windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in + NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") + } + for window in windowScene.windows { + window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + } + }.runOnQueue(.main) + + // The geometry request above can fail transiently (for example before the + // scene is foreground-active), so callers poll this until it settles. + // Screen bounds β€” not the scene's interface orientation β€” decide the + // answer because they match the captured framebuffer: with iPadOS + // windowing active, a floating landscape window still reports a portrait + // screen, and screenshots would come out portrait. + AsyncFunction("getInterfaceOrientation") { () -> String in + guard + let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first + else { + return "unknown" + } + let bounds = windowScene.screen.coordinateSpace.bounds + return bounds.width > bounds.height ? "landscape" : "portrait" + }.runOnQueue(.main) + Function("prepareShowcaseCapture") { for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { SecItemDelete([kSecClass as String: itemClass] as CFDictionary) diff --git a/apps/mobile/modules/t3-terminal/README.md b/apps/mobile/modules/t3-terminal/README.md index 768e3c0704c..32670b893c7 100644 --- a/apps/mobile/modules/t3-terminal/README.md +++ b/apps/mobile/modules/t3-terminal/README.md @@ -40,7 +40,7 @@ fails, run `xcodebuild -downloadComponent MetalToolchain`. ## Rebuilding libghostty-vt for Android The checked-in Android shared libraries and headers are pinned to the revision recorded in -`Vendor/libghostty-vt/VERSION`. Set `ANDROID_NDK_HOME` and run: +`native/libghostty-vt/VERSION` at the repository root. Set `ANDROID_NDK_HOME` and run: ```bash apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index 990dd4bbe9c..b06f18eadce 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -18,13 +18,14 @@ Ghostty's MIT license applies to the vendored framework. Keep this notice in syn ## Ghostty / libghostty-vt The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers. +The web terminal vendors a WebAssembly build from the same revision and uses the same C ABI. - Upstream project: https://github.com/ghostty-org/ghostty - Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` - License: MIT -Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when -updating `Vendor/libghostty-vt`. +Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both +artifacts in sync when updating the repository-root `native/libghostty-vt`. ## MesloLGS NF (Android terminal font) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt index 0273ff6c864..a43a2981b67 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt @@ -13,7 +13,7 @@ add_library(t3terminal SHARED t3_terminal_jni.cpp) target_include_directories( t3terminal - PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../../../Vendor/libghostty-vt/include" + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../../../native/libghostty-vt/include" ) target_link_options( diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh index 7b9aa9b6dc6..55d994d3b36 100755 --- a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh +++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty-vt" +VENDOR_DIR="${MODULE_DIR}/../../../../native/libghostty-vt" PATCH_DIR="${SCRIPT_DIR}/libghostty-android-patches" GHOSTTY_REVISION="${GHOSTTY_REVISION:-9f62873bf195e4d8a762d768a1405a5f2f7b1697}" diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9a5e64aa46f..c12ca979bf2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,7 +11,7 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", - "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", + "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 76a9399772d..719a6a4ad59 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,6 +15,7 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; +import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -392,7 +393,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - title: "Threads", + ...getCompactBrandHeaderOptions(), }, }), Thread: createNativeStackScreen({ diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index ac813bdbe0a..d4c7088fe50 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -22,6 +22,7 @@ import { IconChevronUp, IconCircleCheck, IconCircleXFilled, + IconClock, IconCopy, IconDeviceDesktop, IconDots, @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { camera: IconCamera, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, + clock: IconClock, "chevron.down": IconChevronDown, "chevron.left": IconChevronLeft, "chevron.left.forwardslash.chevron.right": IconCode, diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx new file mode 100644 index 00000000000..f0710e85d36 --- /dev/null +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -0,0 +1,121 @@ +import Constants from "expo-constants"; +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { Platform, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { T3Wordmark } from "./T3Wordmark"; +import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; +import { resolveMobileStageLabel } from "../lib/mobileBranding"; +import { useThemeColor } from "../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; + +// Native leading items inherit different UIKit margins than title views. +const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; +const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; + +/** + * Compact brand lockup sized for native navigation bars. + */ +export function CompactBrandTitle( + props: { + readonly nativeLeadingItem?: boolean; + } = {}, +) { + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const subtleColor = useThemeColor("--color-subtle"); + const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); + const titleOffset = + Platform.OS !== "ios" + ? 0 + : props.nativeLeadingItem + ? Platform.isPad + ? IPAD_NATIVE_LEADING_TITLE_OFFSET + : IOS_NATIVE_LEADING_TITLE_OFFSET + : Platform.isPad + ? IPAD_HOME_TITLE_OFFSET + : 0; + + return ( + + + + Code + + + + {stageLabel} + + + + ); +} + +export function renderCompactBrandTitle() { + return ; +} + +export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { + return [ + { + element: , + hidesSharedBackground: true, + type: "custom", + }, + ]; +} + +export function getCompactBrandHeaderOptions( + fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], +): NativeStackNavigationOptions { + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: "Threads", + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title: "Threads", + unstable_headerLeftItems: renderCompactBrandHeaderItems, + }; + } + + return { + headerTitle: renderCompactBrandTitle, + headerTitleStyle: fallbackTitleStyle, + title: "Threads", + }; +} diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 772d5e8cc14..d52aa05b446 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,14 +1,21 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useState } from "react"; +import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; - -/* ─── Favicon cache (matches web pattern) ────────────────────────────── */ -const loadedFaviconUrls = new Set(); +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -26,10 +33,15 @@ export function ProjectFavicon(props: { : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; return ( createProjectFaviconRequest(props.cacheKey, props.faviconUrl), + [props.cacheKey, props.faviconUrl], + ); + const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); + useLayoutEffect(() => { + if (faviconRequest === null) return; + + const endRequest = beginProjectFaviconRequest(faviconRequest); + setActiveFaviconRequest(faviconRequest); + return endRequest; + }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", + hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); - const showImage = props.faviconUrl !== null && status === "loaded"; + const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; + const showImage = requestIsActive && status === "loaded"; return ( { - if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); + if (!markProjectFaviconLoaded(faviconRequest)) return; setStatus("loaded"); }} - onError={() => setStatus("error")} + onError={() => { + if (!markProjectFaviconFailed(faviconRequest)) return; + setStatus("error"); + }} /> ) : null} diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/components/projectFaviconCache.test.ts new file mode 100644 index 00000000000..d0582a8b5f5 --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; + +describe("project favicon cache", () => { + it("ignores callbacks from a superseded URL", () => { + const cacheKey = "environment-1:/workspace:v1-favicon.svg"; + const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; + const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; + + const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); + const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); + markProjectFaviconLoaded(expiredRequest); + const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); + const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); + + expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); + expect(markProjectFaviconFailed(expiredRequest)).toBe(false); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRefreshedRequest(); + endExpiredRequest(); + }); + + it("evicts the URL that actually failed", () => { + const cacheKey = "environment-1:/workspace:v2-favicon.svg"; + const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; + const request = createProjectFaviconRequest(cacheKey, faviconUrl); + const endRequest = beginProjectFaviconRequest(request); + + markProjectFaviconLoaded(request); + + expect(markProjectFaviconFailed(request)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRequest(); + }); + + it("does not supersede a request until the next request begins", () => { + const cacheKey = "environment-1:/workspace:v3-favicon.svg"; + const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; + const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; + const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); + const endCommittedRequest = beginProjectFaviconRequest(committedRequest); + + createProjectFaviconRequest(cacheKey, abandonedUrl); + + expect(markProjectFaviconLoaded(committedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + + endCommittedRequest(); + }); + + it("requires a cache key before creating a URL-bearing request", () => { + const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; + const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; + + expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); + expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); + }); + + it("restores the remaining active URL when a newer request ends", () => { + const cacheKey = "environment-1:/workspace:v4-favicon.svg"; + const firstRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/first/v4-favicon.svg", + ); + const secondRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/second/v4-favicon.svg", + ); + const endFirstRequest = beginProjectFaviconRequest(firstRequest); + const endSecondRequest = beginProjectFaviconRequest(secondRequest); + + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + endSecondRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(true); + endFirstRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + }); + + it("bounds remembered loaded revisions", () => { + const firstCacheKey = "environment-1:/workspace:revision-0"; + let lastCacheKey = firstCacheKey; + + for (let revision = 0; revision < 300; revision++) { + lastCacheKey = `environment-1:/workspace:revision-${revision}`; + const request = createProjectFaviconRequest( + lastCacheKey, + `https://environment.example/api/assets/revision-${revision}/favicon.svg`, + ); + const endRequest = beginProjectFaviconRequest(request); + markProjectFaviconLoaded(request); + endRequest(); + } + + expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); + expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts new file mode 100644 index 00000000000..da77d7613f2 --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.ts @@ -0,0 +1,94 @@ +export interface ProjectFaviconRequest { + readonly cacheKey: string; + readonly faviconUrl: string; +} + +interface ActiveFaviconRequests { + readonly urls: Map; + currentUrl: string; +} + +const MAX_LOADED_FAVICONS = 256; +const activeFaviconRequests = new Map(); +const loadedFaviconKeys = new Map(); + +export function createProjectFaviconRequest( + cacheKey: string, + faviconUrl: string, +): ProjectFaviconRequest; +export function createProjectFaviconRequest( + cacheKey: string | null, + faviconUrl: string | null, +): ProjectFaviconRequest | null; +export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { + if (!cacheKey || !faviconUrl) return null; + return { cacheKey, faviconUrl }; +} + +export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { + let activeRequests = activeFaviconRequests.get(request.cacheKey); + if (!activeRequests) { + activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; + activeFaviconRequests.set(request.cacheKey, activeRequests); + } + + const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; + activeRequests.urls.delete(request.faviconUrl); + activeRequests.urls.set(request.faviconUrl, activeCount + 1); + activeRequests.currentUrl = request.faviconUrl; + + let ended = false; + return () => { + if (ended) return; + ended = true; + + const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; + if (remainingCount > 0) { + activeRequests.urls.set(request.faviconUrl, remainingCount); + return; + } + + activeRequests.urls.delete(request.faviconUrl); + if (activeRequests.urls.size === 0) { + if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { + activeFaviconRequests.delete(request.cacheKey); + } + return; + } + + if (activeRequests.currentUrl === request.faviconUrl) { + activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; + } + }; +} + +export function hasLoadedProjectFavicon(cacheKey: string | null) { + return cacheKey !== null && loadedFaviconKeys.has(cacheKey); +} + +function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { + return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; +} + +function rememberLoadedProjectFavicon(cacheKey: string) { + loadedFaviconKeys.delete(cacheKey); + loadedFaviconKeys.set(cacheKey, true); + + if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { + loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); + } +} + +export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + rememberLoadedProjectFavicon(request.cacheKey); + return true; +} + +export function markProjectFaviconFailed(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + loadedFaviconKeys.delete(request.cacheKey); + return true; +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 916802e9faf..01440007bc6 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -29,7 +29,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment { @@ -70,7 +73,8 @@ function ArchivedThreadsHeader(props: { const searchIconColor = useThemeColor("--color-icon"); const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; - const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; + const usesCompactMailToolbar = + Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; const androidFilterActions = useMemo( () => [ { @@ -272,7 +276,11 @@ function ArchivedThreadsHeader(props: { ...(usesNativeChrome ? { allowToolbarIntegration: true, - placement: "integratedButton" as const, + // "integratedButton" is an iOS 26 search-bar placement; + // pre-glass iOS keeps the default pull-down placement. + ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { placement: "integratedButton" as const } + : null), } : { placement: "stacked" as const, diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 5d619c688f1..958827ee492 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -134,7 +134,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; + // "Not authorized" covers non-auth causes too; surface the reason so a + // missing link doesn't read as a credential problem. + if (error.reason === "environment_link_not_found") { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; + } + return error.reason + ? `Relay rejected the environment connection request (${error.reason}).` + : "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 18b6c71a293..19392768479 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,11 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, } from "./pairing"; +describe("buildPairingUrl", () => { + it("uses HTTP for a schemeless IP address", () => { + expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( + "http://192.168.1.100:3773/#token=pairing-token", + ); + }); + + it("keeps HTTPS as the default for a schemeless hostname", () => { + expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe( + "https://remote.example.com/#token=pairing-token", + ); + }); + + it("preserves an explicit scheme for an IP address", () => { + expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe( + "https://192.168.1.100:3773/#token=pairing-token", + ); + }); +}); + describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 910efa7f256..569d00cbdd3 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -3,6 +3,21 @@ import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +function isIpLiteral(host: string): boolean { + try { + const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); + if (hostname.includes(":")) return true; + + const octets = hostname.split("."); + return ( + octets.length === 4 && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); + } catch { + return false; + } +} + export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( "PairingQrPayloadEmptyError", {}, @@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `https://${h}`); + const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 012f99536d2..7f5105aac17 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -29,7 +29,10 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; @@ -354,7 +357,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); } - const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView; + const usesCompactMailToolbar = + Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; return ( <> diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 4265107912b..a209dbd7623 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; +import Constants from "expo-constants"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -9,11 +10,16 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; +import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; +import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; -import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { buildHomeListFilterMenu, @@ -61,6 +67,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); + const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored β€” hide them and // key the "customized" icon state off the environment filter alone. @@ -192,8 +199,9 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> @@ -207,7 +215,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { - Alpha + {stageLabel} @@ -320,9 +328,11 @@ function IosHomeHeader(props: HomeHeaderProps) { }), ] : undefined, - unstable_headerToolbarItems: - Platform.OS === "ios" - ? () => [ + // The keys below are set per-branch (not `undefined`) so a later + // reapply cannot clobber options owned by NativeHeaderToolbar. + ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { + unstable_headerToolbarItems: () => [ createNativeMailSearchToolbarItem({ composeButtonId: "home-new-task", composeSystemImageName: "square.and.pencil", @@ -336,14 +346,14 @@ function IosHomeHeader(props: HomeHeaderProps) { placeholder: "Search", searchTextChangeId: "home-search-text", }), - ] - : undefined, - headerSearchBarOptions: - Platform.OS === "ios" - ? undefined - : { + ], + } + : { + // Pre-Liquid-Glass iOS: standard pull-down search in the nav + // bar; create + sort live in the plain bottom toolbar below. + headerSearchBarOptions: { ref: searchBarRef, - allowToolbarIntegration: true, + autoCapitalize: "none" as const, hideNavigationBar: false, placeholder: "Search", onCancelButtonPress: () => { @@ -353,21 +363,11 @@ function IosHomeHeader(props: HomeHeaderProps) { props.onSearchQueryChange(event.nativeEvent.text); }, }, + }), }} /> - {Platform.OS === "ios" ? null : ( - - - - )} - - {Platform.OS === "ios" ? null : ( + {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( ) : null} - - Sort projects - {PROJECT_SORT_OPTIONS.map((option) => ( - props.onProjectSortOrderChange(option.value)} - > - {option.label} - - ))} - + {threadListV2Enabled ? null : ( + + Sort projects + {PROJECT_SORT_OPTIONS.map((option) => ( + props.onProjectSortOrderChange(option.value)} + > + {option.label} + + ))} + + )} - - Sort threads - {THREAD_SORT_OPTIONS.map((option) => ( - props.onThreadSortOrderChange(option.value)} - > - {option.label} - - ))} - + {threadListV2Enabled ? null : ( + + Sort threads + {THREAD_SORT_OPTIONS.map((option) => ( + props.onThreadSortOrderChange(option.value)} + > + {option.label} + + ))} + + )} - - - + { + void checkForAppUpdateOnLaunch(); + }, []); + + const { + archiveThread, + confirmDeleteThread, + settleThread, + snoozeThread, + unsnoozeThread, + unsettleThread, + } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo(() => { @@ -87,7 +100,9 @@ export function HomeRouteScreen() { if (layout.usesSplitView) { return ( <> - + [] }} + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title in case the split branch blanked it. */} - + {/* Restore the compact title after the split branch blanks the detail header. */} + void; /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onSnoozeThread: ( + thread: EnvironmentThreadShell, + snoozedUntil: string, + ) => Promise; + readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -109,6 +119,7 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; +const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -196,6 +207,10 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); + const iosBottomToolbarClearance = + Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED + ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT + : 0; const searchEnvironmentIds = useMemo( () => props.selectedEnvironmentId === null @@ -489,6 +504,18 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onSettleThread], ); + const handleSnoozeThread = useCallback( + (thread: EnvironmentThreadShell, snoozedUntil: string) => { + void props.onSnoozeThread(thread, snoozedUntil); + }, + [props.onSnoozeThread], + ); + const handleUnsnoozeThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onUnsnoozeThread(thread); + }, + [props.onUnsnoozeThread], + ); const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter @@ -506,6 +533,10 @@ export function HomeScreen(props: HomeScreenProps) { () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); // now is quantized to the minute and ticks so the inactivity auto-settle // boundary is actually crossed while the app stays open (mirrors web); // without a clock dependency the partition memoizes a frozen "now". @@ -546,7 +577,15 @@ export function HomeScreen(props: HomeScreenProps) { }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) - return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; + return { + items: [], + hiddenSettledCount: 0, + snoozedCount: 0, + snoozedShelfHeaderIndex: null, + settledCount: 0, + settledShelfHeaderIndex: null, + nextSnoozeWakeAt: null, + }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -561,11 +600,16 @@ export function HomeScreen(props: HomeScreenProps) { settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), + snoozedShelfExpanded, + settledShelfExpanded, + selectedThreadKey: null, }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, + snoozedShelfExpanded, + settledShelfExpanded, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -615,8 +659,15 @@ export function HomeScreen(props: HomeScreenProps) { buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + snoozedCount: threadListV2Layout.snoozedCount, + snoozedShelfExpanded, + snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + settledCount: threadListV2Layout.settledCount, + settledShelfExpanded, + settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, + snoozeLabelNow: `${nowMinute}:00.000Z`, }), - [threadListV2Layout.items, v2PendingTasks], + [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], ); const renderV2Item = useCallback( @@ -643,12 +694,32 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-snoozed-shelf") { + return ( + + ); + } + if (item.type === "v2-settled-shelf") { + return ( + + ); + } const thread = item.item.thread; return ( item.key, []); @@ -725,6 +805,7 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, + snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -733,6 +814,7 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.savedConnectionsById, serverConfigs, + nowMinute, threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], @@ -877,7 +959,7 @@ export function HomeScreen(props: HomeScreenProps) { @@ -948,31 +1030,11 @@ export function HomeScreen(props: HomeScreenProps) { ) : null; // Self-contained: v1's listEmpty keys off projectGroups, which ignores the // v2 project scope, so it can be null (results elsewhere) while this list - // is empty. Search outranks the scope β€” "No results" names the actionable - // fact when a query is active. Snoozed threads outrank the rest: "No - // threads yet" over an inbox that is merely all-snoozed reads as data - // loss. - const v2SnoozedCount = threadListV2Layout.snoozedCount; + // is empty. Snoozed threads need no special empty state: their shelf header + // is a list row even while collapsed. const v2ListEmpty = - hasSearchQuery && threadSearch.isPending && v2SnoozedCount === 0 ? null : hasSearchQuery ? ( - v2SnoozedCount > 0 ? ( - // The snoozed threads already passed this search filter: "No - // results" would claim nothing matched when matches are merely - // parked. - - ) : ( - - ) - ) : v2SnoozedCount > 0 ? ( - + hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( + ) : v2ScopedProjectGroup !== null ? ( 0 ? ( + settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( @@ -1060,16 +1122,19 @@ export function HomeScreen(props: HomeScreenProps) { scrollEventThrottle={16} contentContainerStyle={{ // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). + // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a + // standard 44pt bottom toolbar that overlays the list and is not + // reflected in insets while contentInsetAdjustmentBehavior is + // "never". paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance : Math.max(insets.bottom, 16) + 88, }} scrollIndicatorInsets={ Platform.OS === "ios" ? { - bottom: Math.max(insets.bottom, 16) + 24, + bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, top: 0, } : undefined diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 186c606ae8f..973c4fae9ce 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -1,4 +1,6 @@ import { SymbolView } from "../../components/AppSymbol"; +import { ControlPillMenu } from "../../components/ControlPill"; +import type { MenuAction } from "@react-native-menu/menu"; import * as Haptics from "expo-haptics"; import { createContext, @@ -47,13 +49,67 @@ export const THREAD_SWIPE_SPRING = { stiffness: 330, }; -interface ThreadSwipePrimaryAction { +interface ThreadSwipeAction { readonly accessibilityLabel: string; readonly icon: ComponentProps["name"]; readonly label: string; + readonly menu?: { + readonly actions: MenuAction[]; + readonly onPressAction: NonNullable["onPressAction"]>; + readonly title?: string; + }; readonly onPress: () => void; } +interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { + readonly backgroundColor: string; +} + +function swipeActionsWidth(hasSecondaryAction: boolean) { + return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH; +} + +/** `undefined` keeps the v1 Delete default; `null` means one action only. */ +function resolveSecondaryAction(input: { + readonly close: () => void; + readonly onDelete: () => void; + readonly secondaryAction: ThreadSwipeAction | null | undefined; + readonly threadTitle: string; +}): ThreadSwipeSecondaryAction | null { + if (input.secondaryAction === null) return null; + if (input.secondaryAction === undefined) { + return { + accessibilityLabel: `Delete ${input.threadTitle}`, + backgroundColor: "#ff2d55", + icon: "trash", + label: "Delete", + onPress: () => { + input.close(); + input.onDelete(); + }, + }; + } + const action = input.secondaryAction; + return { + ...action, + backgroundColor: "#5856d6", + menu: + action.menu === undefined + ? undefined + : { + ...action.menu, + onPressAction: (event) => { + input.close(); + action.menu?.onPressAction(event); + }, + }, + onPress: () => { + input.close(); + action.onPress(); + }, + }; +} + /** * Delivers the scroll gate to swipeables via context so that flipping it does * NOT re-render whole rows: putting the flag in list extraData/renderItem deps @@ -173,17 +229,22 @@ export function ThreadSwipeable(props: { readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; /** - * What a full swipe commits: "delete" (default, v1 behavior β€” the Delete - * button stretches) or "primary" β€” the advertised primary action fires and - * its button stretches instead. A full swipe must always match the action - * the stretching button advertises. + * What a full swipe commits. Omitted keeps the v1 Delete behavior only when + * the built-in Delete secondary action is in use; custom or absent + * secondary actions default to the advertised primary action. */ readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; - readonly primaryAction: ThreadSwipePrimaryAction; + readonly primaryAction: ThreadSwipeAction; + /** + * Omitted keeps the v1 destructive Delete action. Explicit null opts out of + * a secondary action entirely so a gated Snooze can never fall back to an + * unadvertised Delete. + */ + readonly secondaryAction?: ThreadSwipeAction | null; /** * Identity of the content being wrapped. When a recycled list reuses this * component for a different item, the swipeable snaps back to closed so an @@ -197,7 +258,11 @@ export function ThreadSwipeable(props: { }) { const swipeableRef = useRef(null); const fullSwipeArmedRef = useRef(false); - const fullSwipeThreshold = Math.max(THREAD_SWIPE_ACTIONS_WIDTH + 44, props.fullSwipeWidth * 0.58); + const hasSecondaryAction = props.secondaryAction !== null; + const actionsWidth = swipeActionsWidth(hasSecondaryAction); + const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58); + const fullSwipeAction = + props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary"); const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); const resetKey = props.resetKey; @@ -251,7 +316,7 @@ export function ThreadSwipeable(props: { if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); - if (props.fullSwipeAction === "primary") { + if (fullSwipeAction === "primary") { props.primaryAction.onPress(); } else { props.onDelete(); @@ -264,9 +329,8 @@ export function ThreadSwipeable(props: { methods.close(), + onDelete: props.onDelete, + secondaryAction: props.secondaryAction, + threadTitle: props.threadTitle, + })} translation={translation} /> )} - rightThreshold={THREAD_SWIPE_ACTIONS_WIDTH * 0.42} + rightThreshold={actionsWidth * 0.42} simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} > {props.children(close)} @@ -290,12 +358,14 @@ export function ThreadSwipeable(props: { function SwipeActionButton(props: { readonly accessibilityLabel: string; + readonly actionsWidth: number; readonly backgroundColor: string; readonly compact: boolean; readonly entryRange: readonly [number, number]; readonly fullSwipeThreshold: number; readonly icon: ComponentProps["name"]; readonly label: string; + readonly menu?: ThreadSwipeAction["menu"]; readonly onPress: () => void; readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; @@ -305,10 +375,10 @@ function SwipeActionButton(props: { const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); - const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + const stretch = Math.max(reveal - props.actionsWidth, 0); const fullSwipeProgress = interpolate( reveal, - [THREAD_SWIPE_ACTIONS_WIDTH, props.fullSwipeThreshold + 20], + [props.actionsWidth, props.fullSwipeThreshold + 20], [0, 1], Extrapolation.CLAMP, ); @@ -327,9 +397,7 @@ function SwipeActionButton(props: { }); const circleStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); - const stretch = props.stretchesOnFullSwipe - ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) - : 0; + const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; return { transform: [{ translateX: -stretch }], @@ -338,9 +406,7 @@ function SwipeActionButton(props: { }); const iconStyle = useAnimatedStyle(() => { const reveal = Math.max(-props.translation.value, 0); - const stretch = props.stretchesOnFullSwipe - ? Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0) - : 0; + const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; const armedProgress = interpolate( reveal, [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], @@ -358,7 +424,7 @@ function SwipeActionButton(props: { } const reveal = Math.max(-props.translation.value, 0); - const stretch = Math.max(reveal - THREAD_SWIPE_ACTIONS_WIDTH, 0); + const stretch = Math.max(reveal - props.actionsWidth, 0); return { opacity: interpolate( reveal, @@ -370,6 +436,63 @@ function SwipeActionButton(props: { }; }); + const button = ( + ({ + alignItems: "center", + height: "100%", + justifyContent: "center", + opacity: pressed ? 0.72 : 1, + width: "100%", + })} + > + + + + + + + + + {props.label} + + + + ); + return ( - ({ - alignItems: "center", - height: "100%", - justifyContent: "center", - opacity: pressed ? 0.72 : 1, - width: "100%", - })} - > - - - - - - - - - {props.label} - - - + {button} + + )} ); } @@ -446,14 +527,14 @@ export function ThreadSwipeActions(props: { readonly compact: boolean; readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeThreshold: number; - readonly onDelete: () => void; readonly onFullSwipeArmedChange: (armed: boolean) => void; - readonly primaryAction: ThreadSwipePrimaryAction; - readonly swipeableMethods: SwipeableMethods; - readonly threadTitle: string; + readonly primaryAction: ThreadSwipeAction; + readonly secondaryAction: ThreadSwipeSecondaryAction | null; readonly translation: SharedValue; }) { - const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; + const secondaryAction = props.secondaryAction; + const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null; + const actionsWidth = swipeActionsWidth(secondaryAction !== null); useAnimatedReaction( () => -props.translation.value >= props.fullSwipeThreshold, (armed, previous) => { @@ -470,14 +551,19 @@ export function ThreadSwipeActions(props: { backgroundColor: props.backgroundColor, flexDirection: "row", height: "100%", - width: THREAD_SWIPE_ACTIONS_WIDTH, + width: actionsWidth, }} > - { - props.swipeableMethods.close(); - props.onDelete(); - }} - stretchesOnFullSwipe={!fullSwipeIsPrimary} - translation={props.translation} - /> + {secondaryAction === null ? null : ( + + )} ); } diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index e200eb7acde..0c621a04e38 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; +import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -22,6 +22,13 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en ); } +function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSnooze === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -192,9 +199,14 @@ export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; readonly settleThread: (thread: EnvironmentThreadShell) => Promise; + readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise; + readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); + const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); + const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); + const snoozeInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -206,6 +218,94 @@ export function useThreadListActions(): { async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, [executeAction], ); + const snoozeThread = useCallback( + async (thread: EnvironmentThreadShell, snoozedUntil: string) => { + const key = scopedThreadKey(thread.environmentId, thread.id); + if (snoozeInFlightThreadKeys.current.has(key)) { + return false; + } + snoozeInFlightThreadKeys.current.add(key); + try { + if (!environmentSupportsSnooze(thread.environmentId)) { + Alert.alert( + "Could not snooze thread", + "This environment's server does not support snoozing yet. Update the server to use Snooze.", + ); + return false; + } + if (!canSnooze(thread, { now: new Date().toISOString() })) { + Alert.alert( + "Could not snooze thread", + thread.hasPendingApprovals || thread.hasPendingUserInput + ? "This thread is waiting on you. Respond to the pending request before snoozing it." + : "This thread is still starting a turn. Try again once it's running.", + ); + return false; + } + + selectionHaptic(); + const result = await snoozeMutation({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + snoozedUntil, + }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not snooze thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be snoozed.", + ); + return false; + } + return true; + } finally { + snoozeInFlightThreadKeys.current.delete(key); + } + }, + [snoozeMutation], + ); + const unsnoozeThread = useCallback( + async (thread: EnvironmentThreadShell) => { + const key = scopedThreadKey(thread.environmentId, thread.id); + if (snoozeInFlightThreadKeys.current.has(key)) { + return false; + } + snoozeInFlightThreadKeys.current.add(key); + try { + if (!environmentSupportsSnooze(thread.environmentId)) { + Alert.alert( + "Could not wake thread", + "This environment's server does not support snoozing yet. Update the server to wake this thread.", + ); + return false; + } + + selectionHaptic(); + const result = await unsnoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not wake thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be woken.", + ); + return false; + } + return true; + } finally { + snoozeInFlightThreadKeys.current.delete(key); + } + }, + [unsnoozeMutation], + ); const unsettleThread = useCallback( async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, [executeAction], @@ -213,7 +313,14 @@ export function useThreadListActions(): { const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; + return { + archiveThread, + confirmDeleteThread, + settleThread, + snoozeThread, + unsnoozeThread, + unsettleThread, + }; } export function useArchivedThreadListActions( diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 820e1222243..8770d96b124 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -1,5 +1,16 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; + +/** + * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass + * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side + * silently drops the item and hides the navigation toolbar entirely. Screens + * that send it must fall back to standard search/toolbar primitives when this + * is false. + */ +export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; + type NativeMailSearchToolbarInput = Omit< HeaderBarButtonMailSearchToolbarItem, "type" | "useFallbackSearchField" diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f354bcd29ac..49adfe75cb2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { - ActivityIndicator, - Alert, - Linking, - Platform, - Pressable, - ScrollView, - View, -} from "react-native"; +import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { - type AtomCommandResult, isAtomCommandInterrupted, reportAtomCommandResult, settleAsyncResult, @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { + type AppUpdateCheckState, + registerHiddenUpdateTap, + runAppUpdateCheck, +} from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -578,12 +574,11 @@ function BetaSettingsSection() { ); } -type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; - function AppSettingsSection() { const icon = useThemeColor("--color-icon"); - const [updateState, setUpdateState] = useState("idle"); + const [updateState, setUpdateState] = useState("idle"); const updateInFlight = useRef(false); + const hiddenUpdateTapCount = useRef(0); const version = Constants.expoConfig?.version ?? "0.0.0"; // Fall back to "production" to match resolveAppVariant in app.config.ts, so a @@ -591,22 +586,11 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} Β· ${variantLabel}` : version; - // Which JS is actually running: the bundle shipped in the binary, or an OTA - // update downloaded on top of it. Surfacing this makes "am I even on the - // right build?" answerable at a glance. - const bundleLabel = Updates.isEnabled - ? Updates.isEmbeddedLaunch - ? "Embedded" - : Updates.updateId - ? `OTA ${Updates.updateId.slice(0, 7)}` - : null - : null; - const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; // "Up to date" is a transient acknowledgement, not a state worth persisting β€” - // drop back to the bundle label so the row keeps answering "what am I running?". + // return the version row to its normal, deliberately quiet state. useEffect(() => { if (updateState !== "current") return; const timer = setTimeout(() => setUpdateState("idle"), 3000); @@ -619,12 +603,24 @@ function AppSettingsSection() { if (updateInFlight.current) return; updateInFlight.current = true; try { - await runUpdateCheck(setUpdateState); + await runAppUpdateCheck({ + onFailure: (message) => Alert.alert("Update failed", message), + onStateChange: setUpdateState, + }); } finally { updateInFlight.current = false; } }, []); + const handleVersionPress = useCallback(() => { + if (!Updates.isEnabled || updateInFlight.current) return; + const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); + hiddenUpdateTapCount.current = tap.nextCount; + if (tap.shouldCheck) { + void checkForUpdate(); + } + }, [checkForUpdate]); + const statusLabel = updateState === "checking" ? "Checking…" @@ -634,7 +630,7 @@ function AppSettingsSection() { ? "Restarting…" : updateState === "current" ? "Up to date" - : bundleLabel; + : null; const versionRow = ( @@ -652,21 +648,6 @@ function AppSettingsSection() { {statusLabel} ) : null} - {Updates.isEnabled ? ( - - {busy ? ( - - ) : ( - - )} - - ) : null} ); @@ -676,10 +657,10 @@ function AppSettingsSection() { {Updates.isEnabled ? ( void checkForUpdate()} + onPress={handleVersionPress} > {versionRow} @@ -690,52 +671,6 @@ function AppSettingsSection() { ); } -async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise { - setUpdateState("checking"); - const check = await settlePromise(() => Updates.checkForUpdateAsync()); - if (check._tag === "Failure") { - reportUpdateFailure(check, "Could not check for updates."); - setUpdateState("idle"); - return; - } - // A rollback directive (`eas update:rollback`) arrives as isAvailable: false - // with isRollBackToEmbedded: true β€” there is nothing newer to install, but the - // running OTA still has to be dropped for the embedded bundle. - if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { - setUpdateState("current"); - return; - } - - setUpdateState("downloading"); - const fetched = await settlePromise(() => Updates.fetchUpdateAsync()); - if (fetched._tag === "Failure") { - reportUpdateFailure(fetched, "Could not download the update."); - setUpdateState("idle"); - return; - } - // isNew is always false for a rollback, so it can't be the sole gate here either. - if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { - setUpdateState("current"); - return; - } - - setUpdateState("restarting"); - // reloadAsync never resolves on success β€” the JS context is torn down β€” so - // reaching the failure branch below is the only way this returns. - const reloaded = await settlePromise(() => Updates.reloadAsync()); - if (reloaded._tag === "Failure") { - reportUpdateFailure(reloaded, "Downloaded, but could not restart the app."); - setUpdateState("idle"); - } -} - -function reportUpdateFailure(result: AtomCommandResult, fallback: string): void { - reportAtomCommandResult(result, { label: "app update check" }); - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - Alert.alert("Update failed", error instanceof Error ? error.message : fallback); -} - function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; } diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 9a4272824ba..ffeca9671b7 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -9,6 +9,8 @@ import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { + applyNativeShowcaseOrientation, + getNativeShowcaseOrientation, getNativeShowcasePairingUrls, getNativeShowcaseScene, markNativeShowcaseReady, @@ -47,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const [pendingTasksReady, setPendingTasksReady] = useState(false); const [requestedScene, setRequestedScene] = useState(null); const [readyScene, setReadyScene] = useState(null); + const [orientationSettled, setOrientationSettled] = useState(false); useEffect(() => { if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; @@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) return () => clearInterval(interval); }, [pairingUrls.length]); + useEffect(() => { + if (!SHOWCASE_ENABLED || orientationSettled) return; + const orientation = getNativeShowcaseOrientation(); + if (orientation === null) { + setOrientationSettled(true); + return; + } + + let cancelled = false; + void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { + isCancelled: () => cancelled, + }).then((applied) => { + if (!cancelled && applied) setOrientationSettled(true); + }); + return () => { + cancelled = true; + }; + }, [orientationSettled]); + useEffect(() => { if (!SHOWCASE_ENABLED) return; @@ -182,7 +204,10 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) scene === null || requestedScene === null || scene !== requestedScene || - !hasFixture + !hasFixture || + // Never report a scene ready while the capture orientation is still + // being applied β€” a screenshot taken early has the wrong dimensions. + !orientationSettled ) { setReadyScene(null); return; @@ -210,7 +235,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; - }, [hasFixture, requestedScene, scene]); + }, [hasFixture, orientationSettled, requestedScene, scene]); if (!SHOWCASE_ENABLED || readyScene === null) return null; diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 1f2e263ebf1..07ca60cf533 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo"; export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; +export type ShowcaseOrientation = "portrait" | "landscape"; + interface NativeShowcaseControls { readonly getShowcasePairingUrl?: () => string | null; readonly getShowcaseScene?: () => string | null; + readonly getShowcaseOrientation?: () => string | null; + readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise; + readonly getInterfaceOrientation?: () => Promise; readonly prepareShowcaseCapture?: () => void; readonly markShowcaseReady?: (scene: ShowcaseScene) => void; } @@ -59,6 +64,35 @@ export function prepareNativeShowcaseCapture(): void { } } +export function getNativeShowcaseOrientation(): ShowcaseOrientation | null { + try { + const orientation = nativeShowcaseControls()?.getShowcaseOrientation?.()?.trim(); + return orientation === "portrait" || orientation === "landscape" ? orientation : null; + } catch { + return null; + } +} + +export async function applyNativeShowcaseOrientation( + orientation: ShowcaseOrientation, +): Promise { + const controls = nativeShowcaseControls(); + if (!controls?.applyShowcaseOrientation || !controls.getInterfaceOrientation) { + // A development build that predates this helper keeps its default + // orientation; report success so callers do not retry forever. + return true; + } + try { + await controls.applyShowcaseOrientation(orientation); + // The geometry request settles asynchronously; confirm it took effect so + // callers can retry attempts made before the scene was foreground-active. + await new Promise((resolve) => setTimeout(resolve, 500)); + return (await controls.getInterfaceOrientation()) === orientation; + } catch { + return false; + } +} + export function markNativeShowcaseReady(scene: ShowcaseScene): void { try { nativeShowcaseControls()?.markShowcaseReady?.(scene); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index f37b5559a4a..6d204e3a8e6 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -42,7 +42,8 @@ import { restoreComposerDraftSnapshot, type ComposerDraft, } from "../../state/use-composer-drafts"; -import { useProjects } from "../../state/entities"; +import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; +import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -90,6 +91,9 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); + const selectedEnvironmentServerConfig = useEnvironmentServerConfig( + selectedProject?.environmentId ?? null, + ); const environmentConnected = selectedProject !== null && connectedEnvironments.find( @@ -539,27 +543,7 @@ export function NewTaskDraftScreen(props: { ); const modelMenuActions = useMemo( - () => - flow.providerGroups.map((group) => ({ - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: group.models.find( - (model) => - flow.selectedModel && - model.selection.instanceId === flow.selectedModel.instanceId && - model.selection.model === flow.selectedModel.model, - )?.label, - subactions: group.models.map((option) => ({ - id: `model:${option.key}`, - title: option.label, - state: - flow.selectedModel && - option.selection.instanceId === flow.selectedModel.instanceId && - option.selection.model === flow.selectedModel.model - ? ("on" as const) - : undefined, - })), - })), + () => buildModelMenuActions(flow.providerGroups, flow.selectedModel), [flow.providerGroups, flow.selectedModel], ); const providerOptionDescriptors = useMemo( @@ -795,7 +779,14 @@ export function NewTaskDraftScreen(props: { return; } const draft = getComposerDraftSnapshot(draftKey); - const modelSelection = draft.modelSelection ?? flow.selectedModel; + // Snapshot read keeps just-typed selector state; the availability gate + // still applies so a stored selection on a disabled provider falls back + // to the flow's resolved model. + const modelSelection = + resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + draft.modelSelection ?? null, + ) ?? flow.selectedModel; const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; const selectedWorktreePath = @@ -847,7 +838,10 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey); + // Drop the workspace selection with the content: the next task should + // re-resolve mode/branch/origin from the server's configured defaults + // instead of resurrecting this task's picks. + clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); } navigation.getParent()?.goBack(); return; @@ -905,7 +899,7 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey); + clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); } navigation.dispatch( StackActions.replace("Thread", { diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fc45cba4260..aab896efe03 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -53,7 +53,7 @@ import { import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { @@ -606,25 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [providerOptionDescriptors], ); const modelMenuActions = useMemo( - () => - providerGroups.map((group) => ({ - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: group.models.find( - (model) => - model.selection.instanceId === currentModelSelection.instanceId && - model.selection.model === currentModelSelection.model, - )?.label, - subactions: group.models.map((option) => ({ - id: `model:${option.key}`, - title: option.label, - state: - option.selection.instanceId === currentModelSelection.instanceId && - option.selection.model === currentModelSelection.model - ? ("on" as const) - : undefined, - })), - })), + () => buildModelMenuActions(providerGroups, currentModelSelection), [providerGroups, currentModelSelection], ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 36a86ceb1e3..8a7fc2ed6df 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -67,7 +67,12 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; -import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; +import { + ThreadListV2PendingRow, + ThreadListV2Row, + ThreadListV2SettledShelfHeader, + ThreadListV2SnoozedShelfHeader, +} from "./thread-list-v2-items"; import { buildThreadListV2Items, buildThreadListV2ListItems, @@ -193,8 +198,14 @@ function ThreadNavigationSidebarPane( const openSwipeableRef = useRef(null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); - const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = - useThreadListActions(); + const { + archiveThread, + confirmDeleteThread, + settleThread, + snoozeThread, + unsnoozeThread, + unsettleThread, + } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -428,6 +439,10 @@ function ThreadNavigationSidebarPane( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); + const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); + const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". @@ -468,7 +483,15 @@ function ThreadNavigationSidebarPane( }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) - return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; + return { + items: [], + hiddenSettledCount: 0, + snoozedCount: 0, + snoozedShelfHeaderIndex: null, + settledCount: 0, + settledShelfHeaderIndex: null, + nextSnoozeWakeAt: null, + }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, @@ -481,11 +504,17 @@ function ThreadNavigationSidebarPane( settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), + snoozedShelfExpanded, + settledShelfExpanded, + selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, + snoozedShelfExpanded, + settledShelfExpanded, + props.selectedThreadKey, options.selectedEnvironmentId, props.searchQuery, matchedThreadKeys, @@ -532,8 +561,15 @@ function ThreadNavigationSidebarPane( const items: SidebarListItem[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, + snoozedCount: threadListV2Layout.snoozedCount, + snoozedShelfExpanded, + snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, + settledCount: threadListV2Layout.settledCount, + settledShelfExpanded, + settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, + snoozeLabelNow: `${nowMinute}:00.000Z`, }); - if (threadListV2Layout.hiddenSettledCount > 0) { + if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { items.push({ type: "v2-show-more", key: "v2-show-more", @@ -543,10 +579,13 @@ function ThreadNavigationSidebarPane( return items; }, [ listLayout.items, + nowMinute, options.selectedEnvironmentId, pendingTasks, props.searchQuery, selectedProjectRefs, + settledShelfExpanded, + snoozedShelfExpanded, threadListV2Enabled, threadListV2Layout, ]); @@ -731,6 +770,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -740,6 +780,7 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, + nowMinute, threadSearchMatchByKey, ], ); @@ -750,7 +791,8 @@ function ThreadNavigationSidebarPane( previous.key === item.key && previous.item.thread === item.item.thread && previous.item.variant === item.item.variant && - previous.item.showSettledDivider === item.item.showSettledDivider + previous.item.snoozed === item.item.snoozed && + previous.snoozeWakeLabelText === item.snoozeWakeLabelText ); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { @@ -762,13 +804,23 @@ function ThreadNavigationSidebarPane( previous.showPendingDivider === item.showPendingDivider ); } + if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { + return previous.count === item.count && previous.expanded === item.expanded; + } + if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { + return previous.count === item.count && previous.expanded === item.expanded; + } if ( previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending" || + previous.type === "v2-snoozed-shelf" || + previous.type === "v2-settled-shelf" || item.type === "v2-thread" || item.type === "v2-show-more" || - item.type === "v2-pending" + item.type === "v2-pending" || + item.type === "v2-snoozed-shelf" || + item.type === "v2-settled-shelf" ) { return false; } @@ -826,7 +878,9 @@ function ThreadNavigationSidebarPane( ); } + case "v2-snoozed-shelf": + return ( + + ); + case "v2-settled-shelf": + return ( + + ); case "v2-show-more": return ( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 - ? threadSearch.isPending && snoozedCount === 0 + ? threadSearch.isPending ? "Searching thread messages…" - : snoozedCount > 0 - ? // Snoozed matches passed this same search filter β€” "No - // matching threads" would misreport them as nonexistent. - snoozedCount === 1 - ? "1 matching thread snoozed" - : "All matching threads snoozed" - : "No matching threads" - : snoozedCount > 0 - ? snoozedCount === 1 - ? "1 thread snoozed" - : `${snoozedCount} threads snoozed` - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} + : "No matching threads" + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 74fe2f4852a..8d4ce7a7fed 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -22,7 +22,11 @@ import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../ import type { TurnCommandMetadata } from "../../lib/commandMetadata"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { + buildModelOptions, + groupByProvider, + resolveSelectableModelSelection, +} from "../../lib/modelOptions"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -347,7 +351,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; - const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + // The server's configured default decides the mode until the user picks one + // explicitly β€” same resolution web uses for new draft threads. + const defaultWorkspaceMode: WorkspaceMode = + selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: @@ -361,22 +369,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + // Stored selections (draft and project default) only count while their + // provider is usable on the server; otherwise the server's default model + // wins instead of silently targeting a disabled provider. + const draftModelSelection = resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + selectedProjectDraft.modelSelection ?? null, + ); + const projectDefaultModelSelection = resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection ?? null, + ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, + draftModelSelection ?? projectDefaultModelSelection, ), - [ - selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection, - selectedProjectDraft.modelSelection, - ], + [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], ); const selectedModel = - selectedProjectDraft.modelSelection ?? - selectedProject?.defaultModelSelection ?? + draftModelSelection ?? + projectDefaultModelSelection ?? modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; @@ -675,12 +690,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } const draft = getComposerDraftSnapshot(selectedProjectDraftKey); const text = draft.text.trim(); - const draftModelSelection = draft.modelSelection ?? selectedModel; + // Same availability gate the composer display applies: a stored + // selection targeting a disabled provider must not ride into the queue. + const draftModelSelection = + resolveSelectableModelSelection( + selectedEnvironmentServerConfig, + draft.modelSelection ?? null, + ) ?? selectedModel; if (text.length === 0 || !draftModelSelection) { return null; } const workspaceSelection = draft.workspaceSelection; - const mode = workspaceSelection?.mode ?? "local"; + // Fall back to the resolved mode (server default) so queued tasks drain + // with the same mode the composer displayed. + const mode = workspaceSelection?.mode ?? workspaceMode; // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values β€” the // stand-in's placeholder title/workspaceRoot must never be written back @@ -722,10 +745,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [ editingPendingProject, editingPendingTask, + selectedEnvironmentServerConfig, selectedModel, selectedProject, selectedProjectDraftKey, startFromOrigin, + workspaceMode, ], ); diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index f0e3f89c07d..4be4089a54d 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,6 +11,7 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; +import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; @@ -35,10 +36,9 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, - headerTitleStyle: { fontSize: 18, fontWeight: "800" }, + ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }), headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, - title: "Threads", unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 9ac4002a9b0..855713946ff 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; +import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -33,7 +34,7 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; export type ThreadListVariant = "compact" | "sidebar"; /** Left inset that aligns compact secondary rows with the title column. */ -export const THREAD_LIST_COMPACT_INSET = 20; +export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; function pullRequestTintColor( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6af5795a94a..8d6874c7855 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,11 +3,20 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; -import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; -import { Platform, Pressable, useWindowDimensions, View } from "react-native"; +import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; +import { + Alert, + Platform, + Pressable, + useColorScheme, + useWindowDimensions, + View, +} from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; @@ -18,7 +27,13 @@ import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; +import { + resolveThreadListV2SnoozeMenuSelection, + resolveThreadListV2SnoozeGateExpiryMs, + resolveThreadListV2Status, + resolveThreadListV2SwipeActions, + type ThreadListV2Status, +} from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** @@ -62,6 +77,11 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const SNOOZED_MENU_ACTIONS: MenuAction[] = [ + { id: "unsnooze", title: "Wake thread", image: "clock" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + // Pre-settlement servers: no lifecycle items, archive fills the gap. const LEGACY_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, @@ -90,6 +110,81 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid ); }); +const SNOOZE_ACCENT_LIGHT = "#2563eb"; +const SNOOZE_ACCENT_DARK = "#60a5fa"; + +export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { + readonly count: number; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly pane?: "screen" | "sidebar"; +}) { + const colorScheme = useColorScheme(); + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} + + + + + ); +}); + +export const ThreadListV2SettledShelfHeader = memo(function ThreadListV2SettledShelfHeader(props: { + readonly count: number; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly pane?: "screen" | "sidebar"; +}) { + const mutedColor = useThemeColor("--color-foreground-muted"); + return ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + {props.expanded ? "Settled" : `Settled (${props.count})`} + + + + + ); +}); + const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -208,7 +303,14 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; - readonly showSettledDivider: boolean; + /** Snoozed-shelf row: shows its wake time and offers Wake. */ + readonly snoozed?: boolean; + /** Preformatted against the parent minute tick so this memoized row's + countdown keeps moving. */ + readonly snoozeWakeLabelText?: string; + /** Parent minute tick passed as a prop so this memoized row refreshes its + native snooze menu while mounted. */ + readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; readonly providerDriver: string | null; @@ -231,11 +333,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; + readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; + /** False on servers that predate thread.snooze/unsnooze. */ + readonly snoozeSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -258,10 +364,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSelectThread, onDeleteThread, onSettleThread, + onSnoozeThread, + onUnsnoozeThread, onUnsettleThread, onArchiveThread, onChangeRequestState, } = props; + const snoozedRow = props.snoozed === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; @@ -283,27 +392,94 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); + const handleSnooze = useCallback( + (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), + [onSnoozeThread, thread], + ); + const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + + // Swipe: the v2 primary action is the lifecycle transition. Every settled + // row can un-settle β€” explicit settles clear the override, auto-settled + // rows get pinned active until real activity clears the pin. + const canUnsettle = variant === "slim"; + const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); + const snoozeGateExpiryMs = props.snoozeSupported + ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: new Date().toISOString() }) + : null; + useEffect(() => { + if (snoozeGateExpiryMs === null) return; + const delayMs = Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647); + const id = setTimeout(() => bumpSnoozeGateTick((tick) => tick + 1), delayMs); + return () => clearTimeout(id); + }, [snoozeGateExpiryMs, snoozeGateTick]); + const swipeActions = resolveThreadListV2SwipeActions({ + variant, + settlementSupported: props.settlementSupported, + snoozeSupported: props.snoozeSupported, + snoozable: canSnooze(thread, { now: new Date().toISOString() }), + snoozed: snoozedRow, + }); + const snoozePresets = useMemo( + () => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)), + [props.snoozePresetMinute, swipeActions.secondary], + ); + const snoozePresetActions = useMemo( + () => + snoozePresets.map((preset) => ({ + id: `snooze:${preset.id}`, + title: preset.label, + subtitle: preset.whenLabel, + })), + [snoozePresets], + ); + const snoozableCardMenuActions = useMemo( + () => [ + { id: "settle", title: "Settle", image: "checkmark" }, + { + id: "snooze", + title: "Snooze", + image: "clock", + subactions: snoozePresetActions, + }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, + ], + [snoozePresetActions], + ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); + const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ + event: nativeEvent.event, + displayedPresets: snoozePresets, + now: new Date(), + }); + if (snoozeSelection._tag === "selected") { + handleSnooze(snoozeSelection.preset.snoozedUntil); + } else if (snoozeSelection._tag === "expired") { + Alert.alert("Could not snooze thread", "That snooze time has passed. Choose another time."); + } }, - [handleArchive, handleDelete, handleSettle, handleUnsettle], + [ + handleArchive, + handleDelete, + handleSettle, + handleSnooze, + handleUnsettle, + handleUnsnooze, + snoozePresets, + ], ); - - // Swipe: the v2 primary action is the lifecycle transition. Every settled - // row can un-settle β€” explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. - const canUnsettle = variant === "slim"; const primaryAction = useMemo(() => { // Pre-settlement server: archive is the swipe action, as in v1. (Slim // rows cannot occur here β€” unsupported environments never classify as // settled.) - if (!props.settlementSupported) { + if (swipeActions.primary === "archive") { return { accessibilityLabel: `Archive ${thread.title}`, icon: "archivebox" as const, @@ -311,7 +487,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPress: handleArchive, }; } - return canUnsettle + if (swipeActions.primary === "unsnooze") { + return { + accessibilityLabel: `Wake ${thread.title} now`, + icon: "clock" as const, + label: "Wake", + onPress: handleUnsnooze, + }; + } + return swipeActions.primary === "unsettle" ? { accessibilityLabel: `Un-settle ${thread.title}`, icon: "arrow.uturn.backward" as const, @@ -325,13 +509,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPress: handleSettle, }; }, [ - canUnsettle, handleArchive, handleSettle, handleUnsettle, - props.settlementSupported, + handleUnsnooze, + swipeActions.primary, thread.title, ]); + const secondaryAction = useMemo( + () => + swipeActions.secondary === "snooze" + ? { + accessibilityLabel: `Choose when to snooze ${thread.title}`, + icon: "clock" as const, + label: "Snooze", + menu: { + actions: snoozePresetActions, + onPressAction: handleMenuAction, + title: "Snooze until", + }, + onPress: () => undefined, + } + : null, + [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], + ); + const swipeAccessibilityHint = + secondaryAction === null + ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` + : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; // The sidebar pane fills selected rows with the accent color (matching the // v1 sidebar), so every piece of row text needs a white-on-accent variant. @@ -453,7 +658,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowContent = (close: () => void) => variant === "card" ? ( ) : ( - {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + {snoozedRow && props.snoozeWakeLabelText !== undefined + ? props.snoozeWakeLabelText + : relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} @@ -563,9 +774,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? ( - - ) : null} ( { + it("accepts a displayed evening preset while its wake time is still future", () => { + const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); + const selectedAt = new Date(2026, 4, 8, 17, 0, 30); + const displayedPresets = resolveSnoozePresets(menuOpenedAt); + + const selection = resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:evening", + displayedPresets, + now: selectedAt, + }); + + expect(selection).toEqual({ + _tag: "selected", + preset: displayedPresets.find((preset) => preset.id === "evening"), + }); + }); + + it("expires a displayed preset once its wake time has passed", () => { + const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30)); + + expect( + resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:evening", + displayedPresets, + now: new Date(2026, 4, 8, 18, 0, 1), + }), + ).toEqual({ _tag: "expired" }); + }); + + it("recomputes presets that remain available instead of using old timestamps", () => { + const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10)); + const selectedAt = new Date(2026, 4, 8, 10, 30); + const selection = resolveThreadListV2SnoozeMenuSelection({ + event: "snooze:hour", + displayedPresets, + now: selectedAt, + }); + + expect(selection._tag).toBe("selected"); + if (selection._tag === "selected") { + expect(selection.preset.snoozedUntil).toBe( + new Date(selectedAt.getTime() + 60 * 60 * 1_000).toISOString(), + ); + } + }); +}); + describe("resolveThreadListV2Enabled", () => { it("defaults on when the device has never chosen", () => { expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( @@ -96,6 +148,105 @@ describe("resolveThreadListV2Status", () => { }); }); +describe("resolveThreadListV2SwipeActions", () => { + it("offers settle and snooze for an active snoozable thread", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: true, + snoozable: true, + }), + ).toEqual({ primary: "settle", secondary: "snooze" }); + }); + + it("offers un-settle and snooze for settled history", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "slim", + settlementSupported: true, + snoozeSupported: true, + snoozable: true, + }), + ).toEqual({ primary: "unsettle", secondary: "snooze" }); + }); + + it("omits snooze when the server or thread does not allow it", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: false, + snoozable: true, + }), + ).toEqual({ primary: "settle", secondary: null }); + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: true, + snoozeSupported: true, + snoozable: false, + }), + ).toEqual({ primary: "settle", secondary: null }); + }); + + it("falls back to archive only for a pre-lifecycle server", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "card", + settlementSupported: false, + snoozeSupported: false, + snoozable: true, + }), + ).toEqual({ primary: "archive", secondary: null }); + }); + + it("offers wake and no snooze on a snoozed row", () => { + expect( + resolveThreadListV2SwipeActions({ + variant: "slim", + settlementSupported: true, + snoozeSupported: true, + snoozable: true, + snoozed: true, + }), + ).toEqual({ primary: "unsnooze", secondary: null }); + }); +}); + +describe("resolveThreadListV2SnoozeGateExpiryMs", () => { + it("reports when an unadopted turn's grace window lapses", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + latestUserMessageAt: "2026-06-02T00:00:30.000Z", + }); + expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe( + Date.parse("2026-06-02T00:02:30.000Z"), + ); + }); + + it("returns null once the thread is snoozable or when only data can unblock it", () => { + expect( + resolveThreadListV2SnoozeGateExpiryMs( + makeThread({ id: ThreadId.make("ready"), title: "Ready" }), + { now: NOW }, + ), + ).toBe(null); + expect( + resolveThreadListV2SnoozeGateExpiryMs( + makeThread({ + id: ThreadId.make("blocked"), + title: "Blocked", + hasPendingApprovals: true, + latestUserMessageAt: NOW, + }), + { now: NOW }, + ), + ).toBe(null); + }); +}); + describe("sortThreadsForListV2", () => { it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ @@ -167,6 +318,93 @@ describe("buildThreadListV2Items", () => { expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); }); + it("builds snoozed rows between active and settled when the shelf is expanded", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("later"), + title: "Wakes later", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("sooner"), + title: "Wakes sooner", + snoozedUntil: "2026-06-02T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + snoozedShelfExpanded: true, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "active", + "sooner", + "later", + "settled", + ]); + expect(layout.items.map((item) => item.snoozed)).toEqual([false, true, true, false]); + expect(layout.snoozedShelfHeaderIndex).toBe(1); + expect(layout.snoozedCount).toBe(2); + }); + + it("collapses to a header-only shelf", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items).toEqual([]); + expect(layout.snoozedCount).toBe(1); + expect(layout.snoozedShelfHeaderIndex).toBe(0); + }); + + it("keeps the selected thread on a collapsed shelf", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("open"), + title: "Open", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("other"), + title: "Other", + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + selectedThreadKey: `${environmentId}:open`, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["open"]); + expect(layout.items[0]?.snoozed).toBe(true); + expect(layout.snoozedCount).toBe(2); + }); + it("keeps snoozed threads visible on environments without the snooze capability", () => { const layout = buildThreadListV2Items({ threads: [ @@ -187,8 +425,8 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(0); }); - it("partitions settled threads into a slim tail with one divider", () => { - const { items } = buildThreadListV2Items({ + it("partitions settled threads into a slim shelf", () => { + const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ @@ -209,13 +447,64 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ ["active", "card"], ["settled", "slim"], ["settled-2", "slim"], ]); - expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); - expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]); + expect(layout.settledCount).toBe(2); + expect(layout.settledShelfHeaderIndex).toBe(1); + }); + + it("collapses settled threads to a counted shelf header", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + settledShelfExpanded: false, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["active"]); + expect(layout.settledCount).toBe(1); + expect(layout.settledShelfHeaderIndex).toBe(1); + }); + + it("keeps the selected settled thread visible when its shelf is collapsed", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("selected"), + title: "Selected", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("other"), + title: "Other", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + settledShelfExpanded: false, + selectedThreadKey: `${environmentId}:selected`, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["selected"]); + expect(layout.settledCount).toBe(2); + expect(layout.settledShelfHeaderIndex).toBe(0); }); it("keeps cards in creation order while settled sorts by recency", () => { @@ -420,13 +709,21 @@ describe("buildThreadListV2ListItems", () => { const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, }); expect( items.map((item) => - item.type === "v2-pending" ? item.pendingTask.title : item.item.thread.id, + item.type === "v2-pending" + ? item.pendingTask.title + : item.type === "v2-thread" + ? item.item.thread.id + : item.type === "v2-snoozed-shelf" + ? "snoozed-shelf" + : "settled-shelf", ), - ).toEqual(["active", "queued-1", "queued-2", "settled"]); + ).toEqual(["active", "queued-1", "queued-2", "settled-shelf", "settled"]); // Only the leading queued row labels the section, exactly like Settled. expect( items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), @@ -448,12 +745,58 @@ describe("buildThreadListV2ListItems", () => { expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); }); - it("leaves the thread order untouched when nothing is queued", () => { - const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); + it("keeps the settled shelf between active and settled rows when nothing is queued", () => { + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [], + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + }); expect(items.map((item) => item.key)).toEqual([ `v2-thread:${environmentId}:active`, + "v2-settled-shelf", `v2-thread:${environmentId}:settled`, ]); }); + + it("places queued tasks before a collapsed snoozed shelf", () => { + const snoozedLayout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "active" }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const items = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [makePendingTask("queued")], + snoozedCount: snoozedLayout.snoozedCount, + snoozedShelfExpanded: false, + snoozedShelfHeaderIndex: snoozedLayout.snoozedShelfHeaderIndex, + settledCount: snoozedLayout.settledCount, + settledShelfHeaderIndex: snoozedLayout.settledShelfHeaderIndex, + }); + + expect(items.map((item) => item.type)).toEqual([ + "v2-thread", + "v2-pending", + "v2-snoozed-shelf", + "v2-settled-shelf", + "v2-thread", + ]); + }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 920b7f0b53a..c88aff4ec02 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,10 +1,20 @@ -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSettled, + effectiveSnoozed, + hasQueuedTurnStart, + QUEUED_TURN_START_GRACE_MS, + resolveSnoozePresets, + snoozeWakeLabel, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +export { snoozeWakeLabel }; + /** * Thread List v2 model, ported from the web sidebar v2 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). @@ -14,6 +24,76 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; * unlabeled resting state. */ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; +export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; + +export function resolveThreadListV2SnoozeMenuSelection(input: { + readonly event: string; + readonly displayedPresets: ReadonlyArray; + readonly now: Date; +}): + | { readonly _tag: "selected"; readonly preset: SnoozePreset } + | { readonly _tag: "expired" } + | { readonly _tag: "not-snooze" } { + if (!input.event.startsWith("snooze:")) return { _tag: "not-snooze" }; + + const currentPreset = resolveSnoozePresets(input.now).find( + (candidate) => input.event === `snooze:${candidate.id}`, + ); + if (currentPreset) return { _tag: "selected", preset: currentPreset }; + + const displayedPreset = input.displayedPresets.find( + (candidate) => input.event === `snooze:${candidate.id}`, + ); + if (displayedPreset && Date.parse(displayedPreset.snoozedUntil) > input.now.getTime()) { + return { _tag: "selected", preset: displayedPreset }; + } + return { _tag: "expired" }; +} + +export function resolveThreadListV2SwipeActions(input: { + readonly variant: "card" | "slim"; + readonly settlementSupported: boolean; + readonly snoozeSupported: boolean; + readonly snoozable: boolean; + /** Row is on the snoozed shelf. */ + readonly snoozed?: boolean; +}): { + readonly primary: Exclude; + readonly secondary: "snooze" | null; +} { + if (input.snoozed === true) { + return { primary: "unsnooze", secondary: null }; + } + const primary = input.settlementSupported + ? input.variant === "slim" + ? "unsettle" + : "settle" + : "archive"; + return { + primary, + secondary: input.snoozeSupported && input.snoozable ? "snooze" : null, + }; +} + +/** + * The point at which a queued-turn snooze guard expires on its own. Rows arm + * a one-shot timer for this boundary so Snooze appears without waiting for an + * unrelated render. User-blocked threads return null because only fresh + * server data can make them snoozable. + */ +export function resolveThreadListV2SnoozeGateExpiryMs( + thread: Pick< + EnvironmentThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "latestUserMessageAt" | "latestTurn" | "session" + >, + options: { readonly now: string }, +): number | null { + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return null; + if (!hasQueuedTurnStart(thread, options)) return null; + const messageAtMs = Date.parse(thread.latestUserMessageAt ?? ""); + if (Number.isNaN(messageAtMs)) return null; + return messageAtMs + QUEUED_TURN_START_GRACE_MS; +} // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. Shared by the compact Home list and @@ -98,8 +178,8 @@ export function sortThreadsForListV2