diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9253bd23eb85..ffaf511ee8b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,41 @@ jobs: if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: node apps/desktop/scripts/verify-preload-bundle.mjs + # Browser tests run source modules; this boots the production chunk graph. The + # desktop build above already produced apps/web/dist, and the client waits about + # 15s for a session that never comes, so this runs here instead of in Test Web, + # which gates the whole run. Chromium comes from the cache Test Web shares. + - name: Resolve Playwright version + id: playwright_version + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + + - name: Install Chromium for the built web client boot check + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' }} + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi + + - name: Boot the built web client + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + timeout-minutes: 3 + run: node apps/web/scripts/verify-built-boot.ts + - name: Typecheck if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }} timeout-minutes: 5 @@ -340,12 +375,6 @@ jobs: - name: Test run: vp run --filter @t3tools/web test - # Browser tests run source modules; this boots the production chunk graph. - - name: Boot the built web client - run: | - vp run --filter @t3tools/web build - node apps/web/scripts/verify-built-boot.ts - # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md index 66e5434c5ed13..7e67d1d059427 100644 --- a/.macroscope/ignore.md +++ b/.macroscope/ignore.md @@ -279,3 +279,7 @@ native/libghostty-vt/include/** # it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review # that script and cpp/t3_highlights.c instead. apps/mobile/modules/t3-highlights/Vendor/** + +# Nitro bindings for T3Highlights. Regenerated by `vp run generate:nitro` in +# apps/mobile from modules/t3-highlights/src/T3Highlights.nitro.ts. +apps/mobile/modules/t3-highlights/nitrogen/generated/** diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 66fc83adae194..8b5f9f712dc10 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -69,14 +70,19 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as MacPermissions from "../permissions/MacPermissions.ts"; import * as PreviewManager from "../preview/Manager.ts"; -it.effect( - "starts the backend and window while Linux registration is stalled and cancels it on shutdown", - () => +it.effect.each([false, true])( + "stops backends together before flushing history and cancels Linux registration, stop defect=%s", + (stopDefect) => Effect.gen(function* () { const registrationStarted = yield* Deferred.make(); const releaseRegistration = yield* Deferred.make(); const registrationStopped = yield* Deferred.make(); const windowStarted = yield* Deferred.make(); + const firstStopStarted = yield* Deferred.make(); + const secondStopStarted = yield* Deferred.make(); + const releaseStops = yield* Deferred.make(); + const historyStarted = yield* Deferred.make(); + const releaseHistory = yield* Deferred.make(); const quitting = yield* Ref.make(false); const events: string[] = []; const record = (event: string) => @@ -169,7 +175,21 @@ it.effect( start: record("backend"), currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }), }), - list: Effect.succeed([]), + list: Effect.succeed([ + { + stop: () => + Deferred.succeed(firstStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + Effect.andThen(stopDefect ? Effect.die("stop defect") : Effect.void), + ), + }, + { + stop: () => + Deferred.succeed(secondStopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStops)), + ), + }, + ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]), Effect.provideService(DesktopServerExposure.DesktopServerExposure, { configureFromSettings: () => Effect.succeed({ mode: "local-only" }), @@ -182,7 +202,10 @@ it.effect( start: Effect.void, } as unknown as DesktopAppActivation.DesktopAppActivation["Service"]), Effect.provideService(DesktopRendererHistory.DesktopRendererHistory, { - shutdown: Effect.void, + shutdown: Deferred.succeed(historyStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseHistory)), + Effect.asVoid, + ), } as DesktopRendererHistory.DesktopRendererHistory["Service"]), ); const app = yield* runApp.pipe( @@ -226,8 +249,19 @@ it.effect( yield* Deferred.await(windowStarted); assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); assert.isFalse(yield* Deferred.isDone(registrationStopped)); - yield* (yield* DesktopShutdown.DesktopShutdown).request; - yield* Fiber.join(app); + const shutdown = yield* DesktopShutdown.DesktopShutdown; + yield* shutdown.request; + yield* Deferred.await(firstStopStarted); + yield* Deferred.await(secondStopStarted); + assert.isFalse(yield* Deferred.isDone(historyStarted)); + assert.isFalse(yield* shutdown.isComplete); + yield* Deferred.succeed(releaseStops, undefined); + yield* Deferred.await(historyStarted); + assert.isFalse(yield* shutdown.isComplete); + yield* Deferred.succeed(releaseHistory, undefined); + const result = yield* Fiber.await(app); + assert.equal(Exit.isFailure(result), stopDefect); + assert.isTrue(yield* shutdown.isComplete); assert.isTrue(yield* Deferred.isDone(registrationStopped)); }).pipe(Effect.provide(DesktopShutdown.layer)), ); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 151c227e47ff4..3c5f499a1cb3c 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,6 +1,5 @@ import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -19,6 +18,7 @@ import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; @@ -160,7 +160,7 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( const instances = yield* pool.list; yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), + (instance) => instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), { concurrency: "unbounded" }, ); }, @@ -363,6 +363,7 @@ const scopedProgram = Effect.scoped( stopAllPoolInstances().pipe( Effect.ensuring(rendererHistory.shutdown), Effect.ensuring(shutdown.markComplete), + Effect.withSpan("desktop.app.shutdown"), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e4714673777d9..2741daa1ddd07 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -720,4 +720,58 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("the quit stop deadline waits for a backend that only exits at the force kill", () => + Effect.scoped( + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const teardownFinished = yield* Deferred.make(); + // The real spawner sends SIGTERM, waits the grace, then SIGKILLs. A backend that + // ignores SIGTERM therefore finishes tearing down just after the grace. + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Deferred.succeed(spawned, undefined); + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Effect.sleep(Duration.seconds(6))), + Effect.andThen(Deferred.succeed(teardownFinished, undefined)), + Effect.asVoid, + ), + ); + return makeProcess({ + exitCode: Deferred.await(teardownFinished).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }); + }), + ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + }); + yield* instance.start; + yield* Deferred.await(spawned); + + // App quit and the updater stop every backend with this deadline. + const quitDone = yield* Deferred.make(); + const quitFiber = yield* instance + .stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }) + .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); + + yield* Deferred.await(teardownStarted); + // Past the SIGTERM grace but before the force kill lands: quit must still be waiting. + yield* TestClock.adjust(Duration.millis(5_500)); + assert.isFalse(yield* Deferred.isDone(quitDone)); + + yield* TestClock.adjust(Duration.millis(500)); + yield* Fiber.join(quitFiber); + assert.isTrue(yield* Deferred.isDone(teardownFinished)); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 2f5b57c4e3c95..98de41e247155 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -41,6 +41,12 @@ const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); // Match the service launcher's grace period so backends can stop provider sessions. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); +// Allow SIGKILL and the spawner exit wait after the SIGTERM grace so quit cannot +// orphan a backend whose shutdown hangs. +export const BACKEND_QUIT_STOP_TIMEOUT = Duration.sum( + DEFAULT_BACKEND_TERMINATE_GRACE, + Duration.seconds(2), +); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; // Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. @@ -306,7 +312,10 @@ const closeRun = ( onNone: () => Effect.void, onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), }); - const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); + const close = Scope.close(run.scope, Exit.void).pipe( + Effect.withSpan("desktop.backendInstance.closeScope"), + Effect.andThen(waitForFiber.pipe(Effect.withSpan("desktop.backendInstance.awaitRun"))), + ); const timeout = options?.timeout; if (!timeout) { @@ -567,6 +576,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped); const exit = yield* handle.exitCode.pipe( + Effect.withSpan("desktop.backendProcess.awaitExit"), Effect.map((exitCode) => ({ code: Option.some(exitCode), reason: `code=${exitCode}`, @@ -602,6 +612,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( }).pipe( Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT), Effect.ignore, + Effect.withSpan("desktop.backendProcess.drainOutput"), ); if (Exit.isFailure(exit)) { return yield* Effect.failCause(exit.cause); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 05c72eb707830..1d7edaf683250 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -121,6 +121,9 @@ export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission" export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; +export const PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL = "desktop:preview-annotation-voice-event"; +export const PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL = "desktop:preview-annotation-voice-state"; + export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index c4c33f6b8dca8..98a0aab831645 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,8 @@ import { DesktopPreviewAnnotationSendEnabledInputSchema, DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewAnnotationVoiceStateSchema, + DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewConfigInputSchema, DesktopPreviewNavigateInputSchema, @@ -363,11 +365,21 @@ const setAnnotationTheme = DesktopIpc.makeIpcMethod({ const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, - payload: DesktopPreviewTabInputSchema, + payload: DesktopPreviewPickElementInputSchema, result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), - handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { + handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId, voice }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.pickElement(tabId, voice); + }), +}); + +const updateAnnotationVoice = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL, + payload: DesktopPreviewAnnotationVoiceStateSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.updateAnnotationVoice")(function* (state) { const manager = yield* PreviewManager.PreviewManager; - return yield* manager.pickElement(tabId); + yield* manager.updateAnnotationVoice(state); }), }); @@ -428,6 +440,7 @@ const saveRecording = DesktopIpc.makeIpcMethod({ export const methods = [ setForwardedShortcuts, + updateAnnotationVoice, createTab, closeTab, registerWebview, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 72245779b9c7e..800f1e744c9d1 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopPreviewAnnotationVoiceEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, @@ -368,7 +369,20 @@ contextBridge.exposeInMainWorld("desktopBridge", { tabId, enabled, }), - pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), + annotationVoice: { + onEvent: (listener) => { + const onEvent = (_event: Electron.IpcRendererEvent, tabId: string, value: unknown) => { + if (typeof tabId !== "string" || typeof value !== "object" || value === null) return; + listener(tabId, value as DesktopPreviewAnnotationVoiceEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, onEvent); + return () => ipcRenderer.off(IpcChannels.PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, onEvent); + }, + update: (state) => + ipcRenderer.invoke(IpcChannels.PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL, state), + }, + pickElement: (tabId, voice) => + ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId, voice }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), captureScreenshot: (tabId) => diff --git a/apps/desktop/src/preview/Annotation.css b/apps/desktop/src/preview/Annotation.css index 0897aad4f86d5..3c5604e1b8cf3 100644 --- a/apps/desktop/src/preview/Annotation.css +++ b/apps/desktop/src/preview/Annotation.css @@ -36,6 +36,7 @@ --t3-popover-foreground: oklch(0.269 0 0); --t3-primary: oklch(0.488 0.217 264); --t3-primary-foreground: white; + --t3-destructive: oklch(0.577 0.245 27.325); --t3-muted: rgb(0 0 0 / 4%); --t3-muted-foreground: oklch(0.556 0 0); --t3-accent: rgb(0 0 0 / 4%); @@ -66,3 +67,8 @@ textarea:focus-visible { outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); outline-offset: -2px; } + +/* Utility display classes must not override the native hidden state. */ +[hidden] { + display: none !important; +} diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index d02abc8ac6123..a8c70b5cecec5 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.visible {\n visibility: visible;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-5 {\n height: calc(var(--spacing) * 5);\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.h-9 {\n height: calc(var(--spacing) * 9);\n}\n.h-full {\n height: 100%;\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-0\\.5 {\n width: calc(var(--spacing) * 0.5);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-7 {\n width: calc(var(--spacing) * 7);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-64 {\n width: calc(var(--spacing) * 64);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.origin-center {\n transform-origin: center;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-between {\n justify-content: space-between;\n}\n.justify-center {\n justify-content: center;\n}\n.justify-end {\n justify-content: flex-end;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.gap-px {\n gap: 1px;\n}\n.truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-full {\n border-radius: calc(infinity * 1px);\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.pb-2 {\n padding-bottom: calc(var(--spacing) * 2);\n}\n.text-right {\n text-align: right;\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.tabular-nums {\n --tw-numeric-spacing: tabular-nums;\n font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);\n}\n.shadow {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-destructive: oklch(0.577 0.245 27.325);\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: -2px;\n}\n[hidden] {\n display: none !important;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ordinal {\n syntax: "*";\n inherits: false;\n}\n@property --tw-slashed-zero {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-figure {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-spacing {\n syntax: "*";\n inherits: false;\n}\n@property --tw-numeric-fraction {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-ordinal: initial;\n --tw-slashed-zero: initial;\n --tw-numeric-figure: initial;\n --tw-numeric-spacing: initial;\n --tw-numeric-fraction: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n}\n'; diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 29011a3db7e01..a4fd88c475bca 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -14,3 +14,5 @@ export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; +export const ANNOTATION_VOICE_EVENT_CHANNEL = "preview:annotation-voice-event"; +export const ANNOTATION_VOICE_STATE_CHANNEL = "preview:annotation-voice-state"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 32b9ad23f9c93..26a6386a9abd2 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2443,6 +2443,70 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("delivers annotation voice state only to the pick session that owns it", () => + withManager((manager) => + Effect.gen(function* () { + const firstReady = yield* Deferred.make(); + const secondReady = yield* Deferred.make(); + let registrations = 0; + const guest = { + ...makeTestPreviewWebContents(async () => ({ + toPNG: () => Buffer.from("unused"), + toJPEG: () => Buffer.from("unused"), + getSize: () => ({ width: 1, height: 1 }), + })), + isFocused: () => true, + focus: vi.fn(), + send: (channel: string, ...args: unknown[]) => { + webviewSend(channel, ...args); + if (channel === "preview:start-pick") + Deferred.doneUnsafe(registrations++ === 0 ? firstReady : secondReady, Effect.void); + }, + once: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, + }; + fromId.mockReturnValue(guest as never); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const first = yield* manager + .pickElement("tab_1", { sessionId: "first", shortcuts: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstReady); + const state = { + tabId: "tab_1", + sessionId: "first", + available: true, + phase: "recording" as const, + status: "Recording", + errorAction: null, + preview: null, + freezesEditor: true, + blocksSubmission: true, + level: 0, + shortcutLabel: "Ctrl+D", + draft: { text: "Tighten spacing", cursor: 15 }, + }; + yield* manager.updateAnnotationVoice(state); + expect(webviewSend).toHaveBeenCalledWith("preview:annotation-voice-state", state); + const second = yield* manager + .pickElement("tab_1", { sessionId: "second", shortcuts: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondReady); + expect(yield* Fiber.join(first)).toBeNull(); + webviewSend.mockClear(); + yield* manager.updateAnnotationVoice(state); + expect(webviewSend).not.toHaveBeenCalled(); + yield* manager.updateAnnotationVoice({ ...state, sessionId: "second" }); + expect(webviewSend).toHaveBeenCalledOnce(); + yield* manager.cancelPickElement("tab_1"); + expect(yield* Fiber.join(second)).toBeNull(); + webviewSend.mockClear(); + yield* manager.updateAnnotationVoice({ ...state, sessionId: "second" }); + expect(webviewSend).not.toHaveBeenCalled(); + }), + ), + ); + effectIt.effect("keeps annotation send grants isolated and updates an active pick", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 81ffa8aa8cd94..8c0904199921d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,10 +1,13 @@ import * as NodeZlib from "node:zlib"; import { + DesktopPreviewAnnotationVoiceEventSchema, DesktopPreviewRecordingInputSchema, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAnnotationVoiceConfig, + DesktopPreviewAnnotationVoiceState, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewOpenLinkEvent, @@ -56,13 +59,19 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; -import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, + ANNOTATION_VOICE_EVENT_CHANNEL, + ANNOTATION_VOICE_STATE_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, @@ -396,9 +405,14 @@ interface PendingRecording { } interface PickSession { + readonly voiceSessionId?: string | undefined; readonly cancel: Effect.Effect; } +const decodeAnnotationVoiceEvent = Schema.decodeUnknownOption( + DesktopPreviewAnnotationVoiceEventSchema, +); + interface BrowserControlSession { readonly webContentsId: number; // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. @@ -2074,16 +2088,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }); - const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) { + // Voice state reaches a guest only while its pick session, by id, still owns the tab. + const updateAnnotationVoice = Effect.fn("PreviewManager.updateAnnotationVoice")(function* ( + state: DesktopPreviewAnnotationVoiceState, + ) { + const sessions = yield* Ref.get(pickSessionsRef); + if (sessions.get(state.tabId)?.voiceSessionId !== state.sessionId) return; + const wc = yield* requireWebContents(state.tabId); + yield* attempt({ operation: "annotationVoice.update", tabId: state.tabId }, () => + wc.send(ANNOTATION_VOICE_STATE_CHANNEL, state), + ); + }); + + const pickElement = Effect.fn("PreviewManager.pickElement")(function* ( + tabId: string, + voice?: DesktopPreviewAnnotationVoiceConfig, + ) { const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { - const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; + const session: PickSession = { + voiceSessionId: voice?.sessionId, + cancel: Effect.suspend(() => cancelPickSession()), + }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage); + wc.ipc.removeListener(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); @@ -2191,6 +2224,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); }; + // Only this guest's listener sees these; the renderer still checks the session id. + const onVoiceEvent = (_event: Electron.IpcMainEvent, value: unknown) => { + const decoded = decodeAnnotationVoiceEvent(value); + if (settled || !voice || Option.isNone(decoded)) return; + if (decoded.value.sessionId !== voice.sessionId) return; + runFork( + Effect.gen(function* () { + const window = yield* Ref.get(mainWindowRef); + if (Option.isNone(window) || window.value.isDestroyed()) return; + yield* attempt({ operation: "annotationVoice.forward", tabId }, () => + window.value.webContents.send( + PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + tabId, + decoded.value, + ), + ).pipe(Effect.ignore); + }), + ); + }; const onDestroyed = () => settle(null); const onNavigated = ( event: Electron.Event, @@ -2210,10 +2262,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); + if (voice) wc.ipc.on(ANNOTATION_VOICE_EVENT_CHANNEL, onVoiceEvent); wc.once("destroyed", onDestroyed); wc.on("did-start-navigation", onNavigated); if (!wc.isFocused()) wc.focus(); - wc.send(START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(tabId) === true); + wc.send( + START_PICK_CHANNEL, + annotationTheme, + annotationSendEnabled.get(tabId) === true, + voice, + ); }); }); runFork( @@ -3312,6 +3370,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + updateAnnotationVoice, prepareWebview, reapplyZoom, refresh, @@ -3552,7 +3611,11 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly pickElement: ( tabId: string, + voice?: DesktopPreviewAnnotationVoiceConfig, ) => Effect.Effect; + readonly updateAnnotationVoice: ( + state: DesktopPreviewAnnotationVoiceState, + ) => Effect.Effect; readonly cancelPickElement: (tabId: string) => Effect.Effect; readonly captureScreenshot: ( tabId: string, @@ -3703,6 +3766,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setAnnotationTheme: operations.setAnnotationTheme, setAnnotationSendEnabled: operations.setAnnotationSendEnabled, pickElement: operations.pickElement, + updateAnnotationVoice: operations.updateAnnotationVoice, cancelPickElement: operations.cancelPickElement, captureScreenshot: operations.captureScreenshot, revealArtifact: operations.revealArtifact, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 4cb5987cdeb85..0549e166dd634 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,8 +1,12 @@ // @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; +import { matchesKeybindingShortcut } from "@t3tools/shared/keybindingMatching"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAnnotationVoiceConfig, + DesktopPreviewAnnotationVoiceEvent, + DesktopPreviewAnnotationVoiceState, PickedElementPayload, PickedElementStackFrame, PreviewAnnotationPayload, @@ -23,6 +27,8 @@ import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, ANNOTATION_THEME_CHANNEL, + ANNOTATION_VOICE_EVENT_CHANNEL, + ANNOTATION_VOICE_STATE_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, @@ -524,7 +530,10 @@ function strokeBounds( return { x: left, y: top, width: right - left, height: bottom - top }; } -function startAnnotation(sendEnabled: boolean): void { +function startAnnotation( + sendEnabled: boolean, + voice: DesktopPreviewAnnotationVoiceConfig | undefined, +): void { activeSession?.teardown(false); let finished = false; const host = document.createElement("div"); @@ -595,6 +604,212 @@ function startAnnotation(sendEnabled: boolean): void { "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; composerRow.appendChild(comment); + // Recording happens in the application renderer, which owns the microphone consent. + // This guest only draws the controls, forwards trusted gestures, and mirrors state. + let voicePhase: DesktopPreviewAnnotationVoiceState["phase"] = "idle"; + let voiceSettings = false; + let voiceBusy = false; + let voiceAvailable = false; + let voiceKeyCode: string | null = null; + let pendingVoiceSubmission: PreviewAnnotationSubmission | null = null; + let voiceSubmissionReady = false; + const voiceButton = createButton("", "Start voice input"); + voiceButton.innerHTML = + ''; + voiceButton.className += " h-8 w-8 shrink-0 p-0 text-muted-foreground"; + voiceButton.hidden = true; + voiceButton.setAttribute("aria-label", "Start voice input"); + composerRow.appendChild(voiceButton); + const voiceRow = document.createElement("div"); + voiceRow.className = "flex min-w-0 items-center justify-end gap-1 px-2 pb-2"; + voiceRow.hidden = true; + const voicePill = document.createElement("div"); + const voiceCancel = createButton("", "Cancel voice input"); + voiceCancel.innerHTML = + ''; + voiceCancel.className += " h-7 w-7 shrink-0 rounded-full p-0"; + const voiceFinish = createButton("", "Finish voice input"); + voiceFinish.className += + " h-7 w-7 shrink-0 rounded-full border-primary bg-primary p-0 text-primary-foreground hover:bg-primary/90"; + const voiceSkip = createButton("Skip", "Skip transcript cleanup"); + voiceSkip.className += " h-7 shrink-0 rounded-full px-2.5"; + const voiceStatus = document.createElement("div"); + voiceStatus.className = "flex h-7 min-w-0 flex-1 items-center gap-2"; + voiceStatus.setAttribute("role", "status"); + const voiceStatusText = document.createElement("span"); + voiceStatusText.className = "min-w-0 flex-1 truncate text-right text-sm text-muted-foreground"; + const waveform = document.createElement("div"); + waveform.className = + "flex h-5 min-w-0 flex-1 items-center justify-between gap-px overflow-hidden"; + waveform.setAttribute("aria-hidden", "true"); + const waveformLevels = Array(24).fill(0); + const waveformBars = waveformLevels.map(() => { + const bar = document.createElement("span"); + bar.className = "h-full w-0.5 shrink-0 origin-center rounded-full bg-primary"; + bar.style.transform = "scaleY(0.08)"; + bar.style.opacity = "0.25"; + waveform.appendChild(bar); + return bar; + }); + const voiceElapsed = document.createElement("span"); + voiceElapsed.className = "shrink-0 text-xs tabular-nums text-muted-foreground"; + let recordingStartedAt = 0; + let elapsedTimer: ReturnType | null = null; + const updateElapsed = () => { + const seconds = Math.floor((Date.now() - recordingStartedAt) / 1_000); + voiceElapsed.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; + }; + voiceStatus.append(voiceStatusText, waveform, voiceElapsed); + voicePill.append(voiceCancel, voiceStatus, voiceSkip, voiceFinish); + voiceRow.appendChild(voicePill); + const voicePreview = document.createElement("p"); + voicePreview.className = "px-2 pb-2 text-sm text-muted-foreground"; + voicePreview.setAttribute("role", "status"); + voicePreview.hidden = true; + const sendVoice = ( + action: DesktopPreviewAnnotationVoiceEvent["action"], + keyboard?: KeyboardEvent, + ) => { + if (!voice || finished) return; + const event: DesktopPreviewAnnotationVoiceEvent = { + sessionId: voice.sessionId, + text: comment.value, + cursor: comment.selectionStart, + selectionEnd: comment.selectionEnd, + action, + ...(keyboard + ? { + keyboard: { + type: keyboard.type === "keyup" ? ("keyup" as const) : ("keydown" as const), + key: keyboard.key, + code: keyboard.code, + metaKey: keyboard.metaKey, + ctrlKey: keyboard.ctrlKey, + shiftKey: keyboard.shiftKey, + altKey: keyboard.altKey, + repeat: keyboard.repeat, + }, + } + : {}), + }; + ipcRenderer.send(ANNOTATION_VOICE_EVENT_CHANNEL, event); + }; + const isDictationChord = (event: KeyboardEvent) => + voice !== undefined && + voice.shortcuts.some((shortcut) => + matchesKeybindingShortcut(event, shortcut, navigator.platform), + ); + for (const button of [voiceButton, voiceFinish, voiceCancel, voiceSkip]) { + button.addEventListener("pointerdown", (event) => event.preventDefault()); + } + // Page scripts cannot reach the closed shadow root, and a synthetic click never counts. + voiceButton.addEventListener("click", (event) => { + if (!event.isTrusted) return; + if (voiceSettings) { + sendVoice("settings"); + return; + } + if (!voiceAvailable || (voiceBusy && voicePhase !== "recording")) return; + sendVoice(voicePhase === "recording" ? "stop" : "start"); + }); + voiceFinish.addEventListener("click", (event) => { + if (event.isTrusted) sendVoice("stop"); + }); + voiceCancel.addEventListener("click", (event) => { + if (!event.isTrusted) return; + pendingVoiceSubmission = null; + sendVoice("cancel"); + }); + voiceSkip.addEventListener("click", (event) => { + if (event.isTrusted) sendVoice("skip"); + }); + const onVoiceState = ( + _event: Electron.IpcRendererEvent, + state: DesktopPreviewAnnotationVoiceState, + ) => { + if (finished || state.sessionId !== voice?.sessionId) return; + const phaseChanged = voicePhase !== state.phase; + const submissionChanged = voiceBusy !== state.blocksSubmission; + const previewChanged = voicePreview.textContent !== (state.preview ?? ""); + voicePhase = state.phase; + voiceSettings = state.errorAction === "settings"; + voiceBusy = state.blocksSubmission; + voiceAvailable = state.available; + comment.readOnly = state.freezesEditor; + if (state.draft) { + voiceSubmissionReady = pendingVoiceSubmission !== null; + comment.value = state.draft.text; + comment.setSelectionRange(state.draft.cursor, state.draft.cursor); + resizeComment(); + } + const recording = state.phase === "recording"; + const active = state.phase !== "idle" && state.phase !== "error"; + voiceButton.hidden = !state.available || active; + voiceButton.disabled = voiceBusy; + const label = voiceSettings ? "Open voice settings" : "Start voice input"; + voiceButton.title = + !voiceSettings && state.shortcutLabel ? `${label} (${state.shortcutLabel})` : label; + voiceButton.setAttribute("aria-label", label); + voiceRow.hidden = state.phase === "idle"; + voicePill.className = active + ? "flex h-9 w-64 min-w-0 items-center gap-2 rounded-full border border-border bg-background p-1" + : "flex min-w-0 items-center gap-1"; + voiceCancel.title = + state.phase === "error" ? "Dismiss voice input error" : "Cancel voice input"; + voiceCancel.setAttribute("aria-label", voiceCancel.title); + voiceStatus.setAttribute("aria-label", state.status ?? "Voice input"); + voiceStatus.setAttribute("aria-live", recording ? "off" : "polite"); + voiceStatusText.textContent = state.status; + voiceStatusText.style.color = state.phase === "error" ? "var(--t3-destructive)" : ""; + voiceStatusText.hidden = recording; + waveform.hidden = !recording; + voiceElapsed.hidden = !recording; + if (recording && elapsedTimer === null) { + recordingStartedAt = Date.now(); + waveformLevels.fill(0); + updateElapsed(); + elapsedTimer = setInterval(updateElapsed, 250); + } else if (!recording && elapsedTimer !== null) { + clearInterval(elapsedTimer); + elapsedTimer = null; + } + if (recording) { + waveformLevels.copyWithin(0, 1); + waveformLevels[waveformLevels.length - 1] = state.level; + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + waveformBars.forEach((bar, index) => { + const level = waveformLevels[index] ?? 0; + bar.style.opacity = String(0.25 + level * 0.75); + bar.style.transform = `scaleY(${reducedMotion ? 0.35 : Math.max(0.08, level)})`; + }); + } + voiceSkip.hidden = state.phase !== "post-processing"; + voiceFinish.hidden = !active || state.phase === "post-processing"; + voiceFinish.disabled = !recording; + voiceFinish.title = recording ? "Finish voice input" : (state.status ?? "Voice input is busy"); + if (recording && state.shortcutLabel) voiceFinish.title += ` (${state.shortcutLabel})`; + voiceFinish.setAttribute("aria-label", voiceFinish.title); + if (phaseChanged) + voiceFinish.innerHTML = recording + ? '' + : ''; + if (!active) voicePill.append(voiceStatus, voiceCancel); + else if (phaseChanged) voicePill.append(voiceCancel, voiceStatus, voiceSkip, voiceFinish); + voicePreview.textContent = state.preview; + voicePreview.hidden = !state.preview; + if (phaseChanged || submissionChanged || previewChanged || state.draft) updateStatus(); + if (pendingVoiceSubmission !== null) { + if (state.phase === "recording") sendVoice("stop"); + if (state.phase === "error") pendingVoiceSubmission = null; + if (state.phase === "idle") { + const submission = pendingVoiceSubmission; + pendingVoiceSubmission = null; + if (voiceSubmissionReady && submission !== null) submitAnnotation(submission); + voiceSubmissionReady = false; + } + } + }; + const dragHandle = document.createElement("button"); dragHandle.type = "button"; dragHandle.textContent = "⠿"; @@ -613,7 +828,7 @@ function startAnnotation(sendEnabled: boolean): void { submit.className += " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; composerRow.appendChild(submit); - editor.appendChild(composerRow); + editor.append(composerRow, voicePreview, voiceRow); const stylePanel = document.createElement("div"); stylePanel.className = @@ -654,12 +869,19 @@ function startAnnotation(sendEnabled: boolean): void { comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; queueEditorLayout(); }; - comment.addEventListener("input", resizeComment); + comment.addEventListener("input", () => { + resizeComment(); + sendVoice("sync"); + }); const updateStatus = (): void => { const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; + if (!hasTargets && voiceBusy) { + pendingVoiceSubmission = null; + sendVoice("cancel"); + } editor.style.display = hasTargets ? "flex" : "none"; - submit.disabled = !hasTargets; + submit.disabled = !hasTargets || voicePhase === "preparing" || pendingCapture; submit.style.opacity = hasTargets ? "1" : "0.45"; adjust.disabled = !hasTargets; stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; @@ -695,7 +917,6 @@ function startAnnotation(sendEnabled: boolean): void { for (const [key, change] of styleChanges) { if (change.targetId === target.id) styleChanges.delete(key); } - updateStatus(); }; const addSelected = (element: Element): void => { @@ -710,7 +931,6 @@ function startAnnotation(sendEnabled: boolean): void { selected.set(element, target); root.append(target.outline, target.label); updateSelectedVisual(target); - updateStatus(); if (editorExpanded) { stylePanel.style.display = "grid"; syncStyleControls(); @@ -721,12 +941,13 @@ function startAnnotation(sendEnabled: boolean): void { const existing = selected.get(element); if (existing) { removeSelected(existing); - return; - } - if (!additive) { - for (const target of Array.from(selected.values())) removeSelected(target); + } else { + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); } - addSelected(element); + updateStatus(); }; const setStyleForSelected = (property: string, value: string): void => { @@ -1140,6 +1361,7 @@ function startAnnotation(sendEnabled: boolean): void { const rect = target.element.getBoundingClientRect(); if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { removeSelected(target); + updateStatus(); return true; } } @@ -1318,7 +1540,11 @@ function startAnnotation(sendEnabled: boolean): void { if (event.relatedTarget === null) clearHoverOutline(); }; + const onWindowFocus = (): void => { + if (editor.style.display !== "none") sendVoice("focus"); + }; const onWindowBlur = (): void => { + sendVoice("blur"); clearHoverOutline(); }; @@ -1344,7 +1570,9 @@ function startAnnotation(sendEnabled: boolean): void { window.removeEventListener("pointerout", onPointerOut, true); window.removeEventListener("click", onClick, true); window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("focus", onWindowFocus); window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("keyup", onVoiceKeyUp, true); window.removeEventListener("scroll", repaint, true); window.removeEventListener("resize", repaint); dragHandle.removeEventListener("pointerdown", onEditorPointerDown); @@ -1354,6 +1582,8 @@ function startAnnotation(sendEnabled: boolean): void { if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + ipcRenderer.off(ANNOTATION_VOICE_STATE_CHANNEL, onVoiceState); + if (elapsedTimer !== null) clearInterval(elapsedTimer); document.documentElement.removeAttribute("data-t3code-annotation-tool"); cursorStyle.remove(); inputLayer?.remove(); @@ -1364,7 +1594,32 @@ function startAnnotation(sendEnabled: boolean): void { const onCancel = (): void => teardown(false); const onCaptured = (): void => teardown(false); + const onVoiceKeyUp = (event: KeyboardEvent): void => { + if (!event.isTrusted || (event.code || event.key) !== voiceKeyCode) return; + voiceKeyCode = null; + event.preventDefault(); + event.stopImmediatePropagation(); + sendVoice("key", event); + }; const onKeyDown = (event: KeyboardEvent): void => { + // Only the keyboard drives dictation; a page cannot synthesize the chord or Escape. + if (voice && event.isTrusted && !event.isComposing && event.keyCode !== 229) { + if (event.key === "Escape" && voiceBusy) { + event.preventDefault(); + event.stopImmediatePropagation(); + voiceKeyCode = null; + pendingVoiceSubmission = null; + sendVoice("key", event); + return; + } + if (voiceAvailable && editor.style.display !== "none" && isDictationChord(event)) { + event.preventDefault(); + event.stopImmediatePropagation(); + voiceKeyCode = event.code || event.key; + sendVoice("key", event); + return; + } + } if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; if (event.key === "Escape") { event.preventDefault(); @@ -1382,6 +1637,16 @@ function startAnnotation(sendEnabled: boolean): void { const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { if (submission === "send" && !sendEnabled) return; + if (voicePhase === "preparing") return; + // Submitting while dictating finishes the recording and submits once the text lands. + if (voiceBusy) { + if (pendingVoiceSubmission === null) { + pendingVoiceSubmission = submission; + voiceSubmissionReady = false; + if (voicePhase === "recording") sendVoice("stop"); + } + return; + } if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) return; pendingCapture = true; @@ -1460,11 +1725,14 @@ function startAnnotation(sendEnabled: boolean): void { window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); window.addEventListener("click", onClick, { capture: true, passive: false }); window.addEventListener("blur", onWindowBlur); + window.addEventListener("focus", onWindowFocus); window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("keyup", onVoiceKeyUp, { capture: true }); window.addEventListener("scroll", repaint, { capture: true, passive: true }); window.addEventListener("resize", repaint, { passive: true }); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + if (voice) ipcRenderer.on(ANNOTATION_VOICE_STATE_CHANNEL, onVoiceState); if (inputLayer) { document.documentElement.appendChild(inputLayer); inputLayer.focus({ preventScroll: true }); @@ -1473,6 +1741,7 @@ function startAnnotation(sendEnabled: boolean): void { rootResizeObserver.observe(document.documentElement); refreshToolButtons(); updateStatus(); + sendVoice(document.hasFocus() ? "focus" : "sync"); activeSession = { teardown, applyTheme: (theme) => applyAnnotationTheme(host, theme), @@ -1485,9 +1754,14 @@ function startAnnotation(sendEnabled: boolean): void { ipcRenderer.on( START_PICK_CHANNEL, - (_event, theme: DesktopPreviewAnnotationTheme | undefined, sendEnabled?: boolean) => { + ( + _event, + theme: DesktopPreviewAnnotationTheme | undefined, + sendEnabled?: boolean, + voice?: DesktopPreviewAnnotationVoiceConfig, + ) => { if (theme) annotationTheme = theme; - startAnnotation(sendEnabled === true); + startAnnotation(sendEnabled === true, voice); }, ); ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => { diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 7e4b530083097..868e1c139fbdd 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -200,16 +200,22 @@ describe("DesktopSettings", () => { withSettings( Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, - mainWindowMaximized: true, - serverExposureMode: "network-accessible", - }); - - const loaded = yield* settings.load; - assert.isNull(loaded.mainWindowBounds); - assert.isFalse(loaded.mainWindowMaximized); - assert.equal(loaded.serverExposureMode, "network-accessible"); + for (const bounds of [ + { x: 10.5, y: 20, width: 400, height: 360 }, + { x: 10, y: 20, width: 399, height: 360 }, + { x: 10, y: 20, width: 400, height: 359 }, + ]) { + yield* writeSettingsPatch({ + mainWindowBounds: bounds, + mainWindowMaximized: true, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.isFalse(loaded.mainWindowMaximized); + assert.equal(loaded.serverExposureMode, "network-accessible"); + } }), ), ); @@ -221,17 +227,24 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 400, height: 360 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { - mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, + mainWindowBounds: { x: -1200, y: 40, width: 400, height: 360 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); + + const restored = yield* Effect.gen(function* () { + const freshSettings = yield* DesktopAppSettings.DesktopAppSettings; + return yield* freshSettings.load; + }).pipe(Effect.provide(DesktopAppSettings.layer)); + assert.deepEqual(restored.mainWindowBounds, persisted.mainWindowBounds); + assert.isTrue(restored.mainWindowMaximized); }), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6cd34322d98c2..4c2ae8d1d1342 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -47,9 +47,12 @@ export interface DesktopSettingsChange { } const DEFAULT_TAILSCALE_SERVE_PORT = 443; -const MIN_MAIN_WINDOW_SIZE = { - width: 840, - height: 620, +// Fits half and third tiles of common scaled displays (down to 480x432 DIP) on +// Wayland, where Electron's client-side frame adds 32x42 DIP to the enforced +// minimum. The web layout already handles phone widths. +export const MIN_MAIN_WINDOW_SIZE = { + width: 400, + height: 360, } as const; export const DesktopWindowBoundsSchema = Schema.Struct({ x: Schema.Int, diff --git a/apps/desktop/src/telemetry/DesktopRendererHistory.ts b/apps/desktop/src/telemetry/DesktopRendererHistory.ts index ed1590f63b19b..dc3e157f95351 100644 --- a/apps/desktop/src/telemetry/DesktopRendererHistory.ts +++ b/apps/desktop/src/telemetry/DesktopRendererHistory.ts @@ -273,7 +273,11 @@ const make = Effect.fn("DesktopRendererHistory.make")(function* () { accepting = false; for (const renderer of active.values()) renderer.removeListeners(); active.clear(); - }).pipe(Effect.andThen(Fiber.interrupt(writerFiber)), Effect.asVoid); + }).pipe( + Effect.andThen(Fiber.interrupt(writerFiber)), + Effect.asVoid, + Effect.withSpan("desktop.rendererHistory.shutdown"), + ); yield* Effect.addFinalizer(() => shutdown); return DesktopRendererHistory.of({ register, recordMetrics, shutdown }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 6279e8b6141a8..14688e5258067 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -25,6 +25,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -819,7 +820,8 @@ export const make = Effect.gen(function* () { yield* Ref.set(installRunningBackendsRef, runningInstances); yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), + (instance) => + instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }), { concurrency: "unbounded" }, ); yield* electronUpdater.quitAndInstall({ diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b2f643e55fb97..292a3540d18bf 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -399,8 +399,8 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, - minWidth: 840, - minHeight: 620, + minWidth: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.width, + minHeight: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.height, show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 0d0a74d510bc3..d06efd4d9d7bb 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -120,7 +120,10 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), entry: ["src/preview-pick-preload.ts"], deps: { - alwaysBundle: (id) => id === "react-grab" || id.startsWith("react-grab/"), + alwaysBundle: (id) => + id === "react-grab" || + id.startsWith("react-grab/") || + id === "@t3tools/shared/keybindingMatching", }, }, { diff --git a/apps/mobile/.swiftlint.yml b/apps/mobile/.swiftlint.yml index 4450e0485c52b..2dd29c067dc48 100644 --- a/apps/mobile/.swiftlint.yml +++ b/apps/mobile/.swiftlint.yml @@ -1,10 +1,10 @@ included: - ios/T3Code - modules/t3-composer-editor/ios - - modules/t3-highlights/ios - modules/t3-native-controls/ios - modules/t3-terminal/ios - modules/t3-review-diff/ios + - modules/t3-voice-transcription/ios excluded: - ios/Pods diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt index 0adeb3fc3c42c..c4aff3c331179 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -126,7 +126,6 @@ internal class ActivityPresentation(data: Map, private val activ fun applyTo(builder: NotificationCompat.Builder, context: Context) { val tint = phase?.let { ContextCompat.getColor(context, it.color) } - builder.setSmallIcon(R.drawable.agent_activity_mark) if (tint != null) builder.setColor(tint) // Tint the summary only when it names an outcome or a request; a plain // "3 working" stays neutral so the accent keeps meaning something. diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index f9b69a7117741..19d14f450f526 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -127,8 +127,8 @@ object AgentNotifications { if (registered && fresh && NotificationManagerCompat.from(context).areNotificationsEnabled()) { channels(context) val scheme = prefs.getString("scheme", "t3code") ?: "t3code" - showAlert(context, prefs, scheme, data) - updateActivity(context, prefs, scheme, data, updatedAt) + val alerted = showAlert(context, prefs, scheme, data) + updateActivity(context, prefs, data, updatedAt, alerted) } } @@ -137,7 +137,7 @@ object AgentNotifications { prefs: SharedPreferences, scheme: String, data: Map - ) { + ): Boolean { // Queue retries carry the same alert id. Keep a bounded history even when // notification A is retried after notification B has already arrived. val alertId = data["alert_id"] @@ -157,7 +157,14 @@ object AgentNotifications { "seenAlertsOrdered", (seen.takeLast(63) + alertId).joinToString("\n") ).apply() + // Android drops posts to a blocked channel, so that alert shows nothing. + return !onScreen && ( + Build.VERSION.SDK_INT < 26 || + manager(context).getNotificationChannel(ALERT_CHANNEL)?.importance != + NotificationManager.IMPORTANCE_NONE + ) } + return false } private fun postAlert( @@ -215,12 +222,21 @@ object AgentNotifications { private fun updateActivity( context: Context, prefs: SharedPreferences, - scheme: String, data: Map, - updatedAt: Long + updatedAt: Long, + alerted: Boolean ) { - if (updatedAt < prefs.getLong("lastUpdate", 0)) return - prefs.edit().putLong("lastUpdate", updatedAt).apply() + val terminal = singleTerminal(data) + // Only a one-row card on the alert's own thread is known to show nothing more + // than the alert. Grouped alerts and cards with other rows stay visible. + val covered = terminal?.takeIf { alerted && data["alert_path"] == data["activity_path"] } + if (covered != null) coverTerminal(prefs, covered, updatedAt) + if (updatedAt < prefs.getLong("lastUpdate", 0)) { + // A newer replay of this same finish may already show the card. + if (isCovered(prefs, prefs.getString("lastTerminal", null))) cancelActivity(context) + return + } + prefs.edit().putLong("lastUpdate", updatedAt).putString("lastTerminal", terminal).apply() val active = data["active"] == "true" // Use absolute state expiry: a replay must not extend a finished card or // make an abandoned host look active indefinitely. Older relays omit it. @@ -237,11 +253,35 @@ object AgentNotifications { // Dismissing a run includes its finished card. A new run, or toggling // activity off/on, arms it again; terminal replays stay dismissed. if (active && !wasActive) prefs.edit().putBoolean("dismissed", false).apply() - if (!prefs.getBoolean("dismissed", false)) { + if (prefs.getBoolean("dismissed", false) || isCovered(prefs, terminal)) { + cancelActivity(context) + } else { + val scheme = prefs.getString("scheme", "t3code") ?: "t3code" showActivity(context, scheme, data, active, remainingMs) } } + /** Identifies a finished one-thread card by its path, phase and absolute expiry. */ + private fun singleTerminal(data: Map): String? { + val phase = data["activity_phase"] + val finished = phase == "completed" || phase == "failed" + val single = data.containsKey("activity_line_0") && !data.containsKey("activity_line_1") + val expiresAt = data["activity_expires_at"] + return "${data["activity_path"]}\n$phase\n$expiresAt" + .takeIf { finished && single && expiresAt != null } + } + + private fun isCovered(prefs: SharedPreferences, terminal: String?) = + terminal != null && terminal == prefs.getString("coveredTerminal", null) + + private fun coverTerminal(prefs: SharedPreferences, terminal: String?, updatedAt: Long) { + if (updatedAt >= prefs.getLong("lastUpdate", 0) || + prefs.getString("lastTerminal", null) == terminal + ) { + prefs.edit().putString("coveredTerminal", terminal).apply() + } + } + private fun showActivity( context: Context, scheme: String, @@ -318,9 +358,8 @@ object AgentNotifications { } } - private fun base(context: Context, channel: String): NotificationCompat.Builder { - val icon = context.resources.getIdentifier("notification_icon", "drawable", context.packageName) - return NotificationCompat.Builder(context, channel) + private fun base(context: Context, channel: String): NotificationCompat.Builder = + NotificationCompat.Builder(context, channel) .setPriority( if (channel == ALERT_CHANNEL @@ -331,10 +370,9 @@ object AgentNotifications { } ) .setDefaults(if (channel == ALERT_CHANNEL) Notification.DEFAULT_ALL else 0) - .setSmallIcon(if (icon != 0) icon else android.R.drawable.ic_dialog_info) + .setSmallIcon(R.drawable.agent_activity_mark) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setShowWhen(false) - } private fun contentIntent( context: Context, diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 7ca6edbc2133e..59ab4d6651e1a 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -24,12 +24,10 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) -class AgentNotificationsTest { - private lateinit var context: Application - private lateinit var manager: NotificationManager - private lateinit var lifecycle: LifecycleRegistry +abstract class AgentNotificationsFixture { + protected lateinit var context: Application + protected lateinit var manager: NotificationManager + protected lateinit var lifecycle: LifecycleRegistry @Before fun setUp() { @@ -52,7 +50,7 @@ class AgentNotificationsTest { AgentNotifications.setThreadOnScreen("/threads/environment/thread") } - private fun update(alertId: String, active: Boolean) = mapOf( + protected fun update(alertId: String, active: Boolean) = mapOf( "device_id" to "device", "user_id" to "user", "updated_at" to System.currentTimeMillis().toString(), @@ -66,7 +64,11 @@ class AgentNotificationsTest { "alert_body" to "Done: Test project", "alert_path" to "/threads/environment/thread", ) +} +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) +class AgentNotificationsTest : AgentNotificationsFixture() { @Test fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() { lifecycle.currentState = Lifecycle.State.RESUMED @@ -118,20 +120,6 @@ class AgentNotificationsTest { assertTrue(manager.activeNotifications.isEmpty()) } - @Test - fun backgroundCompletionAlertsAndClearsOngoingActivity() { - lifecycle.currentState = Lifecycle.State.RESUMED - AgentNotifications.receive(context, update("running", true)) - lifecycle.currentState = Lifecycle.State.CREATED - - AgentNotifications.receive(context, update("completion", false)) - - val alert = manager.activeNotifications.single() - assertEquals("t3-agent-alert", alert.tag) - assertEquals("Test thread", alert.notification.extras.getString(Notification.EXTRA_TITLE)) - assertFalse(alert.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) - } - @Test fun retryOfForegroundSuppressedAlertDoesNotAppearAfterBackgrounding() { lifecycle.currentState = Lifecycle.State.RESUMED @@ -313,21 +301,6 @@ class AgentNotificationsTest { assertTrue(manager.activeNotifications.isEmpty()) } - @Test - fun reorderedActivityDoesNotEraseNewerCardOrDropAnIndependentAlert() { - val now = System.currentTimeMillis() - AgentNotifications.receive(context, update("new", true) + ("updated_at" to now.toString())) - AgentNotifications.receive( - context, - update("older-alert", false) + ("updated_at" to (now - 1000).toString()) - ) - assertEquals(4, manager.activeNotifications.size) - assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) - shadowOf(manager).setNotificationsEnabled(false) - AgentNotifications.receive(context, update("revoked-permission", true)) - assertEquals(4, manager.activeNotifications.size) - } - @Test fun severalThreadsListEveryRowWithItsStatusAndActionsFollowThePriorityThread() { lifecycle.currentState = Lifecycle.State.RESUMED @@ -661,3 +634,91 @@ class AgentNotificationsTest { assertEquals(2, manager.activeNotifications.size) } } + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) +class AgentNotificationsCompletionTest : AgentNotificationsFixture() { + private fun finished(alertId: String, expiresAt: Long) = update(alertId, false) + mapOf( + "activity_line_0" to "Done\tTest thread\tTest project", + "activity_phase" to "completed", + "activity_expires_at" to expiresAt.toString(), + ) + + @Test + fun backgroundCompletionAlertsAndClearsOngoingActivity() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.receive(context, update("running", true)) + lifecycle.currentState = Lifecycle.State.CREATED + + val expiresAt = System.currentTimeMillis() + 900000 + val completion = finished("completion", expiresAt) + AgentNotifications.receive(context, completion) + AgentNotifications.receive(context, completion) + AgentNotifications.receive(context, completion.filterKeys { !it.startsWith("alert_") }) + + val alert = manager.activeNotifications.single() + assertEquals("t3-agent-alert", alert.tag) + assertEquals("Test thread", alert.notification.extras.getString(Notification.EXTRA_TITLE)) + assertFalse(alert.notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + AgentNotifications.receive(context, update("next-run", true)) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + + // An alert for another thread, or a card that also lists another result, stays. + AgentNotifications.receive( + context, + finished("other-thread", expiresAt + 60000) + ("alert_path" to "/threads/other/thread") + ) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + AgentNotifications.receive( + context, + finished("with-muted-result", expiresAt + 120000) + + ("activity_line_1" to "Done\tMuted thread\tOther project") + ) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + } + + @Test + @Config(sdk = [26, 33, 36]) + fun blockedAlertChannelKeepsTheFinishedCard() { + manager.getNotificationChannel("agent-alerts").importance = NotificationManager.IMPORTANCE_NONE + AgentNotifications.receive( + context, + finished("completion", System.currentTimeMillis() + 900000) + ) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + } + + @Test + fun reorderedActivityDoesNotEraseNewerCardOrDropAnIndependentAlert() { + val now = System.currentTimeMillis() + AgentNotifications.receive(context, update("new", true) + ("updated_at" to now.toString())) + AgentNotifications.receive( + context, + finished("older-alert", now + 900000) + ("updated_at" to (now - 1000).toString()) + ) + assertEquals(4, manager.activeNotifications.size) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + // A newer replay of a finish can show its card before the original alert lands. + val finish = finished("finish", now + 960000) + AgentNotifications.receive( + context, + finish.filterKeys { !it.startsWith("alert_") } + ("updated_at" to (now + 2000).toString()) + ) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + AgentNotifications.receive(context, finish + ("updated_at" to (now + 1000).toString())) + assertTrue(manager.activeNotifications.none { it.tag == "t3-agent-activity" }) + assertTrue(manager.activeNotifications.any { it.id == "finish".hashCode() }) + AgentNotifications.receive( + context, + finished("previous-run", now + 900000) + ("updated_at" to now.toString()) + ) + AgentNotifications.receive( + context, + finish.filterKeys { !it.startsWith("alert_") } + ("updated_at" to (now + 3000).toString()) + ) + assertTrue(manager.activeNotifications.none { it.tag == "t3-agent-activity" }) + shadowOf(manager).setNotificationsEnabled(false) + AgentNotifications.receive(context, update("revoked-permission", true)) + assertEquals(5, manager.activeNotifications.size) + } +} diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 6b0534fdaccbd..0fab5b17da8d6 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -12,6 +12,7 @@ import android.os.Build import android.text.Editable import android.text.InputType import android.text.InputFilter +import android.text.Layout import android.text.Spanned import android.text.TextUtils import android.text.TextWatcher @@ -22,6 +23,7 @@ import android.view.Gravity import android.view.GestureDetector import android.view.MotionEvent import android.view.KeyEvent +import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection @@ -32,6 +34,7 @@ import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView import org.json.JSONObject +import kotlin.math.ceil import kotlin.math.max class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( @@ -435,7 +438,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( // wrapping it, so getLineMax leaves it out. val layout = editor.layout val contentHeight = layout?.height ?: editor.measuredHeight - val contentWidth = if (layout?.lineCount == 1) layout.getLineMax(0).toInt() else 0 + val contentWidth = singleLineContentWidth(layout) if (contentHeight == lastContentHeight && contentWidth == lastContentWidth) return lastContentHeight = contentHeight lastContentWidth = contentWidth @@ -497,6 +500,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } } +// Round up: a truncated width can match a slot the line overflows, and the composer +// then flips between its compact and expanded rows. +internal fun singleLineContentWidth(layout: Layout?): Int = + if (layout?.lineCount == 1) ceil(layout.getLineMax(0)).toInt() else 0 + private fun Int.withAlpha(alpha: Int): Int = (this and 0x00FFFFFF) or (alpha shl 24) private fun Context.resolveThemeColor(attribute: Int, fallback: Int): Int { @@ -589,6 +597,9 @@ internal class SelectionAwareEditText( var maxInputChars = Int.MAX_VALUE var clipboardFragment = "" + // Keeps autofill services such as SMS verification codes from writing into the draft. + override fun getAutofillType(): Int = View.AUTOFILL_TYPE_NONE + /** * Editable TextView hints do not ellipsize, so long placeholders wrap and grow the composer. * Truncate the hint to the measured width instead. diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerContentWidthTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerContentWidthTest.kt new file mode 100644 index 0000000000000..458ad4e007d1b --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerContentWidthTest.kt @@ -0,0 +1,49 @@ +package expo.modules.t3composereditor + +import android.text.Layout +import android.view.View +import android.view.ViewGroup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.math.floor + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ComposerContentWidthTest { + private val editor = SelectionAwareEditText(RuntimeEnvironment.getApplication()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + textSize = 16f + setPadding(0, 0, 0, 0) + } + + private fun layoutAt(width: Int): Layout { + editor.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + editor.layout(0, 0, width, editor.measuredHeight) + return editor.layout + } + + // JS keeps the compact row when the reported width is at most the compact slot's. + @Test + fun reportedWidthStillFitsOnOneLine() { + editor.setText("I know t3 code exposes a websocket") + val wide = layoutAt(10_000) + val natural = wide.getLineMax(0) + assertNotEquals("the draft needs a fractional width", floor(natural), natural) + + val reported = singleLineContentWidth(wide) + assertEquals(1, layoutAt(reported).lineCount) + } +} diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt index 441154f3e1b97..22b2167658664 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPasteTest.kt @@ -180,6 +180,15 @@ class ComposerPasteTest { assertNull(intercepted) } + @Test + fun autofillCannotWriteIntoTheDraft() { + assertEquals( + "Autofill services such as SMS verification codes must not target the composer", + View.AUTOFILL_TYPE_NONE, + editor.autofillType, + ) + } + private fun dropEditor(): SelectionAwareEditText { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() return SelectionAwareEditText(activity).apply { diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index e16582ce2b514..c54a1a84e9644 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -132,6 +132,9 @@ public class T3ComposerEditorModule: Module { AsyncFunction("setSelection") { (view: T3ComposerEditorView, start: Int, end: Int) in view.setSelection(start: start, end: end) } + AsyncFunction("readState") { (view: T3ComposerEditorView) -> [String: Any] in + view.readState() + } } } } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 48eb6fdb2d9ec..230aaa134c4d2 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -426,7 +426,9 @@ private final class ComposerTextView: UITextView { } public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDropDelegate, UIGestureRecognizerDelegate { - private let textView = ComposerTextView() + // This view reads `layoutManager`, which otherwise swaps a live TextKit 2 view to + // TextKit 1 mid-edit; that swap can leave typed text laid out but never drawn. + private let textView = ComposerTextView(usingTextLayoutManager: false) private let placeholderLabel = UILabel() private var value = "" private var tokensJson = "[]" @@ -445,6 +447,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var isReadOnly = false private var isApplyingControlledValue = false private var nativeEventCount = 0 + // Count of the latest intercepted paste, so a flush can tell whether JS has received it. + private var lastPasteEventCount = 0 + private var hasControlledDocument = false private var resetGeneration = 0 private var lastContentSize = CGSize.zero private var iconImages: [String: UIImage] = [:] @@ -481,6 +486,14 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.alwaysBounceVertical = false textView.showsVerticalScrollIndicator = true textView.adjustsFontForContentSizeCategory = true + if #available(iOS 26.0, *) { + // UIKit can attach a nearby bar's scroll edge effect to this text view; its backdrop + // then draws over the typed text while the caret stays visible. + let edges = [textView.topEdgeEffect, textView.leftEdgeEffect, textView.bottomEdgeEffect, textView.rightEdgeEffect] + for edge in edges { + edge.isHidden = true + } + } textView.onPasteImages = { [weak self] urls in self?.onComposerPasteImages(["uris": urls]) } @@ -488,6 +501,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro guard let self else { return } let selection = self.sourceSelection() self.nativeEventCount += 1 + self.lastPasteEventCount = self.nativeEventCount var payload: [String: Any] = context payload["value"] = self.textView.serializedText() payload["eventCount"] = self.nativeEventCount @@ -499,6 +513,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro guard let self else { return } let selection = self.sourceSelection() self.nativeEventCount += 1 + self.lastPasteEventCount = self.nativeEventCount self.onComposerPasteText([ "value": self.textView.serializedText(), "eventCount": self.nativeEventCount, @@ -666,12 +681,17 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } if isReset { resetGeneration = document.resetGeneration - nativeEventCount = document.mostRecentEventCount + // JS drops a paste from before the reset by generation, so the flush must not wait for it. + lastPasteEventCount = 0 endComposition() } - if document.isNativeEcho && !isReset && textView.serializedText() != document.value { + // iOS destroys this view under display: none and mounts a fresh one while JS keeps its + // revision and draft. Continue from them, or JS drops every event the new view sends. + nativeEventCount = document.mostRecentEventCount + if document.isNativeEcho && !isReset && hasControlledDocument && textView.serializedText() != document.value { return } + hasControlledDocument = true if tokensJson != document.tokensJson { tokensJson = document.tokensJson tokens = decode([ComposerTokenPayload].self, from: document.tokensJson) ?? [] @@ -793,6 +813,17 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.resignFirstResponder() } + func readState() -> [String: Any] { + let selection = sourceSelection() + return [ + "value": value, + "selection": ["start": selection.start, "end": selection.end], + "eventCount": nativeEventCount, + "resetGeneration": resetGeneration, + "pasteEventCount": lastPasteEventCount, + ] + } + func setSelection(start: Int, end: Int) { let previousSelection = sourceSelection() requestedSelection = ComposerSelectionPayload(start: start, end: end) diff --git a/apps/mobile/modules/t3-highlights/T3HighlightsNative.podspec b/apps/mobile/modules/t3-highlights/T3HighlightsNative.podspec index 0cc380369f0c4..0b23719e5d279 100644 --- a/apps/mobile/modules/t3-highlights/T3HighlightsNative.podspec +++ b/apps/mobile/modules/t3-highlights/T3HighlightsNative.podspec @@ -10,21 +10,28 @@ Pod::Spec.new do |s| s.homepage = 'https://t3tools.com' s.license = { :type => 'UNLICENSED' } s.author = { 'T3 Tools' => 'hello@t3tools.com' } - s.platforms = { :ios => '16.1' } + s.platforms = { :ios => min_ios_version_supported } s.source = { :path => '.' } - s.source_files = 'ios/**/*.swift', 'cpp/**/*.{c,h,inc}', 'Vendor/**/*.{c,h,inc}' - # Swift sees only the wrapper; the generated and runtime headers stay private. - s.public_header_files = 'cpp/t3_highlights.h' + s.source_files = 'cpp/**/*.{c,cpp,h,hpp,inc}', 'Vendor/**/*.{c,h,inc}' + s.public_header_files = [] # Debug builds compile at -O0 by default, which makes the lexers several times # slower. They are generated code, so optimize them in every configuration. s.compiler_flags = '-O2' s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', + # Under C11, wasm2c makes its stack depth counter thread-local, so every lexer + # function entry calls _tlv_get_addr (2–9% of lexing time on an iPhone 12). + # Callers already serialize the lexer (t3_highlights.h), so a plain global is safe. + 'GCC_C_LANGUAGE_STANDARD' => 'gnu99', # Plain malloc memory with explicit bounds checks: no guard-page signal # handlers competing with the app's crash reporting. 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) WASM_RT_USE_MMAP=0 WASM_RT_MEMCHECK_BOUNDS_CHECK=1 WASM_RT_SKIP_SIGNAL_RECOVERY=1', - 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_TARGET_SRCROOT}/Vendor" "${PODS_TARGET_SRCROOT}/Vendor/highlights" "${PODS_TARGET_SRCROOT}/Vendor/wasm2c"', + 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_TARGET_SRCROOT}/cpp" "${PODS_TARGET_SRCROOT}/Vendor" "${PODS_TARGET_SRCROOT}/Vendor/highlights" "${PODS_TARGET_SRCROOT}/Vendor/wasm2c"', } - s.swift_version = '5.9' - s.dependency 'ExpoModulesCore' + + load 'nitrogen/generated/ios/T3HighlightsNative+autolinking.rb' + add_nitrogen_files(s) + + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + install_modules_dependencies(s) end diff --git a/apps/mobile/modules/t3-highlights/android/src/main/cpp/CMakeLists.txt b/apps/mobile/modules/t3-highlights/android/CMakeLists.txt similarity index 72% rename from apps/mobile/modules/t3-highlights/android/src/main/cpp/CMakeLists.txt rename to apps/mobile/modules/t3-highlights/android/CMakeLists.txt index 297ac3d2cb260..28e3f7bc361d5 100644 --- a/apps/mobile/modules/t3-highlights/android/src/main/cpp/CMakeLists.txt +++ b/apps/mobile/modules/t3-highlights/android/CMakeLists.txt @@ -1,18 +1,24 @@ cmake_minimum_required(VERSION 3.22.1) -project(t3highlights LANGUAGES C) +project(t3highlights LANGUAGES C CXX) -set(T3_HIGHLIGHTS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../..") +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(T3_HIGHLIGHTS_ROOT "${CMAKE_SOURCE_DIR}/..") add_library( t3highlights SHARED - t3_highlights_jni.c + src/main/cpp/cpp-adapter.cpp + "${T3_HIGHLIGHTS_ROOT}/cpp/HybridT3Highlights.cpp" + "${T3_HIGHLIGHTS_ROOT}/cpp/Utf16ToUtf8.cpp" "${T3_HIGHLIGHTS_ROOT}/cpp/t3_highlights.c" "${T3_HIGHLIGHTS_ROOT}/Vendor/highlights/highlights.c" "${T3_HIGHLIGHTS_ROOT}/Vendor/wasm2c/wasm-rt-impl.c" "${T3_HIGHLIGHTS_ROOT}/Vendor/wasm2c/wasm-rt-mem-impl.c" ) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/t3highlights+autolinking.cmake) + target_include_directories( t3highlights PRIVATE "${T3_HIGHLIGHTS_ROOT}/cpp" "${T3_HIGHLIGHTS_ROOT}/Vendor" diff --git a/apps/mobile/modules/t3-highlights/android/build.gradle b/apps/mobile/modules/t3-highlights/android/build.gradle index 58f05125d3f50..a24e1cc67304d 100644 --- a/apps/mobile/modules/t3-highlights/android/build.gradle +++ b/apps/mobile/modules/t3-highlights/android/build.gradle @@ -1,11 +1,12 @@ apply plugin: 'com.android.library' apply plugin: 'org.jetbrains.kotlin.android' +apply from: '../nitrogen/generated/android/t3highlights+autolinking.gradle' group = 'com.t3tools.highlights' version = '0.0.0' android { - namespace 'expo.modules.t3highlights' + namespace 'com.margelo.nitro.t3highlights' ndkVersion rootProject.ext.ndkVersion compileSdk rootProject.ext.compileSdkVersion @@ -19,16 +20,45 @@ android { abiFilters(*requestedArchitectures.split(',')) } } + + externalNativeBuild { + cmake { + cppFlags '-frtti -fexceptions -Wall -Wextra' + arguments '-DANDROID_STL=c++_shared', '-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON' + } + } } externalNativeBuild { cmake { - path 'src/main/cpp/CMakeLists.txt' + path 'CMakeLists.txt' version '3.22.1' } } + + buildFeatures { + prefab true + } + + packagingOptions { + excludes = [ + '**/libc++_shared.so', + '**/libfbjni.so', + '**/libjsi.so', + '**/libreactnative.so', + '**/libNitroModules.so', + ] + } } dependencies { - implementation project(':expo-modules-core') + implementation 'com.facebook.react:react-android' + implementation project(':react-native-nitro-modules') +} + +// CMake reads Nitro's prefab headers, which its prepareHeaders task writes. +tasks.configureEach { task -> + if (task.name.contains('configureCMake') || task.name.contains('buildCMake')) { + task.dependsOn(':react-native-nitro-modules:prepareHeaders') + } } diff --git a/apps/mobile/modules/t3-highlights/android/src/main/cpp/cpp-adapter.cpp b/apps/mobile/modules/t3-highlights/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 0000000000000..4901f2f8c66cd --- /dev/null +++ b/apps/mobile/modules/t3-highlights/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,8 @@ +#include +#include + +#include "t3highlightsOnLoad.hpp" + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + return facebook::jni::initialize(vm, [] { margelo::nitro::t3highlights::registerAllNatives(); }); +} diff --git a/apps/mobile/modules/t3-highlights/android/src/main/cpp/t3_highlights_jni.c b/apps/mobile/modules/t3-highlights/android/src/main/cpp/t3_highlights_jni.c deleted file mode 100644 index 2e45d618d22a8..0000000000000 --- a/apps/mobile/modules/t3-highlights/android/src/main/cpp/t3_highlights_jni.c +++ /dev/null @@ -1,41 +0,0 @@ -#include -#include - -#include "t3_highlights.h" - -// HighlightsNative serializes both calls, so the copy reads the records the -// tokenize call just produced. -static const uint32_t* last_records = NULL; -static int64_t last_count = 0; - -JNIEXPORT jint JNICALL Java_expo_modules_t3highlights_HighlightsNative_nativeTokenize( - JNIEnv* env, jclass clazz, jbyteArray source, jint language) { - (void)clazz; - last_records = NULL; - last_count = 0; - if (language < 0 || language > UINT8_MAX) return -1; - - jsize length = (*env)->GetArrayLength(env, source); - jbyte* bytes = (*env)->GetByteArrayElements(env, source, NULL); - if (bytes == NULL) return -1; - int64_t count = t3_highlights_tokenize((const uint8_t*)bytes, (size_t)length, - (uint8_t)language, &last_records); - (*env)->ReleaseByteArrayElements(env, source, bytes, JNI_ABORT); - - if (count < 0 || count > INT32_MAX / 4) { - last_records = NULL; - return -1; - } - last_count = count; - return (jint)count; -} - -JNIEXPORT void JNICALL Java_expo_modules_t3highlights_HighlightsNative_nativeCopyRecords( - JNIEnv* env, jclass clazz, jobject destination) { - (void)clazz; - void* address = (*env)->GetDirectBufferAddress(env, destination); - jlong capacity = (*env)->GetDirectBufferCapacity(env, destination); - size_t byte_count = (size_t)last_count * sizeof(uint32_t); - if (address == NULL || last_records == NULL || capacity < (jlong)byte_count) return; - memcpy(address, last_records, byte_count); -} diff --git a/apps/mobile/modules/t3-highlights/android/src/main/java/com/margelo/nitro/t3highlights/T3HighlightsPackage.kt b/apps/mobile/modules/t3-highlights/android/src/main/java/com/margelo/nitro/t3highlights/T3HighlightsPackage.kt new file mode 100644 index 0000000000000..3fd2aba86782f --- /dev/null +++ b/apps/mobile/modules/t3-highlights/android/src/main/java/com/margelo/nitro/t3highlights/T3HighlightsPackage.kt @@ -0,0 +1,19 @@ +package com.margelo.nitro.t3highlights + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfoProvider + +// Autolinking instantiates this package, which loads the library that registers T3Highlights. +class T3HighlightsPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null + + override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { emptyMap() } + + companion object { + init { + t3highlightsOnLoad.initializeNative() + } + } +} diff --git a/apps/mobile/modules/t3-highlights/android/src/main/java/expo/modules/t3highlights/T3HighlightsModule.kt b/apps/mobile/modules/t3-highlights/android/src/main/java/expo/modules/t3highlights/T3HighlightsModule.kt deleted file mode 100644 index 3b5fcaddd0c44..0000000000000 --- a/apps/mobile/modules/t3-highlights/android/src/main/java/expo/modules/t3highlights/T3HighlightsModule.kt +++ /dev/null @@ -1,37 +0,0 @@ -package expo.modules.t3highlights - -import expo.modules.kotlin.jni.ArrayBuffer -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition -import java.nio.ByteBuffer -import java.nio.ByteOrder - -class T3HighlightsModule : Module() { - override fun definition() = ModuleDefinition { - Name("T3Highlights") - - Function("tokenize") { source: String, language: Int -> - HighlightsNative.tokenize(source, language) - } - } -} - -internal object HighlightsNative { - init { - System.loadLibrary("t3highlights") - } - - // The records live in the lexer's memory until its next run. - @Synchronized - fun tokenize(source: String, language: Int): ArrayBuffer? { - val count = nativeTokenize(source.toByteArray(Charsets.UTF_8), language) - if (count < 0) return null - val records = ByteBuffer.allocateDirect(count * Int.SIZE_BYTES).order(ByteOrder.nativeOrder()) - nativeCopyRecords(records) - return ArrayBuffer(records) - } - - @JvmStatic private external fun nativeTokenize(source: ByteArray, language: Int): Int - - @JvmStatic private external fun nativeCopyRecords(destination: ByteBuffer) -} diff --git a/apps/mobile/modules/t3-highlights/cpp/HybridT3Highlights.cpp b/apps/mobile/modules/t3-highlights/cpp/HybridT3Highlights.cpp new file mode 100644 index 0000000000000..22097f989695b --- /dev/null +++ b/apps/mobile/modules/t3-highlights/cpp/HybridT3Highlights.cpp @@ -0,0 +1,32 @@ +#include "HybridT3Highlights.hpp" + +#include +#include + +#include "t3_highlights.h" + +namespace margelo::nitro::t3highlights { + +namespace { +// The lexer's memory is process-global and every JS runtime shares it. +std::mutex lexerMutex; +} // namespace + +std::optional> HybridT3Highlights::tokenize(SourceText source, + double language) { + if (!(language >= 0 && language <= UINT8_MAX)) return std::nullopt; + const auto languageId = static_cast(language); + if (languageId != language) return std::nullopt; + + std::lock_guard lock(lexerMutex); + const uint32_t* records = nullptr; + const int64_t count = t3_highlights_tokenize(reinterpret_cast(source.utf8.data()), + source.utf8.size(), languageId, &records); + if (count < 0) return std::nullopt; + const auto byteCount = static_cast(count) * sizeof(uint32_t); + if (records == nullptr || byteCount == 0) return ArrayBuffer::allocate(0); + // The records live in the lexer's memory until its next run. + return ArrayBuffer::copy(reinterpret_cast(records), byteCount); +} + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/cpp/HybridT3Highlights.hpp b/apps/mobile/modules/t3-highlights/cpp/HybridT3Highlights.hpp new file mode 100644 index 0000000000000..c7a6f5f8279ad --- /dev/null +++ b/apps/mobile/modules/t3-highlights/cpp/HybridT3Highlights.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include "HybridT3HighlightsSpec.hpp" + +namespace margelo::nitro::t3highlights { + +class HybridT3Highlights : public HybridT3HighlightsSpec { +public: + HybridT3Highlights() : HybridObject(TAG) {} + + std::optional> tokenize(SourceText source, + double language) override; +}; + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/cpp/SourceText.hpp b/apps/mobile/modules/t3-highlights/cpp/SourceText.hpp new file mode 100644 index 0000000000000..87f96a85176a1 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/cpp/SourceText.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +#include "Utf16ToUtf8.hpp" + +namespace margelo::nitro::t3highlights { + +/** A JS string as the UTF-8 bytes the lexer reads. */ +struct SourceText { + std::string utf8; +}; + +} // namespace margelo::nitro::t3highlights + +namespace margelo::nitro { + +// Hermes' own utf8() is several times slower than this for strings it stores as +// UTF-16, which is any string with one non-ASCII character in it. +template <> +struct JSIConverter final { + static inline t3highlights::SourceText fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + t3highlights::SourceText source; + auto read = [&source](bool ascii, const void* data, size_t length) { + if (ascii) { + source.utf8.append(static_cast(data), length); + } else { + t3highlights::appendUtf16AsUtf8(source.utf8, static_cast(data), length); + } + }; + arg.asString(runtime).getStringData(runtime, read); + return source; + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const t3highlights::SourceText& arg) { + return jsi::String::createFromUtf8(runtime, arg.utf8); + } + static inline bool canConvert(jsi::Runtime&, const jsi::Value& value) { + return value.isString(); + } +}; + +} // namespace margelo::nitro diff --git a/apps/mobile/modules/t3-highlights/cpp/Utf16ToUtf8.cpp b/apps/mobile/modules/t3-highlights/cpp/Utf16ToUtf8.cpp new file mode 100644 index 0000000000000..f9e7ed54ed9e5 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/cpp/Utf16ToUtf8.cpp @@ -0,0 +1,58 @@ +#include "Utf16ToUtf8.hpp" + +#include +#include + +namespace margelo::nitro::t3highlights { + +void appendUtf16AsUtf8(std::string& out, const char16_t* data, size_t length) { + const size_t start = out.size(); + out.resize(start + length * 3); + auto* const begin = reinterpret_cast(out.data()); + auto* write = begin + start; + size_t at = 0; + while (at < length) { + // Runs of four ASCII code units are the common case in source code. + if (at + 4 <= length) { + uint64_t chunk; + std::memcpy(&chunk, data + at, sizeof(chunk)); + if ((chunk & 0xff80ff80ff80ff80ULL) == 0) { + write[0] = static_cast(data[at]); + write[1] = static_cast(data[at + 1]); + write[2] = static_cast(data[at + 2]); + write[3] = static_cast(data[at + 3]); + write += 4; + at += 4; + continue; + } + } + uint32_t unit = data[at++]; + if (unit < 0x80) { + *write++ = static_cast(unit); + } else if (unit < 0x800) { + *write++ = static_cast(0xc0 | (unit >> 6)); + *write++ = static_cast(0x80 | (unit & 0x3f)); + } else { + uint32_t codePoint = unit; + if (unit >= 0xd800 && unit <= 0xdfff) { + codePoint = 0xfffd; + if (unit <= 0xdbff && at < length && data[at] >= 0xdc00 && data[at] <= 0xdfff) { + codePoint = 0x10000 + ((unit - 0xd800) << 10) + (data[at++] - 0xdc00); + } + } + if (codePoint < 0x10000) { + *write++ = static_cast(0xe0 | (codePoint >> 12)); + *write++ = static_cast(0x80 | ((codePoint >> 6) & 0x3f)); + *write++ = static_cast(0x80 | (codePoint & 0x3f)); + } else { + *write++ = static_cast(0xf0 | (codePoint >> 18)); + *write++ = static_cast(0x80 | ((codePoint >> 12) & 0x3f)); + *write++ = static_cast(0x80 | ((codePoint >> 6) & 0x3f)); + *write++ = static_cast(0x80 | (codePoint & 0x3f)); + } + } + } + out.resize(static_cast(write - begin)); +} + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/cpp/Utf16ToUtf8.hpp b/apps/mobile/modules/t3-highlights/cpp/Utf16ToUtf8.hpp new file mode 100644 index 0000000000000..b79ab0b6605b8 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/cpp/Utf16ToUtf8.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +namespace margelo::nitro::t3highlights { + +/** Lone surrogates become U+FFFD, as with `jsi::String::utf8`. */ +void appendUtf16AsUtf8(std::string& out, const char16_t* data, size_t length); + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/expo-module.config.json b/apps/mobile/modules/t3-highlights/expo-module.config.json deleted file mode 100644 index 247e2a76a89a0..0000000000000 --- a/apps/mobile/modules/t3-highlights/expo-module.config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "platforms": ["apple", "android"], - "apple": { - "modules": ["T3HighlightsModule"], - "podspecPath": "T3HighlightsNative.podspec" - }, - "android": { - "modules": ["expo.modules.t3highlights.T3HighlightsModule"] - } -} diff --git a/apps/mobile/modules/t3-highlights/ios/T3HighlightsModule.swift b/apps/mobile/modules/t3-highlights/ios/T3HighlightsModule.swift deleted file mode 100644 index 32ee8b6f062ab..0000000000000 --- a/apps/mobile/modules/t3-highlights/ios/T3HighlightsModule.swift +++ /dev/null @@ -1,29 +0,0 @@ -import ExpoModulesCore - -public final class T3HighlightsModule: Module { - private let lock = NSLock() - - public func definition() -> ModuleDefinition { - Name("T3Highlights") - - Function("tokenize") { (source: String, language: Int) -> ArrayBuffer? in - var source = source - return source.withUTF8 { utf8 in - self.lock.withLock { Self.tokenize(utf8, language: language) } - } - } - } - - private static func tokenize(_ utf8: UnsafeBufferPointer, language: Int) -> ArrayBuffer? { - guard let languageId = UInt8(exactly: language) else { return nil } - var records: UnsafePointer? - let count = t3_highlights_tokenize(utf8.baseAddress, utf8.count, languageId, &records) - guard count >= 0 else { return nil } - let byteCount = Int(count) * MemoryLayout.size - let buffer = ArrayBuffer(size: byteCount, initializeToZero: false) - if let records, byteCount > 0 { - buffer.withUnsafeMutableBytes { $0.copyMemory(from: UnsafeRawBufferPointer(start: records, count: byteCount)) } - } - return buffer - } -} diff --git a/apps/mobile/modules/t3-highlights/nitro.json b/apps/mobile/modules/t3-highlights/nitro.json new file mode 100644 index 0000000000000..55879db6a82e5 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitro.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://nitro.margelo.com/nitro.schema.json", + "cxxNamespace": ["t3highlights"], + "ios": { + "iosModuleName": "T3HighlightsNative" + }, + "android": { + "androidNamespace": ["t3highlights"], + "androidCxxLibName": "t3highlights" + }, + "autolinking": { + "T3Highlights": { + "all": { + "language": "c++", + "implementationClassName": "HybridT3Highlights" + } + } + }, + "ignorePaths": ["node_modules", "Vendor"] +} diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/.gitattributes b/apps/mobile/modules/t3-highlights/nitrogen/generated/.gitattributes new file mode 100644 index 0000000000000..fb7a0d5a3f6ce --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/.gitattributes @@ -0,0 +1 @@ +** linguist-generated=true diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/android/kotlin/com/margelo/nitro/t3highlights/t3highlightsOnLoad.kt b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/kotlin/com/margelo/nitro/t3highlights/t3highlightsOnLoad.kt new file mode 100644 index 0000000000000..4dad2cee444c0 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/kotlin/com/margelo/nitro/t3highlights/t3highlightsOnLoad.kt @@ -0,0 +1,35 @@ +/// +/// t3highlightsOnLoad.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.t3highlights + +import android.util.Log + +internal class t3highlightsOnLoad { + companion object { + private const val TAG = "t3highlightsOnLoad" + private var didLoad = false + /** + * Initializes the native part of "t3highlights". + * This method is idempotent and can be called more than once. + */ + @JvmStatic + fun initializeNative() { + if (didLoad) return + try { + Log.i(TAG, "Loading t3highlights C++ library...") + System.loadLibrary("t3highlights") + Log.i(TAG, "Successfully loaded t3highlights C++ library!") + didLoad = true + } catch (e: Error) { + Log.e(TAG, "Failed to load t3highlights C++ library! Is it properly installed and linked? " + + "Is the name correct? (see `CMakeLists.txt`, at `add_library(...)`)", e) + throw e + } + } + } +} diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlights+autolinking.cmake b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlights+autolinking.cmake new file mode 100644 index 0000000000000..597d9f245459f --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlights+autolinking.cmake @@ -0,0 +1,81 @@ +# +# t3highlights+autolinking.cmake +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © Marc Rousavy @ Margelo +# + +# This is a CMake file that adds all files generated by Nitrogen +# to the current CMake project. +# +# To use it, add this to your CMakeLists.txt: +# ```cmake +# include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/t3highlights+autolinking.cmake) +# ``` + +# Define a flag to check if we are building properly +add_definitions(-DBUILDING_T3HIGHLIGHTS_WITH_GENERATED_CMAKE_PROJECT) + +# Enable Raw Props parsing in react-native (for Nitro Views) +add_definitions(-DRN_SERIALIZABLE_STATE) + +# Add all headers that were generated by Nitrogen +include_directories( + "../nitrogen/generated/shared/c++" + "../nitrogen/generated/android/c++" + "../nitrogen/generated/android/" +) + +# Add all .cpp sources that were generated by Nitrogen +target_sources( + # CMake project name (Android C++ library name) + t3highlights PRIVATE + # Autolinking Setup + ../nitrogen/generated/android/t3highlightsOnLoad.cpp + # Shared Nitrogen C++ sources + ../nitrogen/generated/shared/c++/HybridT3HighlightsSpec.cpp + # Android-specific Nitrogen C++ sources + +) + +# From node_modules/react-native/ReactAndroid/cmake-utils/folly-flags.cmake +# Used in node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake +target_compile_definitions( + t3highlights PRIVATE + -DFOLLY_NO_CONFIG=1 + -DFOLLY_HAVE_CLOCK_GETTIME=1 + -DFOLLY_USE_LIBCPP=1 + -DFOLLY_CFG_NO_COROUTINES=1 + -DFOLLY_MOBILE=1 + -DFOLLY_HAVE_RECVMMSG=1 + -DFOLLY_HAVE_PTHREAD=1 + # Once we target android-23 above, we can comment + # the following line. NDK uses GNU style stderror_r() after API 23. + -DFOLLY_HAVE_XSI_STRERROR_R=1 +) + +# Add all libraries required by the generated specs +find_package(fbjni REQUIRED) # <-- Used for communication between Java <-> C++ +find_package(ReactAndroid REQUIRED) # <-- Used to set up React Native bindings (e.g. CallInvoker/TurboModule) +find_package(react-native-nitro-modules REQUIRED) # <-- Used to create all HybridObjects and use the Nitro core library + +# Link all libraries together +target_link_libraries( + t3highlights + fbjni::fbjni # <-- Facebook C++ JNI helpers + ReactAndroid::jsi # <-- RN: JSI + react-native-nitro-modules::NitroModules # <-- NitroModules Core :) +) + +# Link react-native (different prefab between RN 0.75 and RN 0.76) +if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 76) + target_link_libraries( + t3highlights + ReactAndroid::reactnative # <-- RN: Native Modules umbrella prefab + ) +else() + target_link_libraries( + t3highlights + ReactAndroid::react_nativemodule_core # <-- RN: TurboModules Core + ) +endif() diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlights+autolinking.gradle b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlights+autolinking.gradle new file mode 100644 index 0000000000000..f2ecc7dab4aeb --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlights+autolinking.gradle @@ -0,0 +1,27 @@ +/// +/// t3highlights+autolinking.gradle +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +/// This is a Gradle file that adds all files generated by Nitrogen +/// to the current Gradle project. +/// +/// To use it, add this to your build.gradle: +/// ```gradle +/// apply from: '../nitrogen/generated/android/t3highlights+autolinking.gradle' +/// ``` + +logger.warn("[NitroModules] 🔥 t3highlights is boosted by nitro!") + +android { + sourceSets { + main { + java.srcDirs += [ + // Nitrogen files + "${project.projectDir}/../nitrogen/generated/android/kotlin" + ] + } + } +} diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlightsOnLoad.cpp b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlightsOnLoad.cpp new file mode 100644 index 0000000000000..3d9100ff5aa73 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlightsOnLoad.cpp @@ -0,0 +1,49 @@ +/// +/// t3highlightsOnLoad.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#ifndef BUILDING_T3HIGHLIGHTS_WITH_GENERATED_CMAKE_PROJECT +#error t3highlightsOnLoad.cpp is not being built with the autogenerated CMakeLists.txt project. Is a different CMakeLists.txt building this? +#endif + +#include "t3highlightsOnLoad.hpp" + +#include +#include +#include + +#include "HybridT3Highlights.hpp" + +namespace margelo::nitro::t3highlights { + +int initialize(JavaVM* vm) { + return facebook::jni::initialize(vm, []() { + ::margelo::nitro::t3highlights::registerAllNatives(); + }); +} + + + +void registerAllNatives() { + using namespace margelo::nitro; + using namespace margelo::nitro::t3highlights; + + // Register native JNI methods + + + // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "T3Highlights", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridT3Highlights\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); +} + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlightsOnLoad.hpp b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlightsOnLoad.hpp new file mode 100644 index 0000000000000..640ddbd3c9c4a --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/android/t3highlightsOnLoad.hpp @@ -0,0 +1,34 @@ +/// +/// t3highlightsOnLoad.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include +#include +#include + +namespace margelo::nitro::t3highlights { + + [[deprecated("Use registerNatives() instead.")]] + int initialize(JavaVM* vm); + + /** + * Register the native (C++) part of t3highlights, and autolinks all Hybrid Objects. + * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`), + * inside a `facebook::jni::initialize(vm, ...)` call. + * Example: + * ```cpp (cpp-adapter.cpp) + * JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + * return facebook::jni::initialize(vm, []() { + * // register all t3highlights HybridObjects + * margelo::nitro::t3highlights::registerNatives(); + * // any other custom registrations go here. + * }); + * } + * ``` + */ + void registerAllNatives(); + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative+autolinking.rb b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative+autolinking.rb new file mode 100644 index 0000000000000..353434bdf8f73 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative+autolinking.rb @@ -0,0 +1,62 @@ +# +# T3HighlightsNative+autolinking.rb +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © Marc Rousavy @ Margelo +# + +# This is a Ruby script that adds all files generated by Nitrogen +# to the given podspec. +# +# To use it, add this to your .podspec: +# ```ruby +# Pod::Spec.new do |spec| +# # ... +# +# # Add all files generated by Nitrogen +# load 'nitrogen/generated/ios/T3HighlightsNative+autolinking.rb' +# add_nitrogen_files(spec) +# end +# ``` + +def add_nitrogen_files(spec) + Pod::UI.puts "[NitroModules] 🔥 T3HighlightsNative is boosted by nitro!" + + spec.dependency "NitroModules" + + current_source_files = Array(spec.attributes_hash['source_files']) + spec.source_files = current_source_files + [ + # Generated cross-platform specs + "nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}", + # Generated bridges for the cross-platform specs + "nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}", + ] + + current_public_header_files = Array(spec.attributes_hash['public_header_files']) + spec.public_header_files = current_public_header_files + [ + # Generated specs + "nitrogen/generated/shared/**/*.{h,hpp}", + # Swift to C++ bridging helpers + "nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Bridge.hpp" + ] + + current_private_header_files = Array(spec.attributes_hash['private_header_files']) + spec.private_header_files = current_private_header_files + [ + # iOS specific specs + "nitrogen/generated/ios/c++/**/*.{h,hpp}", + # Views are framework-specific and should be private + "nitrogen/generated/shared/**/views/**/*" + ] + + current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {} + spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ + # Use C++ 20 + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + # Enables C++ <-> Swift interop (by default it's only ObjC) + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + # Enables stricter modular headers + "DEFINES_MODULE" => "YES", + # Disable auto-generated ObjC header for Swift (Static linkage on Xcode 26.4 breaks here) + "SWIFT_INSTALL_OBJC_HEADER" => "NO", + }) +end diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Bridge.cpp b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Bridge.cpp new file mode 100644 index 0000000000000..0777bcf6181a6 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Bridge.cpp @@ -0,0 +1,17 @@ +/// +/// T3HighlightsNative-Swift-Cxx-Bridge.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "T3HighlightsNative-Swift-Cxx-Bridge.hpp" + +// Include C++ implementation defined types + + +namespace margelo::nitro::t3highlights::bridge::swift { + + + +} // namespace margelo::nitro::t3highlights::bridge::swift diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Bridge.hpp b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Bridge.hpp new file mode 100644 index 0000000000000..ab5df2d7c2ad2 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Bridge.hpp @@ -0,0 +1,27 @@ +/// +/// T3HighlightsNative-Swift-Cxx-Bridge.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types + + +// Forward declarations of Swift defined types + + +// Include C++ defined types + + +/** + * Contains specialized versions of C++ templated types so they can be accessed from Swift, + * as well as helper functions to interact with those C++ types from Swift. + */ +namespace margelo::nitro::t3highlights::bridge::swift { + + + +} // namespace margelo::nitro::t3highlights::bridge::swift diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Umbrella.hpp b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Umbrella.hpp new file mode 100644 index 0000000000000..f6c94700f08ca --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNative-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,38 @@ +/// +/// T3HighlightsNative-Swift-Cxx-Umbrella.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types + + +// Include C++ defined types + + +// C++ helpers for Swift +#include "T3HighlightsNative-Swift-Cxx-Bridge.hpp" + +// Common C++ types used in Swift +#include +#include +#include +#include + +// Forward declarations of Swift defined types + + +// Include Swift defined types +#if __has_include("T3HighlightsNative-Swift.h") +// This header is generated by Xcode/Swift on every app build. +// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "T3HighlightsNative". +#include "T3HighlightsNative-Swift.h" +// Same as above, but used when building with frameworks (`use_frameworks`) +#elif __has_include() +#include +#else +#error T3HighlightsNative's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "T3HighlightsNative", and try building the app first. +#endif diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNativeAutolinking.mm b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNativeAutolinking.mm new file mode 100644 index 0000000000000..aeb1e552b04c9 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNativeAutolinking.mm @@ -0,0 +1,35 @@ +/// +/// T3HighlightsNativeAutolinking.mm +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#import +#import + +#import + +#include "HybridT3Highlights.hpp" + +@interface T3HighlightsNativeAutolinking : NSObject +@end + +@implementation T3HighlightsNativeAutolinking + ++ (void) load { + using namespace margelo::nitro; + using namespace margelo::nitro::t3highlights; + + HybridObjectRegistry::registerHybridObjectConstructor( + "T3Highlights", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridT3Highlights\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); +} + +@end diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNativeAutolinking.swift b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNativeAutolinking.swift new file mode 100644 index 0000000000000..f5079023cafdd --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/ios/T3HighlightsNativeAutolinking.swift @@ -0,0 +1,16 @@ +/// +/// T3HighlightsNativeAutolinking.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +// TODO: Use empty enums once Swift supports exporting them as namespaces +// See: https://github.com/swiftlang/swift/pull/83616 +public final class T3HighlightsNativeAutolinking { + public typealias bridge = margelo.nitro.t3highlights.bridge.swift + + +} diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/shared/c++/HybridT3HighlightsSpec.cpp b/apps/mobile/modules/t3-highlights/nitrogen/generated/shared/c++/HybridT3HighlightsSpec.cpp new file mode 100644 index 0000000000000..feae1896c7b44 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/shared/c++/HybridT3HighlightsSpec.cpp @@ -0,0 +1,21 @@ +/// +/// HybridT3HighlightsSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridT3HighlightsSpec.hpp" + +namespace margelo::nitro::t3highlights { + + void HybridT3HighlightsSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("tokenize", &HybridT3HighlightsSpec::tokenize); + }); + } + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/nitrogen/generated/shared/c++/HybridT3HighlightsSpec.hpp b/apps/mobile/modules/t3-highlights/nitrogen/generated/shared/c++/HybridT3HighlightsSpec.hpp new file mode 100644 index 0000000000000..07158b9fd128b --- /dev/null +++ b/apps/mobile/modules/t3-highlights/nitrogen/generated/shared/c++/HybridT3HighlightsSpec.hpp @@ -0,0 +1,64 @@ +/// +/// HybridT3HighlightsSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include "SourceText.hpp" + +namespace margelo::nitro::t3highlights { + + using namespace margelo::nitro; + + /** + * An abstract base class for `T3Highlights` + * Inherit this class to create instances of `HybridT3HighlightsSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridT3Highlights: public HybridT3HighlightsSpec { + * public: + * HybridT3Highlights(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridT3HighlightsSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridT3HighlightsSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridT3HighlightsSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual std::optional> tokenize(SourceText source, double language) = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "T3Highlights"; + }; + +} // namespace margelo::nitro::t3highlights diff --git a/apps/mobile/modules/t3-highlights/package.json b/apps/mobile/modules/t3-highlights/package.json index 81ebd561155d7..88fec302b7428 100644 --- a/apps/mobile/modules/t3-highlights/package.json +++ b/apps/mobile/modules/t3-highlights/package.json @@ -2,16 +2,8 @@ "name": "@t3tools/mobile-highlights-native", "version": "0.0.0", "private": true, - "expo-module": { - "ios": { - "modules": [ - "T3HighlightsModule" - ] - }, - "android": { - "modules": [ - "expo.modules.t3highlights.T3HighlightsModule" - ] - } + "types": "./src/T3Highlights.nitro.ts", + "peerDependencies": { + "react-native-nitro-modules": "*" } } diff --git a/apps/mobile/modules/t3-highlights/src/T3Highlights.nitro.ts b/apps/mobile/modules/t3-highlights/src/T3Highlights.nitro.ts new file mode 100644 index 0000000000000..4bb305e029b3d --- /dev/null +++ b/apps/mobile/modules/t3-highlights/src/T3Highlights.nitro.ts @@ -0,0 +1,13 @@ +import type { CustomType, HybridObject } from "react-native-nitro-modules"; + +// Converted by cpp/SourceText.hpp, which reads the string without Hermes' slow UTF-16 to UTF-8 path. +type SourceText = CustomType; + +export interface T3Highlights extends HybridObject<{ ios: "c++"; android: "c++" }> { + /** + * Lexes `source` with @pierre/highlights language `language`. Returns + * `(endUtf16, tokenId)` uint32 pairs, where token ID 0xffffffff ends a line, + * or undefined when the lexer could not run. + */ + tokenize(source: SourceText, language: number): ArrayBuffer | undefined; +} diff --git a/apps/mobile/modules/t3-highlights/tests/run.sh b/apps/mobile/modules/t3-highlights/tests/run.sh new file mode 100755 index 0000000000000..0be43fe22a2b5 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/tests/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail +module_dir=$(cd "$(dirname "$0")/.." && pwd) +output=$(mktemp -d) +trap 'rm -rf "$output"' EXIT +c++ -std=c++20 -O2 -Wall -Wextra -I "$module_dir/cpp" \ + "$module_dir/cpp/Utf16ToUtf8.cpp" "$module_dir/tests/utf16-to-utf8.cpp" -o "$output/utf16-to-utf8" +"$output/utf16-to-utf8" diff --git a/apps/mobile/modules/t3-highlights/tests/utf16-to-utf8.cpp b/apps/mobile/modules/t3-highlights/tests/utf16-to-utf8.cpp new file mode 100644 index 0000000000000..98fdba8db84f7 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/tests/utf16-to-utf8.cpp @@ -0,0 +1,94 @@ +#include +#include +#include +#include + +#include "Utf16ToUtf8.hpp" + +using margelo::nitro::t3highlights::appendUtf16AsUtf8; + +static int failures = 0; + +static std::string encode(const std::u16string& source, std::string out = {}) { + appendUtf16AsUtf8(out, source.data(), source.size()); + return out; +} + +static void expectBytes(const char* name, const std::string& actual, const std::string& expected) { + if (actual == expected) return; + failures++; + std::fprintf(stderr, "%s: expected", name); + for (unsigned char byte : expected) std::fprintf(stderr, " %02x", byte); + std::fprintf(stderr, ", got"); + for (unsigned char byte : actual) std::fprintf(stderr, " %02x", byte); + std::fprintf(stderr, "\n"); +} + +// One code unit or valid pair at a time, as a slow reference. +static std::string reference(const std::u16string& source) { + std::string out; + for (size_t at = 0; at < source.size(); at++) { + uint32_t point = source[at]; + if (point >= 0xd800 && point <= 0xdbff && at + 1 < source.size() && source[at + 1] >= 0xdc00 && + source[at + 1] <= 0xdfff) { + point = 0x10000 + ((point - 0xd800) << 10) + (source[++at] - 0xdc00); + } else if (point >= 0xd800 && point <= 0xdfff) { + point = 0xfffd; + } + if (point < 0x80) { + out += static_cast(point); + } else if (point < 0x800) { + out += static_cast(0xc0 | (point >> 6)); + out += static_cast(0x80 | (point & 0x3f)); + } else if (point < 0x10000) { + out += static_cast(0xe0 | (point >> 12)); + out += static_cast(0x80 | ((point >> 6) & 0x3f)); + out += static_cast(0x80 | (point & 0x3f)); + } else { + out += static_cast(0xf0 | (point >> 18)); + out += static_cast(0x80 | ((point >> 12) & 0x3f)); + out += static_cast(0x80 | ((point >> 6) & 0x3f)); + out += static_cast(0x80 | (point & 0x3f)); + } + } + return out; +} + +int main() { + expectBytes("empty", encode(u""), ""); + expectBytes("nul", encode(std::u16string(u"a\0b", 3)), std::string("a\0b", 3)); + expectBytes("two-byte", encode(u"\u00e9\u07ff"), "\xc3\xa9\xdf\xbf"); + expectBytes("three-byte", encode(u"\u0800\u65e5\uffff"), "\xe0\xa0\x80\xe6\x97\xa5\xef\xbf\xbf"); + expectBytes("pair", encode(u"\U0001F389"), "\xf0\x9f\x8e\x89"); + expectBytes("lone high at end", encode(u"a\xd83c"), "a\xef\xbf\xbd"); + expectBytes("lone high before text", encode(u"\xd83cz"), "\xef\xbf\xbdz"); + expectBytes("high high low", encode(u"\xd83c\xd83c\xdf89"), "\xef\xbf\xbd\xf0\x9f\x8e\x89"); + expectBytes("lone low", encode(u"\xdf89"), "\xef\xbf\xbd"); + expectBytes("appends", encode(u"\u00e9", "ok "), "ok \xc3\xa9"); + // A high surrogate at the end of the input must not pair with the unit after it. + const std::u16string pair = u"\U0001F389"; + std::string split; + appendUtf16AsUtf8(split, pair.data(), 1); + expectBytes("high at input end", split, "\xef\xbf\xbd"); + + // Runs either side of the four-unit ASCII fast path, with a non-ASCII unit at every offset. + for (size_t length = 0; length <= 12; length++) { + for (size_t at = 0; at <= length; at++) { + std::u16string source(length, u'x'); + if (at < length) source[at] = 0x00e9; + expectBytes("fast path boundary", encode(source), reference(source)); + } + } + + const char16_t pool[] = {u'a', u'\n', 0x7f, 0x80, 0x7ff, 0x800, 0xd7ff, 0xd800, 0xdbff, 0xdc00, 0xdfff, 0xe000, 0xffff}; + std::mt19937 random(7); + for (int run = 0; run < 20000; run++) { + std::u16string source(random() % 24, u' '); + for (auto& unit : source) unit = pool[random() % (sizeof(pool) / sizeof(pool[0]))]; + expectBytes("random", encode(source), reference(source)); + } + + if (failures > 0) return 1; + std::printf("utf16-to-utf8: ok\n"); + return 0; +} diff --git a/apps/mobile/modules/t3-highlights/tests/utf16-to-utf8.test.ts b/apps/mobile/modules/t3-highlights/tests/utf16-to-utf8.test.ts new file mode 100644 index 0000000000000..d7a18effe6b71 --- /dev/null +++ b/apps/mobile/modules/t3-highlights/tests/utf16-to-utf8.test.ts @@ -0,0 +1,14 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; +import { it } from "vite-plus/test"; + +// The converter is C++, so its test compiles and runs on the host. +it("transcodes UTF-16 the way jsi::String::utf8 does", () => { + NodeChildProcess.execFileSync( + "bash", + [NodeURL.fileURLToPath(new URL("./run.sh", import.meta.url))], + { + stdio: "pipe", + }, + ); +}); diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt new file mode 100644 index 0000000000000..1df8c646d4832 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3SecretInputModule.kt @@ -0,0 +1,145 @@ +package expo.modules.t3nativecontrols + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.os.Build +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.util.TypedValue +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class T3SecretInputModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3SecretInput") + + View(T3SecretInputView::class) { + Prop("value") { view: T3SecretInputView, value: String -> view.setValue(value) } + Prop("placeholder") { view: T3SecretInputView, value: String -> + view.input.hint = value + } + // Not "accessibilityLabel": React Native applies that to the outer view, not the EditText. + Prop("inputLabel") { view: T3SecretInputView, value: String -> + view.input.contentDescription = value + } + Prop("textColor") { view: T3SecretInputView, value: Int -> view.input.setTextColor(value) } + Prop("placeholderColor") { view: T3SecretInputView, value: Int -> + view.input.setHintTextColor(value) + } + Prop("cursorColor") { view: T3SecretInputView, value: Int -> view.setCursorColor(value) } + Prop("fontSize") { view: T3SecretInputView, value: Float -> + view.input.setTextSize(TypedValue.COMPLEX_UNIT_DIP, value) + } + Prop("editable") { view: T3SecretInputView, value: Boolean -> view.input.isEnabled = value } + Events("onChangeText", "onSubmit") + } + } +} + +/** + * Masked single-line input for secret requests. React Native's own TextInput cannot opt out of + * credential autofill, so password managers offer logins above the keyboard; this view owns the + * EditText so it can. + */ +class T3SecretInputView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + // Yoga sizes this view; the EditText inside is laid out by Android. + override val shouldUseAndroidLayout = true + + // Newer Android can request autofill even with importantForAutofill=no. + // Reporting no autofill type keeps this input out of credential autofill. + val input = object : EditText(context) { + override fun getAutofillType(): Int = View.AUTOFILL_TYPE_NONE + } + private val onChangeText by EventDispatcher() + private val onSubmit by EventDispatcher() + private var applyingValue = false + + // Values sent to JS that its state has not echoed back yet. An echo of an older value would + // otherwise overwrite characters typed in the meantime. + private val unechoed = ArrayDeque() + + init { + input.background = null + input.setPadding(0, 0, 0, 0) + input.gravity = Gravity.CENTER_VERTICAL or Gravity.START + // The autofill APIs arrive with API 26; minSdk is 24. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + input.importantForAutofill = IMPORTANT_FOR_AUTOFILL_NO + } + input.imeOptions = EditorInfo.IME_ACTION_DONE + // Not setSingleLine(): that swaps out the password transformation. The password variation is + // already single line, and the keyboard keeps typed text out of suggestions. + input.inputType = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_PASSWORD or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + // The password variation switches the typeface to monospace. + input.typeface = Typeface.DEFAULT + input.setTextColor(Color.BLACK) + input.setHintTextColor(Color.GRAY) + input.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(s: Editable?) { + if (applyingValue) return + val value = s?.toString() ?: "" + unechoed.addLast(value) + onChangeText(mapOf("value" to value)) + } + } + ) + input.setOnEditorActionListener { _, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_DONE || event?.keyCode == KeyEvent.KEYCODE_ENTER) { + // Hardware Enter reports IME_NULL on Android 13 and earlier, and both key down and key up. + if (event == null || event.action == KeyEvent.ACTION_DOWN) { + onSubmit(mapOf("value" to input.text.toString())) + } + true + } else { + false + } + } + addView( + input, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + ) + } + + /** Applies JS state without echoing it back, and without moving the caret on equal values. */ + fun setValue(value: String) { + val echoed = unechoed.indexOf(value) + if (echoed >= 0) { + repeat(echoed + 1) { unechoed.removeFirst() } + return + } + // A value JS chose itself, such as clearing after a save. + unechoed.clear() + if (input.text.toString() == value) return + applyingValue = true + input.setText(value) + input.setSelection(input.text.length) + applyingValue = false + } + + fun setCursorColor(color: Int) { + input.highlightColor = Color.argb(0x52, Color.red(color), Color.green(color), Color.blue(color)) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + + input.textCursorDrawable = input.textCursorDrawable?.mutate()?.apply { setTint(color) } + input.textSelectHandle?.mutate()?.setTint(color) + input.textSelectHandleLeft?.mutate()?.setTint(color) + input.textSelectHandleRight?.mutate()?.setTint(color) + } +} diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 5ff3009ef4d20..d405bf41cc335 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -13,7 +13,8 @@ "expo.modules.t3nativecontrols.T3NativeControlsModule", "expo.modules.t3nativecontrols.T3WorkspaceNavigationModule", "expo.modules.t3nativecontrols.T3KeyboardCommandsModule", - "expo.modules.t3nativecontrols.T3FoldPostureModule" + "expo.modules.t3nativecontrols.T3FoldPostureModule", + "expo.modules.t3nativecontrols.T3SecretInputModule" ] } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index f96c00962682c..0f56662ec00ee 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -8,7 +8,10 @@ private final class WorkspaceTabBar: UITabBar { super.layoutSubviews() // iOS 26.6 doesn't copy item identifiers onto its tab buttons; the bar holds no other controls. let identified = tabButtons(in: self) { $0.accessibilityIdentifier?.hasPrefix("workspace-tab-") == true } - let buttons = identified.isEmpty ? tabButtons(in: self) { _ in true } : identified + let candidates = identified.isEmpty ? tabButtons(in: self) { _ in true } : identified + // Pressing a tab adds a magnified copy of the row for the glass lens, without identifiers. + // Sizing compose from it leaves compose enlarged until the bar lays out again. + let buttons = candidates.filter { convert($0.bounds, from: $0).size == $0.bounds.size } let row = buttons.reduce(CGRect.null) { $0.union(convert($1.bounds, from: $1)) } if !row.isNull { onContentLayout?(row) } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 801f1c1ee64e6..54ff86c611a8e 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -47,7 +47,8 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private var contentResetKey = "" @Volatile private var rowsDecodeGeneration = 0 - private var tokensDecodeGeneration = 0 + + @Volatile private var tokensDecodeGeneration = 0 private val payloadDecodeExecutor = Executors.newSingleThreadExecutor() private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private val minimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity @@ -203,6 +204,11 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont tokensDecodeGeneration += 1 val generation = tokensDecodeGeneration payloadDecodeExecutor.execute { + if (generation != tokensDecodeGeneration || + Thread.currentThread().isInterrupted + ) { + return@execute + } val decodedTokens = parseTokensObject(value) post { if (generation != tokensDecodeGeneration) return@post @@ -1123,7 +1129,8 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun maxHeaderPathOffset(fileId: String): Int { - val row = rows.firstOrNull { it.kind == "file" && it.resolvedFileId == fileId } ?: return 0 + val rowIndex = fileHeaderIndices.firstOrNull { rows[it].resolvedFileId == fileId } ?: return 0 + val row = rows[rowIndex] val top = 0 val bottom = rowHeight(row) val iconRect = drawing.fileHeaderIconRect(top, bottom, style) diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 8d8a84c406df3..a80a391380750 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -340,7 +340,9 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private var pendingScrollAnimated = false private var isProgrammaticScrollActive = false private var rowsDecodeGeneration = 0 + private var rowsDecodeCancellation: ReviewDiffRowPreparation? private var tokensDecodeGeneration = 0 + private var tokensDecodeCancellation: ReviewDiffRowPreparation? let onDebug = EventDispatcher() let onVisibleFileChange = EventDispatcher() @@ -387,6 +389,11 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { scrollView.addSubview(contentView) } + deinit { + rowsDecodeCancellation?.cancel() + tokensDecodeCancellation?.cancel() + } + public override func layoutSubviews() { super.layoutSubviews() let anchor = contentView.viewportWidth != bounds.width ? contentView.viewportRowAnchor() : nil @@ -426,10 +433,15 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { func setRowsJson(_ rowsJson: String) { contentView.cancelRowPreparation() + rowsDecodeCancellation?.cancel() + let cancellation = ReviewDiffRowPreparation() + rowsDecodeCancellation = cancellation rowsDecodeGeneration += 1 let generation = rowsDecodeGeneration payloadDecodeQueue.async { [weak self] in + // Check weak liveness without retaining the view or reading its UIKit state. + guard !cancellation.isCancelled, self != nil else { return } guard let data = rowsJson.data(using: .utf8) else { return } @@ -475,10 +487,15 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } func setTokensJson(_ tokensJson: String) { + tokensDecodeCancellation?.cancel() + let cancellation = ReviewDiffRowPreparation() + tokensDecodeCancellation = cancellation tokensDecodeGeneration += 1 let generation = tokensDecodeGeneration payloadDecodeQueue.async { [weak self] in + // Entered decoding may finish; only skip full payloads already obsolete in the queue. + guard !cancellation.isCancelled, self != nil else { return } guard let data = tokensJson.data(using: .utf8) else { return } @@ -601,8 +618,11 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { self.contentResetKey = contentResetKey lastVisibleRangeDebugKey = "" contentView.cancelRowPreparation() + rowsDecodeCancellation?.cancel() + rowsDecodeCancellation = nil rowsDecodeGeneration += 1 tokensDecodeGeneration += 1 + tokensDecodeCancellation?.cancel() contentView.tokensByRowId = [:] rows = [] contentView.rows = [] @@ -1909,8 +1929,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private func maxHorizontalOffset(for target: (fileId: String, kind: ReviewDiffHorizontalPanKind)) -> CGFloat { if target.kind == .fileHeaderPath, - let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { - return maxHeaderPathOffset(for: row) + let rowIndex = fileHeaderRowIndex(forFileId: target.fileId) { + return maxHeaderPathOffset(for: rows[rowIndex]) } if codeWrapColumns != nil { return 0 diff --git a/apps/mobile/modules/t3-review-diff/tests/android/build.gradle b/apps/mobile/modules/t3-review-diff/tests/android/build.gradle index fa067950ed044..5a5565e8bf5f3 100644 --- a/apps/mobile/modules/t3-review-diff/tests/android/build.gradle +++ b/apps/mobile/modules/t3-review-diff/tests/android/build.gradle @@ -44,18 +44,39 @@ tasks.register('extractAndroidTestJars') { } def moduleRoot = file('../..') +// Count decoder entry without replacing the production executor, parser, or lifecycle. +def instrumentedView = layout.buildDirectory.file('token-fixture/T3ReviewDiffView.kt') +tasks.register('instrumentTokenFixture') { + def original = file("${moduleRoot}/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt") + inputs.file original + outputs.file instrumentedView + doLast { + def source = original.text + def decode = ' val decodedTokens = parseTokensObject(value)' + def publish = ' canvasView.tokensByRowId = decodedTokens' + assert source.count(decode) == 1 + assert source.count(publish) == 1 + source = source.replace(decode, ' TokenDecodeProbe.enter()\n' + decode) + source = source.replace(publish, publish + '\n TokenDecodeProbe.publish()') + def destination = instrumentedView.get().asFile + destination.parentFile.mkdirs() + destination.text = source + } +} + def sources = files( + instrumentedView, fileTree('stubs') { include '**/*.kt' }, fileTree('src/test/kotlin') { include '**/*.kt' }, fileTree("${moduleRoot}/android/src/test/java") { include '**/*.kt' }, fileTree("${moduleRoot}/android/src/main/java/expo/modules/t3reviewdiff") { include '**/*.kt' - exclude '**/T3ReviewDiffModule.kt' + exclude '**/T3ReviewDiffModule.kt', '**/T3ReviewDiffView.kt' }, ) tasks.register('compileRegression', JavaExec) { - dependsOn 'extractAndroidTestJars' + dependsOn 'extractAndroidTestJars', 'instrumentTokenFixture' classpath = configurations.compiler mainClass = 'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler' inputs.files sources, configurations.testCompileClasspath diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt index d6b4c7456392e..beac68f8e5357 100644 --- a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffGeometryTest.kt @@ -219,6 +219,157 @@ class ReviewDiffGeometryTest { } } + @Test + fun headerPathLimitsKeepFirstMatchAndNeverReadCodeRows() { + val parent = T3ReviewDiffView(RuntimeEnvironment.getApplication(), AppContext()) + val reference = T3ReviewDiffView(RuntimeEnvironment.getApplication(), AppContext()) + val canvas = parent.getChildAt(0) + val oracle = reference.getChildAt(0) + val spec = View.MeasureSpec.makeMeasureSpec(390, View.MeasureSpec.EXACTLY) + for (view in listOf(canvas, oracle)) { + view.measure(spec, spec) + view.layout(0, 0, 390, 600) + } + val longPath = "long/directory/".repeat(12) + val ordinary = headerRows(longPath) + val input = listOf(row("impostor", "value").copy(fileId = "f9")) + ordinary + listOf( + row("duplicate:header", "").copy(kind = "file", fileId = "f9", filePath = "short.kt"), + row("id-fallback", "").copy(kind = "file", fileId = "", filePath = longPath + "fallback.kt"), + row("", "").copy(kind = "file", fileId = "", filePath = longPath + "empty.kt"), + row("orphan:line:0", "code ".repeat(100)).copy(fileId = "orphan"), + ) + try { + // Drive the production collapse path from its full decoded payload boundary. + parent.javaClass.getDeclaredField("rows").apply { isAccessible = true }.set(parent, input) + call(parent, "rebuildVisibleRows") + for (wrap in listOf(false, true)) { + parent.setStyleJson("{\"wordWrap\":$wrap}") + reference.setStyleJson("{\"wordWrap\":$wrap}") + for (collapsed in listOf("[]", "[\"f0\",\"f5\"]", "[]")) { + parent.setCollapsedFileIdsJson(collapsed) + checkHeaderLimits(canvas, oracle, input, wrap, collapsed) + } + } + parent.setStyleJson("{\"wordWrap\":false}") + val counted = CountingRows(ordinary) + call(canvas, "setRows", counted) + val target = panTarget(canvas, "f9", "FILE_HEADER_PATH") + call(canvas, "resetHorizontalOffsets") + counted.reads = 0 + counted.codeReads = 0 + repeat(120) { call(canvas, "scrollByHorizontal", 1, target) } + val panReads = counted.reads + val panCodeReads = counted.codeReads + counted.reads = 0 + counted.codeReads = 0 + repeat(120) { call(canvas, "clampHeaderPathOffsets") } + val clampReads = counted.reads + val clampCodeReads = counted.codeReads + println( + "HEADER_LOOKUP Android updates=120 panReads=$panReads panCodeReads=$panCodeReads " + + "clampReads=$clampReads clampCodeReads=$clampCodeReads" + ) + call( + canvas, + "setRows", + listOf(row("replacement", "").copy(kind = "file", fileId = "f9", filePath = "short.kt")) + ) + assertEquals(0, headerLimit(canvas, "f9")) + assertEquals(0, headerOffset(canvas, "f9")) + parent.setContentResetKey("new-content") + assertEquals(0, headerLimit(canvas, "f9")) + assertTrue((field(canvas, "fileHeaderIndices") as IntArray).isEmpty()) + assertTrue((field(canvas, "headerPathOffsetsByFileId") as Map<*, *>).isEmpty()) + println( + "Passed Android header limits: first/middle/last, duplicate, empty/missing IDs, rename, " + + "collapse/reexpand, replacement/reset, wrap and code bounds" + ) + assertEquals("Header pans must not inspect code rows", 0, panCodeReads) + assertEquals("Header clamps must not inspect code rows", 0, clampCodeReads) + assertTrue("Header pans must read only preceding headers", panReads <= 1320) + assertTrue("Header clamps must read only preceding headers", clampReads <= 1320) + } finally { + parent.cleanup() + reference.cleanup() + } + } + + private fun headerRows(longPath: String): List = (0 until 10).flatMap { file -> + listOf( + row("f$file:header", "").copy( + kind = "file", + fileId = "f$file", + filePath = "${longPath}new-$file.kt", + previousPath = "old-$file.kt", + additions = file + 1, + deletions = file * 2, + ) + ) + + List(100) { line -> row("f$file:line:$line", "code ".repeat(100)).copy(fileId = "f$file") } + } + + private fun checkHeaderLimits( + canvas: View, + oracle: View, + input: List, + wrap: Boolean, + collapsed: String + ) { + for ((caseIndex, id) in listOf( + "f0", + "f5", + "f9", + "id-fallback", + "", + "orphan", + "missing" + ).withIndex()) { + val header = input.firstOrNull { it.kind == "file" && it.resolvedFileId == id } + call(oracle, "setRows", listOfNotNull(header)) + val expected = headerLimit(oracle, id) + val actual = headerLimit(canvas, id) + println("HEADER_LIMIT Android wrap=$wrap collapsed=$collapsed case=$caseIndex limit=$actual") + assertEquals("wrap=$wrap collapsed=$collapsed id=$id", expected, actual) + if (header != null) assertTrue("Long rename/header path must overflow", expected > 0) + val target = panTarget(canvas, id, "FILE_HEADER_PATH") + assertEquals(expected, call(canvas, "maxHorizontalOffset", target)) + call(canvas, "scrollByHorizontal", 1_000_000, target) + assertEquals(expected, headerOffset(canvas, id)) + call(canvas, "scrollByHorizontal", -1_000_000, target) + assertEquals(0, headerOffset(canvas, id)) + call(canvas, "scrollByHorizontal", 1, target) + call(canvas, "clampHeaderPathOffsets") + assertEquals(minOf(1, expected), headerOffset(canvas, id)) + } + val codeTarget = panTarget(canvas, "f9", "CODE") + val codeLimit = call(canvas, "maxHorizontalOffset") as Int + assertEquals(codeLimit, call(canvas, "maxHorizontalOffset", codeTarget)) + if (wrap) assertEquals(0, codeLimit) else assertTrue(codeLimit > 0) + call(canvas, "scrollByHorizontal", 1_000_000, codeTarget) + assertEquals(codeLimit, call(canvas, "horizontalOffset")) + call(canvas, "scrollByHorizontal", -1_000_000, codeTarget) + assertEquals(0, call(canvas, "horizontalOffset")) + } + + private fun headerLimit(canvas: View, id: String): Int = canvas.javaClass + .getDeclaredMethod("maxHeaderPathOffset", String::class.java).apply { isAccessible = true } + .invoke(canvas, id) as Int + + private fun headerOffset(canvas: View, id: String): Int = + (field(canvas, "headerPathOffsetsByFileId") as Map<*, *>)[id] as Int? ?: 0 + + private fun panTarget(canvas: View, id: String, kind: String): Any { + val targetClass = canvas.javaClass.declaredMethods.single { + it.name == "scrollByHorizontal" + }.parameterTypes[1] + val constructor = targetClass.declaredConstructors.single().apply { isAccessible = true } + val enumValue = constructor.parameterTypes[1].enumConstants.single { + (it as Enum<*>).name == + kind + } + return constructor.newInstance(id, enumValue) + } + private fun call(target: Any, name: String, vararg args: Any): Any? = target.javaClass .declaredMethods.single { it.name == name && it.parameterCount == args.size } .apply { isAccessible = true }.invoke(target, *args) @@ -235,9 +386,11 @@ class ReviewDiffGeometryTest { private class CountingRows(private val rows: List) : AbstractList() { var reads = 0 + var codeReads = 0 override val size: Int get() = rows.size override fun get(index: Int): DiffRow { reads += 1 + if (rows[index].kind != "file") codeReads += 1 return rows[index] } } diff --git a/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffTokenDecodeTest.kt b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffTokenDecodeTest.kt new file mode 100644 index 0000000000000..c058e9b8845c2 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/android/src/test/kotlin/expo/modules/t3reviewdiff/ReviewDiffTokenDecodeTest.kt @@ -0,0 +1,293 @@ +package expo.modules.t3reviewdiff + +import android.app.Activity +import android.graphics.Color +import android.os.Looper +import expo.modules.kotlin.AppContext +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Semaphore +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +internal object TokenDecodeProbe { + val entries = AtomicInteger() + val publications = AtomicInteger() + + @Volatile var onEntry: ((Int) -> Unit)? = null + + fun reset() { + entries.set(0) + publications.set(0) + onEntry = null + } + + fun enter() { + val entry = entries.incrementAndGet() + onEntry?.invoke(entry) + } + fun publish() { + publications.incrementAndGet() + } +} + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@LooperMode(LooperMode.Mode.PAUSED) +class ReviewDiffTokenDecodeTest { + @Test + fun queuedFullReplacementSkipsOnlyObsoleteWorkAndPreservesPatchOrdering() { + for (case in queuedCases()) { + fixture { view, executor -> + TokenDecodeProbe.reset() + view.setContentResetKey("content") + view.setTokensResetKey("tokens") + view.setTokensJson(payload("seed")) + drain(executor) + TokenDecodeProbe.reset() + val entered = CountDownLatch(1) + val release = Semaphore(0) + executor.execute { + entered.countDown() + release.acquire() + } + entered.await() + val acknowledgements = mutableListOf() + try { + case.enqueue(view) { id, chunk -> + view.setTokensPatchJson(patchPayload(id, chunk)) { acknowledgements.add(it) } + } + } finally { + release.release() + } + drain(executor) + assertEquals("${case.name} decoder entry", case.entries, TokenDecodeProbe.entries.get()) + // Android's malformed full payload publishes its existing empty-map fallback. + assertEquals( + "${case.name} retained full publication", + case.entries, + TokenDecodeProbe.publications.get() + ) + assertEquals("${case.name} token output", expected(case.ids), tokens(view)) + assertEquals("${case.name} patch acknowledgements", case.acks, acknowledgements) + println( + "Passed tokens: ${case.name}, decoder entries=${case.entries}, " + + "retained full publications=${case.entries}" + ) + } + } + } + + private data class QueuedCase( + val name: String, + val ids: List = listOf("b"), + val entries: Int = 1, + val acks: List = emptyList(), + val enqueue: (T3ReviewDiffView, (String, Int) -> Unit) -> Unit + ) + + private fun queuedCases() = listOf( + QueuedCase("A B C burst", ids = listOf("c")) { view, _ -> + listOf("a", "b", "c").forEach { view.setTokensJson(payload(it)) } + }, + QueuedCase("content reset", ids = emptyList(), entries = 0) { view, _ -> + view.setTokensJson(payload("a")) + view.setContentResetKey("new") + }, + QueuedCase("reset then B") { view, _ -> + view.setTokensJson(payload("a")) + view.setContentResetKey("new") + view.setTokensJson(payload("b")) + }, + QueuedCase("same content key") { view, _ -> + view.setTokensJson(payload("b")) + view.setContentResetKey("content") + }, + QueuedCase("token reset preserves full") { view, _ -> + view.setTokensJson(payload("b")) + view.setTokensResetKey("new") + }, + QueuedCase("same token key") { view, _ -> + view.setTokensJson(payload("b")) + view.setTokensResetKey("tokens") + }, + QueuedCase("current full") { view, _ -> view.setTokensJson(payload("b")) }, + QueuedCase("current malformed", ids = emptyList()) { view, _ -> + view.setTokensJson("invalid JSON") + }, + QueuedCase("obsolete malformed") { view, _ -> + view.setTokensJson("invalid JSON") + view.setTokensJson(payload("b")) + }, + QueuedCase("full patch full", acks = listOf(true)) { view, patch -> + view.setTokensJson(payload("a")) + patch("patch", 0) + view.setTokensJson(payload("b")) + }, + QueuedCase( + "full then patches", + ids = listOf("b", "one", "two"), + acks = listOf(true, true) + ) { view, patch -> + view.setTokensJson(payload("b")) + patch("one", 0) + patch("two", 1) + }, + QueuedCase("patches then full", acks = listOf(true, true)) { view, patch -> + patch("one", 0) + patch("two", 1) + view.setTokensJson(payload("b")) + }, + QueuedCase( + "additive patches", + ids = listOf("seed", "one", "two"), + entries = 0, + acks = listOf(true, true) + ) { _, patch -> + patch("one", 0) + patch("two", 1) + }, + QueuedCase("reset rejects old patch", acks = listOf(false)) { view, patch -> + patch("old", 0) + view.setContentResetKey("new") + view.setTokensJson(payload("b")) + }, + ) + + @Test + fun enteredFullSuccessAndErrorFinishButOnlyTheReplacementPublishes() { + for (invalid in listOf(false, true)) { + fixture { view, executor -> + TokenDecodeProbe.reset() + val entered = CountDownLatch(1) + val release = Semaphore(0) + TokenDecodeProbe.onEntry = { entry -> + if (entry == 1) { + entered.countDown() + release.acquire() + } + } + view.setTokensJson(if (invalid) "invalid JSON" else payload("a")) + entered.await() + try { + view.setContentResetKey("new") + view.setTokensJson(payload("b")) + } finally { + release.release() + } + drain(executor) + assertEquals(2, TokenDecodeProbe.entries.get()) + assertEquals(1, TokenDecodeProbe.publications.get()) + assertEquals(expected(listOf("b")), tokens(view)) + println( + "Passed entered tokens invalid=$invalid, decoder entries=2, retained full publications=1" + ) + } + } + } + + @Test + fun cleanupDiscardsQueuedFullWorkOnTheProductionExecutor() { + fixture { view, executor -> + TokenDecodeProbe.reset() + val entered = CountDownLatch(1) + val worker = AtomicReference() + val release = Semaphore(0) + executor.execute { + worker.set(Thread.currentThread()) + entered.countDown() + try { + release.acquire() + } catch ( + _: InterruptedException + ) { + Thread.currentThread().interrupt() + } + } + entered.await() + view.setTokensJson(payload("discarded")) + view.cleanup() + worker.get().join() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(0, TokenDecodeProbe.entries.get()) + assertEquals(0, TokenDecodeProbe.publications.get()) + assertTrue(tokens(view).isEmpty()) + println("Passed cleanup before entry, decoder entries=0, retained full publications=0") + } + } + + @Test + fun cleanupAfterEntryKeepsTheExistingCurrentFullPublication() { + fixture { view, _ -> + TokenDecodeProbe.reset() + val entered = CountDownLatch(1) + val release = Semaphore(0) + val worker = AtomicReference() + TokenDecodeProbe.onEntry = { + worker.set(Thread.currentThread()) + entered.countDown() + // shutdownNow interrupts this worker; entered decoding must still finish. + release.acquireUninterruptibly() + } + view.setTokensJson(payload("entered")) + entered.await() + try { + view.cleanup() + } finally { + release.release() + } + worker.get().join() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(1, TokenDecodeProbe.entries.get()) + assertEquals(1, TokenDecodeProbe.publications.get()) + assertEquals(expected(listOf("entered")), tokens(view)) + println("Passed cleanup after entry, decoder entries=1, retained full publications=1") + } + } + + private fun fixture(run: (T3ReviewDiffView, ExecutorService) -> Unit) { + val controller = Robolectric.buildActivity(Activity::class.java).setup() + val view = T3ReviewDiffView(controller.get(), AppContext()) + controller.get().setContentView(view) + val executor = field(view, "payloadDecodeExecutor") as ExecutorService + try { + run(view, executor) + } finally { + view.cleanup() + TokenDecodeProbe.reset() + controller.pause().stop().destroy() + } + } + + private fun drain(executor: ExecutorService) { + executor.submit {}.get() + shadowOf(Looper.getMainLooper()).idle() + } + + private fun field(target: Any, name: String): Any = target.javaClass.getDeclaredField(name) + .apply { isAccessible = true }.get(target) + + private fun tokens(view: T3ReviewDiffView) = field( + view.getChildAt(0), + "tokensByRowId" + ) as Map<*, *> + + private fun expected(ids: List) = ids.associateWith { + listOf(DiffToken("漢字 👨‍👩‍👧‍👦 é", Color.RED, 3), DiffToken("tail", null, 0)) + } + + private fun payload(id: String) = + """{"$id":[{"content":"漢字 👨‍👩‍👧‍👦 é","color":"#ff0000","fontStyle":3},{"content":"tail","fontStyle":0}]}""" + + private fun patchPayload(id: String, chunk: Int) = + """{"resetKey":"tokens","chunkIndex":$chunk,"tokensByRowId":${payload(id)}}""" +} diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift b/apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift index bd213f694f0c7..edf02eda0adbb 100644 --- a/apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift +++ b/apps/mobile/modules/t3-review-diff/tests/ios/geometry.swift @@ -103,6 +103,101 @@ geometryView.mergeTokensByRowId(["a:line:29": [ReviewDiffNativeToken(content: ge geometryView.selectedRowIds = ["a:line:29"] check(geometryView.contentHeight == originalHeight, "Tokens and selection must preserve geometry") +// The runner instruments the production resolver in its temporary compile input only. +private enum HeaderLookupReads { + static var enabled = false + static var headers = 0 + static var code = 0 + static func record(_ row: ReviewDiffNativeRow) { + guard enabled else { return } + if row.kind == "file" { headers += 1 } else { code += 1 } + } +} + +private extension ReviewDiffContentView { + func checkHeaderPathLimits() throws { + viewportWidth = 390 + let longPath = String(repeating: "long/directory/", count: 12) + func decode(_ input: [[String: Any]]) throws -> [ReviewDiffNativeRow] { + try JSONDecoder().decode([ReviewDiffNativeRow].self, from: JSONSerialization.data(withJSONObject: input)) + } + var fixture: [[String: Any]] = [] + for file in 0..<10 { + fixture.append(["kind": "file", "id": "f\(file):header", "fileId": "f\(file)", + "filePath": longPath + "new-\(file).swift", "previousPath": "old-\(file).swift", + "additions": file + 1, "deletions": file * 2]) + for line in 0..<100 { + fixture.append(["kind": "line", "id": "f\(file):line:\(line)", "fileId": "f\(file)", + "content": String(repeating: "code ", count: 100)]) + } + } + // A code row sharing the target ID must not hide the first header. + fixture.insert(["kind": "line", "id": "impostor", "fileId": "f9", "content": "value"], at: 0) + fixture += [ + ["kind": "file", "id": "duplicate:header", "fileId": "f9", "filePath": "short.swift"], + ["kind": "file", "id": "empty:header", "fileId": "", "filePath": longPath + "empty.swift"], + ["kind": "file", "id": "derived:header", "filePath": longPath + "derived.swift"], + ["kind": "file", "id": "path-fallback", "filePath": longPath + "fallback.swift"], + ["kind": "file", "id": "id-only"], + ["kind": "line", "id": "orphan:line:0", "fileId": "orphan", "content": String(repeating: "code ", count: 100)] + ] + let input = try decode(fixture) + rows = input + for wrap in [false, true] { + style = try geometryStyle(wrap: wrap) + func verify(_ label: String) { + for (caseIndex, id) in ["f0", "f5", "f9", "", "derived", longPath + "fallback.swift", "id-only", "orphan", "missing"].enumerated() { + let header = rows.first { $0.kind == "file" && $0.resolvedFileId == id } + let expected = header.map { maxHeaderPathOffset(for: $0) } + ?? maxHorizontalOffset(for: (fileId: id, kind: .code)) + let actual = maxHorizontalOffset(for: (fileId: id, kind: .fileHeaderPath)) + print("HEADER_LIMIT iOS wrap=\(wrap) state=\(label) case=\(caseIndex) limit=\(actual)") + check(actual == expected, "\(label) wrap=\(wrap) id=\(id): header path limit changed") + if ["f0", "f5", "f9", "", "derived"].contains(id) { + check(actual > 0, "Long rename/header path must overflow in either wrap mode") + } + // This is the same clamp called after token, style, and viewport changes. + setHorizontalOffset(.greatestFiniteMagnitude, for: id, kind: .fileHeaderPath) + clampHorizontalOffsets() + check(horizontalOffset(for: id, kind: .fileHeaderPath) == expected, "Header clamp changed") + setHorizontalOffset(13, for: id, kind: .code) + clampHorizontalOffsets() + check(horizontalOffset(for: id, kind: .code) == min(13, maxHorizontalOffset(for: (id, .code))), + "Code bounds changed") + } + } + verify("expanded") + collapsedFileIds = ["f0", "f5"] + verify("collapsed") + collapsedFileIds = [] + verify("reexpanded") + } + // Known fixture: ten headers separated by 100 code rows; repeatedly clamp the last. + rows = try decode(Array(fixture[1..<1011])) + style = try geometryStyle(wrap: false) + headerPathOffsetsByFileId = ["f9": 1] + horizontalOffsetsByFileId = [:] + HeaderLookupReads.headers = 0 + HeaderLookupReads.code = 0 + HeaderLookupReads.enabled = true + for _ in 0..<120 { clampHorizontalOffsets() } + HeaderLookupReads.enabled = false + print("HEADER_LOOKUP iOS updates=120 headerVisits=\(HeaderLookupReads.headers) codeVisits=\(HeaderLookupReads.code)") + // Finish semantic checks before rejecting baseline scan work. + rows = try decode([["kind": "file", "id": "replacement:header", "fileId": "f9", "filePath": "short.swift"]]) + check(headerPathOffsetsByFileId.isEmpty && horizontalOffsetsByFileId.isEmpty, "Replacement clears offsets") + check(maxHorizontalOffset(for: ("f9", .fileHeaderPath)) == 0, "Replacement must use current header") + rows = [] + check(fileHeaderRowIndices.isEmpty && headerPathOffsetsByFileId.isEmpty, "Reset must clear old index/offsets") + check(maxHorizontalOffset(for: ("f9", .fileHeaderPath)) == maxHorizontalOffset(for: ("f9", .code)), + "Missing-header fallback after reset changed") + print("Passed iOS header limits: first/middle/last, duplicate, empty/absent IDs, rename, collapse/reexpand, replacement/reset, wrap and code bounds") + check(HeaderLookupReads.code == 0, "Header clamps must not inspect code rows") + check(HeaderLookupReads.headers == 1200, "Header clamps must visit only preceding headers") + } +} +try ReviewDiffContentView(frame: CGRect(x: 0, y: 0, width: 390, height: 600)).checkHeaderPathLimits() + // Configuration changes before publication must restart preparation from a fresh snapshot. private let geometryQueue = DispatchQueue(label: "review-diff.geometry-tests") geometryQueue.suspend() @@ -125,7 +220,7 @@ geometryView.prepareRows(geometryRows, on: geometryQueue, isCurrent: { true }, c DispatchQueue.main.async { check(geometryView.rows.isEmpty && geometryView.contentHeight == 0, "Reset must remain empty after obsolete work drains") print("Passed sparse geometry, reference heights/widths, font/wrap/collapse, hit testing, direct publication, and cancellation") - exit(0) + runRowDecodeTests() } } }) diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/row-decode.swift b/apps/mobile/modules/t3-review-diff/tests/ios/row-decode.swift new file mode 100644 index 0000000000000..cad93d5b8b267 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/row-decode.swift @@ -0,0 +1,262 @@ +// The harness instruments only decoder entry; scheduling and publication use the real view. +private final class RowDecodeProbe { + private let lock = NSLock() + private var entries = 0 + private var onEntry: ((Int) -> Void)? + + func reset(onEntry: ((Int) -> Void)? = nil) { + lock.lock() + entries = 0 + self.onEntry = onEntry + lock.unlock() + } + + func enter() { + lock.lock() + entries += 1 + let entry = entries + let callback = onEntry + lock.unlock() + callback?(entry) + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return entries + } +} + +private let rowDecodeProbe = RowDecodeProbe() + +private func decodeRowsForTest(_ data: Data) throws -> [ReviewDiffNativeRow] { + rowDecodeProbe.enter() + return try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) +} + +private extension ReviewDiffContentView { + func checkSameGeometry(as reference: ReviewDiffContentView) { + check(rowOffsets == reference.rowOffsets && contentHeight == reference.contentHeight, + "Queued decoding must preserve reference row offsets and height") + check(contentWidthsByFileId == reference.contentWidthsByFileId, + "Queued decoding must preserve reference widths") + } +} + +private extension T3ReviewDiffView { + // One worker/main round decodes, the next prepares and publishes geometry. + func afterRowPipeline(_ completion: @escaping () -> Void) { + Self.afterRowPipeline(on: payloadDecodeQueue, completion) + } + + static func afterRowPipeline(on queue: DispatchQueue, _ completion: @escaping () -> Void) { + queue.async { + DispatchQueue.main.async { + queue.async { + DispatchQueue.main.async(execute: completion) + } + } + } + } + + func checkRowsMatch(_ json: String) { + let expected = try! JSONDecoder().decode([ReviewDiffNativeRow].self, from: Data(json.utf8)) + check(rows.map { String(reflecting: $0) } == expected.map { String(reflecting: $0) }, + "Every decoded row field must match the unchanged decoder") + check(contentView.rows.map { String(reflecting: $0) } == rows.map { String(reflecting: $0) }) + contentView.checkReferenceGeometry() + let reference = ReviewDiffContentView(frame: contentView.frame) + reference.style = contentView.style + reference.viewportWidth = contentView.viewportWidth + reference.collapsedFileIds = contentView.collapsedFileIds + reference.collapsedCommentIds = contentView.collapsedCommentIds + reference.rows = expected + contentView.checkSameGeometry(as: reference) + } + + func checkEmptyRows() { + check(rows.isEmpty && contentView.rows.isEmpty && contentView.contentHeight == 0, + "Reset/error must remain empty after the queue drains") + } +} + +private func rowDecodePayload(_ file: String) -> String { + let objects: [[String: Any]] = [ + ["kind": "file", "id": "\(file):header", "fileId": file, "filePath": "\(file).swift"], + ["kind": "hunk", "id": "\(file):hunk", "fileId": file, "text": "@@ context\t@@"], + ["kind": "line", "id": "\(file):ascii", "fileId": file, "content": String(repeating: "let value = 123; ", count: 60), + "change": "add", "newLineNumber": 1, "wordDiffRanges": [["start": 0, "end": 3]]], + ["kind": "line", "id": "\(file):unicode", "fileId": file, "content": "漢字\t👨‍👩‍👧‍👦 e\u{301} مرحبا", "oldLineNumber": 2], + ["kind": "comment", "id": "\(file):comment", "fileId": file, "commentText": "Keep every field"], + ] + return String(data: try! JSONSerialization.data(withJSONObject: objects), encoding: .utf8)! +} + +private extension T3ReviewDiffView { + static func runQueuedRowDecodeCase(_ index: Int) { + let names = ["A to B", "reset", "reset then B", "unchanged reset key", "current success", "current error", "obsolete error"] + guard index < names.count else { + runReleasedRowDecodeCase() + return + } + rowDecodeProbe.reset() + let view = T3ReviewDiffView() + view.bounds = CGRect(x: 0, y: 0, width: 390, height: 844) + view.layoutSubviews() + view.setContentResetKey("section-a") + view.setStyleJson("{\"wordWrap\":true}") + view.pendingScrollFileId = "pending" + var events: [[String: Any]] = [] + view.onDebug.onEvent = { payload in + if (payload["message"] as? String)?.hasPrefix("rows-decode") == true { events.append(payload) } + } + let a = rowDecodePayload("a") + let b = rowDecodePayload("b") + view.payloadDecodeQueue.suspend() + switch index { + case 0: + view.setRowsJson(a) + view.setRowsJson(b) + case 1, 2: + view.setRowsJson(a) + view.setContentResetKey("section-b") + if index == 2 { view.setRowsJson(b) } + case 3: + view.setRowsJson(b) + view.setContentResetKey("section-a") + case 4: + view.setRowsJson(b) + case 5: + view.rows = try! JSONDecoder().decode([ReviewDiffNativeRow].self, from: Data(a.utf8)) + view.contentView.rows = view.rows + view.setRowsJson("invalid JSON") + default: + view.setRowsJson("invalid JSON") + view.setRowsJson(b) + } + view.payloadDecodeQueue.resume() + view.afterRowPipeline { + let expected = index == 1 ? 0 : 1 + check(rowDecodeProbe.count == expected, + "\(names[index]): expected \(expected) decoder entries, got \(rowDecodeProbe.count)") + if index == 1 { + view.checkEmptyRows() + check(events.isEmpty, "Reset work must not publish success or error") + } else if index == 5 { + view.checkEmptyRows() + check(view.pendingScrollFileId == nil, "Current error must clear pending scroll") + check(events.count == 1 && events[0]["message"] as? String == "rows-decode-failed") + do { + _ = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: Data("invalid JSON".utf8)) + check(false) + } catch { + check(events[0]["error"] as? String == error.localizedDescription, "Preserve the current error") + } + } else { + view.checkRowsMatch(b) + check(events.count == 1 && events[0]["message"] as? String == "rows-decoded", + "Only the current payload may publish success") + } + print("Passed queued row decode: \(names[index]), decoder entries=\(rowDecodeProbe.count)") + runQueuedRowDecodeCase(index + 1) + } + } + + static func runReleasedRowDecodeCase() { + rowDecodeProbe.reset() + var view: T3ReviewDiffView? = T3ReviewDiffView() + let isReleased = { [weak view] in view == nil } + let queue = view!.payloadDecodeQueue + queue.suspend() + view!.setRowsJson(rowDecodePayload("released")) + view = nil + check(isReleased(), "Queued row work must not retain the view") + queue.resume() + T3ReviewDiffView.afterRowPipeline(on: queue) { + check(rowDecodeProbe.count == 0, "Release before worker resume must skip decoding") + print("Passed release before worker resume, decoder entries=0") + runRunningRowDecodeCase(invalid: false) + } + } + + static func runRunningRowDecodeCase(invalid: Bool) { + let view = T3ReviewDiffView() + let release = DispatchSemaphore(value: 0) + let b = rowDecodePayload("current") + var publications = 0 + view.onDebug.onEvent = { payload in + if (payload["message"] as? String)?.hasPrefix("rows-decode") == true { + check(payload["message"] as? String == "rows-decoded", "Running stale errors must not publish") + publications += 1 + } + } + rowDecodeProbe.reset { entry in + guard entry == 1 else { return } + DispatchQueue.main.async { + view.setContentResetKey("new-section") + view.setRowsJson(b) + release.signal() + view.afterRowPipeline { + check(rowDecodeProbe.count == 2, "An entered decoder must finish; the current payload must also decode") + check(publications == 1, "Running obsolete success/error must not publish") + view.checkRowsMatch(b) + rowDecodeProbe.reset() + print("Passed running \(invalid ? "error" : "success") replaced by current rows, decoder entries=2") + if invalid { runReleasedRunningRowDecodeCase() } else { runRunningRowDecodeCase(invalid: true) } + } + } + release.wait() + } + view.setRowsJson(invalid ? "invalid JSON" : rowDecodePayload("running")) + } + + static func runReleasedRunningRowDecodeCase() { + var view: T3ReviewDiffView? = T3ReviewDiffView() + let isReleased = { [weak view] in view == nil } + let queue = view!.payloadDecodeQueue + let release = DispatchSemaphore(value: 0) + var publications = 0 + view!.onDebug.onEvent = { payload in + if (payload["message"] as? String)?.hasPrefix("rows-decode") == true { publications += 1 } + } + rowDecodeProbe.reset { _ in + DispatchQueue.main.async { + view = nil + check(isReleased(), "An entered decoder must not retain the view") + release.signal() + afterRowPipeline(on: queue) { + check(rowDecodeProbe.count == 1 && publications == 0, + "An entered decode may finish after release but must not publish") + rowDecodeProbe.reset() + print("Passed release after decoder entry without retaining the view, decoder entries=1") + runAdditiveTokenPatchCase() + } + } + release.wait() + } + view!.setRowsJson(rowDecodePayload("released-running")) + } + + static func runAdditiveTokenPatchCase() { + let view = T3ReviewDiffView() + view.setTokensResetKey("tokens") + view.payloadDecodeQueue.suspend() + var acknowledgements: [Bool] = [] + for id in ["one", "two"] { + view.setTokensPatchJson("{\"resetKey\":\"tokens\",\"tokensByRowId\":{\"\(id)\":[{\"content\":\"\(id)\"}]}}") { + acknowledgements.append($0) + } + } + view.payloadDecodeQueue.resume() + view.afterRowPipeline { + check(acknowledgements == [true, true], "Every additive token patch must be acknowledged") + check(Set(view.contentView.tokensByRowId.keys) == Set(["one", "two"]), "Token patches must never coalesce") + print("Passed additive token patches and acknowledgements; all 12 row/payload lifecycle cases passed") + T3ReviewDiffView.runQueuedTokenDecodeCase(0) + } + } +} + +private func runRowDecodeTests() { + T3ReviewDiffView.runQueuedRowDecodeCase(0) +} diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/token-decode.swift b/apps/mobile/modules/t3-review-diff/tests/ios/token-decode.swift new file mode 100644 index 0000000000000..140e99326ab6a --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/token-decode.swift @@ -0,0 +1,222 @@ +// Instrument decoder entry and accepted publication in the real serial queue. +private final class TokenDecodeProbe { + private let lock = NSLock() + private var entries = 0 + private var publications = 0 + private var onEntry: ((Int) -> Void)? + + func reset(onEntry: ((Int) -> Void)? = nil) { + lock.lock() + entries = 0 + publications = 0 + self.onEntry = onEntry + lock.unlock() + } + + func enter() { + lock.lock() + entries += 1 + let entry = entries + let callback = onEntry + lock.unlock() + callback?(entry) + } + + func publish() { + lock.lock() + publications += 1 + lock.unlock() + } + + func checkCounts(_ expectedEntries: Int, _ expectedPublications: Int, _ name: String) { + lock.lock() + defer { lock.unlock() } + check(entries == expectedEntries, + "\(name): expected \(expectedEntries) token decoder entries, got \(entries)") + check(publications == expectedPublications, + "\(name): expected \(expectedPublications) retained full publications, got \(publications)") + print("Passed tokens: \(name), decoder entries=\(entries), retained full publications=\(publications)") + } +} + +private let tokenDecodeProbe = TokenDecodeProbe() + +private func decodeTokensForTest( + _ type: [String: [ReviewDiffNativeToken]].Type, from data: Data +) throws -> [String: [ReviewDiffNativeToken]] { + tokenDecodeProbe.enter() + return try JSONDecoder().decode(type, from: data) +} + +private func tokenPayload(_ id: String) -> String { + "{\"\(id)\":[{\"content\":\"漢字 👨‍👩‍👧‍👦 é\",\"color\":\"#ff0000\",\"fontStyle\":3},{\"content\":\"tail\",\"fontStyle\":0}]}" +} + +private func tokenPatch(_ id: String, key: String = "tokens", chunk: Int = 0) -> String { + "{\"resetKey\":\"\(key)\",\"chunkIndex\":\(chunk),\"tokensByRowId\":\(tokenPayload(id))}" +} + +private extension T3ReviewDiffView { + static func afterTokens(on queue: DispatchQueue, _ completion: @escaping () -> Void) { + queue.async { DispatchQueue.main.async(execute: completion) } + } + + func checkTokens(_ payloads: [String]) { + var expected: [String: [ReviewDiffNativeToken]] = [:] + for payload in payloads { + let decoded = try! JSONDecoder().decode([String: [ReviewDiffNativeToken]].self, from: Data(payload.utf8)) + expected.merge(decoded) { _, next in next } + } + let describe = { (value: [String: [ReviewDiffNativeToken]]) in + value.mapValues { $0.map { String(reflecting: $0) } } + } + check(describe(contentView.tokensByRowId) == describe(expected), "Token fields/output order changed") + } + + static func runQueuedTokenDecodeCase(_ index: Int) { + let names = ["A B C burst", "content reset", "reset then B", "same content key", + "token reset preserves full", "same token key", "current full", "current malformed", + "obsolete malformed", "full patch full", "full then patches", "patches then full", + "additive patches", "reset rejects old patch"] + guard index < names.count else { runReleasedTokenDecodeCase(); return } + tokenDecodeProbe.reset() + let view = T3ReviewDiffView() + view.setContentResetKey("content") + view.setTokensResetKey("tokens") + var errors: [String] = [] + view.onDebug.onEvent = { payload in + if payload["message"] as? String == "tokens-decode-failed" { + errors.append(payload["error"] as! String) + } + } + view.contentView.tokensByRowId = try! JSONDecoder().decode( + [String: [ReviewDiffNativeToken]].self, from: Data(tokenPayload("seed").utf8) + ) + var acknowledgements: [Bool] = [] + let patch = { (id: String, chunk: Int) in + view.setTokensPatchJson(tokenPatch(id, chunk: chunk)) { acknowledgements.append($0) } + } + view.payloadDecodeQueue.suspend() + switch index { + case 0: + for id in ["a", "b", "c"] { view.setTokensJson(tokenPayload(id)) } + case 1, 2: + view.setTokensJson(tokenPayload("a")) + view.setContentResetKey("new") + if index == 2 { view.setTokensJson(tokenPayload("b")) } + case 3: + view.setTokensJson(tokenPayload("b")) + view.setContentResetKey("content") + case 4, 5: + view.setTokensJson(tokenPayload("b")) + view.setTokensResetKey(index == 4 ? "new" : "tokens") + case 6: view.setTokensJson(tokenPayload("b")) + case 7: view.setTokensJson("invalid JSON") + case 8: + view.setTokensJson("invalid JSON") + view.setTokensJson(tokenPayload("b")) + case 9: + view.setTokensJson(tokenPayload("a")) + patch("patch", 0) + view.setTokensJson(tokenPayload("b")) + case 10: + view.setTokensJson(tokenPayload("b")) + patch("one", 0) + patch("two", 1) + case 11: + patch("one", 0) + patch("two", 1) + view.setTokensJson(tokenPayload("b")) + case 12: + patch("one", 0) + patch("two", 1) + default: + patch("old", 0) + view.setContentResetKey("new") + view.setTokensJson(tokenPayload("b")) + } + view.payloadDecodeQueue.resume() + afterTokens(on: view.payloadDecodeQueue) { + let entries = index == 1 || index == 12 ? 0 : 1 + let publications = entries + tokenDecodeProbe.checkCounts(entries, publications, names[index]) + if index == 1 || index == 7 { + view.checkTokens([]) + } else if index == 0 { + view.checkTokens([tokenPayload("c")]) + } else if index == 10 { + view.checkTokens([tokenPayload("b"), tokenPayload("one"), tokenPayload("two")]) + } else if index == 12 { + view.checkTokens([tokenPayload("seed"), tokenPayload("one"), tokenPayload("two")]) + } else { + view.checkTokens([tokenPayload("b")]) + } + check(acknowledgements == (index == 9 ? [true] : index == 10 || index == 11 || index == 12 ? [true, true] : index == 13 ? [false] : []), + "All additive chunks must retain their acknowledgement semantics") + if index == 7 { + do { + _ = try JSONDecoder().decode([String: [ReviewDiffNativeToken]].self, from: Data("invalid JSON".utf8)) + } catch { check(errors == [error.localizedDescription], "Current error/fallback changed") } + } else { check(errors.isEmpty, "Obsolete errors must not publish") } + runQueuedTokenDecodeCase(index + 1) + } + } + + static func runReleasedTokenDecodeCase() { + tokenDecodeProbe.reset() + var view: T3ReviewDiffView? = T3ReviewDiffView() + let queue = view!.payloadDecodeQueue + let released = { [weak view] in view == nil } + queue.suspend() + view!.setTokensJson(tokenPayload("released")) + view = nil + check(released(), "Queued token closure must not retain the view") + queue.resume() + afterTokens(on: queue) { + tokenDecodeProbe.checkCounts(0, 0, "release before entry") + runEnteredTokenDecodeCase(invalid: false) + } + } + + static func runEnteredTokenDecodeCase(invalid: Bool) { + let view = T3ReviewDiffView() + let release = DispatchSemaphore(value: 0) + tokenDecodeProbe.reset { entry in + guard entry == 1 else { return } + DispatchQueue.main.async { + view.setContentResetKey("new") + view.setTokensJson(tokenPayload("b")) + release.signal() + afterTokens(on: view.payloadDecodeQueue) { + tokenDecodeProbe.checkCounts(2, 1, "entered \(invalid ? "error" : "success") then replacement") + view.checkTokens([tokenPayload("b")]) + tokenDecodeProbe.reset() + if invalid { runReleasedEnteredTokenDecodeCase() } else { runEnteredTokenDecodeCase(invalid: true) } + } + } + release.wait() + } + view.setTokensJson(invalid ? "invalid JSON" : tokenPayload("a")) + } + + static func runReleasedEnteredTokenDecodeCase() { + var view: T3ReviewDiffView? = T3ReviewDiffView() + let released = { [weak view] in view == nil } + let queue = view!.payloadDecodeQueue + let release = DispatchSemaphore(value: 0) + tokenDecodeProbe.reset { _ in + DispatchQueue.main.async { + view = nil + check(released(), "Entered decoder must not keep the view alive") + release.signal() + afterTokens(on: queue) { + tokenDecodeProbe.checkCounts(1, 0, "release after entry") + tokenDecodeProbe.reset() + exit(0) + } + } + release.wait() + } + view!.setTokensJson(tokenPayload("a")) + } +} diff --git a/apps/mobile/modules/t3-review-diff/tests/run-ios.sh b/apps/mobile/modules/t3-review-diff/tests/run-ios.sh index a8ecdd88947d5..25ffdb9cbe2ec 100644 --- a/apps/mobile/modules/t3-review-diff/tests/run-ios.sh +++ b/apps/mobile/modules/t3-review-diff/tests/run-ios.sh @@ -12,6 +12,35 @@ from pathlib import Path module = Path(sys.argv[1]) source = (module / "ios/T3ReviewDiffView.swift").read_text() source = source.replace("import ExpoModulesCore\n", "") +# Count entry into the real decoder without replacing the production queue or lifecycle. +row_decode = "try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)" +assert source.count(row_decode) == 1 +source = source.replace(row_decode, "try decodeRowsForTest(data)") +# Instrument the real full-token decoder and accepted publication only. +start = source.index(" let decodedTokens = try JSONDecoder().decode(") +end = source.index(" DispatchQueue.main.async", start) +decode = source[start:end] +source = source[:start] + decode.replace("try JSONDecoder().decode(", "try decodeTokensForTest(") + source[end:] +publish = " self.contentView.tokensByRowId = decodedTokens" +assert source.count(publish) == 1 +source = source.replace(publish, publish + "\n tokenDecodeProbe.publish()") +# Include the accepted malformed-current fallback in publication counts. +start = source.index(" func setTokensJson(") +end = source.index(" func setTokensPatchJson(", start) +method = source[start:end] +fallback = " self.contentView.tokensByRowId = [:]" +assert method.count(fallback) == 1 +source = source[:start] + method.replace(fallback, fallback + "\n tokenDecodeProbe.publish()") + source[end:] +# Count the actual lookup's row visits only in the compiled test copy. +needle = """ private func resolvedFileId(for row: ReviewDiffNativeRow) -> String { + row.resolvedFileId + }""" +if source.count(needle) != 1: + raise RuntimeError("Header lookup instrumentation seam changed") +source = source.replace(needle, """ private func resolvedFileId(for row: ReviewDiffNativeRow) -> String { + HeaderLookupReads.record(row) + return row.resolvedFileId + }""") stubs = """ public final class AppContext {} open class ExpoView: UIView { @@ -20,16 +49,20 @@ open class ExpoView: UIView { public required init?(coder: NSCoder) { fatalError("init(coder:) is unused") } } final class EventDispatcher { - func callAsFunction(_ payload: [String: Any]) {} + var onEvent: (([String: Any]) -> Void)? + func callAsFunction(_ payload: [String: Any]) { onEvent?(payload) } } """ # Expo alone is stubbed; keep the real scroll container as well as the content view. Path(sys.argv[2]).write_text(source + "\n" + stubs + "\n" + (module / "tests/ios/main.swift").read_text() + "\n" + (module / "tests/ios/anchor.swift").read_text() + "\n" + + (module / "tests/ios/row-decode.swift").read_text() + "\n" + + (module / "tests/ios/token-decode.swift").read_text() + "\n" + (module / "tests/ios/geometry.swift").read_text()) PYTHON "$compiler" -O -target "$(uname -m)-apple-ios16.1-macabi" -sdk "$sdk" \ + -module-cache-path "$output/module-cache" \ -F "$sdk/System/iOSSupport/System/Library/Frameworks" \ "$module_dir/ios/ReviewDiffCodeLayout.swift" "$output/main.swift" \ -o "$output/layout-tests" diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 703bcdf361b49..2734b0ceaa00b 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -19,6 +19,9 @@ class T3TerminalModule : Module() { view.writeOutput(data, reset) } + // The Android input field already clears after every change. + AsyncFunction("resetInput") { _: T3TerminalView -> } + Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> view.terminalKey = terminalKey } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 5646ab8c5884b..14f91964dcaf7 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -10,6 +10,7 @@ import android.view.KeyEvent import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.FrameLayout @@ -21,11 +22,19 @@ import kotlin.math.max class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { private val container = FrameLayout(context) private val terminalCanvas = TerminalCanvasView(context) + private val sentText = TerminalSentText() // Newer Android can request autofill even with importantForAutofill=no. // Reporting no autofill type keeps terminal input out of credential autofill. private val inputView = object : EditText(context) { override fun getAutofillType(): Int = View.AUTOFILL_TYPE_NONE + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? = + super.onCreateInputConnection(outAttrs)?.let { connection -> + TerminalInputConnection(connection, sentText, { !readOnly }) { count -> + if (!readOnly) onInput(mapOf("data" to "\u007F".repeat(count))) + } + } } private val onInput by EventDispatcher() private val onResize by EventDispatcher() @@ -282,6 +291,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { keyCode == KeyEvent.KEYCODE_DEL -> { + sentText.removeCodePoints(1) onInput(mapOf("data" to "\u007F")) true } @@ -304,6 +314,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex val end = (start + count).coerceAtMost(s.length) if (start < end) { val insertedText = s.subSequence(start, end).toString() + sentText.append(insertedText) onInput(mapOf("data" to insertedText)) } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalInputConnection.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalInputConnection.kt new file mode 100644 index 0000000000000..9d73e08cc84b0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalInputConnection.kt @@ -0,0 +1,70 @@ +package expo.modules.t3terminal + +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper + +private const val MAX_DELETE_LENGTH = 1024 + +// Text the terminal already received from the hidden input. The input is empty +// by the time an IME deletes, so this is what its UTF-16 lengths refer to. +internal class TerminalSentText { + private val text = StringBuilder() + private var deletedHighSurrogate = false + + fun append(sent: CharSequence) { + discardDeletedSurrogate() + text.append(sent) + if (text.length > MAX_DELETE_LENGTH) text.delete(0, text.length - MAX_DELETE_LENGTH) + } + + fun removeUnits(units: Int): Int { + val start = (text.length - units).coerceAtLeast(0) + val removed = units - (text.length - start) + text.codePointCount(start, text.length) - + if (deletedHighSurrogate && units > 0) 1 else 0 + // The low half already sent DEL; retain the high half only for IME unit accounting. + deletedHighSurrogate = start > 0 && start < text.length && + Character.isHighSurrogate(text[start - 1]) && Character.isLowSurrogate(text[start]) + text.setLength(start) + return removed + } + + fun removeCodePoints(count: Int): Int { + discardDeletedSurrogate() + val known = count.coerceAtMost(text.codePointCount(0, text.length)) + text.setLength(text.offsetByCodePoints(text.length, -known)) + return count + } + + private fun discardDeletedSurrogate() { + if (deletedHighSurrogate) text.setLength(text.length - 1) + deletedHighSurrogate = false + } +} + +// The hidden input clears after every change, so text an IME deletes was +// already sent to the terminal. Some keyboards delete through these calls +// instead of KEYCODE_DEL, so forward them as one DEL per code point. +internal class TerminalInputConnection( + target: InputConnection, + private val sentText: TerminalSentText, + private val canTrackDeletes: () -> Boolean, + private val sendDeletes: (count: Int) -> Unit +) : InputConnectionWrapper(target, true) { + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + if (beforeLength > 0 && canTrackDeletes()) { + forwardDeletes(sentText.removeUnits(beforeLength)) + } + return super.deleteSurroundingText(beforeLength, afterLength) + } + + override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean { + if (beforeLength > 0 && canTrackDeletes()) { + forwardDeletes(sentText.removeCodePoints(beforeLength)) + } + return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength) + } + + private fun forwardDeletes(count: Int) { + sendDeletes(count.coerceAtMost(MAX_DELETE_LENGTH)) + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalInputConnectionTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalInputConnectionTest.kt new file mode 100644 index 0000000000000..83cf850ca108a --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalInputConnectionTest.kt @@ -0,0 +1,97 @@ +package expo.modules.t3terminal + +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class TerminalInputConnectionTest { + private val input = EditText(RuntimeEnvironment.getApplication()) + private val sentText = TerminalSentText() + private val deletes = mutableListOf() + private var writable = true + private val connection = TerminalInputConnection( + input.onCreateInputConnection(EditorInfo())!!, + sentText, + { writable }, + ) { if (writable) deletes += it } + + private fun type(text: String) { + connection.commitText(text, 1) + sentText.append(input.text) + input.text.clear() + } + + @Test + fun imeDeletionReachesTheTerminalAfterTheInputClears() { + type("abc") + connection.deleteSurroundingText(1, 0) + connection.deleteSurroundingTextInCodePoints(2, 0) + connection.deleteSurroundingText(Int.MAX_VALUE, 0) + + assertEquals(listOf(1, 2, 1024), deletes) + } + + @Test + fun utf16DeletionOfAnEmojiSendsOneDelete() { + type("a😀") + connection.deleteSurroundingText(2, 0) + connection.deleteSurroundingText(1, 0) + + assertEquals(listOf(1, 1), deletes) + + for (codePoints in listOf(false, true)) { + deletes.clear() + type("a😀") + writable = false + if (codePoints) { + connection.deleteSurroundingTextInCodePoints(1, 0) + } else { + connection.deleteSurroundingText(2, 0) + } + writable = true + connection.deleteSurroundingText(2, 0) + connection.deleteSurroundingText(1, 0) + + assertEquals(listOf(1, 1), deletes) + } + + deletes.clear() + type("a😀") + connection.deleteSurroundingText(1, 0) + connection.deleteSurroundingText(1, 0) + connection.deleteSurroundingText(1, 0) + + assertEquals(listOf(1, 0, 1), deletes) + + deletes.clear() + type("a😀") + connection.deleteSurroundingText(1, 0) + connection.deleteSurroundingTextInCodePoints(1, 0) + + assertEquals(listOf(1, 1), deletes) + + deletes.clear() + type("a😀") + connection.deleteSurroundingText(1, 0) + type("b") + connection.deleteSurroundingText(1, 0) + connection.deleteSurroundingText(1, 0) + + assertEquals(listOf(1, 1, 1), deletes) + + deletes.clear() + type("x😀a") + sentText.removeCodePoints(1) + connection.deleteSurroundingText(2, 0) + connection.deleteSurroundingText(1, 0) + + assertEquals(listOf(1, 1), deletes) + } +} diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index d68e46a5416b1..e2770ba53864e 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -16,6 +16,10 @@ public class T3TerminalModule: Module { view.writeOutput(data, reset: reset) } + AsyncFunction("resetInput") { (view: T3TerminalView) in + view.resetInputField() + } + Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in view.terminalKey = terminalKey } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 0ae146bb1241a..fad1199a4a4f8 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -133,12 +133,16 @@ private final class TerminalInputField: UITextField { action: #selector(handleHardwareKeyCommand(_:)) ) + // While an IME composes, arrows, Tab and Escape pick or cancel candidates instead. override var keyCommands: [UIKeyCommand]? { - Self.hardwareKeyCommands + markedTextRange == nil ? Self.hardwareKeyCommands : nil } override func deleteBackward() { - onDeleteBackward?() + // Backspace edits marked or committed field text first; the field diff sends that DEL. + if markedTextRange == nil, !hasText { + onDeleteBackward?() + } super.deleteBackward() } @@ -198,6 +202,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private var lastAppliedBuffer = "" private var streamedOutput: TerminalOutputBuffer? private var pendingVerticalScrollPoints: CGFloat = 0 + private var sentInput = "" private var app: ghostty_app_t? private var surface: ghostty_surface_t? private var isCreatingSurface = false @@ -348,19 +353,22 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { inputField.spellCheckingType = .no inputField.smartDashesType = .no inputField.smartQuotesType = .no + inputField.smartInsertDeleteType = .no inputField.returnKeyType = .send - inputField.keyboardType = .asciiCapable + inputField.keyboardType = .default inputField.enablesReturnKeyAutomatically = false inputField.translatesAutoresizingMaskIntoConstraints = false inputField.alpha = 0.02 inputField.isAccessibilityElement = false inputField.accessibilityElementsHidden = true inputField.addTarget(self, action: #selector(handleInputEditingDidBegin), for: .editingDidBegin) + inputField.addTarget(self, action: #selector(handleInputEditingChanged), for: .editingChanged) inputField.onDeleteBackward = { [weak self] in self?.emitInput("\u{7F}") } inputField.onInsert = { [weak self] data in self?.emitInput(data) + self?.resetInputField() } focusTapGesture.addTarget(self, action: #selector(handleViewportTap)) @@ -417,21 +425,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } - public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if !string.isEmpty { - // Some software keyboards deliver Return through this delegate instead of - // textFieldShouldReturn, so normalize that path too. - emitInput(TerminalInputSequence.normalizingReturn(string)) - return false - } - - emitInput("\u{7F}") - return false - } - public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + // Return during a composition commits it; that is not Enter. + guard textField.markedTextRange == nil else { return false } emitInput(TerminalInputSequence.carriageReturn) - textField.text = "" + resetInputField() return false } @@ -480,11 +478,44 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + // Korean keyboards build a syllable by replacing the committed character before the cursor, + // so the field keeps what was sent and each change goes out as DELs plus the new suffix. + // Scalars, not Characters: a Thai or Indic mark joins the previous grapheme, and resending + // that grapheme after one DEL would leave its base twice in a code-point line editor. + @objc + private func handleInputEditingChanged() { + guard inputField.markedTextRange == nil else { return } + let text = inputField.text ?? "" + let fieldScalars = text.unicodeScalars + let sentScalars = sentInput.unicodeScalars + let commonCount = zip(fieldScalars, sentScalars).prefix { $0.0 == $0.1 }.count + let unsent = String(String.UnicodeScalarView(sentScalars.dropFirst(commonCount))) + let added = String(String.UnicodeScalarView(fieldScalars.dropFirst(commonCount))) + // Some software keyboards deliver Return as an inserted newline instead of textFieldShouldReturn. + emitInput(String(repeating: "\u{7F}", count: unsent.count) + TerminalInputSequence.normalizingReturn(added)) + sentInput = text + // A Hangul syllable never spans whitespace; past the cap, keep the last character to compose onto. + if text.last?.isWhitespace == true { + resetInputField() + } else if text.count > 64 { + let last = String(text.suffix(1)) + inputField.text = last + sentInput = last + } + } + @objc private func handleInputEditingDidBegin() { + // A composition abandoned by losing focus must not reach the PTY with the next keystroke. + resetInputField() textInputModeDidChange() } + func resetInputField() { + inputField.text = "" + sentInput = "" + } + private func createSurfaceIfPossible() { guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return } guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return } diff --git a/apps/mobile/modules/t3-voice-transcription/android/build.gradle b/apps/mobile/modules/t3-voice-transcription/android/build.gradle index 05995a7cc4cce..ab2d08e070621 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/build.gradle +++ b/apps/mobile/modules/t3-voice-transcription/android/build.gradle @@ -16,9 +16,11 @@ android { dependencies { implementation project(':expo-modules-core') + implementation project(':expo-audio') implementation 'androidx.media3:media3-common:1.9.1' implementation 'com.google.mlkit:genai-speech-recognition:1.0.0-alpha2' testImplementation 'junit:junit:4.13.2' + testImplementation 'com.facebook.react:react-android' testImplementation 'org.robolectric:robolectric:4.16.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2' } diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt index 95423844c1d2c..ce722817dd131 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriber.kt @@ -22,6 +22,10 @@ internal class VoiceTranscriptionException( cause: Throwable? = null ) : Exception(message, cause) +// ML Kit reports every mode UNAVAILABLE for tags with Unicode extensions, which Android adds +// for regional preferences such as the temperature unit (en-US-u-mu-celsius). +internal fun speechLocale(systemLocale: Locale): Locale = systemLocale.stripExtensions() + private val recognitionModes = listOf( SpeechRecognizerOptions.Mode.MODE_ADVANCED, SpeechRecognizerOptions.Mode.MODE_BASIC @@ -158,5 +162,16 @@ internal class MlKitSpeechClient(context: Context, locale: Locale, mode: Int) : } } + suspend fun transcribeLive(capture: LivePcmCapture, pipe: PcmPipe): String = pipe.use { + val request = speechRecognizerRequest { audioSource = pipe.source } + try { + collectGoogleVoiceTranscript( + livePcmResponses(capture, pipe) { recognizer.startRecognition(request) } + ) + } finally { + withContext(NonCancellable) { runCatching { recognizer.stopRecognition() } } + } + } + override fun close() = recognizer.close() } diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LivePcmCapture.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LivePcmCapture.kt new file mode 100644 index 0000000000000..bd728283d8ada --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LivePcmCapture.kt @@ -0,0 +1,216 @@ +package expo.modules.t3voicetranscription + +import java.io.File +import kotlin.math.abs +import kotlin.math.log10 +import kotlin.time.TimeSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout + +internal interface PcmCaptureSource { + val capacityBytes: Int + fun start() + suspend fun read(buffer: ByteArray, blocking: Boolean): Int + fun stop() + fun release() +} + +internal class LivePcmCapture( + private val source: PcmCaptureSource, + val file: File, + private val output: PcmOutput, + private val clock: TimeSource = TimeSource.Monotonic +) { + @Volatile private var worker: Job? = null + private val completion = CompletableDeferred>() + private val microphoneStopped = CompletableDeferred>() + + @Volatile private var stopping = false + private var sourceStopped = false + private var sourceReleased = false + + @Volatile var bytes = 0L + private set + + @Volatile var metering = -160.0 + private set + val isFinished get() = completion.isCompleted + + @Volatile var inputExhausted = false + private set + val hasStarted get() = worker != null + val isRecording get() = hasStarted && !microphoneStopped.isCompleted + + fun start(scope: CoroutineScope, seconds: Double, onFinished: (Throwable?) -> Unit) { + check(worker == null && !sourceReleased) { "The microphone is not prepared." } + worker = scope.launch { + var result = runCatching { + source.start() + writeCapture(seconds, onFinished) + requireCompleteAudio(bytes > 0 && file.length() == bytes) { + "The microphone did not finish its captured audio." + } + } + result = finishSource(result) + inputExhausted = result.isSuccess + result = runCatching { output.finish() }.fold( + onSuccess = { result }, + onFailure = { failure -> mergeFailure(result, failure) } + ) + if (microphoneStopped.complete(result)) onFinished(result.exceptionOrNull()) + completion.complete(result) + }.also { job -> + job.invokeOnCompletion { error -> + if (!completion.isCompleted) { + val result = + finishSource(Result.failure(error ?: CancellationException("Capture stopped."))) + completion.complete(result) + microphoneStopped.complete(result) + runCatching { output.finish() } + } + } + } + } + + private suspend fun writeCapture( + seconds: Double, + onFinished: (Throwable?) -> Unit + ) = coroutineScope { + val started = clock.markNow() + val chunks = Channel(100) + var sent = 0L + val sender = launch { + var deadline = 0L + for (chunk in chunks) { + deadline = maxOf( + deadline + (chunk.size * 1000L + 31999) / 32000, + started.elapsedNow().inWholeMilliseconds + ) + delay((deadline - started.elapsedNow().inWholeMilliseconds).coerceAtLeast(0)) + output.write(chunk, chunk.size) + deadline = started.elapsedNow().inWholeMilliseconds + sent += chunk.size + } + } + val result = finishSource(runCatching { captureChunks(seconds, chunks) }) + microphoneStopped.complete(result) + if (result.exceptionOrNull() !is CancellationException) onFinished(result.exceptionOrNull()) + chunks.close() + result.getOrThrow() + sender.join() + requireCompleteAudio(sent == bytes) { "Speech recognition did not receive all captured audio." } + } + + private suspend fun captureChunks(seconds: Double, chunks: Channel) { + val buffer = ByteArray(3200) + var drained = 0 + file.outputStream().use { stream -> + while (true) { + currentCoroutineContext().ensureActive() + val draining = stopping || bytes >= seconds * 32000 + val count = source.read(buffer, !draining) + requireCompleteAudio(count in 0..buffer.size && count % 2 == 0) { + "The microphone returned incomplete audio." + } + if (count == 0) { + requireCompleteAudio(draining) { "The microphone stopped supplying audio." } + break + } + if (draining) { + drained += count + requireCompleteAudio(drained <= source.capacityBytes + buffer.size) { + "The microphone audio queue did not drain." + } + } + stream.write(buffer, 0, count) + // Keep draining the microphone while inference starts or applies backpressure. + if (!chunks.trySend(buffer.copyOf(count)).isSuccess) { + throw VoiceTranscriptionException( + "audio-overflow", + "Speech recognition fell behind capture." + ) + } + bytes += count + var peak = 0 + for (i in 0 until count step 2) { + val sample = ((buffer[i].toInt() and 255) or (buffer[i + 1].toInt() shl 8)).toShort() + peak = maxOf(peak, abs(sample.toInt())) + } + metering = if (peak == 0) -160.0 else 20 * log10(peak / 32768.0) + } + } + } + + suspend fun stop() { + stopping = true + if (worker == null) { + finishSource(Result.success(Unit)).getOrThrow() + output.finish() + return + } + try { + withTimeout(2000) { microphoneStopped.await().getOrThrow() } + } catch (error: TimeoutCancellationException) { + cancel() + throw VoiceTranscriptionException("incomplete-audio", "The microphone did not stop.", error) + } + } + + suspend fun awaitFinished() = completion.await().getOrThrow() + + fun abort() { + stopping = true + worker?.cancel() + runCatching { stopSource() } + runCatching { output.finish() } + if (worker == null) { + val result = finishSource(Result.failure(CancellationException("Capture was cancelled."))) + completion.complete(result) + microphoneStopped.complete(result) + } + } + + suspend fun cancel() = withContext(NonCancellable) { + abort() + worker?.join() + finishSource(Result.success(Unit)) + } + + @Synchronized private fun stopSource() { + if (sourceStopped) return + sourceStopped = true + source.stop() + } + + @Synchronized private fun finishSource(result: Result): Result { + if (sourceReleased) return result + sourceReleased = true + var outcome = runCatching { stopSource() }.fold( + onSuccess = { result }, + onFailure = { mergeFailure(result, it) } + ) + outcome = runCatching { source.release() }.fold( + onSuccess = { outcome }, + onFailure = { mergeFailure(outcome, it) } + ) + return outcome + } +} + +private fun mergeFailure(result: Result, failure: Throwable): Result { + val original = result.exceptionOrNull() ?: return Result.failure(failure) + if (original !== failure) original.addSuppressed(failure) + return result +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LiveVoiceRecorder.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LiveVoiceRecorder.kt new file mode 100644 index 0000000000000..b5032b587cb57 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/LiveVoiceRecorder.kt @@ -0,0 +1,198 @@ +package expo.modules.t3voicetranscription + +import android.content.Context +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.MediaRecorder +import android.net.Uri +import androidx.annotation.RequiresApi +import expo.modules.audio.RecordingInputRoute +import expo.modules.audio.getMapFromDeviceInfo +import expo.modules.audio.recordingInputs +import expo.modules.audio.recordingInputUid +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.sharedobjects.SharedObject +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job + +@RequiresApi(31) +internal class LiveVoiceRecorder( + private val context: Context, + appContext: AppContext, + private val scope: CoroutineScope, + private val onRelease: (String) -> Unit +) : SharedObject(appContext) { + val id = UUID.randomUUID().toString() + private val manager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val route = RecordingInputRoute(manager) + private var audio: AudioRecord? = null + private var pipe: PcmPipe? = null + private var capture: LivePcmCapture? = null + private var inputSelection: Job? = null + private var inputCanceled = false + private var inferenceStarted = false + val uri: String? get() = capture?.file?.let { Uri.fromFile(it).toString() } + + @Suppress("TooGenericExceptionCaught") // Release native handles for every setup failure. + fun prepare() { + check(capture?.isRecording != true) { "Another voice recording is active." } + capture?.abort() + pipe?.close() + inputCanceled = false + inferenceStarted = false + val capacity = maxOf( + 32000, + AudioRecord.getMinBufferSize( + 16000, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + ) + val recorder = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) + .setAudioFormat( + AudioFormat.Builder().setSampleRate(16000) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT).build() + ) + .setBufferSizeInBytes(capacity) + .build() + try { + check(recorder.state == AudioRecord.STATE_INITIALIZED) { "The microphone could not prepare." } + val output = PcmPipe.open() + try { + val file = File.createTempFile("dictation-live-", ".pcm", context.cacheDir) + capture = LivePcmCapture( + object : PcmCaptureSource { + override val capacityBytes = capacity + override fun start() { + recorder.startRecording() + check(recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + "The microphone could not start." + } + } + override suspend fun read(buffer: ByteArray, blocking: Boolean) = recorder.read( + buffer, + 0, + buffer.size, + if (blocking) AudioRecord.READ_BLOCKING else AudioRecord.READ_NON_BLOCKING + ) + override fun stop() { + if (recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) recorder.stop() + } + override fun release() { + try { + recorder.release() + } finally { + route.close() + } + } + }, + file, + output + ) + audio = recorder + pipe = output + } catch (error: Throwable) { + output.close() + throw error + } + } catch (error: Throwable) { + recorder.release() + throw error + } + } + + fun record(seconds: Double) { + val recording = requireNotNull(capture) { "The microphone is not prepared." } + recording.start(scope, seconds) { error -> + emit( + "recordingStatusUpdate", + mapOf( + "id" to id, + "isFinished" to true, + "hasError" to (error != null), + "error" to error?.message, + "url" to Uri.fromFile(recording.file).toString() + ) + ) + } + } + + suspend fun stop() { + val recording = capture + val output = pipe + try { + val selection = synchronized(this) { inputSelection } + selection?.cancel() + selection?.join() + recording?.stop() + } finally { + // Recognition can start after Stop and still needs the captured PCM pipe. + if (recording?.hasStarted != true) output?.close() + } + } + + fun status() = mapOf( + "canRecord" to (capture != null), + "isRecording" to (capture?.isRecording == true), + "durationMillis" to (capture?.bytes?.div(32) ?: 0), + "fileSize" to (capture?.file?.length() ?: 0), + "metering" to (capture?.metering ?: -160.0), + "mediaServicesDidReset" to false, + "url" to uri + ) + + fun inputs() = recordingInputs(manager).let { inputs -> + inputs.map { getMapFromDeviceInfo(it, inputs) } + } + + // Release the preferred route on cancellation as well as selection errors. + @Suppress("TooGenericExceptionCaught") + suspend fun setInput(uid: String) { + val job = currentCoroutineContext().job + synchronized(this) { + check(!inputCanceled) { "Microphone selection was cancelled." } + inputSelection = job + } + try { + val inputs = recordingInputs(manager) + val selected = inputs.firstOrNull { recordingInputUid(it, inputs) == uid } + ?: error("The preferred microphone is disconnected.") + route.select(selected) + val routed = recordingInputs(manager) + val source = routed.firstOrNull { it.isSource && recordingInputUid(it, routed) == uid } + ?: error("The preferred microphone is disconnected.") + check(audio?.setPreferredDevice(source) == true) { "Could not select the microphone." } + } catch (error: Throwable) { + route.close() + throw error + } finally { + synchronized(this) { if (inputSelection === job) inputSelection = null } + } + } + + @Synchronized fun cancelInputSelection() { + inputCanceled = true + inputSelection?.cancel() + } + + suspend fun transcribe(uri: String, client: MlKitSpeechClient): String { + check(this.uri == uri && !inferenceStarted) { "The voice recording is no longer available." } + inferenceStarted = true + return client.transcribeLive(requireNotNull(capture), requireNotNull(pipe)) + } + + override fun sharedObjectDidRelease() { + super.sharedObjectDidRelease() + cancelInputSelection() + capture?.abort() + pipe?.close() + onRelease(id) + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt index 8673ce0d790e8..d8a5c9d790674 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/PcmOutput.kt @@ -80,6 +80,31 @@ internal fun pacedRecordedResponses( } } +internal fun livePcmResponses( + capture: LivePcmCapture, + output: PcmOutput, + responses: () -> Flow +): Flow = flow { + try { + withTimeout(330000) { + responses().collect { emit(it) } + requireCompleteAudio(capture.inputExhausted) { + "Speech recognition ended before receiving the complete recording." + } + capture.awaitFinished() + output.verifyConsumed() + } + } catch (e: TimeoutCancellationException) { + throw VoiceTranscriptionException( + "incomplete-audio", + "Speech recognition did not finish the complete recording. No transcript was added. Retry dictation.", + e + ) + } finally { + capture.cancel() + } +} + internal inline fun requireCompleteAudio(condition: Boolean, message: () -> String) { if (!condition) throw VoiceTranscriptionException("incomplete-audio", message()) } diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt index b49791bea2b9b..3218690967f97 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt +++ b/apps/mobile/modules/t3-voice-transcription/android/src/main/java/expo/modules/t3voicetranscription/T3VoiceTranscriptionModule.kt @@ -6,7 +6,9 @@ import expo.modules.kotlin.Promise import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.modules.ModuleDefinitionBuilder import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -22,17 +24,20 @@ class T3VoiceTranscriptionModule : Module() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lock = Any() private var operation: Pair? = null + private val recorders = ConcurrentHashMap() override fun definition() = ModuleDefinition { Name("T3VoiceTranscription") + defineDecodeRecording() + AsyncFunction("checkAvailability").Coroutine { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { false } else { withContext(Dispatchers.IO) { val context = requireNotNull(appContext.reactContext) - val locale = Resources.getSystem().configuration.locales[0] + val locale = speechLocale(Resources.getSystem().configuration.locales[0]) isGoogleVoiceTranscriptionSupported { MlKitSpeechClient(context, locale, it) } } } @@ -47,7 +52,7 @@ class T3VoiceTranscriptionModule : Module() { ) } val context = requireNotNull(appContext.reactContext) - val locale = Resources.getSystem().configuration.locales[0] + val locale = speechLocale(Resources.getSystem().configuration.locales[0]) val mode = prepareGoogleVoiceTranscription { MlKitSpeechClient(context, locale, it) } mapOf("locale" to locale.toLanguageTag(), "mode" to mode) } @@ -66,6 +71,27 @@ class T3VoiceTranscriptionModule : Module() { } } + AsyncFunction("startLive") { + id: Int, + uri: String, + language: String, + mode: Int, + promise: Promise + -> + runOperation(id, promise, "transcription-failed") { + val recorder = recorders.values.firstOrNull { it.uri == uri } + ?: error("The voice recording is no longer available.") + val context = requireNotNull(appContext.reactContext) + MlKitSpeechClient(context, Locale.forLanguageTag(language), mode).use { + recorder.transcribe(uri, it) + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + defineLiveRecorder() + } + AsyncFunction("cancel") { id: Int, promise: Promise -> scope.launch { val job = synchronized(lock) { operation?.takeIf { it.first == id }?.second } @@ -74,7 +100,52 @@ class T3VoiceTranscriptionModule : Module() { } } - OnDestroy { scope.cancel() } + OnDestroy { + recorders.values.toList().forEach { it.sharedObjectDidRelease() } + scope.cancel() + } + } + + private fun ModuleDefinitionBuilder.defineDecodeRecording() { + AsyncFunction("decodeRecording").Coroutine { uri: String -> + val context = requireNotNull(appContext.reactContext) + withContext(Dispatchers.IO) { + require(android.net.Uri.parse(uri).scheme == "file") { "Expected a local recording." } + withRecordedPcm(context, uri) { pcm -> + requireCompleteAudio(pcm.length() <= 16000L * 2 * 301) { + "Recording exceeds five minutes." + } + pcm.readBytes() + } + } + } + } + + private fun ModuleDefinitionBuilder.defineLiveRecorder() { + Class(LiveVoiceRecorder::class) { + Constructor { + check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + LiveVoiceRecorder(requireNotNull(appContext.reactContext), appContext, scope) { + recorders.remove(it) + }.also { recorders[it.id] = it } + } + Property("uri") { recorder -> recorder.uri } + AsyncFunction("prepareToRecordAsync") Coroutine { recorder: LiveVoiceRecorder -> + withContext(Dispatchers.IO) { recorder.prepare() } + } + Function("record") { recorder: LiveVoiceRecorder, options: Map -> + recorder.record(options["forDuration"] ?: 300.0) + } + AsyncFunction("stop") Coroutine { recorder: LiveVoiceRecorder -> recorder.stop() } + Function("getStatus") { recorder: LiveVoiceRecorder -> recorder.status() } + Function("getAvailableInputs") { recorder: LiveVoiceRecorder -> recorder.inputs() } + AsyncFunction("setInput") Coroutine { recorder: LiveVoiceRecorder, uid: String -> + recorder.setInput(uid) + } + Function("cancelInputSelection") { recorder: LiveVoiceRecorder -> + recorder.cancelInputSelection() + } + } } private fun runOperation( diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriberTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriberTest.kt index aaa76dfbf07e2..445411bbb6957 100644 --- a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriberTest.kt +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/GoogleVoiceTranscriberTest.kt @@ -4,6 +4,7 @@ import com.google.mlkit.genai.common.FeatureStatus import com.google.mlkit.genai.common.GenAiException import com.google.mlkit.genai.speechrecognition.SpeechRecognizerOptions import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import java.util.Locale import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.flow @@ -14,6 +15,18 @@ import org.junit.Assert.assertTrue import org.junit.Test class GoogleVoiceTranscriberTest { + @Test + fun dropsRegionalPreferenceExtensionsFromTheSpeechLocale() { + for ((system, expected) in listOf( + "en-US-u-mu-celsius" to "en-US", + "en-GB-u-fw-mon-mu-celsius" to "en-GB", + "ar-EG-u-nu-latn" to "ar-EG", + "zh-Hant-TW" to "zh-Hant-TW" + )) { + assertEquals(expected, speechLocale(Locale.forLanguageTag(system)).toLanguageTag()) + } + } + @Test fun preservesUtterancesAndTheirSpacingWithoutDuplicatingHypotheses() = runBlocking { for ((finals, trailing, expected) in listOf( diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LivePcmCaptureTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LivePcmCaptureTest.kt new file mode 100644 index 0000000000000..b47c47c37eb1b --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LivePcmCaptureTest.kt @@ -0,0 +1,184 @@ +package expo.modules.t3voicetranscription + +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import java.io.ByteArrayOutputStream +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LivePcmCaptureTest { + private class Source : PcmCaptureSource { + override val capacityBytes = 32000 + val chunks = Channel(Channel.UNLIMITED) + val reading = Channel(Channel.UNLIMITED) + var released = false + override fun start() = Unit + override suspend fun read(buffer: ByteArray, blocking: Boolean): Int { + reading.send(Unit) + val chunk = if (blocking) chunks.receive() else chunks.tryReceive().getOrNull() ?: return 0 + chunk.copyInto(buffer) + return chunk.size + } + override fun stop() = Unit + override fun release() { + released = true + } + } + + private class Output(private val gate: CompletableDeferred? = null) : PcmOutput { + val bytes = ByteArrayOutputStream() + val first = CompletableDeferred() + val writing = CompletableDeferred() + val ended = CompletableDeferred() + var unread = false + override suspend fun write(buffer: ByteArray, count: Int) { + writing.complete(Unit) + gate?.await() + bytes.write(buffer, 0, count) + first.complete(Unit) + } + override fun finish() { + ended.complete(Unit) + } + override fun verifyConsumed() = requireCompleteAudio(!unread) { "Unread captured audio." } + override fun close() = Unit + } + + @Test fun transcribesDuringCaptureAndDrainsTheShortTailAfterStop() = runTest { + val source = Source() + val output = Output() + val file = File.createTempFile("live-capture-test-", ".pcm") + val first = ByteArray(3200) { (it % 251).toByte() } + val tail = byteArrayOf(1, 2, 3, 4, 5, 6) + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + try { + capture.start(this, 300.0) {} + source.chunks.send(first) + output.first.await() + assertTrue(capture.isRecording) + assertFalse(output.ended.isCompleted) + assertArrayEquals(first, output.bytes.toByteArray()) + source.reading.receive() + source.reading.receive() + + val stopped = async(start = CoroutineStart.UNDISPATCHED) { capture.stop() } + source.chunks.send(tail) + stopped.await() + assertTrue(source.released) + assertFalse(capture.isRecording) + val text = collectGoogleVoiceTranscript( + livePcmResponses(capture, output) { + flow { + output.ended.await() + emit( + SpeechRecognizerResponse.FinalTextResponse("Opening. Repeat. Repeat.", emptyList()) + ) + emit(SpeechRecognizerResponse.FinalTextResponse(" 中間。", emptyList())) + emit(SpeechRecognizerResponse.PartialTextResponse(" An unfinished ending")) + emit(SpeechRecognizerResponse.PartialTextResponse(" End.")) + emit(SpeechRecognizerResponse.CompletedResponse) + } + } + ) + assertEquals("Opening. Repeat. Repeat. 中間。 End.", text) + assertArrayEquals(first + tail, output.bytes.toByteArray()) + assertArrayEquals(first + tail, file.readBytes()) + } finally { + capture.cancel() + file.delete() + } + } + + @Test fun rejectsIncompleteRecognitionAndUnreadCapturedAudio() = runTest { + for (failure in listOf("early", "unread", "timeout")) { + val early = failure == "early" + val source = Source() + val output = Output().apply { unread = failure == "unread" } + val file = File.createTempFile("live-capture-test-", ".pcm") + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + try { + capture.start(this, if (early) 300.0 else 0.1) {} + source.chunks.send(ByteArray(3200)) + val result = runCatching { + collectGoogleVoiceTranscript( + livePcmResponses(capture, output) { + flow { + if (!early) output.ended.await() + if (failure == "timeout") awaitCancellation() + emit(SpeechRecognizerResponse.FinalTextResponse("Incomplete.", emptyList())) + emit(SpeechRecognizerResponse.CompletedResponse) + } + } + ) + } + assertEquals( + "incomplete-audio", + (result.exceptionOrNull() as VoiceTranscriptionException).code + ) + assertTrue(source.released) + } finally { + capture.cancel() + file.delete() + } + } + } + + @Test fun durationLimitDrainsQueuedPcmBeforeReleasingTheMicrophone() = runTest { + val source = Source() + val output = Output() + val file = File.createTempFile("live-capture-test-", ".pcm") + val expected = ByteArray(6400) { (it % 251).toByte() } + byteArrayOf(42, 0) + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + val stopped = CompletableDeferred() + try { + source.chunks.send(expected.copyOfRange(0, 3200)) + source.chunks.send(expected.copyOfRange(3200, 6400)) + source.chunks.send(expected.copyOfRange(6400, expected.size)) + capture.start(this, 0.1) { stopped.complete(Unit) } + stopped.await() + assertTrue(source.released) + assertEquals(expected.size.toLong(), file.length()) + capture.awaitFinished() + assertArrayEquals(expected, output.bytes.toByteArray()) + } finally { + capture.cancel() + file.delete() + } + } + + @Test fun cancellationWaitsForCaptureAndFeederCleanup() = runTest { + for (phase in listOf("prepared", "reading", "writing")) { + val source = Source() + val output = Output(if (phase == "writing") CompletableDeferred() else null) + val file = File.createTempFile("live-capture-test-", ".pcm") + val capture = LivePcmCapture(source, file, output, testScheduler.timeSource) + try { + if (phase != "prepared") { + capture.start(this, 300.0) {} + source.reading.receive() + if (phase == "writing") { + source.chunks.send(ByteArray(3200)) + output.writing.await() + } + } + capture.cancel() + assertTrue(source.released) + assertTrue(output.ended.isCompleted) + assertTrue(capture.isFinished) + assertTrue(runCatching { capture.awaitFinished() }.isFailure) + } finally { + file.delete() + } + } + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LiveVoiceRecorderTest.kt b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LiveVoiceRecorderTest.kt new file mode 100644 index 0000000000000..6c78053e529e8 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/android/src/test/java/expo/modules/t3voicetranscription/LiveVoiceRecorderTest.kt @@ -0,0 +1,123 @@ +package expo.modules.t3voicetranscription + +import android.content.Context +import android.media.AudioRecord +import android.os.ParcelFileDescriptor +import com.facebook.react.bridge.BridgeReactContext +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.ModulesProvider +import expo.modules.kotlin.modules.Module +import java.io.File +import java.lang.ref.WeakReference +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [31]) +class LiveVoiceRecorderTest { + private class Source(private val failStop: Boolean = false) : PcmCaptureSource { + override val capacityBytes = 32000 + val chunks = Channel(Channel.UNLIMITED) + val reading = Channel(Channel.UNLIMITED) + override fun start() = Unit + override suspend fun read(buffer: ByteArray, blocking: Boolean): Int { + reading.send(Unit) + val chunk = if (blocking) chunks.receive() else chunks.tryReceive().getOrNull() ?: return 0 + chunk.copyInto(buffer) + return chunk.size + } + override fun stop() { + check(!failStop) { "The microphone could not stop." } + } + override fun release() = Unit + } + + @Test fun preparingAgainReleasesTheUnusedMicrophone() = runTest { + withAppContext { context, appContext -> + val recorder = LiveVoiceRecorder(context, appContext, this) {} + try { + recorder.prepare() + val previous = ReflectionHelpers.getField(recorder, "audio") + assertEquals(AudioRecord.STATE_INITIALIZED, previous.state) + recorder.prepare() + assertEquals(AudioRecord.STATE_UNINITIALIZED, previous.state) + } finally { + recorder.sharedObjectDidRelease() + } + } + } + + @Test fun stoppingBeforeInferencePreservesQueuedAudioAndClosesUnusedPipes() = runTest { + withAppContext { context, appContext -> + for (phase in listOf("prepared", "recording", "draining", "prepared-failure")) { + val started = phase == "recording" || phase == "draining" + val source = Source(phase == "prepared-failure") + val pipe = PcmPipe.open() + val reader = ReflectionHelpers.getField( + pipe, + "reader" + ) + val file = File.createTempFile("recorder-stop-test-", ".pcm") + val capture = LivePcmCapture(source, file, pipe, testScheduler.timeSource) + val recorder = LiveVoiceRecorder(context, appContext, this) {} + ReflectionHelpers.setField(recorder, "capture", capture) + ReflectionHelpers.setField(recorder, "pipe", pipe) + try { + assertTrue(reader.fd.valid()) + if (started) { + capture.start(this, 300.0) {} + source.reading.receive() + source.chunks.send(ByteArray(3200)) + source.reading.receive() + val stopped = async(start = CoroutineStart.UNDISPATCHED) { + if (phase == "draining") capture.stop() else recorder.stop() + } + source.chunks.send(byteArrayOf(1, 2)) + stopped.await() + if (phase == "draining") recorder.stop() + assertFalse(capture.isFinished) + } else { + assertEquals(phase == "prepared-failure", runCatching { recorder.stop() }.isFailure) + } + assertEquals(started, reader.fd.valid()) + } finally { + capture.cancel() + recorder.sharedObjectDidRelease() + file.delete() + } + } + } + } + + private suspend fun withAppContext(block: suspend (Context, AppContext) -> Unit) { + val context = RuntimeEnvironment.getApplication() + val reactContext = BridgeReactContext(context) + val appContext = AppContext( + object : ModulesProvider { + override fun getModulesMap() = emptyMap, String?>() + }, + expo.modules.core.ModuleRegistry(emptyList(), emptyList()), + WeakReference(reactContext) + ) + try { + block(context, appContext) + } finally { + appContext.javaClass.declaredMethods.first { + it.name.startsWith("onDestroy") && it.parameterCount == 0 + } + .invoke(appContext) + reactContext.destroy() + } + } +} diff --git a/apps/mobile/modules/t3-voice-transcription/expo-module.config.json b/apps/mobile/modules/t3-voice-transcription/expo-module.config.json index 5867f41ffe75e..9626dea6a1aea 100644 --- a/apps/mobile/modules/t3-voice-transcription/expo-module.config.json +++ b/apps/mobile/modules/t3-voice-transcription/expo-module.config.json @@ -1,5 +1,9 @@ { - "platforms": ["android"], + "platforms": ["apple", "android"], + "apple": { + "modules": ["T3VoiceTranscriptionModule"], + "podspecPath": "ios/T3VoiceTranscription.podspec" + }, "android": { "modules": ["expo.modules.t3voicetranscription.T3VoiceTranscriptionModule"] } diff --git a/apps/mobile/modules/t3-voice-transcription/ios/T3VoiceTranscription.podspec b/apps/mobile/modules/t3-voice-transcription/ios/T3VoiceTranscription.podspec new file mode 100644 index 0000000000000..946a2c70de075 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/ios/T3VoiceTranscription.podspec @@ -0,0 +1,13 @@ +Pod::Spec.new do |s| + s.name = 'T3VoiceTranscription' + s.version = '1.0.0' + s.summary = 'Local recording conversion for T3 Code dictation.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platform = :ios, '18.0' + s.source = { :path => '.' } + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '*.swift' + s.frameworks = 'AudioToolbox' +end diff --git a/apps/mobile/modules/t3-voice-transcription/ios/T3VoiceTranscriptionModule.swift b/apps/mobile/modules/t3-voice-transcription/ios/T3VoiceTranscriptionModule.swift new file mode 100644 index 0000000000000..8c02fbd84a9d5 --- /dev/null +++ b/apps/mobile/modules/t3-voice-transcription/ios/T3VoiceTranscriptionModule.swift @@ -0,0 +1,44 @@ +import AudioToolbox +import ExpoModulesCore +import Foundation + +public class T3VoiceTranscriptionModule: Module { + public func definition() -> ModuleDefinition { + Name("T3VoiceTranscription") + + AsyncFunction("decodeRecording") { (uri: URL) throws -> Data in + guard uri.isFileURL else { throw CocoaError(.fileReadUnsupportedScheme) } + var opened: ExtAudioFileRef? + try check(ExtAudioFileOpenURL(uri as CFURL, &opened)) + guard let file = opened else { throw CocoaError(.fileReadCorruptFile) } + defer { ExtAudioFileDispose(file) } + var format = AudioStreamBasicDescription( + mSampleRate: 16_000, mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, + mBytesPerPacket: 2, mFramesPerPacket: 1, mBytesPerFrame: 2, + mChannelsPerFrame: 1, mBitsPerChannel: 16, mReserved: 0) + let formatSize = UInt32(MemoryLayout.size) + try check(ExtAudioFileSetProperty(file, kExtAudioFileProperty_ClientDataFormat, formatSize, &format)) + var pcm = Data() + var chunk = [Int16](repeating: 0, count: 8192) + try chunk.withUnsafeMutableBytes { bytes in + while true { + var frames = UInt32(bytes.count / 2) + var buffers = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer( + mNumberChannels: 1, mDataByteSize: UInt32(bytes.count), mData: bytes.baseAddress)) + try check(ExtAudioFileRead(file, &frames, &buffers)) + if frames == 0 { break } + let count = Int(frames) * 2 + guard pcm.count + count <= 16_000 * 2 * 301 else { throw CocoaError(.fileReadTooLarge) } + pcm.append(contentsOf: bytes.prefix(count)) + } + } + guard !pcm.isEmpty else { throw CocoaError(.fileReadCorruptFile) } + return pcm + } + } +} + +private func check(_ status: OSStatus) throws { + guard status == noErr else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 745060b6f285a..115e2c84d3687 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -46,6 +46,7 @@ "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "generate:highlights": "node scripts/generate-highlights.mts", + "generate:nitro": "cd modules/t3-highlights && nitrogen", "eas-build-on-success": "node scripts/upload-sourcemaps.mts build", "test": "vp test run --project unit --project react && T3_MOBILE_TEST_PLATFORM=android vp test run --project react src/components/FilePreviewModal.test.tsx", "typecheck": "effect-tsgo tsrs --noEmit" @@ -150,6 +151,7 @@ "aws4fetch": "1.0.20", "babel-preset-expo": "~58.0.17", "jsdom": "^30.0.1", + "nitrogen": "0.37.1", "tailwindcss": "^4.0.0", "vitest": "catalog:" }, diff --git a/apps/mobile/plugins/withAndroidTabletOrientation.cjs b/apps/mobile/plugins/withAndroidTabletOrientation.cjs index 9f3582e7f2b90..8a10767525753 100644 --- a/apps/mobile/plugins/withAndroidTabletOrientation.cjs +++ b/apps/mobile/plugins/withAndroidTabletOrientation.cjs @@ -1,4 +1,4 @@ -const { withMainActivity } = require("expo/config-plugins"); +const { AndroidConfig, withAndroidManifest, withMainActivity } = require("expo/config-plugins"); // FULL_USER respects rotation lock. Foldables change smallestScreenWidthDp without recreating the activity. @@ -34,32 +34,41 @@ function insertAfter(contents, anchor, insertion, description) { } module.exports = function withAndroidTabletOrientation(config) { + config = withAndroidManifest(config, (nextConfig) => { + const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(nextConfig.modResults); + delete mainActivity.$["android:screenOrientation"]; + return nextConfig; + }); return withMainActivity(config, (nextConfig) => { let contents = nextConfig.modResults.contents; if (nextConfig.modResults.language !== "kt") { throw new Error("withAndroidTabletOrientation: MainActivity must be Kotlin."); } - if (contents.includes("SCREEN_ORIENTATION_FULL_USER")) { - return nextConfig; + if (!contents.includes("SCREEN_ORIENTATION_FULL_USER")) { + contents = insertAfter( + contents, + "import android.os.Bundle", + "\nimport android.content.pm.ActivityInfo\nimport android.content.res.Configuration", + "the android.os.Bundle import", + ); + contents = insertAfter( + contents, + "class MainActivity : ReactActivity() {", + ORIENTATION_METHODS, + "the MainActivity class declaration", + ); } - contents = insertAfter( - contents, - "import android.os.Bundle", - "\nimport android.content.pm.ActivityInfo\nimport android.content.res.Configuration", - "the android.os.Bundle import", - ); - contents = insertAfter( - contents, - "class MainActivity : ReactActivity() {", - ORIENTATION_METHODS, - "the MainActivity class declaration", + // Relocate the call in existing native projects too, before splash setup and React content. + contents = contents.replace( + /override fun onCreate\(savedInstanceState: Bundle\?\) \{[\s\S]*?\n \}/, + (onCreate) => onCreate.replace(/\n[ \t]*applyTabletOrientation\(\)/g, ""), ); contents = insertAfter( contents, - "super.onCreate(null)", + "override fun onCreate(savedInstanceState: Bundle?) {", ORIENTATION_ON_CREATE_CALL, - "the super.onCreate call", + "the onCreate declaration", ); nextConfig.modResults.contents = contents; diff --git a/apps/mobile/plugins/withAndroidTabletOrientation.test.ts b/apps/mobile/plugins/withAndroidTabletOrientation.test.ts new file mode 100644 index 0000000000000..00ae53e76539e --- /dev/null +++ b/apps/mobile/plugins/withAndroidTabletOrientation.test.ts @@ -0,0 +1,102 @@ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { AndroidConfig, compileModsAsync, XML } from "expo/config-plugins"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import withAndroidTabletOrientation from "./withAndroidTabletOrientation.cjs"; + +const MANIFEST = ` + + + + + + + + +`; + +// Trimmed from the MainActivity.kt that `expo prebuild` generates for this app. +const MAIN_ACTIVITY = `package com.t3tools.t3code + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // setTheme(R.style.AppTheme); + super.onCreate(null) + } + + override fun getMainComponentName(): String = "main" +} +`; + +const projectRoots: string[] = []; + +afterEach(() => { + for (const root of projectRoots.splice(0)) NodeFS.rmSync(root, { recursive: true, force: true }); +}); + +async function prebuildAndroid(input = { manifest: MANIFEST, mainActivity: MAIN_ACTIVITY }) { + const projectRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "tablet-orientation-")); + projectRoots.push(projectRoot); + const sourceRoot = NodePath.join(projectRoot, "android/app/src/main"); + const activityPath = NodePath.join(sourceRoot, "java/com/t3tools/t3code/MainActivity.kt"); + const manifestPath = NodePath.join(sourceRoot, "AndroidManifest.xml"); + NodeFS.mkdirSync(NodePath.dirname(activityPath), { recursive: true }); + NodeFS.writeFileSync(manifestPath, input.manifest); + NodeFS.writeFileSync(activityPath, input.mainActivity); + + const config = withAndroidTabletOrientation({ + name: "T3 Code", + slug: "t3code", + orientation: "portrait", + }); + // Prebuild registers Expo's orientation mod after app plugins. Mods run in + // reverse order, so our plugin must remove the portrait lock Expo writes. + await compileModsAsync(AndroidConfig.Orientation.withOrientation(config), { + projectRoot, + platforms: ["android"], + }); + return { + manifest: NodeFS.readFileSync(manifestPath, "utf8"), + mainActivity: NodeFS.readFileSync(activityPath, "utf8"), + }; +} + +describe("withAndroidTabletOrientation", () => { + it("generates an unlocked launcher manifest while preserving rotation and fold handling", async () => { + const { manifest } = await prebuildAndroid(); + const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow( + (await XML.parseXMLAsync(manifest)) as AndroidConfig.Manifest.AndroidManifest, + ); + + expect(mainActivity.$["android:screenOrientation"]).toBeUndefined(); + expect(mainActivity.$["android:configChanges"]?.split("|")).toEqual( + expect.arrayContaining(["orientation", "screenSize", "smallestScreenSize"]), + ); + }); + + it("generates the phone/tablet policy before activity creation and stays stable on repeated prebuild", async () => { + const once = await prebuildAndroid(); + + expect(once.mainActivity).toMatch( + /override fun onCreate\(savedInstanceState: Bundle\?\)\s*\{[^}]*applyTabletOrientation\(\)[^}]*super\.onCreate\(null\)/, + ); + expect(once.mainActivity).toMatch( + /private fun applyTabletOrientation\(\)\s*\{\s*requestedOrientation = if \(resources\.configuration\.smallestScreenWidthDp >= 600\)\s*\{\s*ActivityInfo\.SCREEN_ORIENTATION_FULL_USER\s*\} else \{\s*ActivityInfo\.SCREEN_ORIENTATION_PORTRAIT\s*\}/, + ); + expect(await prebuildAndroid(once)).toEqual(once); + const existingActivity = once.mainActivity + .replace( + "override fun onCreate(savedInstanceState: Bundle?) {\n applyTabletOrientation()", + "override fun onCreate(savedInstanceState: Bundle?) {", + ) + .replace("super.onCreate(null)", "super.onCreate(null)\n applyTabletOrientation()"); + expect(await prebuildAndroid({ ...once, mainActivity: existingActivity })).toEqual(once); + }); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 43b1c089c0b59..544092d744dbd 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -82,8 +82,8 @@ import { launchWorkspaceURL, recordWorkspaceSelections, settingsLinkPage, -} from "./features/workspace/workspace-selection-store"; -import { workspaceDetailLinkAction } from "./features/workspace/workspace-detail-selection"; +} from "./features/layout/workspace/workspace-selection-store"; +import { workspaceDetailLinkAction } from "./features/layout/workspace/workspace-detail-selection"; import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; import { attachRootNavigation, diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 79f0932b1e9b8..ef79ef182cc57 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -18,15 +18,15 @@ import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/ui/AppText"; import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-navigation"; -import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; +import { getCompactBrandHeaderOptions } from "./features/layout/workspace/brand-header-options"; import { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle"; -import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; +import { WorkspaceRootScreen } from "./features/layout/workspace/WorkspaceRootScreen"; import { recordHomeFocus, recordHomeTransitionEnd, recordHomeTransitionStart, } from "./features/home/home-route-visibility"; -import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; +import { AndroidWorkspaceTabsProvider } from "./features/layout/workspace/AndroidWorkspaceTabsProvider"; import { PullRequestRouteScreen, PullRequestFileRouteScreen, @@ -113,7 +113,8 @@ import { } from "./features/settings/ScheduledTaskPickerScreens"; import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; -import { SettingsMicrophoneRouteScreen } from "./features/settings/SettingsMicrophoneRouteScreen"; +import { SettingsVoiceRouteScreen } from "./features/settings/SettingsVoiceRouteScreen"; +import { SettingsVoiceModelsRouteScreen } from "./features/settings/SettingsVoiceModelsRouteScreen"; import { SettingsOpenSourceLicenseRouteScreen, SettingsOpenSourceLicensesRouteScreen, @@ -128,313 +129,212 @@ import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { SettingsLabsRouteScreen } from "./features/settings/ThreadSettingsSections"; import { SettingsEnvironmentFilterProvider } from "./features/settings/settings-environment-filter"; import { openSettings, settingsOpenIn } from "./features/settings/settings-navigation"; -import { DEFAULT_SETTINGS_PAGE } from "./features/workspace/workspace-tab-navigation"; +import { DEFAULT_SETTINGS_PAGE } from "./features/layout/workspace/workspace-tab-navigation"; import { isWorkspaceOverlayRoute, registerPresentedRoutes, } from "./features/layout/workspace-overlay-routes"; import { ShowcaseReadyMarker } from "./features/showcase/ShowcaseCaptureCoordinator"; -import { - SettingsLegalDocumentCloseHeaderButton, - SettingsLegalDocumentExternalHeaderButton, -} from "./features/settings/components/SettingsLegalDocumentRouteScreen"; -import { GLASS_HEADER_OPTIONS, type AppScreenOptions } from "./native/glass-header-options"; +import { immersiveRoute, modalRoute, pageRoute, sheetRoute } from "./features/layout/route-options"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; -import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; -import { workspaceDetailRouter } from "./features/workspace/workspace-detail-selection"; - -// Hiding Android's native header after mount can leave detail panes measured short. -// These screens render their own Material toolbar, so hide it from the first layout. -const DETAIL_HEADER_OPTIONS: AppScreenOptions = { - ...GLASS_HEADER_OPTIONS, - headerShown: Platform.OS !== "android", -}; +import { isPresentedRoute } from "./native/workspace-stack-projection"; +import { workspaceDetailRouter } from "./features/layout/workspace/workspace-detail-selection"; -// Like Messages on the Duo: reading back through history (scrolling up) minimizes the -// header, and reversing toward the latest message restores it. Other devices ignore it. -const THREAD_HEADER_OPTIONS: AppScreenOptions = { - ...DETAIL_HEADER_OPTIONS, - unstable_headerMinimizationBehavior: "onScrollUp", -}; - -// The Duo minimizes headers on scroll down by default. Home and the pull request lists -// stutter against it on hardware (not reproducible in the simulator), so they keep theirs. -const FIXED_DUO_HEADER: AppScreenOptions = { unstable_headerMinimizationBehavior: "never" }; - -const SOLID_HEADER_OPTIONS: AppScreenOptions = { - headerBackButtonDisplayMode: "minimal", - headerBackTitle: "", - headerLargeTitle: false, - headerShadowVisible: false, - headerShown: true, - headerTitleStyle: { fontSize: 18, fontWeight: "800" }, - headerTransparent: false, - unstable_navigationItemStyle: Platform.OS === "ios" ? "editor" : undefined, -}; - -const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { - ...SOLID_HEADER_OPTIONS, - unstable_navigationItemStyle: undefined, -}; - -const SHEET_GLASS_HEADER_OPTIONS: AppScreenOptions = { - ...GLASS_HEADER_OPTIONS, - unstable_navigationItemStyle: undefined, -}; - -const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { - ...SHEET_SOLID_HEADER_OPTIONS, - headerBackVisible: false, - headerLeft: SettingsLegalDocumentCloseHeaderButton, - headerRight: () => , - presentation: "fullScreenModal", -}; +const settingsPage = (title: string) => pageRoute({ title, itemStyle: "standard" }); // Thread settings keep the Duo's vertical bar so cancel, search, filter and confirm sit in the // rail. The picker stack spans the sheet so its header reaches the rail, and insets its content. -const THREAD_SETTINGS_SHEET_OPTIONS = { - presentation: "formSheet" as const, - sheetAllowedDetents: [1], - sheetGrabberVisible: true, - nativeContentInsetHorizontally: false, -}; +const THREAD_SETTINGS_ROUTE = sheetRoute({ + detent: "full", + header: "none", + android: "card", + spansRail: true, +}); -// Settings pages are root routes so they share the workspace's columns and header. The -// `Settings` route marks the Settings tab as open (see settings-navigation.ts). -const SETTINGS_PAGE_OPTIONS = SHEET_GLASS_HEADER_OPTIONS; +// Search sheets push on Android like the branch picker; a formSheet there leaves the list's +// header peeking above it once the keyboard opens. +const SEARCH_SHEET_ROUTE = sheetRoute({ detent: 0.75, android: "card", itemStyle: "editor" }); const SETTINGS_SCREENS = { Settings: createNativeStackScreen({ screen: SettingsRouteScreen, linking: "settings", options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Settings", + ...pageRoute({ title: "Settings", itemStyle: "standard", root: true }), ...(Platform.OS === "android" ? { animation: "none" as const } : {}), - // Android draws its own app bar here. Hidden only from inside the screen, the native one - // showed for the first frame of the Settings tab and pushed the page down. - headerShown: Platform.OS !== "android", - headerBackVisible: false, }, }), SettingsEnvironments: createNativeStackScreen({ screen: SettingsEnvironmentsRouteScreen, linking: "settings/environments", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Environments", - }, + options: settingsPage("Environments"), }), SettingsEnvironmentDetail: createNativeStackScreen({ screen: SettingsEnvironmentDetailRouteScreen, linking: "settings/environments/:environmentId", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Environment" }, + options: settingsPage("Environment"), }), // Old links to New threads and Agent behavior open Threads, which absorbed them. SettingsEnvironmentNewThreads: createNativeStackScreen({ screen: SettingsThreadsRouteScreen, linking: "settings/new-threads", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, + options: settingsPage("Threads"), }), SettingsEnvironmentSourceControl: createNativeStackScreen({ screen: SettingsEnvironmentSourceControlRouteScreen, linking: "settings/source-control", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Source control" }, + options: settingsPage("Source control"), }), SettingsEnvironmentAgentBehavior: createNativeStackScreen({ screen: SettingsThreadsRouteScreen, linking: "settings/agent-behavior", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, + options: settingsPage("Threads"), }), SettingsWorktrees: createNativeStackScreen({ screen: SettingsWorktreesRouteScreen, linking: "settings/worktrees", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Worktrees" }, + options: settingsPage("Worktrees"), }), SettingsLabs: createNativeStackScreen({ screen: SettingsLabsRouteScreen, linking: "settings/labs", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Labs" }, + options: settingsPage("Labs"), }), SettingsProviderAccounts: createNativeStackScreen({ screen: SettingsProviderAccountsRouteScreen, linking: "settings/provider-accounts", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Providers" }, + options: settingsPage("Providers"), }), SettingsEnvironmentMaintenance: createNativeStackScreen({ screen: SettingsEnvironmentMaintenanceRouteScreen, linking: "settings/maintenance", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Maintenance" }, + options: settingsPage("Maintenance"), }), SettingsNotifications: createNativeStackScreen({ screen: SettingsNotificationsRouteScreen, linking: "settings/notifications", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Notifications" }, + options: settingsPage("Notifications"), }), SettingsThreads: createNativeStackScreen({ screen: SettingsThreadsRouteScreen, linking: "settings/thread-preferences", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, + options: settingsPage("Threads"), }), SettingsAbout: createNativeStackScreen({ screen: SettingsAboutRouteScreen, linking: "settings/about", - options: { ...SETTINGS_PAGE_OPTIONS, title: "About" }, + options: settingsPage("About"), }), SettingsEnvironmentNew: createNativeStackScreen({ screen: ConnectionsNewRouteScreen, linking: "settings/environment-new", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Add Environment", - }, + options: settingsPage("Add Environment"), }), SettingsArchive: createNativeStackScreen({ screen: ArchivedThreadsRouteScreen, linking: "settings/archive", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Archive", - }, + options: settingsPage("Archive"), }), SettingsNavigation: createNativeStackScreen({ screen: SettingsNavigationRouteScreen, linking: "settings/navigation-tabs", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Navigation tabs" }, + options: settingsPage("Navigation tabs"), }), SettingsAppearance: createNativeStackScreen({ screen: SettingsAppearanceRouteScreen, linking: "settings/appearance", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Appearance", - }, + options: settingsPage("Appearance"), }), SettingsProjectGrouping: createNativeStackScreen({ screen: SettingsProjectGroupingRouteScreen, linking: "settings/project-grouping", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "General", - }, + options: settingsPage("General"), }), SettingsOrganization: createNativeStackScreen({ screen: SettingsProjectGroupingRouteScreen, linking: "settings/organization", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "General", - }, + options: settingsPage("General"), }), SettingsProjectOverview: createNativeStackScreen({ screen: SettingsProjectOverviewRouteScreen, linking: "settings/project", - options: { ...SETTINGS_PAGE_OPTIONS, title: "Projects" }, + options: settingsPage("Projects"), }), SettingsKeyboard: createNativeStackScreen({ screen: SettingsKeyboardRouteScreen, linking: "settings/keyboard", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Keyboard", - }, + options: settingsPage("Keyboard"), }), - SettingsMicrophone: createNativeStackScreen({ - screen: SettingsMicrophoneRouteScreen, - linking: "settings/microphone", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Microphone", - }, + SettingsVoice: createNativeStackScreen({ + screen: SettingsVoiceRouteScreen, + linking: { path: "settings/voice", alias: ["settings/microphone"] }, + options: settingsPage("Voice"), + }), + SettingsVoiceModels: createNativeStackScreen({ + screen: SettingsVoiceModelsRouteScreen, + options: SEARCH_SHEET_ROUTE, }), SettingsFollowUp: createNativeStackScreen({ screen: SettingsFollowUpRouteScreen, linking: "settings/follow-ups", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Chat", - }, + options: settingsPage("Chat"), }), SettingsScheduledTasks: createNativeStackScreen({ screen: SettingsScheduledTasksRouteScreen, linking: "settings/scheduled-tasks", options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Scheduled tasks", + ...settingsPage("Scheduled tasks"), headerTitleStyle: { fontSize: 16, fontWeight: "800" }, }, }), SettingsScheduledTaskNew: createNativeStackScreen({ screen: SettingsScheduledTaskNewRouteScreen, linking: "settings/scheduled-tasks/new", - options: { ...SETTINGS_PAGE_OPTIONS, title: "New scheduled task" }, + options: settingsPage("New scheduled task"), }), SettingsScheduledTaskEdit: createNativeStackScreen({ screen: SettingsScheduledTaskEditRouteScreen, - options: { ...SETTINGS_PAGE_OPTIONS, title: "Edit scheduled task" }, + options: settingsPage("Edit scheduled task"), }), SettingsScheduledTaskBranch: createNativeStackScreen({ screen: ScheduledTaskBranchPickerRouteScreen, - options: { ...SETTINGS_PAGE_OPTIONS, title: "Base branch" }, + options: settingsPage("Base branch"), }), SettingsScheduledTaskModel: createNativeStackScreen({ screen: ScheduledTaskModelPickerRouteScreen, - options: { - ...SETTINGS_PAGE_OPTIONS, - headerShown: false, - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : { - ...THREAD_SETTINGS_SHEET_OPTIONS, - }), - }, + options: THREAD_SETTINGS_ROUTE, }), SettingsClientStorage: createNativeStackScreen({ screen: SettingsClientStorageRouteScreen, linking: "settings/client-storage", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Client Storage", - }, + options: settingsPage("Client Storage"), }), SettingsDiagnostics: createNativeStackScreen({ screen: SettingsDiagnosticsRouteScreen, linking: "settings/diagnostics", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Diagnostics", - }, + options: settingsPage("Diagnostics"), }), // These params snapshot the tap time, local filters, and freshly aggregated pools. // A linking path would round-trip them as URL query strings and preserve stale values. SettingsUsageAccount: createNativeStackScreen({ screen: UsageLimitAccountScreen, - options: { ...SETTINGS_PAGE_OPTIONS, title: "Account" }, + options: settingsPage("Account"), }), SettingsOpenSourceLicenses: createNativeStackScreen({ screen: SettingsOpenSourceLicensesRouteScreen, linking: "settings/open-source-licenses", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Open source licenses", - }, + options: settingsPage("Open source licenses"), }), SettingsOpenSourceLicense: createNativeStackScreen({ screen: SettingsOpenSourceLicenseRouteScreen, linking: "settings/open-source-licenses/:entryKey", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "License notice", - }, + options: settingsPage("License notice"), }), SettingsUsage: createNativeStackScreen({ screen: UsageRouteScreen, linking: "settings/usage", - options: { - ...SETTINGS_PAGE_OPTIONS, - title: "Usage", - }, + options: settingsPage("Usage"), }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, @@ -588,14 +488,9 @@ const RootStackConfig = createWorkspaceStackNavigator({ focus: recordHomeFocus, }, options: { - ...GLASS_HEADER_OPTIONS, - ...FIXED_DUO_HEADER, + ...pageRoute({ duoMinimize: "never", root: true }), contentStyle: { backgroundColor: "transparent" }, - headerBackVisible: false, ...getCompactBrandHeaderOptions(), - // Android draws Home's bars itself. Set here, not by the screen: Home also renders in the - // sidebar column, and that copy clears the route's set options when it unmounts. - headerShown: Platform.OS !== "android", }, }), PullRequest: createNativeStackScreen({ @@ -604,385 +499,229 @@ const RootStackConfig = createWorkspaceStackNavigator({ path: "pull-requests/:environmentId/:projectId/:repository/:number", parse: { number: Number }, }, - options: { ...DETAIL_HEADER_OPTIONS, ...FIXED_DUO_HEADER }, + options: pageRoute({ duoMinimize: "never" }), }), PullRequestGuideStop: createNativeStackScreen({ screen: PullRequestGuideStopScreen, - options: DETAIL_HEADER_OPTIONS, + options: pageRoute(), }), PullRequestFile: createNativeStackScreen({ screen: PullRequestFileRouteScreen, - options: DETAIL_HEADER_OPTIONS, + options: pageRoute(), }), PullRequestFiles: createNativeStackScreen({ screen: PullRequestFilesSheet, - options: { - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.6), - sheetGrabberVisible: true, - sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, - }, + options: sheetRoute({ detent: 0.6 }), }), PullRequestReviewers: createNativeStackScreen({ screen: PullRequestReviewersScreen, - options: { - ...DETAIL_HEADER_OPTIONS, - ...FIXED_DUO_HEADER, - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.75), - sheetGrabberVisible: true, - headerBackVisible: false, - }, + options: sheetRoute({ detent: 0.75, itemStyle: "editor", duoMinimize: "never" }), }), PullRequestLabels: createNativeStackScreen({ screen: PullRequestLabelsScreen, - // Presented like PullRequestFilterSearch, whose search sheet it shares. - options: - Platform.OS === "android" - ? { presentation: "card", headerShown: false } - : { - ...DETAIL_HEADER_OPTIONS, - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.75), - sheetGrabberVisible: true, - headerBackVisible: false, - }, + options: SEARCH_SHEET_ROUTE, }), PullRequestFilterSearch: createNativeStackScreen({ screen: PullRequestFilterSearchScreen, - // Android pushes it like the branch picker; a formSheet there leaves the list's header - // peeking above it once the keyboard opens. - options: - Platform.OS === "android" - ? { presentation: "card", headerShown: false } - : { - ...DETAIL_HEADER_OPTIONS, - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.75), - sheetGrabberVisible: true, - headerBackVisible: false, - }, + options: SEARCH_SHEET_ROUTE, }), PullRequestDiscussion: createNativeStackScreen({ screen: PullRequestDiscussionScreen, - options: DETAIL_HEADER_OPTIONS, + options: pageRoute(), }), PullRequestInlineComment: createNativeStackScreen({ screen: PullRequestInlineCommentScreen, - options: { - ...(Platform.OS === "android" - ? { presentation: "fullScreenModal" as const } - : { ...SHEET_GLASS_HEADER_OPTIONS, ...HORIZONTAL_FORM_SHEET_OPTIONS }), - sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.75), - sheetGrabberVisible: Platform.OS !== "android", - }, + options: sheetRoute({ detent: 0.75, android: "fullScreen" }), }), PullRequestReview: createNativeStackScreen({ screen: PullRequestReviewSheet, - options: { - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.75), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.75, androidFit: true }), }), PullRequestEdit: createNativeStackScreen({ screen: PullRequestEditScreen, - options: { - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.75), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.75, androidFit: true }), }), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] }, - options: THREAD_HEADER_OPTIONS, + options: pageRoute({ duoMinimize: "onScrollUp" }), }), NewTaskProject: createNativeStackScreen({ screen: NewTaskRouteScreen, linking: { path: "new", alias: ["new/project"] }, - options: { ...GLASS_HEADER_OPTIONS, title: "Choose project" }, + options: pageRoute({ title: "Choose project" }), }), NewTaskEnvironment: createNativeStackScreen({ linking: "new/draft/environment", screen: NewTaskEnvironmentPickerRouteScreen, - options: { ...GLASS_HEADER_OPTIONS, title: "Environment" }, + options: pageRoute({ title: "Environment" }), }), NewTaskBranch: createNativeStackScreen({ linking: "new/draft/branch", screen: NewTaskBranchPickerRouteScreen, - options: { ...GLASS_HEADER_OPTIONS, title: "Branch" }, + options: pageRoute({ title: "Branch" }), }), AddProject: createNativeStackScreen({ linking: "new/add-project", screen: AddProjectSourceRoute, - options: { - ...GLASS_HEADER_OPTIONS, - title: "Add project", - }, + options: pageRoute({ title: "Add project" }), }), AddProjectRepository: createNativeStackScreen({ linking: "new/add-project/repository", screen: AddProjectRepositoryRoute, - options: GLASS_HEADER_OPTIONS, + options: pageRoute(), }), AddProjectDestination: createNativeStackScreen({ linking: "new/add-project/destination", screen: AddProjectDestinationRoute, - options: GLASS_HEADER_OPTIONS, + options: pageRoute(), }), AddProjectLocal: createNativeStackScreen({ linking: "new/add-project/local", screen: AddProjectLocalRoute, - options: GLASS_HEADER_OPTIONS, + options: pageRoute(), }), AddProjectNew: createNativeStackScreen({ linking: "new/add-project/new", screen: AddProjectNewRoute, - options: GLASS_HEADER_OPTIONS, + options: pageRoute(), }), NewTaskFiles: createNativeStackScreen({ linking: "new/draft/files", screen: ThreadFilesTreeScreen, - options: SOLID_HEADER_OPTIONS, + options: pageRoute({ header: "solid" }), }), NewTaskFile: createNativeStackScreen({ linking: "new/draft/files/:path*", screen: ThreadFileScreen, - options: SOLID_HEADER_OPTIONS, + options: pageRoute({ header: "solid" }), }), NewTaskAttachment: createNativeStackScreen({ screen: AttachmentFileScreen, linking: "new/draft/attachments/:attachmentId", - options: SOLID_HEADER_OPTIONS, + options: pageRoute({ header: "solid" }), }), NewTaskThreadSettings: createNativeStackScreen({ screen: NewTaskThreadSettingsRouteScreen, linking: "new/draft/settings", - options: { - gestureEnabled: true, - headerShown: false, - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : THREAD_SETTINGS_SHEET_OPTIONS), - }, + options: THREAD_SETTINGS_ROUTE, }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, linking: `${THREAD_LINKING_PREFIX}/terminal`, - options: SOLID_HEADER_OPTIONS, + options: pageRoute({ header: "solid" }), }), ThreadInspectorFullscreen: createNativeStackScreen({ screen: FullscreenThreadRouteScreen, - options: { - ...THREAD_HEADER_OPTIONS, - presentation: "fullScreenModal", - }, + options: modalRoute({ header: "glass", itemStyle: "editor", duoMinimize: "onScrollUp" }), }), ThreadDevicePreview: createNativeStackScreen({ screen: DevicePreviewRouteScreen, linking: `${THREAD_LINKING_PREFIX}/devices`, - options: { - presentation: "fullScreenModal", - headerShown: false, - gestureEnabled: false, - autoHideHomeIndicator: true, - navigationBarHidden: true, - }, + options: immersiveRoute({ hideSystemBars: true }), }), ThreadBrowserPreview: createNativeStackScreen({ screen: BrowserPreviewRouteScreen, linking: `${THREAD_LINKING_PREFIX}/browser`, - options: { - presentation: "fullScreenModal", - headerShown: false, - gestureEnabled: false, - }, + options: { ...modalRoute({ header: "solid" }), gestureEnabled: false }, }), ThreadReview: createNativeStackScreen({ screen: ReviewSheet, linking: `${THREAD_LINKING_PREFIX}/review`, - options: GLASS_HEADER_OPTIONS, + options: pageRoute(), }), ThreadReviewComment: createNativeStackScreen({ screen: ReviewCommentComposerSheet, linking: `${THREAD_LINKING_PREFIX}/review-comment`, - options: { - // Android formSheets cannot host the keyboard-driven comment composer. - ...(Platform.OS === "android" - ? { presentation: "fullScreenModal" as const } - : HORIZONTAL_FORM_SHEET_OPTIONS), - sheetAllowedDetents: Platform.OS === "android" ? undefined : formSheetDetents(0.55), - sheetGrabberVisible: Platform.OS !== "android", - }, + options: sheetRoute({ detent: 0.55, android: "fullScreen" }), }), ThreadFiles: createNativeStackScreen({ screen: ThreadFilesTreeScreen, linking: `${THREAD_LINKING_PREFIX}/files`, - options: { - ...GLASS_HEADER_OPTIONS, - title: "Files", - }, + options: pageRoute({ title: "Files" }), }), ThreadFile: createNativeStackScreen({ screen: ThreadFileScreen, linking: `${THREAD_LINKING_PREFIX}/files/:path*`, - options: GLASS_HEADER_OPTIONS, + options: pageRoute(), }), ThreadMcpApp: createNativeStackScreen({ screen: McpAppFullscreenScreen, linking: `${THREAD_LINKING_PREFIX}/apps/:itemId`, - options: { - presentation: "fullScreenModal", - headerShown: false, - gestureEnabled: false, - }, + options: { ...modalRoute({ header: "solid" }), gestureEnabled: false }, }), ThreadAttachment: createNativeStackScreen({ screen: AttachmentFileScreen, linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, - options: SOLID_HEADER_OPTIONS, + options: pageRoute({ header: "solid" }), }), ThreadSettingsSheet: createNativeStackScreen({ screen: ExistingThreadSettingsRouteScreen, - options: { - gestureEnabled: true, - headerShown: false, - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : { - ...THREAD_SETTINGS_SHEET_OPTIONS, - }), - }, + options: THREAD_SETTINGS_ROUTE, }), ThreadQueue: createNativeStackScreen({ screen: ThreadQueueSheet, - options: { - ...HORIZONTAL_FORM_SHEET_OPTIONS, - headerShown: false, - sheetAllowedDetents: formSheetDetents(0.65, 0.95), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.65, androidMaxDetent: 0.95 }), }), ThreadBackgroundWork: createNativeStackScreen({ screen: ThreadBackgroundWorkSheet, - options: { - ...HORIZONTAL_FORM_SHEET_OPTIONS, - headerShown: false, - sheetAllowedDetents: formSheetDetents(0.5, 0.9), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.5, androidMaxDetent: 0.9 }), }), ThreadAgents: createNativeStackScreen({ screen: ThreadAgentsSheet, - options: { - ...HORIZONTAL_FORM_SHEET_OPTIONS, - headerShown: false, - sheetAllowedDetents: formSheetDetents(0.5, 0.9), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.5, androidMaxDetent: 0.9 }), }), GitOverview: createNativeStackScreen({ screen: GitOverviewSheet, linking: `${THREAD_LINKING_PREFIX}/git`, - options: { - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.55), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.55 }), }), ThreadPullRequests: createNativeStackScreen({ screen: ThreadPullRequestsSheet, linking: `${THREAD_LINKING_PREFIX}/pull-requests`, - options: { - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.55), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.55 }), }), GitCommit: createNativeStackScreen({ screen: GitCommitSheet, linking: `${THREAD_LINKING_PREFIX}/git/commit`, - options: { - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.8), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.8, androidFit: true }), }), GitBranches: createNativeStackScreen({ screen: GitBranchesSheet, linking: `${THREAD_LINKING_PREFIX}/git/branches`, - options: { - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.55), - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.55, androidFit: true }), }), GitConfirm: createNativeStackScreen({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, - options: { - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: [0.55, 0.7], - sheetGrabberVisible: true, - }, + options: sheetRoute({ detent: 0.55, maxDetent: 0.7, androidFit: true }), }), ...SETTINGS_SCREENS, SettingsLegal: createNativeStackScreen({ screen: SettingsLegalRouteScreen, linking: "settings/legal", - options: { - ...LEGAL_DOCUMENT_HEADER_OPTIONS, - title: "Legal", - }, + options: modalRoute({ header: "solid", title: "Legal" }), }), ConnectOnboarding: createNativeStackScreen({ screen: ConnectOnboardingRouteScreen, linking: "connect-onboarding", - options: { - // Android root formSheets lack a native stack bar; the route provides its own sheet AppBar. - ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), + options: sheetRoute({ title: "Set up T3 Connect", - gestureEnabled: true, - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: formSheetDetents(0.6, 0.95), - sheetGrabberVisible: true, - }, + header: "solid", + detent: 0.6, + androidMaxDetent: 0.95, + }), }), Connections: createNativeStackScreen({ screen: ConnectionsRouteScreen, linking: "connections", - options: { - title: "Environments", - ...(Platform.OS === "android" - ? { presentation: "card" as const, headerShown: false } - : { - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: [0.55, 0.7], - sheetGrabberVisible: true, - }), - }, + options: sheetRoute({ title: "Environments", detent: 0.55, maxDetent: 0.7, android: "card" }), }), ConnectionsNew: createNativeStackScreen({ screen: ConnectionsNewRouteScreen, linking: "connections/new", - options: { - // Mount the iOS header immediately to avoid remounting the pairing form. - ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), - title: "Add Environment", - ...HORIZONTAL_FORM_SHEET_OPTIONS, - sheetAllowedDetents: [0.55, 0.7], - sheetGrabberVisible: true, - sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, - }, + options: sheetRoute({ title: "Add Environment", detent: 0.55, maxDetent: 0.7 }), }), NotFound: createNativeStackScreen({ screen: NotFoundScreen, @@ -1025,11 +764,7 @@ function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: registerPresentedRoutes( Object.entries(RootStackConfig.config.screens ?? {}).flatMap(([name, screen]) => { const options = (screen as { readonly options?: unknown }).options; - const presentation = - options && typeof options === "object" - ? (options as { readonly presentation?: string }).presentation - : undefined; - return presentation !== undefined && presentation !== "card" ? [name] : []; + return options && typeof options === "object" && isPresentedRoute(options) ? [name] : []; }), ); diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 7a251a2e57c16..222e536cf4b5c 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,6 +1,5 @@ import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns"; import Constants from "expo-constants"; -import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { useState } from "react"; import { Platform, View } from "react-native"; @@ -93,18 +92,3 @@ export function CompactBrandTitle( title ); } - -function renderCompactBrandTitle() { - return ; -} - -export function getCompactBrandHeaderOptions( - fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], -): NativeStackNavigationOptions { - return { - headerTitle: renderCompactBrandTitle, - headerTitleStyle: fallbackTitleStyle, - title: "Threads", - unstable_headerLeftItems: undefined, - }; -} diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 8121bfdece763..d7fb3a3c4cd14 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -13,7 +13,7 @@ import type { import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { videoMimeType } from "@t3tools/shared/video"; -import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { REVIEW_MONO_FONT_FAMILY } from "../features/review/reviewDiffRendering"; import { ReviewCommentCard, useReviewCommentColors } from "../features/review/ReviewCommentCard"; @@ -193,7 +193,7 @@ export function ComposerContextSheet(props: { ; readonly controlledDocumentJson: string; readonly onComposerChange: (event: { readonly nativeEvent: NativeEvent }) => void; } @@ -20,17 +29,27 @@ const native = vi.hoisted(() => ({ text: "", eventCount: 0, resetGeneration: 0, + pasteEventCount: 0, mounts: 0, props: null as NativeProps | null, deliver: null as ((event: NativeEvent) => void) | null, })); -function NativeView(props: NativeProps) { +function NativeView({ ref, ...props }: NativeProps) { const propsRef = useRef(props); useLayoutEffect(() => { - Object.assign(native, { text: "", eventCount: 0, resetGeneration: 0 }); + Object.assign(native, { text: "", eventCount: 0, resetGeneration: 0, pasteEventCount: 0 }); native.mounts += 1; }, []); + useImperativeHandle(ref, () => ({ + readState: async () => ({ + value: native.text, + selection: { start: native.text.length, end: native.text.length }, + eventCount: native.eventCount, + resetGeneration: native.resetGeneration, + pasteEventCount: native.pasteEventCount, + }), + })); useLayoutEffect(() => { propsRef.current = props; native.props = props; @@ -55,6 +74,18 @@ function NativeView(props: NativeProps) { return null; } +// A native edit whose event has not reached JS yet. +function pend(value: string): NativeEvent { + native.eventCount += 1; + native.text = value; + return { + value, + selection: { start: value.length, end: value.length }, + eventCount: native.eventCount, + resetGeneration: native.resetGeneration, + }; +} + function emit(value: string, resetGeneration = native.resetGeneration) { native.eventCount += 1; native.text = value; @@ -116,13 +147,21 @@ import { useComposerDraft, } from "../state/use-composer-drafts"; import { ComposerEditor } from "./ComposerEditor"; +import type { ComposerEditorHandle } from "../native/T3ComposerEditor.types"; const DRAFT_KEY = "environment:thread"; -function ThreadComposer({ draftKey = DRAFT_KEY }: { draftKey?: string }) { +function ThreadComposer({ + draftKey = DRAFT_KEY, + editorRef, +}: { + draftKey?: string; + editorRef?: Ref; +}) { const draft = useComposerDraft(draftKey); return ( setComposerDraftText(draftKey, text)} @@ -190,3 +229,51 @@ it("keeps a pending event from the previous native editor out of the selected dr expect(getComposerDraftSnapshot(second).text).toBe("Draft B"); expect(native.text).toBe("Draft B"); }); + +async function renderFlushableComposer() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const editor = createRef(); + const root = createRoot(document.createElement("div")); + await act(async () => + root.render( + + + , + ), + ); + return { + editor, + [Symbol.asyncDispose]: async () => act(() => root.unmount()), + }; +} + +it("sends the autocorrection the Send tap accepted, and its late event cannot refill the draft", async () => { + await using screen = await renderFlushableComposer(); + await act(async () => emit("Hello wrold")); + const lateCorrection = pend("Hello world"); + + await act(async () => screen.editor.current!.flushPendingInput()); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe("Hello world"); + + await act(async () => { + clearComposerDraftContent(DRAFT_KEY); + native.deliver!(lateCorrection); + }); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe(""); +}); + +it("does not flush past a paste whose event has not reached JS", async () => { + await using screen = await renderFlushableComposer(); + await act(async () => emit("Before")); + const paste = pend("Before pasted"); + native.pasteEventCount = paste.eventCount; + const caretMove = pend("Before pasted"); + + await act(async () => screen.editor.current!.flushPendingInput()); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe("Before"); + + await act(async () => native.deliver!(paste)); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe("Before pasted"); + await act(async () => native.deliver!(caretMove)); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe("Before pasted"); +}); diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index a7c2de56d83ad..f733c59ff7ed1 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -12,7 +12,7 @@ import type { } from "./FilePreviewModal.types"; import { imageViewingProps, MediaImagePreview } from "./MediaImagePreview"; import { PdfPreview } from "./PdfPreview"; -import { ControlPill } from "./ui/ControlPill"; +import { SheetHeader } from "./ui/SheetHeader"; import { AppText as Text } from "./ui/AppText"; function PdfDocumentPreview(props: { @@ -31,12 +31,11 @@ function PdfDocumentPreview(props: { className="flex-1 bg-card" style={{ paddingTop: insets.top, paddingBottom: insets.bottom }} > - - - {props.source.name ?? "PDF"} - - - + diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 52c294e36468c..2a7b226574e73 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -1,14 +1,11 @@ import { createContext, useContext } from "react"; -import { Pressable, View } from "react-native"; +import { View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useMediaActions } from "../state/mediaActions"; -import { AppText } from "./ui/AppText"; -import { SymbolView } from "./ui/AppSymbol"; import type { FilePreviewImages } from "./FilePreviewModal.types"; -import { MediaActionsMenu } from "./MediaActionsMenu"; -import { MediaSourceCaption } from "./MediaSourceCaption"; +import { MediaPreviewHeader } from "./MediaPreviewHeader"; type MediaImagePreviewProps = { readonly source: FilePreviewImages; @@ -24,21 +21,12 @@ function ImagePreviewHeader(header: { readonly imageIndex: number }) { const mediaActions = useMediaActions(source?.actionsSource, props.onRequestClose); return ( - - - {source?.name ?? "Image"} - - - - - - - + ); } diff --git a/apps/mobile/src/components/MediaPreviewHeader.tsx b/apps/mobile/src/components/MediaPreviewHeader.tsx new file mode 100644 index 0000000000000..c6d405ff94e64 --- /dev/null +++ b/apps/mobile/src/components/MediaPreviewHeader.tsx @@ -0,0 +1,35 @@ +import { Pressable, View } from "react-native"; + +import type { useMediaActions } from "../state/mediaActions"; +import { SymbolView } from "./ui/AppSymbol"; +import { AppText } from "./ui/AppText"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +/** The bar over a full-screen image or video: always dark, whatever the app theme. */ +export function MediaPreviewHeader(props: { + readonly title: string; + readonly media: ReturnType; + readonly closeAccessibilityLabel: string; + readonly onClose: () => void; +}) { + return ( + <> + + + {props.title} + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx index 4e63851dcd3eb..2f9d0063ca9eb 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -17,9 +17,7 @@ import { import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { AppText } from "./ui/AppText"; -import { SymbolView } from "./ui/AppSymbol"; -import { MediaActionsMenu } from "./MediaActionsMenu"; -import { MediaSourceCaption } from "./MediaSourceCaption"; +import { MediaPreviewHeader } from "./MediaPreviewHeader"; import { MediaVideoPlayer } from "./MediaVideoPlayer"; export type { VideoPreviewSource } from "../lib/videoPreviewSource"; @@ -118,21 +116,12 @@ function OpenVideoPreviewModal(props: { className="flex-1 bg-black" style={{ paddingTop: insets.top, paddingBottom: insets.bottom }} > - - - {props.name} - - - - - - - + {playback.uri === null && !playback.unavailable ? ( diff --git a/apps/mobile/src/components/ui/AppBar.android.test.tsx b/apps/mobile/src/components/ui/AppBar.android.test.tsx index c4cf88d185ab2..12859f11dfe1a 100644 --- a/apps/mobile/src/components/ui/AppBar.android.test.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.test.tsx @@ -23,6 +23,10 @@ vi.mock("./WorkspaceAppBar.android", () => ({ vi.mock("./Menu", () => ({ Menu: (props: { readonly children?: ReactNode }) => props.children })); vi.mock("./ScreenHeaderButton.android", () => ({ ScreenHeaderButton: () => null })); vi.mock("./SearchField", () => ({ SearchField: () => null })); +vi.mock("./Button", () => ({ Button: () => null })); +vi.mock("../../features/layout/adaptive-workspace-context", () => ({ + useAdaptiveWorkspaceLayout: () => ({ layout: { usesSplitView: false } }), +})); vi.mock("./useToolbarLayout", () => ({ useToolbarLayout: () => ({ height: 48, paddingTop: 0, paddingBottom: 0 }), })); diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index a37ee4d74adf6..f312999c58d14 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -2,13 +2,16 @@ import { type RefObject, use, useRef, useState } from "react"; import { Keyboard, type TextInputInstance, View } from "react-native"; import { useHardwareBack } from "../../lib/backHandler"; import { useToolbarLayout } from "./useToolbarLayout"; +import { RoutedFormSheetContext } from "./RoutedFormSheetContext"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useSidebarToggle } from "../../features/layout/useSidebarToggle"; +import { useAdaptiveWorkspaceLayout } from "../../features/layout/adaptive-workspace-context"; import { InspectorCloseContext } from "../../features/layout/inspector-bar-store"; import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "./AndroidScreenHeader"; import { ScreenHeaderButton } from "./ScreenHeaderButton.android"; import { Menu } from "./Menu"; +import { Button } from "./Button"; import { SearchField } from "./SearchField"; import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; import type { AppBarMenuItem, AppBarProps, AppBarSearch } from "./AppBar.types"; @@ -26,7 +29,14 @@ interface SearchState { readonly inputRef: RefObject; } -export function AppBar(props: AppBarProps) { +export function AppBar(barProps: AppBarProps) { + const { layout } = useAdaptiveWorkspaceLayout(); + const props = { + ...barProps, + onBack: + barProps.onBack ?? + (layout.usesSplitView ? barProps.backInSplitView?.onPress : barProps.onBackToList), + }; return props.search ? ( ) : ( @@ -66,7 +76,8 @@ function SearchableAppBar(props: AppBarProps & { readonly search: AppBarSearch } function AppBarView(props: AppBarProps & { readonly searchState?: SearchState }) { const { search, searchState } = props; - const { paddingTop, paddingBottom } = useToolbarLayout(props.sheet); + const inFormSheet = use(RoutedFormSheetContext); + const { paddingTop, paddingBottom } = useToolbarLayout(props.sheet || inFormSheet); const { themeVariables } = useAppearancePreferences(); const inWorkspace = useHasWorkspaceAppBar(); const sidebarToggle = useSidebarToggle(); @@ -143,11 +154,19 @@ function AppBarView(props: AppBarProps & { readonly searchState?: SearchState }) )); + const confirmButton = props.confirm ? ( + ), - }), + ); + return { + AppBar: (props: { + readonly menus?: ReadonlyArray<{ readonly items: ReadonlyArray }>; + }) =>
{props.menus?.map((menu) => renderItems(menu.items))}
, }; }); -vi.mock("../../components/ui/AppBar", () => ({ AppBar: () => null })); -vi.mock("../../components/ui/AndroidScreenHeader", () => ({ AndroidHeaderIconButton: () => null })); -vi.mock("../../components/ui/Menu", () => ({ Menu: () => null })); +vi.mock("../../components/ui/useBarInset", () => ({ useContentTopInset: () => 0 })); vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); vi.mock("../../components/ui/Progress", () => ({ Progress: () => null })); vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); @@ -182,7 +182,6 @@ function setup() { const props: ComponentProps = { name: "archive.zip", subtitle: "ZIP", - tintColor: "#000000", sizeBytes: 0, uri: "file:///first.zip", isLocal: true, diff --git a/apps/mobile/src/features/files/ZipArchiveView.tsx b/apps/mobile/src/features/files/ZipArchiveView.tsx index 66290e9d6c381..fb79723d6e39d 100644 --- a/apps/mobile/src/features/files/ZipArchiveView.tsx +++ b/apps/mobile/src/features/files/ZipArchiveView.tsx @@ -17,20 +17,17 @@ import { fetch } from "expo/fetch"; import { File } from "expo-file-system"; import { useNavigation, usePreventRemove } from "@react-navigation/native"; import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { Keyboard, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Keyboard, Pressable, ScrollView, TextInput, View } from "react-native"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { AppBar } from "../../components/ui/AppBar"; +import { useContentTopInset } from "../../components/ui/useBarInset"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; import { EmptyState } from "../../components/ui/EmptyState"; import { Progress } from "../../components/ui/Progress"; -import { Toolbar } from "../../components/ui/Toolbar"; -import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; import * as Dialog from "../../components/ui/Dialog"; import { FilePreviewModal } from "../../components/FilePreviewModal"; import { @@ -296,67 +293,32 @@ function ZipTreeRowView(props: { function ZipArchiveHeader(props: { readonly name: string; readonly subtitle: string; - readonly tintColor: string; readonly onSave: () => void; readonly onRemove: (() => void) | null; }) { const navigation = useNavigation(); - const isAndroid = Platform.OS === "android"; return ( - <> - - {isAndroid ? ( - { - if (navigation.canGoBack()) navigation.goBack(); - }} - actions={[ - { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave }, - ...(props.onRemove - ? [ - { - accessibilityLabel: "Remove from draft", - icon: "trash" as const, - onPress: props.onRemove, - destructive: true, - overflowOnly: true, - }, - ] - : []), - ]} - /> - ) : ( - - {props.onRemove ? ( - - - Save ZIP - - - Remove from draft - - - ) : ( - - )} - - )} - + { + if (navigation.canGoBack()) navigation.goBack(); + }} + actions={[ + { accessibilityLabel: "Save ZIP", icon: "square.and.arrow.up", onPress: props.onSave }, + ...(props.onRemove + ? [ + { + accessibilityLabel: "Remove from draft", + icon: "trash" as const, + onPress: props.onRemove, + destructive: true, + overflowOnly: true, + }, + ] + : []), + ]} + /> ); } @@ -436,13 +398,13 @@ function ZipArchiveList(props: { return ( `${row.node.entry ? "f" : "d"}:${row.node.path}`} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustsScrollIndicatorInsets estimatedItemSize={44} recycleItems keyboardDismissMode="on-drag" @@ -509,7 +471,6 @@ function ZipArchiveList(props: { export function ZipArchiveView(props: { readonly name: string; readonly subtitle: string; - readonly tintColor: string; readonly sizeBytes: number; readonly uri: string | null; readonly isLocal: boolean; @@ -529,9 +490,7 @@ export function ZipArchiveView(props: { const selected = selection && selection.archive === archive ? selection.entry : null; const entryOpen = selected !== null; usePreventRemove(entryOpen, () => setSelection(null)); - const insets = useSafeAreaInsets(); - const columnMetrics = useNativeColumnLayoutMetrics(); - const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; + const headerInset = useContentTopInset(); return ( {entryOpen ? null : ( @@ -542,7 +501,6 @@ export function ZipArchiveView(props: { ? `${archive.fileCount} ${archive.fileCount === 1 ? "file" : "files"}${props.sizeBytes > 0 ? ` · ${formatAttachmentSize(props.sizeBytes)}` : ""}` : props.subtitle } - tintColor={props.tintColor} onSave={props.onSave} onRemove={props.onRemove} /> @@ -753,73 +711,49 @@ function ZipEntryView(props: { }); const folders = entry.path.split("/").slice(0, -1); const subtitle = `${[props.archiveName, ...folders].join(" / ")} · ${formatAttachmentSize(entry.sizeBytes)}`; - const isAndroid = Platform.OS === "android"; return ( - setRendered((value) => !value), + }, + ] + : []), + ...(canAddToChat + ? [ + { + id: "add-to-chat", + title: "Add to chat", + icon: "plus.bubble", + disabled: busy !== null, + onPress: addToChat, + }, + ] + : []), + { + id: "save", + title: "Save file", + icon: "square.and.arrow.up", + disabled: busy !== null, + onPress: save, + }, + ], + }, + ]} /> - {isAndroid ? ( - setRendered((value) => !value), - }, - ] - : []), - ...(canAddToChat - ? [ - { - accessibilityLabel: "Add to chat", - icon: "plus.bubble" as const, - onPress: addToChat, - disabled: busy !== null, - }, - ] - : []), - { - accessibilityLabel: "Save file", - icon: "square.and.arrow.up", - onPress: save, - loading: busy === "save", - }, - ]} - /> - ) : ( - - - {renderedMode ? ( - setRendered((value) => !value)} - > - {rendered ? "Show source" : "Show preview"} - - ) : null} - {canAddToChat ? ( - - Add to chat - - ) : null} - - Save file - - - - )} {content.status === "failed" ? ( ({ vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0 }) })); vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: Nothing })); vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null })); +vi.mock("../../components/ui/useBarInset", () => ({ useContentTopInset: () => 0 })); vi.mock("@legendapp/list/react-native", () => ({ LegendList: (props: { data: ReadonlyArray; diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index bc1fe6af518a2..b5c86e3295c7b 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -15,9 +15,9 @@ import { useWorkspaceState } from "../../state/workspace"; import { FabScrollContext } from "./FabScrollContext"; import { updateFabScroll } from "./fab-scroll"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; -import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; +import { WorkspaceTabsContext, useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; +import { FLOATING_TABS, floatingTabsMetrics } from "../layout/workspace/floating-workspace-tabs"; +import { useEnabledNavigationTabs } from "../layout/workspace/navigation-tabs"; import { useAppearancePreferences, useAndroidFloatingTabs, diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx deleted file mode 100644 index 70c23920b7662..0000000000000 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { ThreadListToolbar } from "./ThreadListToolbar"; -import type { HomeHeaderProps } from "./HomeHeader.types"; - -export type { HomeHeaderEnvironment } from "./HomeHeader.types"; - -function checkedMenuState(checked: boolean) { - return checked ? ("on" as const) : undefined; -} - -export function HomeHeader(props: HomeHeaderProps) { - const hasCustomListOptions = - props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; - const menuActions: MenuAction[] = [ - { - id: "environment", - title: "Environment", - subactions: [ - { - id: "environment:all", - title: "All environments", - state: checkedMenuState(props.selectedEnvironmentId === null), - }, - ...props.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.label, - state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), - })), - ], - }, - ...(props.projects.length === 0 - ? [] - : ([ - { - id: "project", - title: "Project", - subactions: [ - { - id: "project:all", - title: "All projects", - state: checkedMenuState(props.selectedProjectKey === null), - }, - ...props.projects.map((project) => ({ - id: `project:${project.key}`, - title: project.label, - state: checkedMenuState(props.selectedProjectKey === project.key), - })), - ], - }, - ] satisfies MenuAction[])), - ]; - const handleMenuAction = (event: { nativeEvent: { event: string } }) => { - const id = event.nativeEvent.event; - if (id === "environment:all") { - props.onEnvironmentChange(null); - return; - } - - if (id.startsWith("environment:")) { - const environmentId = id.slice("environment:".length); - const environment = props.environments.find( - (candidate) => candidate.environmentId === environmentId, - ); - if (environment) { - props.onEnvironmentChange(environment.environmentId); - } - return; - } - - if (id === "project:all") { - props.onProjectChange(null); - return; - } - - if (id.startsWith("project:")) { - const projectKey = id.slice("project:".length); - if (props.projects.some((project) => project.key === projectKey)) { - props.onProjectChange(projectKey); - } - return; - } - }; - - return ( - <> - - handleMenuAction({ nativeEvent: { event } })} - onOpenEnvironments={props.onOpenEnvironments} - /> - - ); -} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx deleted file mode 100644 index 2062c518afef3..0000000000000 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { HomeHeaderProps } from "./HomeHeader.types"; - -export type { HomeHeaderEnvironment } from "./HomeHeader.types"; - -/** The iOS thread list lives in the workspace, whose WorkspaceToolbar owns its header and search. */ -export function HomeHeader(_props: HomeHeaderProps) { - return null; -} diff --git a/apps/mobile/src/features/home/HomeHeader.types.ts b/apps/mobile/src/features/home/HomeHeader.types.ts deleted file mode 100644 index 3c5f580f9eefb..0000000000000 --- a/apps/mobile/src/features/home/HomeHeader.types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import type { - HomeListFilterMenuEnvironment, - HomeListFilterMenuProject, -} from "./home-list-filter-menu"; - -export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; - -export interface HomeHeaderProps { - readonly environments: ReadonlyArray; - readonly projects: ReadonlyArray; - readonly searchQuery: string; - readonly selectedEnvironmentId: EnvironmentId | null; - readonly selectedProjectKey: string | null; - readonly onSearchQueryChange: (query: string) => void; - readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; - readonly onProjectChange: (projectKey: string | null) => void; - readonly onOpenEnvironments: () => void; - readonly onOpenSettings: () => void; - readonly onStartNewTask: () => void; -} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 40d540104ee94..99ede8afe7c4a 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,13 +1,12 @@ import { uuidv4 } from "../../lib/uuid"; import { ThreadUndoNotice } from "./ThreadUndoNotice"; -import { Toolbar } from "../../components/ui/Toolbar"; import { use } from "react"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useMemo, useState } from "react"; -import { Platform, useWindowDimensions } from "react-native"; +import { Platform } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { @@ -15,37 +14,30 @@ import { resolveScratchProjectScopeKey, } from "@t3tools/client-runtime/state/project-grouping"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { useDeviceType, useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; -import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { AppBar } from "../../components/ui/AppBar"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; -import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; -import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { openSettings } from "../settings/settings-navigation"; -import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; -import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useSelectWorkspaceTab } from "../layout/workspace/useSelectWorkspaceTab"; +import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; +import { WorkspaceListEmptyDetail } from "../layout/workspace/WorkspaceListEmptyDetail"; +import { WorkspaceListHeader } from "../layout/workspace/WorkspaceListHeader"; +import { buildHomeListFilterMenuItems } from "./home-list-filter-menu"; export function HomeRouteScreen() { - const { width: windowWidth } = useWindowDimensions(); const nativePrimaryColumn = use(NativePrimaryColumnContext); - const columnMetrics = useNativeColumnLayoutMetrics(); - const deviceType = useDeviceType(); - const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; - const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery, startNewTask } = + const { layout, primarySidebarSearchQuery, setPrimarySidebarSearchQuery, startNewTask } = useAdaptiveWorkspaceLayout(); const selectDetail = useSelectWorkspaceDetail(); const projects = useProjects(); @@ -161,77 +153,37 @@ export function HomeRouteScreen() { if (layout.usesSplitView && !nativePrimaryColumn) { return ( - <> - [], - headerSearchBarOptions: undefined, - unstable_headerRightItems: () => [], - unstable_headerToolbarItems: () => [], - } - } - /> - {Platform.OS === "ios" ? ( - - - - ) : null} - {Platform.OS === "android" ? : null} - openSettings(navigation, "SettingsEnvironmentNew") - : undefined - } - onStartNewTask={ - Platform.OS === "android" && panes.primarySidebarVisible ? undefined : startNewTask - } - /> - + openSettings(navigation, "SettingsEnvironmentNew")} + onStartNewTask={startNewTask} + /> ); } return ( - + <> - {/* Screen options merge shallowly; restore the header after split view. */} - {workspace !== null && Platform.OS === "ios" ? null : ( - openSettings(navigation, "SettingsEnvironments"), - }), - headerShown: Platform.OS !== "android", - }} - /> - )} - openSettings(navigation, "SettingsEnvironments")} - onOpenSettings={() => selectTab("settings")} - onSearchQueryChange={setSearchQuery} - onStartNewTask={startNewTask} + search={{ + value: searchQuery, + onChangeText: setSearchQuery, + placeholder: "Search", + accessibilityLabel: "Search threads", + }} + filter={{ + customized: selectedEnvironmentId !== null || selectedProjectKey !== null, + items: buildHomeListFilterMenuItems({ + environments, + projects: projectFilterOptions, + selectedEnvironmentId, + selectedProjectKey, + onEnvironmentChange: setSelectedEnvironmentId, + onProjectChange: setSelectedProjectKey, + }), + }} /> (null); const homeScrollGesture = useNativeGesture(); const insets = useSafeAreaInsets(); + const emptyTopInset = useWorkspaceListEmptyTopInset(); const { fabClearance } = useAndroidControlSizing(); const screenMetrics = useNativeLayoutMetrics(); const contentSideInsets = usesNativeWorkspaceColumns @@ -682,7 +683,7 @@ export function HomeScreen(props: HomeScreenProps) { style={{ paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24), - paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0, + paddingTop: emptyTopInset, paddingLeft: 32 + (contentSideInsets?.left ?? 0), paddingRight: 32 + (contentSideInsets?.right ?? 0), }} @@ -797,7 +798,7 @@ export function HomeScreen(props: HomeScreenProps) { onTouchStart={(event) => trackListTouches(event, true)} onTouchEnd={(event) => trackListTouches(event, false)} onTouchCancel={(event) => trackListTouches(event, false)} - renderScrollComponent={Platform.OS === "ios" ? renderHomeScrollView : undefined} + renderScrollComponent={renderHomeScrollView} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} @@ -826,7 +827,7 @@ export function HomeScreen(props: HomeScreenProps) { } ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + automaticallyAdjustsScrollIndicatorInsets // UIKit owns the bar's inset, so a minimizing bar and a resized column move // the list in the same frame as the bar. contentInsetAdjustmentBehavior="automatic" diff --git a/apps/mobile/src/features/home/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index a7d6894c35d91..c6165e649b4b5 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -1,22 +1,22 @@ import { Pressable, ScrollView } from "react-native"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { ListFilterMenu } from "../workspace/ListFilterMenu"; -import type { HomeHeaderProps } from "./HomeHeader.types"; +import { ListFilterMenu } from "../layout/workspace/ListFilterMenu"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { + HomeListFilterMenuEnvironment, + HomeListFilterMenuProject, +} from "./home-list-filter-menu"; -export function ThreadListFilters( - props: Pick< - HomeHeaderProps, - | "environments" - | "projects" - | "selectedEnvironmentId" - | "selectedProjectKey" - | "onEnvironmentChange" - | "onProjectChange" - > & { - /** List padding the scroller bleeds through so pills start where row content does. */ - readonly gutter: number; - }, -) { +export function ThreadListFilters(props: { + readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; + /** List padding the scroller bleeds through so pills start where row content does. */ + readonly gutter: number; +}) { const selectedEnvironment = props.environments.find( (environment) => environment.environmentId === props.selectedEnvironmentId, ); diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx deleted file mode 100644 index 8ed77bbcaa89f..0000000000000 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ThreadListToolbarProps } from "./ThreadListToolbar.types"; - -/** iOS lists use native header items; only Android draws this toolbar. */ -export function ThreadListToolbar(_props: ThreadListToolbarProps) { - return null; -} diff --git a/apps/mobile/src/features/home/ThreadListToolbar.types.ts b/apps/mobile/src/features/home/ThreadListToolbar.types.ts deleted file mode 100644 index 6dbf71331542b..0000000000000 --- a/apps/mobile/src/features/home/ThreadListToolbar.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; - -export interface ThreadListToolbarProps { - readonly searchQuery: string; - readonly onSearchQueryChange: (query: string) => void; - readonly filterActions: MenuAction[]; - readonly filterCustomized: boolean; - readonly onFilterSelect: (id: string) => void; - readonly onOpenEnvironments: () => void; - readonly onRequestVisibility?: () => void; -} diff --git a/apps/mobile/src/features/home/ThreadUndoNotice.tsx b/apps/mobile/src/features/home/ThreadUndoNotice.tsx index 364873a3eadeb..caeb47a3c2195 100644 --- a/apps/mobile/src/features/home/ThreadUndoNotice.tsx +++ b/apps/mobile/src/features/home/ThreadUndoNotice.tsx @@ -10,8 +10,8 @@ import { useNativeLayoutMetrics, useNativeColumnLayoutMetrics, } from "../../native/native-layout-metrics"; -import { workspaceSearchBottomClearance } from "../workspace/workspace-tabs-state"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { workspaceSearchBottomClearance } from "../layout/workspace/workspace-tabs-state"; +import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { dismissThreadUndoNotice, readThreadUndoNotice, diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 58cf4138483a2..540831ad18f0e 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,4 +1,3 @@ -import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { createContext, use, useEffect, useState, type ReactNode } from "react"; import { Animated, Platform, Pressable, View } from "react-native"; @@ -6,11 +5,6 @@ import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { - brandTitleOffset, - CompactBrandTitle, - getCompactBrandHeaderOptions, -} from "../../components/CompactBrandTitle"; import { useDeadlinePassed } from "../../lib/clock"; import { useWorkspaceState } from "../../state/workspace"; import { @@ -159,24 +153,3 @@ export function WorkspaceConnectionTitle(props: { ); } - -export function getConnectionAwareBrandHeaderOptions(opts: { - readonly headerWidth: number; - readonly trailingItemCount?: number; - readonly onOpenEnvironments: () => void; - readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; -}): NativeStackNavigationOptions { - const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); - - return { - ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle), - headerTitle: () => ( - } - maxWidth={maxWidth} - onPress={opts.onOpenEnvironments} - statusOffset={brandTitleOffset()} - /> - ), - }; -} diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index ac53e427d21f3..617c3fa0988ef 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,5 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; + export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; readonly label: string; @@ -10,82 +12,57 @@ export interface HomeListFilterMenuProject { readonly label: string; } -type HomeListFilterMenuAction = { - readonly type: "action"; - readonly title: string; - readonly subtitle?: string; - readonly state?: "on" | "off"; - readonly onPress: () => void; -}; - -type HomeListFilterMenuSubmenu = { - readonly type: "submenu"; - readonly title: string; - readonly items: HomeListFilterMenuAction[]; -}; - -export interface HomeListFilterMenu { - readonly title: string; - readonly items: Array; -} - -export function buildHomeListFilterMenu(props: { +export function buildHomeListFilterMenuItems(props: { readonly environments: ReadonlyArray; readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; -}): HomeListFilterMenu { - const items: Array = []; - - items.push({ - type: "submenu", - title: "Environment", - items: [ - { - type: "action", - title: "All environments", - subtitle: "Show threads from every environment", - state: props.selectedEnvironmentId === null ? "on" : "off", - onPress: () => props.onEnvironmentChange(null), - }, - ...props.environments.map((environment) => ({ - type: "action" as const, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), - })), - ], - }); +}): AppBarMenuItem[] { + const items: AppBarMenuItem[] = [ + { + id: "environment", + title: "Environment", + items: [ + { + id: "environment:all", + title: "All environments", + subtitle: "Show threads from every environment", + selected: props.selectedEnvironmentId === null, + onPress: () => props.onEnvironmentChange(null), + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + selected: props.selectedEnvironmentId === environment.environmentId, + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }, + ]; if (props.projects.length > 0) { items.push({ - type: "submenu", + id: "project", title: "Project", items: [ { - type: "action", + id: "project:all", title: "All projects", subtitle: "Show threads from every project", - state: props.selectedProjectKey === null ? "on" : "off", + selected: props.selectedProjectKey === null, onPress: () => props.onProjectChange(null), }, ...props.projects.map((project) => ({ - type: "action" as const, + id: `project:${project.key}`, title: project.label, - state: props.selectedProjectKey === project.key ? ("on" as const) : ("off" as const), + selected: props.selectedProjectKey === project.key, onPress: () => props.onProjectChange(project.key), })), ], }); } - return { - title: "Thread list options", - items, - }; + return items; } diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index e9e9e50d4527f..198ac724f526c 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -71,8 +71,8 @@ import { parseActiveThreadPath, type HardwareKeyboardCommand } from "./hardwareK import { threadJumpIndex } from "./threadKeyboardShortcuts"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { openSettings } from "../settings/settings-navigation"; -import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; -import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useSelectWorkspaceTab } from "../layout/workspace/useSelectWorkspaceTab"; +import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; const PALETTE_COMMANDS: ReadonlyArray = [ "commandPalette", diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 135715f064924..98fba788bbd31 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -18,7 +18,7 @@ import { copyThreadReference } from "../../lib/copyThreadReference"; import { firstWorkspaceDetailOpen, workspaceHasColumns, -} from "../workspace/workspace-selection-store"; +} from "../layout/workspace/workspace-selection-store"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useThreadShell } from "../../state/entities"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; @@ -32,7 +32,7 @@ import { subscribeToHardwareKeyboardCommandRegistrations, type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; -import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; +import { selectWorkspaceDetail } from "../layout/workspace/workspace-detail-selection"; const EMPTY_COPY_FEEDBACK: GitActionProgress = { phase: "idle", diff --git a/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx new file mode 100644 index 0000000000000..f628e495f9e29 --- /dev/null +++ b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx @@ -0,0 +1,84 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { KeyboardEventData } from "react-native-keyboard-controller"; +import { expect, it, vi } from "vite-plus/test"; + +const listeners = vi.hoisted(() => new Map void>>()); + +// Exercises the patched library source with only its native bindings replaced. +vi.mock("react-native-keyboard-controller/src/bindings", () => ({ + KeyboardEvents: { + addListener: (name: string, listener: (event: KeyboardEventData) => void) => { + const named = listeners.get(name) ?? new Set(); + named.add(listener); + listeners.set(name, named); + return { remove: () => named.delete(listener) }; + }, + }, + KeyboardControllerNative: { dismiss: () => {} }, +})); + +import { useKeyboardState } from "react-native-keyboard-controller/src/hooks/useKeyboardState"; +import { KeyboardController } from "react-native-keyboard-controller/src/module"; + +function emit(name: string, height: number) { + const event: KeyboardEventData = { + height, + duration: 0, + timestamp: 1, + target: 42, + type: "default", + appearance: "light", + }; + act(() => { + for (const listener of listeners.get(name) ?? []) listener(event); + }); +} + +function KeyboardState() { + const { isVisible, height } = useKeyboardState(); + return {JSON.stringify({ isVisible, height })}; +} + +// The library keeps keyboard state at module scope; every test starts and ends hidden. +function setup() { + emit("keyboardDidHide", 0); + return { [Symbol.dispose]: () => emit("keyboardDidHide", 0) }; +} + +function renderKeyboardState() { + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render()); + return { container, [Symbol.dispose]: () => act(() => root.unmount()) }; +} + +// Android's syncKeyboardPosition repairs a nonanimated IME inset change with +// keyboardDidHide then keyboardDidShow, and no keyboardWillShow. +it("reports the keyboard shown after Android repairs the IME inset without keyboardWillShow", () => { + using _keyboard = setup(); + using screen = renderKeyboardState(); + + emit("keyboardDidShow", 376); + + expect({ + isVisible: KeyboardController.isVisible(), + height: KeyboardController.state().height, + hook: JSON.parse(screen.container.textContent ?? ""), + }).toEqual({ isVisible: true, height: 376, hook: { isVisible: true, height: 376 } }); +}); + +it("waits for the native hide before dismiss resolves after a didShow-only repair", async () => { + using _keyboard = setup(); + emit("keyboardDidShow", 376); + + let dismissed = false; + const dismissal = KeyboardController.dismiss().then(() => { + dismissed = true; + }); + await act(async () => {}); + + expect(dismissed).toBe(false); + emit("keyboardDidHide", 0); + await dismissal; +}); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 7196929b8a855..bf8b3ad9b08dc 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -58,6 +58,7 @@ import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; import { environmentThreadShells } from "../../state/threads"; +import { withKnownPullRequestSummary } from "../../state/use-thread-pr"; import { mobilePreferencesAtom } from "../../state/preferences"; import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, @@ -68,20 +69,20 @@ import { useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { useEnabledNavigationTabs } from "../workspace/navigation-tabs"; -import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs"; +import { useEnabledNavigationTabs } from "./workspace/navigation-tabs"; +import { FLOATING_TABS, floatingTabsMetrics } from "./workspace/floating-workspace-tabs"; import { AndroidWorkspaceNavigation, FloatingWorkspaceTabs, ANDROID_WORKSPACE_RAIL_WIDTH, -} from "../workspace/AndroidWorkspaceNavigation"; +} from "./workspace/AndroidWorkspaceNavigation"; import { selectWorkspaceDetail, type WorkspaceDetail, type WorkspaceDetailSheet, -} from "../workspace/workspace-detail-selection"; -import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +} from "./workspace/workspace-detail-selection"; +import { startOpeningDetail } from "./workspace/useSelectWorkspaceDetail"; +import { useWorkspaceTabs } from "./workspace/WorkspaceTabsContext"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { newTaskDestination } from "../threads/new-task-project-selection"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -102,20 +103,20 @@ import { import { useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { AndroidHingeObserver } from "../../native/AndroidHingeObserver"; import { WindowHingeContext } from "../../native/window-hinge-context"; -import { useAndroidWorkspaceRoot } from "../workspace/AndroidWorkspaceTabsProvider"; +import { useAndroidWorkspaceRoot } from "./workspace/AndroidWorkspaceTabsProvider"; import { dispatchRootStackReset, rootStackState } from "../../lib/root-stack-reset"; import { currentWorkspaceSelections, workspaceSelectionCheck, -} from "../workspace/workspace-selection-store"; +} from "./workspace/workspace-selection-store"; import { workspaceTabColumns, workspaceTabNavigationState, -} from "../workspace/workspace-tab-navigation"; -import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; +} from "./workspace/workspace-tab-navigation"; +import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; import { SettingsWorkspaceLayout } from "../settings/SettingsWorkspaceLayout"; -import { WorkspaceThreadSidebar } from "../workspace/WorkspaceThreadSidebar"; +import { WorkspaceThreadSidebar } from "./workspace/WorkspaceThreadSidebar"; export { useAdaptiveWorkspaceLayout }; @@ -244,10 +245,14 @@ function AdaptiveWorkspaceLayoutContent( const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); const [fileInspectorPreferredVisible, setFileInspectorPreferredVisible] = useState(true); const [nativeInspectorWidthFraction, setNativeInspectorWidthFraction] = useState(0.4); - const nativeInspectorWidth = nativeInspectorColumnWidth( - viewportWidth, - nativeInspectorWidthFraction, - ); + const inspectorWidthFor = (fraction: number) => + nativeInspectorColumnWidth({ + viewportWidth, + horizontalSafeArea: + (nativeMetrics?.safeArea.left ?? 0) + (nativeMetrics?.safeArea.right ?? 0), + fraction, + }); + const nativeInspectorWidth = inspectorWidthFor(nativeInspectorWidthFraction); const [fileInspectorPreferredWidth, setFileInspectorPreferredWidth] = useState( null, ); @@ -585,6 +590,7 @@ function AdaptiveWorkspaceLayoutContent( columns: workspaceTabColumns(Platform.OS, tab, options?.columns ?? layout.usesSplitView), check: workspaceSelectionCheck, overlays, + seedPullRequest: withKnownPullRequestSummary, }, ); if (layout.variant === "medium") { @@ -1000,9 +1006,7 @@ function AdaptiveWorkspaceLayoutContent( setAuxiliaryPaneWidth: setFileInspectorPreferredWidth, nativeInspectorWidth, setNativeInspectorWidth: (width: number) => - setNativeInspectorWidthFraction( - nativeInspectorColumnWidth(viewportWidth, width / viewportWidth) / viewportWidth, - ), + setNativeInspectorWidthFraction(inspectorWidthFor(width / viewportWidth) / viewportWidth), composerKeyboardActive, keyboardHoldsSidebar, setComposerEditing, diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index 7a7d717259cb0..e04b72cb9685d 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -6,7 +6,7 @@ import { NewThreadButton } from "../../components/NewThreadButton"; import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import { EmptyState } from "../../components/ui/EmptyState"; import { ScreenContent } from "../../components/ui/ScreenContent"; -import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; +import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; export function WorkspaceEmptyDetail(props: { readonly tab?: WorkspaceTab | "settings"; diff --git a/apps/mobile/src/features/layout/adaptive-workspace-context.ts b/apps/mobile/src/features/layout/adaptive-workspace-context.ts index 1002514100706..d82e6202c633b 100644 --- a/apps/mobile/src/features/layout/adaptive-workspace-context.ts +++ b/apps/mobile/src/features/layout/adaptive-workspace-context.ts @@ -1,10 +1,7 @@ -import type { WorkspaceTab } from "../workspace/workspace-tabs-state"; +import type { WorkspaceTab } from "./workspace/workspace-tabs-state"; import { createContext, use } from "react"; import type { InspectorContent } from "./inspector-panes"; -import type { - WorkspaceDetail, - WorkspaceDetailSheet, -} from "../workspace/workspace-detail-selection"; +import type { WorkspaceDetail, WorkspaceDetailSheet } from "./workspace/workspace-detail-selection"; import { deriveFileInspectorPaneLayout, deriveLayout, diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx index 46592527501aa..2ee2000d400f0 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.android.tsx @@ -21,7 +21,10 @@ import { use, type ComponentProps } from "react"; import { RoutedFormSheetContext } from "../../components/ui/RoutedFormSheetContext"; -import { projectStackPresentation } from "../../native/workspace-stack-projection"; +import { + isPresentedRoute, + projectStackPresentation, +} from "../../native/workspace-stack-projection"; import { WorkspaceColumnsRendererContext, WorkspacePresentationHostContext, @@ -37,8 +40,7 @@ function WorkspaceStackView(props: ViewProps) { const isOverlay = (route: Route) => { const descriptor = props.descriptors[route.key] ?? props.describe(route, true); - const presentation = descriptor.options.presentation; - return presentation !== undefined && presentation !== "card"; + return isPresentedRoute(descriptor.options); }; const projection = projectStackPresentation(props.state, isOverlay); const workspaceRoutes = projection.workspace; @@ -121,14 +123,19 @@ function WorkspaceStackNavigator({ descriptors={Object.fromEntries( Object.entries(descriptors).map(([key, descriptor]) => [ key, - descriptor.options.presentation === "formSheet" - ? { - ...descriptor, - render: () => ( - {descriptor.render()} - ), - } - : descriptor, + { + ...descriptor, + // Screens draw their bar in-tree; hiding the native one only after mount + // shows it for the first frame and leaves the page measured short. + options: { ...descriptor.options, headerShown: false }, + ...(descriptor.options.presentation === "formSheet" + ? { + render: () => ( + {descriptor.render()} + ), + } + : undefined), + }, ]), )} navigation={navigation} diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index ab5d8baad876b..14ffb11abf22b 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -33,16 +33,17 @@ import { Split, type SplitHostCommands } from "react-native-screens"; import { SafeAreaView } from "react-native-safe-area-context"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { NATIVE_INSPECTOR_MIN_MAIN_WIDTH } from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { WorkspaceTabsProvider, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; +import { WorkspaceTabsProvider, useWorkspaceTabs } from "./workspace/WorkspaceTabsContext"; import { WorkspaceNativeTabs, WorkspaceSidebarTabsFooter, -} from "../workspace/WorkspaceNativeTabs.ios"; -import { workspaceUsesColumns } from "../workspace/workspace-tabs-state"; +} from "./workspace/WorkspaceNativeTabs.ios"; +import { workspaceUsesColumns } from "./workspace/workspace-tabs-state"; import { isSettingsRoute, settingsOpenIn } from "../settings/settings-navigation"; import { workspaceRoutesOf } from "./workspace-overlay-routes"; -import { setNativelyPoppingWorkspaceRoute } from "../workspace/workspace-detail-selection"; +import { setNativelyPoppingWorkspaceRoute } from "./workspace/workspace-detail-selection"; import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -70,6 +71,7 @@ import { partitionStackPresentations, reconcileStackScreens, type CollapsedSplitColumn, + isPresentedRoute, } from "../../native/workspace-stack-projection"; import { NativePrimaryColumnContext, @@ -389,7 +391,7 @@ function WorkspaceColumns( minimumPrimaryColumnWidth: 280, maximumPrimaryColumnWidth: Math.max(380, layout.listPaneWidth ?? 0), preferredPrimaryColumnWidthOrFraction: layout.listPaneWidth ?? 320, - minimumSecondaryColumnWidth: 320, + minimumSecondaryColumnWidth: NATIVE_INSPECTOR_MIN_MAIN_WIDTH, // UIKit can retain its automatic width despite a new preference; constrain both edges. minimumInspectorColumnWidth: inspectorWidth, maximumInspectorColumnWidth: inspectorWidth, @@ -504,8 +506,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean const [primaryVisible, setPrimaryVisible] = useState(false); const metrics = useNativeLayoutMetrics(); const projection = projectWorkspaceStack(props.state, (route) => { - const presentation = props.descriptors[route.key]?.options.presentation; - return presentation !== undefined && presentation !== "card"; + return isPresentedRoute(props.descriptors[route.key]?.options); }); // The root's willDisappear reaches JS frames into the push, so hide the dock in the commit that // pushes; the native events still own every later change, including back gestures. @@ -565,8 +566,7 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean ), }; const overlays = partitionStackPresentations(projection.overlays, (route) => { - const presentation = props.descriptors[route.key]?.options.presentation; - return presentation !== undefined && presentation !== "card"; + return isPresentedRoute(props.descriptors[route.key]?.options); }); const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; return ( @@ -645,8 +645,7 @@ function WorkspaceStackNavigator({ if (!nativeWorkspaceColumnsReady) return null; const settingsOpen = settingsOpenIn( state.routes.slice(0, state.index + 1).filter((route) => { - const presentation = descriptors[route.key]?.options.presentation; - return presentation === undefined || presentation === "card"; + return !isPresentedRoute(descriptors[route.key]?.options); }), ); return ( diff --git a/apps/mobile/src/features/layout/inspector-host.tsx b/apps/mobile/src/features/layout/inspector-host.tsx index bec36d336bd9c..4a24eafb9b6da 100644 --- a/apps/mobile/src/features/layout/inspector-host.tsx +++ b/apps/mobile/src/features/layout/inspector-host.tsx @@ -47,14 +47,8 @@ function PaneScreenContent(props: { return ( - {props.pane.nativeStack ? ( - content - ) : ( - <> - - {content} - - )} + + {content} ); diff --git a/apps/mobile/src/features/layout/inspector-panes.ts b/apps/mobile/src/features/layout/inspector-panes.ts index ab0366341c083..54dd8ef86fc67 100644 --- a/apps/mobile/src/features/layout/inspector-panes.ts +++ b/apps/mobile/src/features/layout/inspector-panes.ts @@ -5,8 +5,6 @@ export interface InspectorPane { readonly render: () => ReactNode; /** Opening one of these panes soon mounts the others too, so switching between them is instant. */ readonly prewarm?: boolean; - /** The pane is a native stack of its own, which draws its bar and insets its content itself. */ - readonly nativeStack?: boolean; } /** What a route shows in the workspace's inspector: its panes and the one on screen. */ diff --git a/apps/mobile/src/features/layout/nested-stacks.tsx b/apps/mobile/src/features/layout/nested-stacks.tsx new file mode 100644 index 0000000000000..7ee894db65928 --- /dev/null +++ b/apps/mobile/src/features/layout/nested-stacks.tsx @@ -0,0 +1,59 @@ +import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; +import type { ReactNode } from "react"; +import { Platform } from "react-native"; + +import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { createV5StackNavigator } from "../../native/createV5StackNavigator"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import type { AppScreenOptions } from "../../native/glass-header-options"; +import { pageRoute } from "./route-options"; + +/** + * Screen options for a stack nested in a sheet, so its pages push inside the sheet with the + * same bar as a sheet route. Android hides the native header: pages draw AppBar in-tree. + */ +export function useSheetStackOptions(): AppScreenOptions { + const theme = useUniwindTheme(); + const background = theme["--color-sheet-solid"]; + return { + ...pageRoute({ itemStyle: "standard" }), + animation: Platform.OS === "android" ? "default" : "slide_from_right", + contentStyle: { backgroundColor: background }, + gestureEnabled: true, + headerShown: Platform.OS !== "android", + headerStyle: { backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED ? "transparent" : background }, + headerTintColor: theme["--color-foreground"], + }; +} + +const Standalone = createV5StackNavigator<{ Standalone: undefined }>(); + +/** + * One native stack screen outside the app's navigation tree, for content that needs a native bar + * but is not a route: a popover, or a column the workspace hosts. Its bar is set from inside with + * AppBar or SheetHeader like any screen. + */ +export function StandaloneStack(props: { + readonly surface?: "screen" | "sidebar"; + readonly options?: AppScreenOptions; + readonly children: ReactNode; +}) { + const navigationTheme = useMobileNavigationTheme(props.surface); + return ( + + + + {() => props.children} + + + + ); +} diff --git a/apps/mobile/src/features/layout/route-options.ts b/apps/mobile/src/features/layout/route-options.ts new file mode 100644 index 0000000000000..b8b30e2d02a96 --- /dev/null +++ b/apps/mobile/src/features/layout/route-options.ts @@ -0,0 +1,158 @@ +import { Platform } from "react-native"; + +import { GLASS_HEADER_OPTIONS, type AppScreenOptions } from "../../native/glass-header-options"; +import { formSheetDetents } from "../../native/sheet-surface"; + +/** + * Route presets for the root stack. A route says what it is (a page, a sheet, a modal); the + * preset owns how each platform presents it, so screens never set presentation options. + * + * Android never shows the native stack header: every screen draws its bar in-tree with AppBar or + * SheetHeader, and the Android navigators hide the native one from the first frame. + */ + +type HeaderStyle = "glass" | "solid" | "none"; + +const SOLID_HEADER_OPTIONS: AppScreenOptions = { + headerBackButtonDisplayMode: "minimal", + headerBackTitle: "", + headerLargeTitle: false, + headerShadowVisible: false, + headerShown: true, + headerTitleStyle: { fontSize: 18, fontWeight: "800" }, + headerTransparent: false, +}; + +function headerOptions(header: HeaderStyle, itemStyle: "editor" | "standard"): AppScreenOptions { + if (header === "none") return { headerShown: false }; + if (header === "glass") { + // The editor style needs liquid glass, which GLASS_HEADER_OPTIONS already gates it on. + return itemStyle === "editor" + ? GLASS_HEADER_OPTIONS + : { ...GLASS_HEADER_OPTIONS, unstable_navigationItemStyle: undefined }; + } + return { + ...SOLID_HEADER_OPTIONS, + unstable_navigationItemStyle: + itemStyle === "editor" && Platform.OS === "ios" ? "editor" : undefined, + }; +} + +export interface PageRouteOptions { + readonly title?: string; + /** Glass bars let content scroll under them. Defaults to glass. */ + readonly header?: HeaderStyle; + /** iOS 26 groups a page's bar items like an editor; settings pages use the standard look. */ + readonly itemStyle?: "editor" | "standard"; + /** + * How the Duo minimizes the bar while scrolling. "never" for lists that stutter against the + * default on hardware; "onScrollUp" for conversations read backwards, like Messages. + */ + readonly duoMinimize?: "never" | "onScrollUp"; + /** The page is the root of its column, so it never shows Back. */ + readonly root?: boolean; +} + +/** A page in the workspace: pushed on compact windows, a column page on wide ones. */ +export function pageRoute(options: PageRouteOptions = {}): AppScreenOptions { + return { + ...headerOptions(options.header ?? "glass", options.itemStyle ?? "editor"), + ...(options.title === undefined ? undefined : { title: options.title }), + ...(options.duoMinimize + ? { unstable_headerMinimizationBehavior: options.duoMinimize } + : undefined), + ...(options.root ? { headerBackVisible: false } : undefined), + }; +} + +export interface SheetRouteOptions { + readonly title?: string; + /** Defaults to glass. "none" when the sheet draws no bar at all. */ + readonly header?: HeaderStyle; + /** The initial detent, or "full" for a sheet that always covers the window. */ + readonly detent: number | "full"; + /** The largest detent. Defaults to the full window on iOS, where the Duo needs it. */ + readonly maxDetent?: number; + /** Android's expanded detent when `maxDetent` is unset. */ + readonly androidMaxDetent?: number; + /** Android sizes the sheet to its content instead of using detents. */ + readonly androidFit?: boolean; + /** + * Where an Android formSheet can't host the screen: "card" pushes it, "fullScreen" presents it + * full screen (Android formSheets can't host a keyboard-driven composer). + */ + readonly android?: "sheet" | "card" | "fullScreen"; + /** The sheet spans the Duo's rail, and its content insets itself. */ + readonly spansRail?: boolean; + readonly itemStyle?: "editor" | "standard"; + readonly duoMinimize?: "never" | "onScrollUp"; +} + +function sheetDetents(options: SheetRouteOptions): number[] { + if (options.detent === "full") return [1]; + if (options.maxDetent !== undefined) return [options.detent, options.maxDetent]; + return formSheetDetents(options.detent, options.androidMaxDetent); +} + +/** A sheet over the workspace: a formSheet with a grabber that closes, never goes back. */ +export function sheetRoute(options: SheetRouteOptions): AppScreenOptions { + const header = headerOptions(options.header ?? "glass", options.itemStyle ?? "standard"); + const shared = { + ...header, + ...(options.title === undefined ? undefined : { title: options.title }), + ...(options.duoMinimize + ? { unstable_headerMinimizationBehavior: options.duoMinimize } + : undefined), + headerBackVisible: false, + gestureEnabled: true, + } satisfies AppScreenOptions; + if (Platform.OS === "android" && options.android === "card") { + return { ...shared, presentation: "card" }; + } + if (Platform.OS === "android" && options.android === "fullScreen") { + return { ...shared, presentation: "fullScreenModal" }; + } + return { + ...shared, + presentation: "formSheet", + sheetPrefersHorizontalBars: true, + sheetGrabberVisible: true, + sheetAllowedDetents: + Platform.OS === "android" && options.androidFit ? "fitToContents" : sheetDetents(options), + ...(Platform.OS === "android" ? { sheetCornerRadius: 28 } : undefined), + ...(options.spansRail ? { nativeContentInsetHorizontally: false } : undefined), + } as AppScreenOptions; +} + +export interface ModalRouteOptions { + readonly title?: string; + /** Defaults to "none": the screen draws its own SheetHeader or has no bar. */ + readonly header?: HeaderStyle; + readonly itemStyle?: "editor" | "standard"; + readonly duoMinimize?: "never" | "onScrollUp"; +} + +/** A full-screen modal over the workspace with a close button, not a back button. */ +export function modalRoute(options: ModalRouteOptions = {}): AppScreenOptions { + return { + ...headerOptions(options.header ?? "none", options.itemStyle ?? "standard"), + ...(options.title === undefined ? undefined : { title: options.title }), + ...(options.duoMinimize + ? { unstable_headerMinimizationBehavior: options.duoMinimize } + : undefined), + headerBackVisible: false, + presentation: "fullScreenModal", + }; +} + +/** Full-screen media (a device stream, a browser, an app) with no bar and no dismiss gesture. */ +export function immersiveRoute(options: { readonly hideSystemBars?: boolean } = {}) { + return { + presentation: "fullScreenModal", + headerShown: false, + gestureEnabled: false, + ...(options.hideSystemBars + ? { autoHideHomeIndicator: true, navigationBarHidden: true } + : undefined), + } satisfies AppScreenOptions; +} diff --git a/apps/mobile/src/features/layout/useOpenWorkspaceDetail.ts b/apps/mobile/src/features/layout/useOpenWorkspaceDetail.ts new file mode 100644 index 0000000000000..64966c35844ce --- /dev/null +++ b/apps/mobile/src/features/layout/useOpenWorkspaceDetail.ts @@ -0,0 +1,20 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { Platform } from "react-native"; + +import { useSelectWorkspaceDetail } from "./workspace/useSelectWorkspaceDetail"; +import type { WorkspaceDetail, WorkspaceDetailSheet } from "./workspace/workspace-detail-selection"; + +/** + * Opens a thread or pull request from another page. Android shows one detail page at a time, so + * it selects the detail; iOS pushes it over the current page, so Back returns there. A `sheet` + * opens over the detail on Android and on its own over the current page on iOS. + */ +export function useOpenWorkspaceDetail() { + const navigation = useNavigation(); + const selectDetail = useSelectWorkspaceDetail(); + return (detail: WorkspaceDetail, sheet?: WorkspaceDetailSheet) => { + if (Platform.OS === "android") selectDetail(detail, sheet); + else if (sheet) navigation.navigate(sheet.name, sheet.params); + else navigation.dispatch(StackActions.push(detail.name, detail.params)); + }; +} diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx similarity index 92% rename from apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx rename to apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx index 292a546506040..246a0c9d7cb74 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsx @@ -11,18 +11,18 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; -import { rootNavigationRef } from "../../lib/rootNavigation"; -import { useWorkspaceConnectionState } from "../../state/workspace"; +import { useAndroidControlSizing } from "../../../components/ui/useAndroidControlSizing"; +import { useToolbarLayout } from "../../../components/ui/useToolbarLayout"; +import { rootNavigationRef } from "../../../lib/rootNavigation"; +import { useWorkspaceConnectionState } from "../../../state/workspace"; import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; import { androidWorkspaceTabNavigationState, type AndroidWorkspaceNavigationTab, } from "./android-workspace-tabs"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { useSoftKeyboardShowing } from "../keyboard/useSoftKeyboardShowing"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; +import { useSoftKeyboardShowing } from "../../keyboard/useSoftKeyboardShowing"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; import { useAndroidWorkspaceRoot } from "./AndroidWorkspaceTabsProvider"; import { useNavigationTabOrder, useHiddenNavigationTabs } from "./navigation-tabs"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.tsx similarity index 100% rename from apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.tsx rename to apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.tsx diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceSidebar.android.tsx similarity index 79% rename from apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx rename to apps/mobile/src/features/layout/workspace/AndroidWorkspaceSidebar.android.tsx index 73cd078c9b829..5d784359d4f42 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceSidebar.android.tsx +++ b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceSidebar.android.tsx @@ -1,23 +1,23 @@ -import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; +import { useAndroidFloatingTabs } from "../../settings/appearance/AppearancePreferencesProvider"; import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { useEnabledNavigationTabs } from "./navigation-tabs"; import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; import { useNavigationState } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; +import { useAndroidControlSizing } from "../../../components/ui/useAndroidControlSizing"; import { FLOATING_TABS } from "./floating-workspace-tabs"; import { View } from "react-native"; -import { ScreenContent } from "../../components/ui/ScreenContent"; -import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; -import { PullRequestToolbar } from "../pull-requests/PullRequestToolbar.android"; -import { selectedPullRequestKey } from "../pull-requests/pullRequestSelection"; -import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { ScreenContent } from "../../../components/ui/ScreenContent"; +import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen"; +import { PullRequestToolbar } from "./PullRequestToolbar.android"; +import { selectedPullRequestKey } from "../../pull-requests/pullRequestSelection"; +import { AndroidHomeFabLayout } from "../../home/AndroidHomeFab"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; import { useAndroidWorkspaceRoot } from "./AndroidWorkspaceTabsProvider"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; import { WorkspaceTabPanes } from "./WorkspaceTabPanes"; import { WorkspaceThreadSidebar } from "./WorkspaceThreadSidebar"; -import { WorkspaceAppBarScope } from "../../components/ui/WorkspaceAppBar.android"; +import { WorkspaceAppBarScope } from "../../../components/ui/WorkspaceAppBar.android"; /** The Home route in the sidebar column: the selected tab's list. */ export function AndroidWorkspaceSidebar() { diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx b/apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx similarity index 100% rename from apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx rename to apps/mobile/src/features/layout/workspace/AndroidWorkspaceTabsProvider.tsx diff --git a/apps/mobile/src/features/workspace/ListFilterMenu.tsx b/apps/mobile/src/features/layout/workspace/ListFilterMenu.tsx similarity index 79% rename from apps/mobile/src/features/workspace/ListFilterMenu.tsx rename to apps/mobile/src/features/layout/workspace/ListFilterMenu.tsx index dbba1aaba04ec..6905dee8bdd6d 100644 --- a/apps/mobile/src/features/workspace/ListFilterMenu.tsx +++ b/apps/mobile/src/features/layout/workspace/ListFilterMenu.tsx @@ -1,8 +1,8 @@ import { Pressable } from "react-native"; -import { Chip } from "../../components/ui/Chip"; -import { Menu } from "../../components/ui/Menu"; -import type { MenuProps } from "../../components/ui/Menu.types"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { Chip } from "../../../components/ui/Chip"; +import { Menu } from "../../../components/ui/Menu"; +import type { MenuProps } from "../../../components/ui/Menu.types"; +import { useAppearancePreferences } from "../../settings/appearance/AppearancePreferencesProvider"; export function ListFilterMenu(props: { readonly label: string; diff --git a/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx b/apps/mobile/src/features/layout/workspace/PullRequestToolbar.android.tsx similarity index 85% rename from apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx rename to apps/mobile/src/features/layout/workspace/PullRequestToolbar.android.tsx index bf7eec3d3669a..332c1befe18ba 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx +++ b/apps/mobile/src/features/layout/workspace/PullRequestToolbar.android.tsx @@ -1,13 +1,13 @@ import { useNavigation } from "@react-navigation/native"; import { useEffect } from "react"; import { BackHandler, Keyboard } from "react-native"; -import { AppBar } from "../../components/ui/AppBar"; -import { appBarMenuItemsFromActions } from "../../components/ui/menuActionItems"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { WorkspaceBrandTitle } from "../workspace/WorkspaceBrandTitle"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import type { PullRequestFilterMenu } from "./PullRequestListFilters"; -import { openSettings } from "../settings/settings-navigation"; +import { AppBar } from "../../../components/ui/AppBar"; +import { appBarMenuItemsFromActions } from "../../../components/ui/menuActionItems"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; +import { WorkspaceBrandTitle } from "./WorkspaceBrandTitle"; +import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import type { PullRequestFilterMenu } from "../../pull-requests/PullRequestListFilters"; +import { openSettings } from "../../settings/settings-navigation"; /** PR search shares the thread list's Android header. */ export function PullRequestToolbar(props: { diff --git a/apps/mobile/src/features/workspace/WorkspaceBrandTitle.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceBrandTitle.tsx similarity index 75% rename from apps/mobile/src/features/workspace/WorkspaceBrandTitle.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceBrandTitle.tsx index a1e2d4fc32a03..c14fabc083f51 100644 --- a/apps/mobile/src/features/workspace/WorkspaceBrandTitle.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceBrandTitle.tsx @@ -1,5 +1,5 @@ -import { CompactBrandTitle } from "../../components/CompactBrandTitle"; -import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle"; +import { CompactBrandTitle } from "../../../components/CompactBrandTitle"; +import { WorkspaceConnectionTitle } from "../../home/WorkspaceConnectionTitle"; /** The title of a workspace list tab: the brand and the connection status. */ export function WorkspaceBrandTitle(props: { diff --git a/apps/mobile/src/features/workspace/WorkspaceDock.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceDock.tsx similarity index 92% rename from apps/mobile/src/features/workspace/WorkspaceDock.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceDock.tsx index b4583cbb7d2a4..f6a9966f67ad7 100644 --- a/apps/mobile/src/features/workspace/WorkspaceDock.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceDock.tsx @@ -1,10 +1,10 @@ import { Keyboard, Pressable, View } from "react-native"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { GlassSurface } from "../../components/ui/GlassSurface"; +import { SymbolView } from "../../../components/ui/AppSymbol"; +import { GlassSurface } from "../../../components/ui/GlassSurface"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { WORKSPACE_DOCK } from "./workspace-tabs-state"; -import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; +import { PullRequestGlyph } from "../../pull-requests/pullRequestGlyphs"; export function WorkspaceDock(props: { readonly onNewTask: () => void }) { const workspace = useWorkspaceTabs()!; diff --git a/apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceExtraTab.tsx similarity index 70% rename from apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceExtraTab.tsx index d24f7c1bb27b8..17fc0069eb8e6 100644 --- a/apps/mobile/src/features/workspace/WorkspaceExtraTab.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceExtraTab.tsx @@ -1,6 +1,6 @@ import { type NavigationTab } from "./navigation-tab-order"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { UsageRouteScreen } from "../usage/UsageRouteScreen"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { UsageRouteScreen } from "../../usage/UsageRouteScreen"; export function WorkspaceExtraTab(_props: { tab: Exclude; diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx new file mode 100644 index 0000000000000..fb1d86ad127f6 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListEmptyDetail.tsx @@ -0,0 +1,51 @@ +import { Platform } from "react-native"; + +import { AppBar } from "../../../components/ui/AppBar"; +import { Toolbar } from "../../../components/ui/Toolbar"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; +import { WorkspaceEmptyDetail } from "../WorkspaceEmptyDetail"; + +/** + * The thread list's route in split view, where the list is the sidebar beside it: an empty + * detail. iOS clears the list's bar and keeps only New task. + */ +export function WorkspaceListEmptyDetail(props: { + readonly hasConnections: boolean; + readonly onAddConnection: () => void; + readonly onStartNewTask: () => void; +}) { + const { panes } = useAdaptiveWorkspaceLayout(); + return ( + <> + [], + headerSearchBarOptions: undefined, + unstable_headerRightItems: () => [], + unstable_headerToolbarItems: () => [], + }} + /> + + + + + + + ); +} diff --git a/apps/mobile/src/features/home/ThreadListToolbar.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.android.tsx similarity index 65% rename from apps/mobile/src/features/home/ThreadListToolbar.android.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceListHeader.android.tsx index babbf32859a80..5c6f793584151 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.android.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.android.tsx @@ -1,16 +1,18 @@ import { useEffect, useState } from "react"; import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { BackHandler, Keyboard } from "react-native"; -import { AppBar } from "../../components/ui/AppBar"; -import { appBarMenuItemsFromActions } from "../../components/ui/menuActionItems"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { WorkspaceBrandTitle } from "../workspace/WorkspaceBrandTitle"; -import { useWorkspaceConnectionState } from "../../state/workspace"; -import type { ThreadListToolbarProps } from "./ThreadListToolbar.types"; +import { AppBar } from "../../../components/ui/AppBar"; +import { useWorkspaceConnectionState } from "../../../state/workspace"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; +import { WorkspaceBrandTitle } from "./WorkspaceBrandTitle"; +import { useWorkspaceTabs } from "./WorkspaceTabsContext"; +import { + workspaceListFilterIcon, + type WorkspaceListHeaderProps, +} from "./WorkspaceListHeader.types"; -export function ThreadListToolbar(props: ThreadListToolbarProps) { +export function WorkspaceListHeader(props: WorkspaceListHeaderProps) { const navigation = useNavigation(); const workspace = useWorkspaceTabs(); const isTabActive = workspace?.isTabActive; @@ -18,11 +20,12 @@ export function ThreadListToolbar(props: ThreadListToolbarProps) { const listFocused = homeFocused && (workspace === null || workspace.state.selectedTab === "threads"); const connectionState = useWorkspaceConnectionState(); - const { onRequestVisibility, onSearchQueryChange } = props; + const { search, sidebar } = props; + const onSearchQueryChange = search.onChangeText; const [searchOpen, setSearchOpen] = useState(false); - const searching = searchOpen || props.searchQuery.length > 0; + const searching = searchOpen || search.value.length > 0; const openSearch = () => { - onRequestVisibility?.(); + sidebar?.onRequestVisibility(); setSearchOpen(true); return true; }; @@ -58,15 +61,15 @@ export function ThreadListToolbar(props: ThreadListToolbarProps) { title="T3 Code" titleContent={ } search={{ - value: props.searchQuery, + value: search.value, onChangeText: onSearchQueryChange, - placeholder: "Search", - accessibilityLabel: "Search threads", + placeholder: search.placeholder, + accessibilityLabel: search.accessibilityLabel, active: searching, onOpen: openSearch, onClose: closeSearch, @@ -76,10 +79,8 @@ export function ThreadListToolbar(props: ThreadListToolbarProps) { ? [ { title: "Filter threads", - icon: props.filterCustomized - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle", - items: appBarMenuItemsFromActions(props.filterActions, props.onFilterSelect), + icon: workspaceListFilterIcon(props.filter.customized), + items: props.filter.items, }, ] : undefined diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.tsx new file mode 100644 index 0000000000000..840c0c9144f77 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.tsx @@ -0,0 +1,70 @@ +import { useRef } from "react"; +import type { SearchBarCommands } from "react-native-screens"; + +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; +import { getConnectionAwareBrandHeaderOptions } from "./brand-header-options"; +import { createWorkspaceListHeaderItems } from "./workspace-list-header-items"; +import { + workspaceListFilterIcon, + type WorkspaceListHeaderProps, +} from "./WorkspaceListHeader.types"; + +export function WorkspaceListHeader(props: WorkspaceListHeaderProps) { + return props.sidebar ? : null; +} + +function SidebarColumnHeader( + props: WorkspaceListHeaderProps & { + readonly sidebar: NonNullable; + }, +) { + const { search, sidebar } = props; + const searchBarRef = useRef(null); + useHardwareKeyboardCommand("focusSearch", () => { + const focus = () => searchBarRef.current?.focus(); + if (!sidebar.visible) { + sidebar.onRequestVisibility(); + // Focus once the revealed sidebar has slid in. + setTimeout(focus, 240); + } else { + focus(); + } + return true; + }); + const items = createWorkspaceListHeaderItems({ + filter: { + accessibilityLabel: "Filter threads", + icon: workspaceListFilterIcon(props.filter.customized), + title: "Thread list options", + items: props.filter.items, + }, + onOpenSettings: props.onOpenSettings, + }); + + return ( + search.onChangeText(""), + onChangeText: (event) => search.onChangeText(event.nativeEvent.text), + }, + unstable_headerRightItems: () => items, + }} + /> + ); +} diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.types.ts b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.types.ts new file mode 100644 index 0000000000000..c367118cc93b1 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceListHeader.types.ts @@ -0,0 +1,36 @@ +import type { AppBarMenuItem } from "../../../components/ui/AppBar.types"; + +/** The header of the workspace thread list, on Home and in the sidebar column. */ +export interface WorkspaceListHeaderProps { + /** Spoken for the brand title, such as "T3 Code, Threads". */ + readonly accessibilityLabel: string; + /** The connection status beside the brand opens environment settings. */ + readonly onOpenEnvironments: () => void; + readonly search: { + readonly value: string; + readonly onChangeText: (value: string) => void; + readonly placeholder: string; + readonly accessibilityLabel: string; + }; + readonly filter: { + readonly customized: boolean; + readonly items: ReadonlyArray; + }; + /** iOS: a settings button. Android reaches Settings from the workspace navigation. */ + readonly onOpenSettings?: () => void; + /** + * Set when the list is the workspace's sidebar column, which draws its own bar on iOS. Without + * it the iOS workspace root's toolbar draws the list's bar and search. + */ + readonly sidebar?: { + readonly width: number; + readonly visible: boolean; + readonly onRequestVisibility: () => void; + }; +} + +export function workspaceListFilterIcon(customized: boolean) { + return customized + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle"; +} diff --git a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx similarity index 95% rename from apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx index fbf99fb155f54..2ede0bcddee4b 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceNativeTabs.ios.tsx @@ -17,19 +17,19 @@ import { import { NativeWorkspaceTabActivityContext, WorkspaceSlotOptionsContext, -} from "../../native/v5-workspace-context"; +} from "../../../native/v5-workspace-context"; import { useCompactWorkspaceTabs, useFloatingWorkspaceTabs, useSystemWorkspaceTabBar, } from "./useSystemWorkspaceTabBar"; -import { useControlTheme } from "../../components/ui/useControlTheme"; -import { useDeviceType } from "../../native/native-layout-metrics"; +import { useControlTheme } from "../../../components/ui/useControlTheme"; +import { useDeviceType } from "../../../native/native-layout-metrics"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout"; import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; -const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png"); +const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png"); /** Retain each native controller tree; removing its root can leave RNS's stack empty. */ function RetainedWorkspaceTab(props: { diff --git a/apps/mobile/src/features/workspace/WorkspaceNoTabs.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceNoTabs.tsx similarity index 80% rename from apps/mobile/src/features/workspace/WorkspaceNoTabs.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceNoTabs.tsx index 1db0f2529defe..561502dd54054 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNoTabs.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceNoTabs.tsx @@ -1,7 +1,7 @@ import { useNavigation } from "@react-navigation/native"; import { View } from "react-native"; -import { EmptyState } from "../../components/ui/EmptyState"; -import { openSettings } from "../settings/settings-navigation"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { openSettings } from "../../settings/settings-navigation"; export function WorkspaceNoTabs() { const navigation = useNavigation(); diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx similarity index 82% rename from apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx index ec7a3bb8dcfe6..a03a36bc94762 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.android.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.android.tsx @@ -1,4 +1,4 @@ -import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider"; +import { useAndroidFloatingTabs } from "../../settings/appearance/AppearancePreferencesProvider"; import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { useEnabledNavigationTabs } from "./navigation-tabs"; import { NAVIGATION_TAB_ORDER } from "./navigation-tab-order"; @@ -6,22 +6,22 @@ import { WorkspaceExtraTab } from "./WorkspaceExtraTab"; import type { ReactNode } from "react"; import { View } from "react-native"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { AppBar } from "../../components/ui/AppBar"; -import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { ScreenContent } from "../../components/ui/ScreenContent"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { HomeRouteScreen } from "../home/HomeRouteScreen"; -import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; -import { PullRequestToolbar } from "../pull-requests/PullRequestToolbar.android"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { WorkspaceSettledContentWidthContext } from "../layout/workspace-content-width"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { AppBar } from "../../../components/ui/AppBar"; +import { WorkspaceEmptyDetail } from "../WorkspaceEmptyDetail"; +import { ScreenContent } from "../../../components/ui/ScreenContent"; +import { useAndroidControlSizing } from "../../../components/ui/useAndroidControlSizing"; +import { HomeRouteScreen } from "../../home/HomeRouteScreen"; +import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen"; +import { PullRequestToolbar } from "./PullRequestToolbar.android"; +import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout"; +import { WorkspaceSettledContentWidthContext } from "../workspace-content-width"; import { AndroidWorkspaceNavigation, FloatingWorkspaceTabs } from "./AndroidWorkspaceNavigation"; import { FLOATING_TABS } from "./floating-workspace-tabs"; import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext"; import { WorkspaceTabPanes } from "./WorkspaceTabPanes"; -import { WorkspaceAppBarScope } from "../../components/ui/WorkspaceAppBar.android"; -import { usePaneRole } from "../../components/ui/Pane"; +import { WorkspaceAppBarScope } from "../../../components/ui/WorkspaceAppBar.android"; +import { usePaneRole } from "../../../components/ui/Pane"; import { AndroidWorkspaceSidebar } from "./AndroidWorkspaceSidebar.android"; import type { WorkspaceTab } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx similarity index 52% rename from apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx index bed075eea317c..e8e7c0d325983 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx @@ -1,50 +1,43 @@ import { WorkspaceNoTabs } from "./WorkspaceNoTabs"; import { WORKSPACE_TAB_LABELS, useEnabledNavigationTabs } from "./navigation-tabs"; import { WorkspaceExtraTab } from "./WorkspaceExtraTab"; -import { SettingsRouteScreen } from "../settings/SettingsRouteScreen"; +import { WorkspaceSettingsList } from "../../settings/SettingsRouteScreen"; import { WorkspaceSectionToolbar } from "./WorkspaceSectionToolbar"; import { useNavigation } from "@react-navigation/native"; import { use } from "react"; -import { Platform, View, useWindowDimensions } from "react-native"; +import { View, useWindowDimensions } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; -import { CompactBrandTitle } from "../../components/CompactBrandTitle"; -import { AppBar } from "../../components/ui/AppBar"; -import { HeaderItemsWidthContext } from "../../native/header-items-width"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; -import { isDuoDevice } from "../../lib/device-type"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { CompactBrandTitle } from "../../../components/CompactBrandTitle"; +import { HeaderItemsWidthContext } from "../../../native/header-items-width"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { NativePrimaryColumnContext } from "../../../native/v5-workspace-context"; +import { isDuoDevice } from "../../../lib/device-type"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; import { useDeviceType, useNativeColumnLayoutMetrics, useNativeLayoutMetrics, -} from "../../native/native-layout-metrics"; -import { HomeRouteScreen } from "../home/HomeRouteScreen"; -import { dispatchHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { WorkspaceConnectionTitle } from "../home/WorkspaceConnectionTitle"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { PullRequestsScreen } from "../pull-requests/PullRequestsScreen"; +} from "../../../native/native-layout-metrics"; +import { HomeRouteScreen } from "../../home/HomeRouteScreen"; +import { WorkspaceConnectionTitle } from "../../home/WorkspaceConnectionTitle"; +import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout"; +import { PullRequestsScreen } from "../../pull-requests/PullRequestsScreen"; import { WorkspaceTabsContext, WorkspaceTabsProvider, useWorkspaceTabs, } from "./WorkspaceTabsContext"; import { WorkspaceToolbar } from "./WorkspaceToolbar"; -import { - WORKSPACE_DOCK, - workspaceDockBottomClearance, - workspaceToolbarHeight, -} from "./workspace-tabs-state"; +import { WORKSPACE_DOCK, workspaceDockBottomClearance } from "./workspace-tabs-state"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; import { useCompactWorkspaceTabs, useSidebarWorkspaceTabs, useSystemWorkspaceTabBar, } from "./useSystemWorkspaceTabBar"; -import { openSettings } from "../settings/settings-navigation"; -import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; +import { openSettings } from "../../settings/settings-navigation"; const TAB_TITLES = WORKSPACE_TAB_LABELS; @@ -73,7 +66,6 @@ function WorkspaceRootTitle(props: { function WorkspaceRootContent() { const navigation = useNavigation(); - const selectTab = useSelectWorkspaceTab(); const { width: windowWidth } = useWindowDimensions(); const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); const primaryColumn = use(NativePrimaryColumnContext); @@ -107,89 +99,58 @@ function WorkspaceRootContent() { const bottomClearance = layout.variant === "medium" && state.selectedTab === "usage" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) - : Platform.OS === "ios" && isDuoDevice(deviceType) + : isDuoDevice(deviceType) ? // The column's safe area already holds the Duo's search toolbar and keyboard. Math.max(insets.bottom, keyboardHeight) + 16 : compactTabs || deviceType === "pad" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) - : Platform.OS === "ios" - ? Math.max(insets.bottom, keyboardHeight) + - (systemTabBar || sidebarTabs || tab.searchMode !== "resting" - ? sidebarPicker - ? 80 - : 16 - : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16) - : keyboardHeight > 0 && tab.searchMode !== "resting" - ? keyboardHeight + 64 - : Math.max(insets.bottom, 12) + workspaceToolbarHeight(tab.searchMode) + 16; + : Math.max(insets.bottom, keyboardHeight) + + (systemTabBar || sidebarTabs || tab.searchMode !== "resting" + ? sidebarPicker + ? 80 + : 16 + : WORKSPACE_DOCK.height + WORKSPACE_DOCK.bottomGap + 16); const context = { ...workspace, bottomClearance }; return ( - {Platform.OS === "ios" ? ( - [] } + : {}), + unstable_headerLeftItems: () => [], + // Beside a detail column the tab bar names the tab and the detail header the page, + // so the sidebar keeps the brand. + headerTitle: + sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") + ? renderHeaderTitle : undefined, - contentStyle: undefined, - ...(enabled.length === 0 - ? { headerSearchBarOptions: undefined, unstable_headerRightItems: () => [] } - : {}), - unstable_headerLeftItems: () => [], - // Beside a detail column the tab bar names the tab and the detail header the page, - // so the sidebar keeps the brand. - headerTitle: - sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") - ? renderHeaderTitle - : undefined, - }} - /> - ) : ( - { - dispatchHardwareKeyboardCommand("focusSearch"); - }, - }, - ] - : []), - { - accessibilityLabel: "Settings", - icon: "gearshape", - onPress: () => selectTab("settings"), - }, - ]} - /> - )} + }} + /> {settingsOpen ? ( - + ) : enabled.length === 0 ? ( ) : state.selectedTab === "threads" ? ( @@ -203,12 +164,9 @@ function WorkspaceRootContent() { {deviceType !== "pad" && deviceType !== "phone" && sidebarPicker ? ( ) : null} - {/* Android's Settings list carries its own search in its app bar. */} - {enabled.length === 0 && !settingsOpen ? null : ( - settingsOpen - ? Platform.OS === "ios" - : state.selectedTab === "threads" || state.selectedTab === "pullRequests" - ) ? ( + {enabled.length === 0 && !settingsOpen ? null : settingsOpen || + state.selectedTab === "threads" || + state.selectedTab === "pullRequests" ? ( ) : ( @@ -219,11 +177,8 @@ function WorkspaceRootContent() { } export function WorkspaceRootScreen() { - const { layout } = useAdaptiveWorkspaceLayout(); const workspace = useWorkspaceTabs(); - return Platform.OS === "android" && layout.usesSplitView ? ( - - ) : workspace ? ( + return workspace ? ( ) : ( diff --git a/apps/mobile/src/features/workspace/WorkspaceSearchField.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSearchField.tsx similarity index 94% rename from apps/mobile/src/features/workspace/WorkspaceSearchField.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceSearchField.tsx index 98fb746e71d19..c5667fd7308e2 100644 --- a/apps/mobile/src/features/workspace/WorkspaceSearchField.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSearchField.tsx @@ -2,8 +2,8 @@ import type { Ref } from "react"; import { Keyboard, Pressable, TextInput, View, type TextInputInstance } from "react-native"; import { useResolveClassNames } from "uniwind"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { GlassSurface } from "../../components/ui/GlassSurface"; +import { SymbolView } from "../../../components/ui/AppSymbol"; +import { GlassSurface } from "../../../components/ui/GlassSurface"; import { WORKSPACE_TOOLBAR, type WorkspaceTab } from "./workspace-tabs-state"; export function WorkspaceSearchField({ diff --git a/apps/mobile/src/features/workspace/WorkspaceSectionToolbar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx similarity index 89% rename from apps/mobile/src/features/workspace/WorkspaceSectionToolbar.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx index 611d6c8b2f2ed..1676a927ecc6f 100644 --- a/apps/mobile/src/features/workspace/WorkspaceSectionToolbar.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSectionToolbar.tsx @@ -1,7 +1,7 @@ import { selectedNavigationTab, visibleNavigationTabs } from "./navigation-tab-order"; -import { Toolbar } from "../../components/ui/Toolbar"; -import { useControlTheme } from "../../components/ui/useControlTheme"; -import { useDeviceType } from "../../native/native-layout-metrics"; +import { Toolbar } from "../../../components/ui/Toolbar"; +import { useControlTheme } from "../../../components/ui/useControlTheme"; +import { useDeviceType } from "../../../native/native-layout-metrics"; import { NAVIGATION_TAB_ICONS, WORKSPACE_TAB_LABELS, @@ -12,7 +12,7 @@ import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; import { useCompactWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; -const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png"); +const PULL_REQUEST_ICON = require("../../../../assets/icons/pull-request.png"); export function WorkspaceTabToolbarButtons() { const order = useNavigationTabOrder(); diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.android.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.android.tsx new file mode 100644 index 0000000000000..2f13950ebb379 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.android.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +/** Android draws the sidebar's bar in-tree, so the column needs no stack of its own. */ +export function WorkspaceSidebarColumn(props: { + readonly width: number; + readonly children: ReactNode; +}) { + return <>{props.children}; +} diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.tsx new file mode 100644 index 0000000000000..743adc0d78a36 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarColumn.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { StyleSheet, View } from "react-native"; + +import { StandaloneStack } from "../nested-stacks"; +import { pageRoute } from "../route-options"; +import { getCompactBrandHeaderOptions } from "./brand-header-options"; + +const SIDEBAR_COLUMN_OPTIONS = { + ...pageRoute(), + ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }), +}; + +/** The iOS sidebar column hosts its own stack so it gets a native bar with the brand title. */ +export function WorkspaceSidebarColumn(props: { + readonly width: number; + readonly children: ReactNode; +}) { + return ( + + + {props.children} + + + ); +} diff --git a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.ios.tsx similarity index 97% rename from apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.ios.tsx index d46809c4bb1f7..0ebd5deb39132 100644 --- a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.ios.tsx @@ -1,7 +1,7 @@ import { selectedNavigationTab, visibleNavigationTabs } from "./navigation-tab-order"; import { requireNativeView } from "expo"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; -import { useControlTheme } from "../../components/ui/useControlTheme"; +import { useControlTheme } from "../../../components/ui/useControlTheme"; import { useNavigationTabOrder, useHiddenNavigationTabs } from "./navigation-tabs"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { useSelectWorkspaceTab } from "./useSelectWorkspaceTab"; diff --git a/apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.tsx similarity index 100% rename from apps/mobile/src/features/workspace/WorkspaceSidebarTabs.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceSidebarTabs.tsx diff --git a/apps/mobile/src/features/workspace/WorkspaceTabPanes.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabPanes.tsx similarity index 100% rename from apps/mobile/src/features/workspace/WorkspaceTabPanes.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceTabPanes.tsx diff --git a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx similarity index 98% rename from apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx index 6faa8c73d05ef..9ecb6fa933fa8 100644 --- a/apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceTabsContext.tsx @@ -2,7 +2,7 @@ import { RegistryContext, useAtomValue } from "@effect/atom-react"; import { AsyncResult, Atom } from "effect/reactivity"; import { createContext, use, useState, type Dispatch, type ReactNode } from "react"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { mobilePreferencesAtom } from "../../../state/preferences"; import { navigationCustomizationAvailable, resolveHiddenNavigationTabs, diff --git a/apps/mobile/src/features/workspace/WorkspaceThreadSidebar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceThreadSidebar.tsx similarity index 86% rename from apps/mobile/src/features/workspace/WorkspaceThreadSidebar.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceThreadSidebar.tsx index 9b968908094c5..f2602bd596017 100644 --- a/apps/mobile/src/features/workspace/WorkspaceThreadSidebar.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceThreadSidebar.tsx @@ -5,13 +5,13 @@ import type { import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation, useNavigationState } from "@react-navigation/native"; -import { scopedThreadKey } from "../../lib/scopedEntities"; -import { uuidv4 } from "../../lib/uuid"; -import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { openSettings } from "../settings/settings-navigation"; -import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; -import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests"; +import { scopedThreadKey } from "../../../lib/scopedEntities"; +import { uuidv4 } from "../../../lib/uuid"; +import { usePendingTaskListActions } from "../../home/usePendingTaskListActions"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; +import { openSettings } from "../../settings/settings-navigation"; +import { ThreadNavigationSidebar } from "../../threads/ThreadNavigationSidebar"; +import { useOpenThreadPullRequests } from "../../threads/use-open-thread-pull-requests"; import { useSelectWorkspaceDetail } from "./useSelectWorkspaceDetail"; type Route = { readonly name: string; readonly params?: object }; diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.ios.tsx similarity index 88% rename from apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceToolbar.ios.tsx index 00da5083cb3a3..0734c1e877482 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.ios.tsx @@ -1,20 +1,21 @@ -import { useActiveNewTask } from "../../state/active-new-task"; -import { Toolbar } from "../../components/ui/Toolbar"; +import { useActiveNewTask } from "../../../state/active-new-task"; +import { Toolbar } from "../../../components/ui/Toolbar"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { use, useCallback, useEffect, useRef } from "react"; -import { Platform } from "react-native"; -import { isDuoDevice } from "../../lib/device-type"; -import { useDeviceType } from "../../native/native-layout-metrics"; +import { isDuoDevice } from "../../../lib/device-type"; +import { useDeviceType } from "../../../native/native-layout-metrics"; +import { createNativeHeaderMenu } from "../../../components/ui/nativeHeaderMenu.ios"; +import { useSettingsEnvironmentMenu } from "../../settings/components/SettingsEnvironmentFilterHeader"; import type { SearchBarCommands } from "react-native-screens"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; +import { NativeStackScreenOptions } from "../../../native/StackHeader"; import { NativePrimaryColumnContext, NativeWorkspaceTabActivityContext, -} from "../../native/v5-workspace-context"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +} from "../../../native/v5-workspace-context"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; +import { useAdaptiveWorkspaceLayout } from "../AdaptiveWorkspaceLayout"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { shouldRestoreNativeSearch } from "./workspace-tabs-state"; import { useFloatingWorkspaceTabs, useSidebarWorkspaceTabs } from "./useSystemWorkspaceTabBar"; @@ -39,7 +40,6 @@ export function WorkspaceToolbar(props: { const bottomSearch = duoSearch && sidebarTabs !== "picker"; // On the Duo, compose belongs to the threads tab, in the detail column's bar when unfolded. const nativeToolbarCompose = - Platform.OS === "ios" && deviceType === "duo-folded" && !workspace.settingsOpen && workspace.state.selectedTab === "threads" && @@ -48,6 +48,7 @@ export function WorkspaceToolbar(props: { const focused = useIsFocused(); const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const searchRef = useRef(null); + const settingsMenu = useSettingsEnvironmentMenu(); // The Settings list takes the selected tab's place, so search follows it. const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab; const tab = workspace.state.tabs[selectedTab]; @@ -181,9 +182,13 @@ export function WorkspaceToolbar(props: { : undefined, }} /> - {/* UIKit lays trailing items out from the edge inward. A trailing toolbar replaces the - header's own items, so an empty one would hide the Settings scope filter. */} - {workspace.settingsOpen && !foldedSearch ? null : ( + {/* UIKit lays trailing items out from the edge inward. */} + {workspace.settingsOpen ? ( + + {searchButton} + {createNativeHeaderMenu(settingsMenu)} + + ) : ( {searchButton} {nativeToolbarCompose && foldedSearch ? : null} diff --git a/apps/mobile/src/features/workspace/WorkspaceToolbar.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx similarity index 93% rename from apps/mobile/src/features/workspace/WorkspaceToolbar.tsx rename to apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx index b9b8c84734e8a..32f84d373d73b 100644 --- a/apps/mobile/src/features/workspace/WorkspaceToolbar.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceToolbar.tsx @@ -10,15 +10,15 @@ import { import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; -import { GlassSurface } from "../../components/ui/GlassSurface"; -import { useHardwareBack } from "../../lib/backHandler"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; +import { SymbolView, type AppSymbolName } from "../../../components/ui/AppSymbol"; +import { GlassSurface } from "../../../components/ui/GlassSurface"; +import { useHardwareBack } from "../../../lib/backHandler"; +import { useHardwareKeyboardCommand } from "../../keyboard/hardwareKeyboardCommands"; import { WorkspaceDock } from "./WorkspaceDock"; import { WorkspaceSearchField } from "./WorkspaceSearchField"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { WORKSPACE_TOOLBAR, type WorkspaceTab } from "./workspace-tabs-state"; -import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; +import { PullRequestGlyph } from "../../pull-requests/pullRequestGlyphs"; const TABS: ReadonlyArray<{ id: WorkspaceTab; label: string; icon: AppSymbolName }> = [ { id: "threads", label: "Threads", icon: "text.bubble" }, diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.test.ts similarity index 94% rename from apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts rename to apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.test.ts index 35513187eab3b..e88bb83eecc3d 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts +++ b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { Hct, SchemeTonalSpot, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; -import { MOBILE_THEME_IDS } from "../../lib/mobileTheme"; -import { getMobileThemeRuntimeVariables } from "../../lib/mobileThemeVariables"; -import { materialYouPaletteToMobileThemeVariables } from "../../lib/materialYouTheme.android"; -import type { MaterialYouPalette } from "../../lib/materialYouPalette"; +import { MOBILE_THEME_IDS } from "../../../lib/mobileTheme"; +import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; +import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme.android"; +import type { MaterialYouPalette } from "../../../lib/materialYouPalette"; import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; function luminance(hex: string) { diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.ts similarity index 98% rename from apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts rename to apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.ts index 48216bfec4250..0e070f4bfaecc 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts +++ b/apps/mobile/src/features/layout/workspace/android-workspace-navigation-colors.ts @@ -4,7 +4,7 @@ import { themeColorWithAlpha, themeColorWithContrast, type MobileThemeVariables, -} from "../../lib/mobileTheme"; +} from "../../../lib/mobileTheme"; // A dark page swallows the native shadow, so the fill alone has to lift the pill off the page // and off the cards it floats over. diff --git a/apps/mobile/src/features/workspace/android-workspace-tabs.ts b/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts similarity index 91% rename from apps/mobile/src/features/workspace/android-workspace-tabs.ts rename to apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts index e4d50b47708b5..63fde490b6fa6 100644 --- a/apps/mobile/src/features/workspace/android-workspace-tabs.ts +++ b/apps/mobile/src/features/layout/workspace/android-workspace-tabs.ts @@ -1,10 +1,10 @@ import type { NavigationState } from "@react-navigation/native"; -import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation"; +import { androidWorkspaceNavigationState } from "../../../lib/android-workspace-navigation"; import { isSettingsRoute, settingsNavigationState, settingsOpenIn, -} from "../settings/settings-navigation"; +} from "../../settings/settings-navigation"; import type { WorkspaceTab } from "./workspace-tabs-state"; import { rememberedSettingsPage, workspaceSelectionsIn } from "./workspace-tab-navigation"; diff --git a/apps/mobile/src/features/layout/workspace/brand-header-options.tsx b/apps/mobile/src/features/layout/workspace/brand-header-options.tsx new file mode 100644 index 0000000000000..d8330e4eb6e5e --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/brand-header-options.tsx @@ -0,0 +1,41 @@ +import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; +import { Platform } from "react-native"; + +import { brandTitleOffset, CompactBrandTitle } from "../../../components/CompactBrandTitle"; +import { WorkspaceConnectionTitle } from "../../home/WorkspaceConnectionTitle"; + +function renderCompactBrandTitle() { + return ; +} + +export function getCompactBrandHeaderOptions( + fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], +): NativeStackNavigationOptions { + return { + headerTitle: renderCompactBrandTitle, + headerTitleStyle: fallbackTitleStyle, + title: "Threads", + unstable_headerLeftItems: undefined, + }; +} + +export function getConnectionAwareBrandHeaderOptions(opts: { + readonly headerWidth: number; + readonly trailingItemCount?: number; + readonly onOpenEnvironments: () => void; + readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; +}): NativeStackNavigationOptions { + const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); + + return { + ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle), + headerTitle: () => ( + } + maxWidth={maxWidth} + onPress={opts.onOpenEnvironments} + statusOffset={brandTitleOffset()} + /> + ), + }; +} diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts b/apps/mobile/src/features/layout/workspace/floating-workspace-tabs.test.ts similarity index 94% rename from apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts rename to apps/mobile/src/features/layout/workspace/floating-workspace-tabs.test.ts index 59dc131b1c870..bfb2feeb5be2c 100644 --- a/apps/mobile/src/features/workspace/floating-workspace-tabs.test.ts +++ b/apps/mobile/src/features/layout/workspace/floating-workspace-tabs.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vite-plus/test"; import { floatingTabsMetrics } from "./floating-workspace-tabs"; -import { resolveAndroidNavigationStyle } from "../../lib/androidNavigationStyle"; +import { resolveAndroidNavigationStyle } from "../../../lib/androidNavigationStyle"; it.each([ [undefined, "Samsung", false, "one-ui"], diff --git a/apps/mobile/src/features/workspace/floating-workspace-tabs.ts b/apps/mobile/src/features/layout/workspace/floating-workspace-tabs.ts similarity index 100% rename from apps/mobile/src/features/workspace/floating-workspace-tabs.ts rename to apps/mobile/src/features/layout/workspace/floating-workspace-tabs.ts diff --git a/apps/mobile/src/features/workspace/navigation-tab-order.test.ts b/apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts similarity index 100% rename from apps/mobile/src/features/workspace/navigation-tab-order.test.ts rename to apps/mobile/src/features/layout/workspace/navigation-tab-order.test.ts diff --git a/apps/mobile/src/features/workspace/navigation-tab-order.ts b/apps/mobile/src/features/layout/workspace/navigation-tab-order.ts similarity index 100% rename from apps/mobile/src/features/workspace/navigation-tab-order.ts rename to apps/mobile/src/features/layout/workspace/navigation-tab-order.ts diff --git a/apps/mobile/src/features/workspace/navigation-tabs.ts b/apps/mobile/src/features/layout/workspace/navigation-tabs.ts similarity index 87% rename from apps/mobile/src/features/workspace/navigation-tabs.ts rename to apps/mobile/src/features/layout/workspace/navigation-tabs.ts index b7b9b73ae6048..3472242dc4f56 100644 --- a/apps/mobile/src/features/workspace/navigation-tabs.ts +++ b/apps/mobile/src/features/layout/workspace/navigation-tabs.ts @@ -8,9 +8,9 @@ import { enabledNavigationTabs, type NavigationTab, } from "./navigation-tab-order"; -import type { AppSymbolName } from "../../components/ui/AppSymbol"; -import { mobilePreferencesAtom } from "../../state/preferences"; -import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; +import type { AppSymbolName } from "../../../components/ui/AppSymbol"; +import { mobilePreferencesAtom } from "../../../state/preferences"; +import { PullRequestGlyph } from "../../pull-requests/pullRequestGlyphs"; import { resolveWorkspaceTabOrder } from "./workspace-tabs-state"; export const WORKSPACE_TAB_LABELS = { diff --git a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts similarity index 97% rename from apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts rename to apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts index 92e157a45f041..dfc8e13d162c1 100644 --- a/apps/mobile/src/features/workspace/useAndroidWorkspaceNavigation.ts +++ b/apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts @@ -4,7 +4,7 @@ import { BackHandler, Keyboard, Platform } from "react-native"; import { resetAndroidWorkspace, returnToAndroidWorkspaceList, -} from "../../lib/android-workspace-navigation"; +} from "../../../lib/android-workspace-navigation"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/useSearchScrollRestoration.test.tsx b/apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.test.tsx similarity index 100% rename from apps/mobile/src/features/workspace/useSearchScrollRestoration.test.tsx rename to apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.test.tsx diff --git a/apps/mobile/src/features/workspace/useSearchScrollRestoration.ts b/apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.ts similarity index 100% rename from apps/mobile/src/features/workspace/useSearchScrollRestoration.ts rename to apps/mobile/src/features/layout/workspace/useSearchScrollRestoration.ts diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts similarity index 89% rename from apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts rename to apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts index 59d1ac1fd8dcd..e487d9af071ba 100644 --- a/apps/mobile/src/features/workspace/useSelectWorkspaceDetail.ts +++ b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceDetail.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { startOpeningEnvironmentThread } from "../../state/threads"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; +import { startOpeningEnvironmentThread } from "../../../state/threads"; import { selectWorkspaceDetail, type WorkspaceDetail, diff --git a/apps/mobile/src/features/workspace/useSelectWorkspaceTab.ts b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts similarity index 87% rename from apps/mobile/src/features/workspace/useSelectWorkspaceTab.ts rename to apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts index 8882a9f9d0b24..25d227b2736dc 100644 --- a/apps/mobile/src/features/workspace/useSelectWorkspaceTab.ts +++ b/apps/mobile/src/features/layout/workspace/useSelectWorkspaceTab.ts @@ -1,6 +1,6 @@ import { Keyboard } from "react-native"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts b/apps/mobile/src/features/layout/workspace/useSystemWorkspaceTabBar.ts similarity index 87% rename from apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts rename to apps/mobile/src/features/layout/workspace/useSystemWorkspaceTabBar.ts index f5a3f0af70346..3cf78b71048d3 100644 --- a/apps/mobile/src/features/workspace/useSystemWorkspaceTabBar.ts +++ b/apps/mobile/src/features/layout/workspace/useSystemWorkspaceTabBar.ts @@ -1,8 +1,8 @@ -import { isDuoDevice } from "../../lib/device-type"; +import { isDuoDevice } from "../../../lib/device-type"; import { Platform } from "react-native"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { useAdaptiveWorkspaceLayout } from "../layout/adaptive-workspace-context"; -import { useDeviceType, useNativeLayoutMetrics } from "../../native/native-layout-metrics"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; +import { useAdaptiveWorkspaceLayout } from "../adaptive-workspace-context"; +import { useDeviceType, useNativeLayoutMetrics } from "../../../native/native-layout-metrics"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import { workspaceUsesColumns } from "./workspace-tabs-state"; diff --git a/apps/mobile/src/features/workspace/workspace-detail-selection.test.ts b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts similarity index 100% rename from apps/mobile/src/features/workspace/workspace-detail-selection.test.ts rename to apps/mobile/src/features/layout/workspace/workspace-detail-selection.test.ts diff --git a/apps/mobile/src/features/workspace/workspace-detail-selection.ts b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts similarity index 98% rename from apps/mobile/src/features/workspace/workspace-detail-selection.ts rename to apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts index fed18d6089f73..ad7f37f14036c 100644 --- a/apps/mobile/src/features/workspace/workspace-detail-selection.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-detail-selection.ts @@ -6,7 +6,7 @@ import type { Router, StackNavigationState, } from "@react-navigation/native"; -import { workspaceRoutesOf } from "../layout/workspace-overlay-routes"; +import { workspaceRoutesOf } from "../workspace-overlay-routes"; type RootParams = ReactNavigation.RootParamList; diff --git a/apps/mobile/src/features/layout/workspace/workspace-list-header-items.ts b/apps/mobile/src/features/layout/workspace/workspace-list-header-items.ts new file mode 100644 index 0000000000000..4ee54230835d7 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/workspace-list-header-items.ts @@ -0,0 +1,69 @@ +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, +} from "@react-navigation/native-stack"; + +import type { AppBarMenuItem } from "../../../components/ui/AppBar.types"; +import { withNativeGlassHeaderItem } from "../native-glass-header-items"; + +type NativeHeaderMenuItems = NativeStackHeaderItemMenu["menu"]["items"]; +type NativeHeaderIcon = NonNullable["icon"]>; + +function sfSymbolIcon(name: string): NativeHeaderIcon { + return { type: "sfSymbol", name: name as never }; +} + +function toNativeHeaderMenuItems(items: ReadonlyArray): NativeHeaderMenuItems { + return items.map((item) => + "items" in item + ? { + type: "submenu" as const, + label: item.title ?? "", + items: toNativeHeaderMenuItems(item.items), + } + : { + type: "action" as const, + label: item.title, + description: item.subtitle, + onPress: item.onPress, + state: item.selected ? ("on" as const) : undefined, + }, + ); +} + +/** The sidebar column's bar items, as native header items so the title can size around them. */ +export function createWorkspaceListHeaderItems(input: { + readonly filter: { + readonly accessibilityLabel: string; + readonly icon: string; + readonly title: string; + readonly items: ReadonlyArray; + }; + readonly onOpenSettings?: () => void; +}): NativeStackHeaderItem[] { + return [ + withNativeGlassHeaderItem({ + type: "menu", + axisBehavior: "horizontalOnly", + label: "", + accessibilityLabel: input.filter.accessibilityLabel, + icon: sfSymbolIcon(input.filter.icon), + menu: { + title: input.filter.title, + items: toNativeHeaderMenuItems(input.filter.items), + }, + }), + ...(input.onOpenSettings + ? [ + withNativeGlassHeaderItem({ + type: "button", + axisBehavior: "horizontalOnly", + label: "Settings", + accessibilityLabel: "Open settings", + icon: sfSymbolIcon("gearshape"), + onPress: input.onOpenSettings, + }), + ] + : []), + ]; +} diff --git a/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts b/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts new file mode 100644 index 0000000000000..ae7048bcdba04 --- /dev/null +++ b/apps/mobile/src/features/layout/workspace/workspace-list-insets.ts @@ -0,0 +1,15 @@ +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; + +/** A workspace list scrolls under its bar where the bar is glass; under a solid bar it starts below. */ +export const WORKSPACE_LIST_SCROLL_INSETS = { + automaticallyAdjustsScrollIndicatorInsets: NATIVE_LIQUID_GLASS_SUPPORTED, + contentInsetAdjustmentBehavior: NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never", +} as const; + +/** How far an empty workspace list keeps its message below a glass bar and its search. */ +export function useWorkspaceListEmptyTopInset(): number { + const insets = useSafeAreaInsets(); + return NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0; +} diff --git a/apps/mobile/src/features/workspace/workspace-selection-store.ts b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts similarity index 90% rename from apps/mobile/src/features/workspace/workspace-selection-store.ts rename to apps/mobile/src/features/layout/workspace/workspace-selection-store.ts index 62adcd070059f..19d2653ddedfd 100644 --- a/apps/mobile/src/features/workspace/workspace-selection-store.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-selection-store.ts @@ -3,27 +3,27 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Linking from "expo-linking"; import { AppState, Dimensions, Platform } from "react-native"; -import { environmentCatalog } from "../../connection/catalog"; -import { deriveLayout } from "../../lib/layout"; +import { environmentCatalog } from "../../../connection/catalog"; +import { deriveLayout } from "../../../lib/layout"; import { NATIVE_METRICS_TIMEOUT_MS, nativeWorkspaceColumnsSupportedFor, -} from "../../native/NativeWorkspaceColumns"; +} from "../../../native/NativeWorkspaceColumns"; import { currentNativeLayoutMetrics, firstNativeLayoutMetrics, -} from "../../native/native-layout-metrics"; -import { loadPreferences, savePreferencesPatch } from "../../persistence/imperative"; -import { rootStackState, type RootStackNavigation } from "../../lib/root-stack-reset"; -import { workspaceRoutesOf } from "../layout/workspace-overlay-routes"; -import type { WorkspaceSelections } from "../../persistence/mobile-preferences"; -import { appAtomRegistry } from "../../state/atom-registry"; -import { environmentPresentations } from "../../state/presentation"; +} from "../../../native/native-layout-metrics"; +import { loadPreferences, savePreferencesPatch } from "../../../persistence/imperative"; +import { rootStackState, type RootStackNavigation } from "../../../lib/root-stack-reset"; +import { workspaceRoutesOf } from "../workspace-overlay-routes"; +import type { WorkspaceSelections } from "../../../persistence/mobile-preferences"; +import { appAtomRegistry } from "../../../state/atom-registry"; +import { environmentPresentations } from "../../../state/presentation"; import { environmentThreadShells, startOpeningEnvironmentThread, threadEnvironment, -} from "../../state/threads"; +} from "../../../state/threads"; import { rememberedSettingsPage, workspaceSelectionsIn, diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts similarity index 96% rename from apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts rename to apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 1d30f2c02e6d7..3f217fd70c8bc 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { restoreSettingsLinkState } from "../settings/settings-navigation"; +import { restoreSettingsLinkState } from "../../settings/settings-navigation"; import { workspaceSelectionsIn, workspaceTabColumns, @@ -101,6 +101,11 @@ describe("opening a workspace tab", () => { "Settings", "SettingsEnvironments", ]); + expect( + names( + workspaceTabNavigationState(state, "settings", { settings: "SettingsMicrophone" }, columns), + ), + ).toEqual(["Home", "Settings", "SettingsVoice"]); }); it("keeps the route that is already open, so re-selecting a tab doesn't rebuild it", () => { diff --git a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts similarity index 91% rename from apps/mobile/src/features/workspace/workspace-tab-navigation.ts rename to apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts index 3674c52972e03..6a44e617e53ed 100644 --- a/apps/mobile/src/features/workspace/workspace-tab-navigation.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.ts @@ -1,9 +1,9 @@ -import type { WorkspaceSelections } from "../../persistence/mobile-preferences"; +import type { WorkspaceSelections } from "../../../persistence/mobile-preferences"; import { SETTINGS_PAGE_TARGETS, type SettingsPageTarget, -} from "../settings/components/settings-page-targets"; -import { settingsNavigationState } from "../settings/settings-navigation"; +} from "../../settings/components/settings-page-targets"; +import { settingsNavigationState } from "../../settings/settings-navigation"; import type { WorkspaceTab } from "./workspace-tabs-state"; type RouteLike = { @@ -32,6 +32,7 @@ const REMEMBERED_SETTINGS_PAGES: ReadonlySet = new Set( ); export function rememberedSettingsPage(selections: WorkspaceSelections): SettingsPageTarget { + if (selections.settings === "SettingsMicrophone") return "SettingsVoice"; return selections.settings && REMEMBERED_SETTINGS_PAGES.has(selections.settings) ? (selections.settings as SettingsPageTarget) : DEFAULT_SETTINGS_PAGE; @@ -150,6 +151,7 @@ function detailRoute( tab: "threads" | "pullRequests", selections: WorkspaceSelections, check: WorkspaceSelectionCheck, + seedPullRequest?: (identity: Omit) => object, ): RouteLike | undefined { if (tab === "threads") { const draft = selections.threadsDraft; @@ -184,7 +186,11 @@ function detailRoute( (route) => route.name === "PullRequest" && samePullRequest(pullRequestSelection(route.params), pullRequest), - ) ?? { key: pullRequest.key, name: "PullRequest", params: identityOf(pullRequest) } + ) ?? { + key: pullRequest.key, + name: "PullRequest", + params: seedPullRequest?.(identityOf(pullRequest)) ?? identityOf(pullRequest), + } ); } @@ -204,6 +210,7 @@ export function workspaceTabNavigationState< readonly columns: boolean; readonly check: WorkspaceSelectionCheck; readonly overlays?: readonly RouteLike[]; + readonly seedPullRequest?: (identity: Omit) => object; }, ) { const overlays = options.overlays ?? []; @@ -221,7 +228,7 @@ export function workspaceTabNavigationState< const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" }; const detail = options.columns && tab !== "usage" - ? detailRoute(state, tab, selections, options.check) + ? detailRoute(state, tab, selections, options.check, options.seedPullRequest) : undefined; const routes = [...[home, ...(detail ? [detail] : [])].map(keyed), ...overlays]; return { ...state, index: routes.length - 1, routes }; diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts similarity index 92% rename from apps/mobile/src/features/workspace/workspace-tabs-state.test.ts rename to apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts index 06d4a49e05fee..f1c839630eb34 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.test.ts @@ -181,7 +181,7 @@ describe("workspace root navigation", () => { const mocks = vi.hoisted(() => ({ split: false, floating: false })); -vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ +vi.mock("../../settings/appearance/AppearancePreferencesProvider", () => ({ useAndroidFloatingTabs: () => mocks.floating, })); vi.mock("react-native", () => ({ @@ -192,7 +192,7 @@ vi.mock("react-native-safe-area-context", () => ({ SafeAreaInsetsContext: createContext({ top: 0, right: 0, bottom: 24, left: 0 }), useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 24, left: 0 }), })); -vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ +vi.mock("../AdaptiveWorkspaceLayout", () => ({ useAdaptiveWorkspaceLayout: () => ({ layout: { usesSplitView: mocks.split }, startNewTask: () => undefined, @@ -211,30 +211,30 @@ vi.mock("./WorkspaceExtraTab", () => ({ return createElement("button", { onClick: () => setTab("Usage") }, tab); }, })); -vi.mock("../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); -vi.mock("../../components/ui/AppBar", () => ({ AppBar: () => null })); -vi.mock("../layout/WorkspaceEmptyDetail", () => ({ WorkspaceEmptyDetail: () => null })); -vi.mock("../../components/ui/ScreenContent", () => ({ +vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../../components/ui/AppBar", () => ({ AppBar: () => null })); +vi.mock("../WorkspaceEmptyDetail", () => ({ WorkspaceEmptyDetail: () => null })); +vi.mock("../../../components/ui/ScreenContent", () => ({ ScreenContent: ({ children }: { children: ReactNode }) => createElement("div", null, children), })); -vi.mock("../../components/ui/useAndroidControlSizing", () => ({ +vi.mock("../../../components/ui/useAndroidControlSizing", () => ({ useAndroidControlSizing: () => ({ fabClearance: 64, scale: 1, fabSize: 56 }), })); -vi.mock("../home/HomeRouteScreen", () => ({ HomeRouteScreen: () => null })); -vi.mock("../pull-requests/PullRequestFilterMenuContext", () => ({ +vi.mock("../../home/HomeRouteScreen", () => ({ HomeRouteScreen: () => null })); +vi.mock("../../pull-requests/PullRequestFilterMenuContext", () => ({ PullRequestFilterMenuProvider: ({ children }: { children: ReactNode }) => createElement("div", null, children), })); -vi.mock("../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null })); -vi.mock("../pull-requests/PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null })); +vi.mock("../../pull-requests/PullRequestsScreen", () => ({ PullRequestsScreen: () => null })); +vi.mock("./PullRequestToolbar.android", () => ({ PullRequestToolbar: () => null })); vi.mock("./AndroidWorkspaceNavigation", () => ({ AndroidWorkspaceNavigation: () => null, FloatingWorkspaceTabs: () => null, })); vi.mock("./WorkspaceNoTabs", () => ({ WorkspaceNoTabs: () => null })); -vi.mock("../../components/ui/Pane", () => ({ usePaneRole: () => "screen" })); +vi.mock("../../../components/ui/Pane", () => ({ usePaneRole: () => "screen" })); vi.mock("./AndroidWorkspaceSidebar.android", () => ({ AndroidWorkspaceSidebar: () => null })); -vi.mock("../../components/ui/WorkspaceAppBar.android", () => ({ +vi.mock("../../../components/ui/WorkspaceAppBar.android", () => ({ WorkspaceAppBarScope: ({ children }: { children: ReactNode }) => children, })); diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.ts b/apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts similarity index 100% rename from apps/mobile/src/features/workspace/workspace-tabs-state.ts rename to apps/mobile/src/features/layout/workspace/workspace-tabs-state.ts diff --git a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx index 8812c7347a5be..3a1b640b41933 100644 --- a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx @@ -1,9 +1,4 @@ import type { StaticScreenProps } from "@react-navigation/native"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { - addProjectRemoteSourceLabel, - parseAddProjectRemoteSource, -} from "@t3tools/client-runtime/operations/projects"; import { AddProjectRepositoryScreen } from "./AddProjectScreen"; @@ -15,14 +10,5 @@ type AddProjectRepositoryRouteParams = { export function AddProjectRepositoryRoute({ route, }: StaticScreenProps) { - const params = route.params ?? {}; - const source = Array.isArray(params.source) ? params.source[0] : params.source; - const title = addProjectRemoteSourceLabel(parseAddProjectRemoteSource(source)); - - return ( - <> - - - - ); + return ; } diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index b251310ef12f9..ab331d0149e15 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -4,7 +4,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeModule from "node:module"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import { workspaceDetailRouter } from "../workspace/workspace-detail-selection"; +import { workspaceDetailRouter } from "../layout/workspace/workspace-detail-selection"; import type { NavigationState } from "@react-navigation/native"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -46,8 +46,8 @@ function loadRouters() { }; } -vi.mock("../workspace/useSelectWorkspaceDetail", async () => { - const { selectWorkspaceDetail } = await import("../workspace/workspace-detail-selection"); +vi.mock("../layout/workspace/useSelectWorkspaceDetail", async () => { + const { selectWorkspaceDetail } = await import("../layout/workspace/workspace-detail-selection"); return { useSelectWorkspaceDetail: () => (detail: Parameters[0]) => state.dispatch(selectWorkspaceDetail(detail)), diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index ddd6ec120d392..93680aa38159e 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,5 +1,5 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; +import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; import * as Dialog from "../../components/ui/Dialog"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { NavigationRow } from "../../components/ui/NavigationRow"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestCompletionSuggestions.tsx b/apps/mobile/src/features/pull-requests/PullRequestCompletionSuggestions.tsx new file mode 100644 index 0000000000000..2d403d68727d7 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestCompletionSuggestions.tsx @@ -0,0 +1,105 @@ +import { Pressable, ScrollView, StyleSheet, View } from "react-native"; + +import { AppText as Text } from "../../components/ui/AppText"; +import { GlassSurface } from "../../components/ui/GlassSurface"; +import { PullRequestAvatar } from "./PullRequestPresentation"; +import type { PullRequestCompletion } from "@t3tools/shared/pullRequestComposerTrigger"; +import type { usePullRequestCompletions } from "./usePullRequestCompletions"; + +const COPY = { + mention: { + label: "Mention a user", + pending: "Searching users...", + failed: "Couldn't search users on the host", + none: "No matching users", + }, + reference: { + label: "Reference an issue or change request", + pending: "Searching...", + failed: "Couldn't search the host", + none: "No matching issues or change requests", + }, +} as const; + +const STATE_LABEL = { open: "Open", closed: "Closed", merged: "Merged" } as const; + +function CompletionRow({ completion }: { readonly completion: PullRequestCompletion }) { + if (completion.kind === "mention") { + const { actor } = completion; + return ( + <> + + + {actor.login} + + {actor.name && actor.name !== actor.login ? ( + + {actor.name} + + ) : null} + + ); + } + const { candidate } = completion; + return ( + <> + + {candidate.reference} + + + {candidate.title} + + + {STATE_LABEL[candidate.state]} + + + ); +} + +/** The `@`, `#` and `!` suggestions for a pull request text input, rendered just above it. */ +export function PullRequestCompletionSuggestions({ + completions, +}: { + readonly completions: ReturnType; +}) { + if (completions.kind === null) return null; + const copy = COPY[completions.kind]; + return ( + + {completions.candidates.length > 0 ? ( + + {completions.candidates.map((completion, index) => ( + completions.select(completion)} + className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60" + style={{ + borderBottomWidth: + index === completions.candidates.length - 1 ? 0 : StyleSheet.hairlineWidth, + }} + > + + + ))} + + ) : ( + + + {copy[completions.isLoading ? "pending" : completions.failed ? "failed" : "none"]} + + + )} + + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx index b298094f74bea..9e4231ad65dd2 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx @@ -1,4 +1,3 @@ -import { Toolbar } from "../../components/ui/Toolbar"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import { useAtomRefresh } from "@effect/atom-react"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; @@ -9,7 +8,7 @@ import { import { AsyncResult } from "effect/reactivity"; import type { PullRequestThreadComment } from "@t3tools/contracts"; import { useRef, useState } from "react"; -import { Platform, ScrollView, View } from "react-native"; +import { ScrollView, View } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -17,7 +16,6 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/ import { AppBar } from "../../components/ui/AppBar"; import { Button } from "../../components/ui/Button"; import { GlassSurface } from "../../components/ui/GlassSurface"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; import { relativeTime } from "../../lib/time"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -28,6 +26,8 @@ import { PullRequestCommentFooter } from "./PullRequestReactions"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; +import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions"; +import { usePullRequestCompletions } from "./usePullRequestCompletions"; export function PullRequestDiscussionScreen({ route, @@ -57,6 +57,13 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss const loadMore = useAtomCommand(composerPullRequests.threadComments, { reportFailure: false }); const thread = activity.data?.reviewThreads.find((thread) => thread.id === params.discussionId); const [body, setBody] = useState(""); + const completions = usePullRequestCompletions({ + environmentId: params.environmentId, + projectId: params.projectId, + value: body, + onChangeValue: setBody, + support: detail.data?.capabilities ?? null, + }); const [extra, setExtra] = useState([]); const [cursor, setCursor] = useState(); const [busy, setBusy] = useState(false); @@ -136,38 +143,23 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss return ( - navigation.goBack()} + actions={ + permissions.canResolve && thread + ? [ + { + accessibilityLabel: resolveLabel, + icon: thread.isResolved ? "arrow.uturn.backward" : "checkmark", + disabled: busy || activity.isPending, + onPress: () => void toggleResolved(), + }, + ] + : [] + } /> - {Platform.OS === "android" ? ( - navigation.goBack()} - actions={ - permissions.canResolve && thread - ? [ - { - accessibilityLabel: resolveLabel, - icon: thread.isResolved ? "arrow.uturn.backward" : "checkmark", - disabled: busy || activity.isPending, - onPress: () => void toggleResolved(), - }, - ] - : [] - } - /> - ) : null} - {permissions.canResolve && thread ? ( - - void toggleResolved()} - /> - - ) : null} {canReply ? ( - + + setComposerHeight(event.nativeEvent.layout.height)} @@ -247,6 +243,9 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss > (null); const value = draft ?? original ?? ""; + const completions = usePullRequestCompletions({ + environmentId: params.environmentId, + projectId: params.projectId, + value: value, + onChangeValue: setDraft, + support: params.field === "title" ? null : (pr?.capabilities ?? null), + }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const inFlight = useRef(false); @@ -131,8 +140,12 @@ export function PullRequestEditScreen({ route }: StaticScreenProps } > + navigation.goBack()} - // iOS gets the native menu below; SheetHeader would render this as a custom bar item. - trailing={Platform.OS === "android" ? : undefined} + menus={[ + { + title: "Filter review files", + icon: "line.3.horizontal.decrease", + items: [ + { + id: "hide-tests", + title: "Hide test files", + icon: "line.3.horizontal.decrease", + selected: filter.hideTestFiles, + onPress: filter.toggle, + }, + ], + }, + ]} /> - - - - Hide test files - - - item.key} diff --git a/apps/mobile/src/features/pull-requests/PullRequestFilterSearchScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestFilterSearchScreen.tsx index b932412d2f19a..8be52952fe53f 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestFilterSearchScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestFilterSearchScreen.tsx @@ -117,7 +117,6 @@ export function PullRequestFilterSearchScreen({ placeholder={placeholder} closeAccessibilityLabel={kind === "labels" ? "Close label filter" : "Close author filter"} clearAccessibilityLabel={kind === "labels" ? "Clear label search" : "Clear author search"} - searchTextChangeId="pr-filter-option-search" query={query} onQueryChange={setQuery} > diff --git a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx index d0b7a5ae88019..d199fe3990ca4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestInlineCommentScreen.tsx @@ -15,6 +15,8 @@ import { usePullRequestReviewDraft } from "./usePullRequestReviewDraft"; import { reviewPositionLabel } from "./pullRequestDiffComments"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; +import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions"; +import { usePullRequestCompletions } from "./usePullRequestCompletions"; export type InlineCommentParams = PullRequestParams & { readonly draftId?: string; @@ -33,6 +35,13 @@ export function PullRequestInlineCommentScreen({ route }: StaticScreenProps + diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index 38ac98f3b2a84..cbc2c10ce3d6e 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -4,7 +4,7 @@ import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/cont import { Atom } from "effect/reactivity"; import { Platform, ScrollView } from "react-native"; import type { MenuProps } from "../../components/ui/Menu.types"; -import { ListFilterMenu } from "../workspace/ListFilterMenu"; +import { ListFilterMenu } from "../layout/workspace/ListFilterMenu"; import { environmentProjects } from "../../state/projects"; import { environmentThreadShells } from "../../state/threads"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index f08b5c4846884..d829f6f649b86 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -11,7 +11,10 @@ import { } from "./PullRequestMedia"; import { videoMimeType } from "@t3tools/shared/video"; import { createContext, useContext, useMemo, useState } from "react"; -import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import { + changeRequestReferenceAutolinker, + resolveChangeRequestMediaUrl, +} from "@t3tools/client-runtime/pull-request-markdown"; import { sourceControlClients, type ReferenceAutolink, @@ -37,6 +40,7 @@ const AutolinksContext = createContext<((text: string) => ReadonlyArray(null); export function PullRequestMarkdownRepository(props: { /** The change request the markdown belongs to, whose host decides what its text links. */ @@ -57,11 +61,15 @@ export function PullRequestMarkdownRepository(props: { projectId: target.input.projectId, }); const cwd = project?.workspaceRoot; - const media = cwd ? { environmentId: target.environmentId, cwd } : null; + const media = cwd + ? { environmentId: target.environmentId, cwd, repository: target.input.repository } + : null; return ( - {props.children} + + {props.children} + ); @@ -157,6 +165,9 @@ function PullRequestMarkdownContent(props: { const hostLineBreaks = useContext(LineBreaksContext); const autolinks = useContext(AutolinksContext); const mediaContext = useContext(PullRequestMediaContext); + const changeRequestUrl = useContext(ChangeRequestUrlContext); + const resolveMediaUrl = (href: string) => + changeRequestUrl ? resolveChangeRequestMediaUrl(href, changeRequestUrl) : href; const markdown = linkReferenceAutolinks(props.markdown, autolinks); const openPullRequest = useOpenPullRequest(); const openLink = (href: string) => { @@ -165,6 +176,7 @@ function PullRequestMarkdownContent(props: { const gallery: FilePreviewItem[] = markdownImageSources( props.imageGroupMarkdown ?? props.markdown, ) + .map(resolveMediaUrl) .filter( (uri) => /^https?:\/\//i.test(uri) && @@ -173,7 +185,7 @@ function PullRequestMarkdownContent(props: { .map((uri) => pullRequestImageSource(uri, mediaContext)); const [preview, setPreview] = useState(null); const renderImage: MarkdownImageRenderer = (image) => ( - + ); const styles = useMarkdownPreviewStyles(); return ( @@ -184,7 +196,9 @@ function PullRequestMarkdownContent(props: { textStyle={styles.nativeTextStyle} onLinkPress={openLink} renderImage={renderImage} - onImagePress={(href) => setPreview(pullRequestImageSource(href, mediaContext))} + onImagePress={(href) => + setPreview(pullRequestImageSource(resolveMediaUrl(href), mediaContext)) + } onTaskListItemPress={props.onTaskListItemPress} /> setPreview(null)} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 54d3a82227731..3fcfbceb842f4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -1,12 +1,10 @@ import { createContext, useContext, useState } from "react"; -import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; -import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { - SOURCE_CONTROL_MEDIA_ASSET, - type AssetResource, - type EnvironmentId, -} from "@t3tools/contracts"; + pullRequestMediaSource, + pullRequestImageSource, + type PullRequestMediaLocation, +} from "./pullRequestMediaSource"; +import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; import { videoMimeType } from "@t3tools/shared/video"; import { Pressable, View } from "react-native"; import { Image } from "expo-image"; @@ -25,25 +23,19 @@ import { export const PullRequestMediaVisibleContext = createContext(true); -export const PullRequestMediaContext = createContext<{ - readonly environmentId: EnvironmentId; - readonly cwd: string; -} | null>(null); +export const PullRequestMediaContext = createContext(null); export function usePullRequestMediaUrl(url: string) { const context = useContext(PullRequestMediaContext); - const fetchUrl = sourceControlClients.mediaUrl(url)?.fetchUrl; - const resource: AssetResource | null = - context && fetchUrl - ? { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: context.cwd, url: fetchUrl } - : null; + const source = pullRequestMediaSource(url, context); + const resource = "resource" in source ? (source.resource ?? null) : null; const asset = useAssetUrlState(context?.environmentId ?? null, resource); const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource); return { uri: asset._tag === "Success" ? asset.url : resource ? null : url, cacheKey: context && resource ? assetImageCacheKey(context.environmentId, resource) : undefined, unavailable: resource !== null && asset._tag === "Failure", - source: pullRequestMediaSource(url, context), + source, resolvePlaybackUri: resource ? async () => (await refresh()) ?? null : undefined, }; } diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index cc09ac5c5d6d7..2d0414b503653 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -162,7 +162,7 @@ export function PullRequestMergeControls(props: { }, }); // A stack can partly merge before the provider reports a failure. - refreshDetail(); + if (!AsyncResult.isSuccess(response)) refreshDetail(); refreshActivity(); stack.refresh(); pending.current = false; diff --git a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx index 647aa26e46373..2494dd93ea0fb 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx @@ -1,6 +1,6 @@ import { Chip } from "../../components/ui/Chip"; import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; -import type { PullRequestListEntry } from "@t3tools/contracts"; +import type { PullRequestListEntry, PullRequestSummary } from "@t3tools/contracts"; import { View } from "react-native"; import { PullRequestCardGroup, PullRequestCardRow } from "./PullRequestControls"; import { PullRequestStackCard } from "./PullRequestStackCard"; @@ -14,11 +14,14 @@ export function PullRequestOverviewLoading({ params, stack, }: { - readonly summary: PullRequestListEntry; + readonly summary: PullRequestListEntry | PullRequestSummary; readonly params: PullRequestParams; readonly stack: PullRequestStackState; }) { - const review = pullRequestListReviewStatus(summary); + const review = pullRequestListReviewStatus({ + reviewDecision: summary.reviewDecision ?? undefined, + viewerReviewRequested: "viewerReviewRequested" in summary && summary.viewerReviewRequested, + }); const conflicting = summary.mergeability === "conflicting"; const clean = summary.mergeability === "mergeable"; return ( @@ -89,7 +92,7 @@ export function PullRequestOverviewLoading({ - {summary.labels.length ? ( + {"labels" in summary && summary.labels.length ? ( {summary.labels.map((label) => ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx index bb923689d4266..ab121d0c41e7d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestReviewSheet.tsx @@ -19,6 +19,8 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { PullRequestSheetContent as SheetContent } from "./PullRequestSheetContent"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; +import { PullRequestCompletionSuggestions } from "./PullRequestCompletionSuggestions"; +import { usePullRequestCompletions } from "./usePullRequestCompletions"; function useActionSubmission(params: PullRequestParams) { const navigation = useNavigation(); @@ -81,6 +83,13 @@ export function PullRequestReviewSheet({ ...draft, ...(params.mode === "review" ? { summary: body } : { comment: body }), })); + const completions = usePullRequestCompletions({ + environmentId: params.environmentId, + projectId: params.projectId, + value: body, + onChangeValue: setBody, + support: result.data?.capabilities ?? null, + }); const stale = store.draft.comments.some((comment) => comment.headSha !== result.data?.headSha); const [requestedVerdict, setRequestedVerdict] = useState("comment"); const offered = VERDICTS.filter((option) => permissions.verdicts.includes(option.value)); @@ -169,8 +178,12 @@ export function PullRequestReviewSheet({ {reviewing ? "Summary" : "Comment"} + - - - ); -} +const TRUNCATED_LABELS: Record string> = { + unsearchable: () => + "The provider has more reviewers than it returned. Find additional reviewers on the provider.", + searchable: () => "The provider has more reviewers than are listed here. Search to find them.", + "search-failed": (error) => + `Only listed reviewers are matched: the provider search failed. ${error ?? ""}`, + "search-truncated": () => + "More reviewers match than the provider returned. Keep typing to narrow it down.", +}; export function PullRequestReviewersScreen({ route }: StaticScreenProps) { const navigation = useNavigation(); - const insets = useSafeAreaInsets(); + const contentTopInset = useContentTopInset(); const listRef = useRef>(null); - const nativeMetrics = useNativeLayoutMetrics(); - const integratedSearch = - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && - (!nativeMetrics || nativeMetrics.verticalBarEdge === "none"); const target = pullRequestTarget(route.params); const detail = useEnvironmentQuery(composerPullRequests.detail(target)); const pr = detail.data; @@ -76,21 +67,35 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps(null); const [error, setError] = useState(null); const inFlight = useRef(false); - const candidates = (() => { - const needle = query.trim().toLowerCase(); - return [...(result.data?.candidates ?? [])] - .filter((candidate) => - `${candidate.login} ${candidate.name ?? ""}`.toLowerCase().includes(needle), - ) - .sort( - (a, b) => Number(b.isRequested) - Number(a.isRequested) || a.login.localeCompare(b.login), - ); - })(); + const debouncedQuery = useDebouncedValue(query.trim(), 250); + const searchInput = { + loaded: result.data, + searchable: pr?.capabilities.reviewers.searchCandidates === true, + query, + debouncedQuery, + }; + const hostQuery = hostReviewerQuery(searchInput); + const searchResult = useEnvironmentQuery( + hostQuery !== null + ? composerPullRequests.reviewerSearch({ + ...target, + input: { ...target.input, query: hostQuery }, + }) + : null, + ); + const view = reviewerCandidateView({ + ...searchInput, + search: searchResult, + }); + const candidates = [...view.candidates].sort( + (a, b) => Number(b.isRequested) - Number(a.isRequested) || a.login.localeCompare(b.login), + ); + const queryError = result.error ?? view.searchError; // A new search shows its best matches from the top. const search = (next: string) => { setQuery(next); listRef.current?.scrollToOffset({ - offset: Platform.OS === "ios" ? -insets.top : 0, + offset: -contentTopInset, animated: false, }); }; @@ -118,76 +123,48 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps search(event.nativeEvent.text), - onCancelButtonPress: () => search(""), - hideNavigationBar: false, - hideWhenScrolling: false, - obscureBackground: false, - allowToolbarIntegration: integratedSearch, - placement: integratedSearch ? ("automatic" as const) : ("stacked" as const), - }; + const reviewerSearch: AppBarSearch | undefined = listCandidates + ? { + mode: "inline", + compactToolbar: true, + value: query, + onChangeText: search, + placeholder: "Search reviewers", + } + : undefined; + const searchToolbarInset = useSearchToolbarInset(reviewerSearch); return ( - navigation.goBack()} - accessibilityLabel="Close request review" - /> - - listCandidates && integratedSearch - ? [ - createNativeMailSearchToolbarItem({ - placeholder: "Search reviewers", - onSearchTextChange: search, - showsSearchDismissButton: true, - searchTextChangeId: "pr-reviewer-search", - }), - ] - : [], - }} + closeAccessibilityLabel="Close request review" + search={reviewerSearch} /> {listCandidates ? ( `${candidate.kind}:${candidate.id}`} - renderScrollComponent={ - Platform.OS === "ios" ? (props) => : undefined - } - contentInsetAdjustmentBehavior="never" + renderScrollComponent={NativeScrollView} + contentInsetAdjustmentBehavior="automatic" automaticallyAdjustKeyboardInsets keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" - contentContainerStyle={{ paddingBottom: 24 }} + contentContainerStyle={{ paddingBottom: 24 + searchToolbarInset }} ListHeaderComponent={ Tap someone to request a review. Tap a requested reviewer to remove the request. - {Platform.OS === "android" ? ( - - ) : null} {!allowed ? ( Requesting reviews requires write access and an open {singular}. @@ -235,30 +212,34 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps ) : ( - {result.error ?