diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 1904f3790a4a..6a7fc4a6d5b5 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -36,6 +36,7 @@ const EXTRA_OWNERS = [ // scripts/build-desktop-artifact.ts reads the GNOME extension manifest. [/^apps\/desktop\/gnome-extension\//, ["@t3tools/scripts"]], [/^native\/browser-secret\//, ["@t3tools/desktop"]], + [/^native\/kde-snap-shot\//, ["@t3tools/desktop"]], [/^native\/libghostty-vt\//, ["@t3tools/web"]], ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; @@ -112,10 +113,10 @@ function ownersOf(file, packages) { // A manifest can change dependencies or versions other packages read. if (path.posix.basename(file) === "package.json") return null; if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; + const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); // scripts/build-desktop-artifact.test.ts reads the crates' sources. - if (RUST.test(file)) return ["@t3tools/scripts"]; + if (RUST.test(file)) return ["@t3tools/scripts", ...extra]; - const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); const owner = packages .filter((pkg) => file.startsWith(`${pkg.dir}/`)) .sort((a, b) => b.dir.length - a.dir.length)[0]; diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts deleted file mode 100644 index 666c36d391de..000000000000 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { - DesktopBackendPortUnavailableError, - DesktopDevelopmentBackendPortRequiredError, -} from "./DesktopApp.ts"; - -describe("DesktopApp errors", () => { - it("preserves unavailable backend port context", () => { - const error = new DesktopBackendPortUnavailableError({ - startPort: 3_773, - maxPort: 65_535, - hosts: ["127.0.0.1", "0.0.0.0", "::"], - }); - - assert.equal(error.startPort, 3_773); - assert.equal(error.maxPort, 65_535); - assert.deepEqual(error.hosts, ["127.0.0.1", "0.0.0.0", "::"]); - assert.equal( - error.message, - "No desktop backend port is available on hosts 127.0.0.1, 0.0.0.0, :: between 3773 and 65535.", - ); - }); - - it("reports the required development port", () => { - const error = new DesktopDevelopmentBackendPortRequiredError(); - - assert.equal(error.message, "T3CODE_PORT is required in desktop development."); - }); -}); diff --git a/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts deleted file mode 100644 index ae78080539b7..000000000000 --- a/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; - -import { DesktopLifecycleRelaunchError } from "./DesktopLifecycle.ts"; -import { DesktopApplicationMenuActionError } from "../window/DesktopApplicationMenu.ts"; - -describe("desktop detached action errors", () => { - it("preserves the complete relaunch failure cause and reason", () => { - const cause = Cause.combine( - Cause.fail(new Error("shutdown failed")), - Cause.die(new Error("relaunch defect")), - ); - const error = new DesktopLifecycleRelaunchError({ - reason: "apply update", - cause, - }); - - assert.strictEqual(error.cause, cause); - assert.equal(error.reason, "apply update"); - assert.equal(error.message, 'Desktop relaunch failed for reason "apply update".'); - }); - - it("preserves the complete menu action failure cause and action", () => { - const cause = Cause.combine( - Cause.fail(new Error("window unavailable")), - Cause.die(new Error("dispatch defect")), - ); - const error = new DesktopApplicationMenuActionError({ - action: "open-settings", - cause, - }); - - assert.strictEqual(error.cause, cause); - assert.equal(error.action, "open-settings"); - assert.equal(error.message, 'Desktop menu action "open-settings" failed.'); - }); -}); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 72a05e9d657d..b4e2fbe9f14b 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -1,5 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -19,29 +18,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; -import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; - -function makeStubInstance( - id: DesktopBackendPool.BackendInstanceId, - label: string, -): DesktopBackendPool.DesktopBackendInstance { - const snapshot: DesktopBackendSnapshot = { - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }; - return { - id, - label: Effect.succeed(label), - start: Effect.void, - stop: () => Effect.void, - currentConfig: Effect.succeed(Option.none()), - snapshot: Effect.succeed(snapshot), - waitForReady: (_timeout: Duration.Duration) => Effect.succeed(false), - }; -} +import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; function layerPool(labelRef: Ref.Ref): Layer.Layer { return DesktopBackendPool.layer.pipe( @@ -111,36 +88,6 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer { - it.effect("layerTest exposes registered instances by id", () => - Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const fetchedPrimary = yield* pool.get(DesktopBackendPool.PRIMARY_INSTANCE_ID); - const fetchedWsl = yield* pool.get(DesktopBackendPool.BackendInstanceId("wsl:ubuntu")); - const fetchedMissing = yield* pool.get(DesktopBackendPool.BackendInstanceId("missing")); - const all = yield* pool.list; - const resolvedPrimary = yield* pool.primary; - - assert.equal(yield* Option.getOrThrow(fetchedPrimary).label, "Windows"); - assert.equal(yield* Option.getOrThrow(fetchedWsl).label, "WSL (Ubuntu)"); - assert.isTrue(Option.isNone(fetchedMissing)); - assert.lengthOf(all, 2); - assert.equal(resolvedPrimary.id, DesktopBackendPool.PRIMARY_INSTANCE_ID); - }).pipe( - Effect.provide( - DesktopBackendPool.layerTest([ - makeStubInstance(DesktopBackendPool.PRIMARY_INSTANCE_ID, "Windows"), - makeStubInstance(DesktopBackendPool.BackendInstanceId("wsl:ubuntu"), "WSL (Ubuntu)"), - ]), - ), - ), - ); - - it.effect("layerTest dies when no instances are supplied", () => - Effect.exit( - DesktopBackendPool.DesktopBackendPool.pipe(Effect.provide(DesktopBackendPool.layerTest([]))), - ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), - ); - it.effect("resolves the primary label lazily after pool layer construction", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index b3f695daf2de..670dcb898405 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -359,23 +359,6 @@ describe("WSL runtime cache", () => { expect(deleted).toBeGreaterThan(kept); }); - it("treats a runtime whose executable no longer runs as a cache miss", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null'); - - const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); - const readinessDefined = script.indexOf("runtime_is_ready() {"); - const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - expect(entryCheckDefined).toBeGreaterThan(-1); - expect(entryCheckDefined).toBeLessThan(readinessDefined); - expect(readinessDefined).toBeLessThan(readyShortCircuit); - }); - it("re-hashes the executable against the digest the install recorded", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", @@ -624,6 +607,28 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE); }); + it("reinstalls an executable that fails --version despite a matching recorded digest", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const brokenEntry = "#!/bin/sh\nexit 42\n"; + const corrupt = runShell( + [ + "set -eu", + `printf '%s' ${sh(brokenEntry)} > ${sh(fixture.serverEntry)}`, + `chmod +x ${sh(fixture.serverEntry)}`, + `sha256sum ${sh(fixture.serverEntry)} | cut -d ' ' -f 1 > ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-ready`)}`, + ].join("\n"), + ); + expect(corrupt.status, corrupt.stderr).toBe(0); + expect(runShell(`${sh(fixture.serverEntry)} --version`).status).toBe(42); + + const repaired = fixture.install(); + expect(repaired.status, repaired.stderr).toBe(0); + expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot); + expect(runShell(`cat ${sh(fixture.serverEntry)}`).stdout).toBe(SERVER_ENTRY_SOURCE); + expect(runShell(`${sh(fixture.serverEntry)} --version`).status).toBe(0); + }); + it("falls back instead of launching a corrupted cache it cannot reinstall", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7858f2236989..893d6f8e076f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,5 @@ import "vite-plus/test/config"; -import { defineConfig } from "vite-plus"; +import { defaultInclude, defineConfig } from "vite-plus"; import { isDesktopRuntimeExternalDependency } from "../../scripts/lib/desktop-external-packages.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; @@ -141,6 +141,7 @@ export default defineConfig({ }, ], test: { + include: [...defaultInclude, "../../native/kde-snap-shot/src/*.test.ts"], // The Windows lane runs workspace suites concurrently; filesystem-heavy // desktop integration tests can exceed Vitest's 5 second default there. testTimeout: 15_000, diff --git a/apps/marketing/package.json b/apps/marketing/package.json index e36e91bb7482..1de412b42381 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -8,7 +8,8 @@ "dev": "node scripts/stage-install-scripts.mjs && astro dev", "build": "node scripts/stage-install-scripts.mjs && astro build", "preview": "astro preview", - "typecheck": "astro check" + "typecheck": "astro check", + "test": "vp test run" }, "dependencies": { "@t3tools/shared": "workspace:*", diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz deleted file mode 100644 index 4aa9184ac209..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.27.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz new file mode 100644 index 000000000000..33a1dcca0721 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz differ diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle index 01e035450f0e..9ecf11f68dbc 100644 --- a/apps/mobile/modules/t3-native-controls/android/build.gradle +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -26,6 +26,7 @@ android { dependencies { implementation project(':expo-modules-core') implementation 'androidx.compose.ui:ui-android:1.10.6' + implementation 'androidx.activity:activity:1.8.2' implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17' implementation 'androidx.window:window:1.5.1' } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 3d48060dcaf5..3a924af4889e 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -2,25 +2,60 @@ package expo.modules.t3nativecontrols import android.content.Context import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scrim +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import expo.modules.kotlin.AppContext import expo.modules.kotlin.modules.Module @@ -28,6 +63,8 @@ import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView +private val TABS = listOf("threads", "pullRequests", "settings") + class T3WorkspaceNavigationModule : Module() { override fun definition() = ModuleDefinition { Name("T3WorkspaceNavigation") @@ -35,8 +72,21 @@ class T3WorkspaceNavigationModule : Module() { Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String -> view.selectedTab = value } + Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value } Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value } Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value } + Prop("newEnabled") { view: T3WorkspaceNavigationView, value: Boolean -> + view.newEnabled = value + } + Prop("safeAreaTop") { view: T3WorkspaceNavigationView, value: Float -> + view.safeAreaTop = value + } + Prop("safeAreaBottom") { view: T3WorkspaceNavigationView, value: Float -> + view.safeAreaBottom = value + } + Prop("headerHeight") { view: T3WorkspaceNavigationView, value: Float -> + view.headerHeight = value + } Prop("containerColor") { view: T3WorkspaceNavigationView, value: Int -> view.containerColor = value } @@ -49,12 +99,18 @@ class T3WorkspaceNavigationModule : Module() { Prop("unselectedColor") { view: T3WorkspaceNavigationView, value: Int -> view.unselectedColor = value } - Events("onTabSelected") + Prop("actionColor") { view: T3WorkspaceNavigationView, value: Int -> + view.actionColor = value + } + Prop("actionContentColor") { view: T3WorkspaceNavigationView, value: Int -> + view.actionContentColor = value + } + Events("onTabSelected", "onNewThread", "onOverlayChange") } } } -/** Material3 bottom navigation for the compact workspace. */ +/** One Material3 selection surface moves from below the list to its leading edge. */ class T3WorkspaceNavigationView( context: Context, appContext: AppContext @@ -63,13 +119,22 @@ class T3WorkspaceNavigationView( override val shouldUseAndroidLayout = true private val onTabSelected by EventDispatcher() + private val onNewThread by EventDispatcher() + private val onOverlayChange by EventDispatcher() var selectedTab by mutableStateOf("threads") + var rail by mutableStateOf(false) var dark by mutableStateOf(false) var textScale by mutableStateOf(1f) + var newEnabled by mutableStateOf(false) + var safeAreaTop by mutableStateOf(0f) + var safeAreaBottom by mutableStateOf(0f) + var headerHeight by mutableStateOf(56f) var containerColor by mutableStateOf(0) var selectedColor by mutableStateOf(0) var indicatorColor by mutableStateOf(0) var unselectedColor by mutableStateOf(0) + var actionColor by mutableStateOf(0) + var actionContentColor by mutableStateOf(0) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // Fabric measures the replacement bar before attaching it on a fold. @@ -101,30 +166,30 @@ class T3WorkspaceNavigationView( ) setContent { MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { - val tabs = listOf("threads", "pullRequests") - NavigationBar( - modifier = Modifier.fillMaxSize(), - containerColor = Color(containerColor), - tonalElevation = androidx.compose.ui.unit.Dp(0f), - windowInsets = WindowInsets(0), - ) { - tabs.forEach { tab -> - NavigationBarItem( - selected = selectedTab == tab, - onClick = { onTabSelected(mapOf("tab" to tab)) }, - icon = { tabIcon(tab) }, - label = { - val title = if (tab == "threads") "Chats" else "Pull requests" - Text(title, fontSize = (12 * textScale).sp) - }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Color(selectedColor), - selectedTextColor = Color(selectedColor), - indicatorColor = Color(indicatorColor), - unselectedIconColor = Color(unselectedColor), - unselectedTextColor = Color(unselectedColor), - ), - ) + if (rail) { + rail() + } else { + NavigationBar( + modifier = Modifier.fillMaxSize(), + containerColor = Color(containerColor), + tonalElevation = androidx.compose.ui.unit.Dp(0f), + windowInsets = WindowInsets(0), + ) { + TABS.forEach { tab -> + NavigationBarItem( + selected = selectedTab == tab, + onClick = { onTabSelected(mapOf("tab" to tab)) }, + icon = { Icon(tabIconPainter(tab), contentDescription = null) }, + label = { Text(tabTitle(tab), fontSize = (12 * textScale).sp) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Color(selectedColor), + selectedTextColor = Color(selectedColor), + indicatorColor = Color(indicatorColor), + unselectedIconColor = Color(unselectedColor), + unselectedTextColor = Color(unselectedColor), + ), + ) + } } } } @@ -135,15 +200,192 @@ class T3WorkspaceNavigationView( } @Composable - private fun tabIcon(tab: String) { - val drawable = if (tab == "threads") { - R.drawable.t3_chats - } else { - R.drawable.t3_pull_requests - } - Icon( - painter = painterResource(drawable), - contentDescription = if (tab == "threads") "Chats" else "Pull requests", + private fun rail() { + // One rail surface widens in place over the list. Material's ModalWideNavigationRail + // instead moves its destinations into a dialog window copy, which blanks them for a few + // frames while the window attaches and lays them out at a different height. + var expanded by remember { mutableStateOf(false) } + // React Native sizes this view to the rail. While the rail is open it spans the workspace, + // and it only widens once the host has made room, so the surface is never clipped. + var roomy by remember { mutableStateOf(false) } + val progress by animateFloatAsState( + targetValue = if (expanded && roomy) 1f else 0f, + animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), ) + val overlay = expanded || progress != 0f + LaunchedEffect(overlay) { onOverlayChange(mapOf("overlay" to overlay)) } + // The activity handles Back before React Native, so an open rail closes first. + val backDispatcher = (appContext.currentActivity as? ComponentActivity)?.onBackPressedDispatcher + DisposableEffect(backDispatcher, expanded) { + val callback = object : OnBackPressedCallback(expanded) { + override fun handleOnBackPressed() { + expanded = false + } + } + backDispatcher?.addCallback(callback) + onDispose { callback.remove() } + } + val density = LocalDensity.current + Box( + Modifier.fillMaxSize().onSizeChanged { + roomy = with(density) { it.width.toDp() } >= EXPANDED_RAIL_WIDTH + }, + ) { + val fraction = progress.coerceIn(0f, 1f) + if (fraction > 0f) { + Scrim( + contentDescription = "Close navigation rail", + onClick = { expanded = false }, + alpha = { fraction }, + ) + } + Column( + Modifier + .fillMaxHeight() + .width(lerp(RAIL_WIDTH, EXPANDED_RAIL_WIDTH, progress.coerceAtLeast(0f))) + .shadow(RAIL_SHADOW * fraction, RAIL_EXPANDED_SHAPE, clip = false) + .background(Color(containerColor), RAIL_EXPANDED_SHAPE) + .padding(top = safeAreaTop.dp, bottom = safeAreaBottom.dp + 12.dp) + .selectableGroup(), + ) { + // As tall as the list toolbar so the menu lines up with the toolbar's actions. + Box( + Modifier.height(headerHeight.dp).padding(start = RAIL_INSET), + contentAlignment = Alignment.CenterStart, + ) { + IconButton( + onClick = { expanded = !expanded }, + modifier = Modifier.semantics { + contentDescription = + if (expanded) "Collapse navigation rail" else "Expand navigation rail" + }, + ) { + Icon( + painterResource(R.drawable.t3_menu), + contentDescription = null, + tint = Color(unselectedColor), + ) + } + } + newThreadButton(fraction) { + expanded = false + onNewThread(emptyMap()) + } + Spacer(Modifier.weight(1f)) + TABS.forEach { tab -> + railItem(tab, fraction) { + expanded = false + onTabSelected(mapOf("tab" to tab)) + } + } + Spacer(Modifier.weight(1f)) + } + } + } + + /** The collapsed FAB widens with the rail and reveals its label as it goes. */ + @Composable + private fun newThreadButton(fraction: Float, onClick: () -> Unit) { + Surface( + onClick = onClick, + enabled = newEnabled, + shape = MaterialTheme.shapes.large, + color = Color(actionColor), + contentColor = Color(actionContentColor), + shadowElevation = 3.dp, + modifier = Modifier + .padding(start = RAIL_INSET, top = 12.dp) + .height(FAB_SIZE) + .width(lerp(FAB_SIZE, EXPANDED_RAIL_WIDTH - RAIL_INSET * 2, fraction)) + .alpha(if (newEnabled) 1f else DISABLED_ALPHA) + .semantics { contentDescription = "New thread" }, + ) { + Row( + Modifier.padding(start = (FAB_SIZE - ICON_SIZE) / 2).clipToBounds(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(painterResource(R.drawable.t3_new_thread), contentDescription = null) + Text( + "New thread", + Modifier.padding(start = 12.dp).alpha(labelAlpha(fraction)).clearAndSetSemantics {}, + style = MaterialTheme.typography.labelLarge, + fontSize = (14 * textScale).sp, + maxLines = 1, + softWrap = false, + ) + } + } + } + + /** + * A destination whose indicator stretches from a circle around the icon into a pill around + * the icon and label. The icon never moves, so the collapsed and expanded rails share it. + */ + @Composable + private fun railItem(tab: String, fraction: Float, onClick: () -> Unit) { + val selected = selectedTab == tab + val title = expandedTabTitle(tab) + val tint = Color(if (selected) selectedColor else unselectedColor) + Box( + Modifier + .padding(start = RAIL_INSET, bottom = 4.dp) + .height(ITEM_HEIGHT) + .width(lerp(ITEM_HEIGHT, EXPANDED_RAIL_WIDTH - RAIL_INSET * 2, fraction)) + .clip(CircleShape) + .background(if (selected) Color(indicatorColor) else Color.Transparent) + .selectable(selected = selected, role = Role.Tab, onClick = onClick) + .semantics { contentDescription = title }, + ) { + Row( + Modifier.fillMaxHeight().padding(start = (ITEM_HEIGHT - ICON_SIZE) / 2).clipToBounds(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(tabIconPainter(tab), contentDescription = null, tint = tint) + Text( + title, + Modifier.padding(start = 12.dp).alpha(labelAlpha(fraction)).clearAndSetSemantics {}, + color = tint, + style = MaterialTheme.typography.labelLarge, + fontSize = (14 * textScale).sp, + maxLines = 1, + softWrap = false, + ) + } + } + } + + private fun tabTitle(tab: String) = when (tab) { + "threads" -> "Chats" + "pullRequests" -> "PRs" + else -> "Settings" + } + + private fun expandedTabTitle(tab: String) = + if (tab == "pullRequests") "Pull Requests" else tabTitle(tab) + + @Composable + private fun tabIconPainter(tab: String) = painterResource( + when (tab) { + "threads" -> R.drawable.t3_chats + "pullRequests" -> R.drawable.t3_pull_requests + else -> R.drawable.t3_settings + } + ) + + private companion object { + // Material's 80dp compact rail, minus 8dp: still a 48dp touch target with 12dp margins. + val RAIL_WIDTH = 72.dp + val EXPANDED_RAIL_WIDTH = 240.dp + val RAIL_INSET = 12.dp + val ITEM_HEIGHT = 48.dp + val FAB_SIZE = 48.dp + val ICON_SIZE = 24.dp + val RAIL_SHADOW = 6.dp + val RAIL_EXPANDED_SHAPE = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp) + const val DISABLED_ALPHA = 0.4f + + // Labels fade in over the second half of the widening, once there is room for them, and + // fade out over the first half of the narrowing, before the surface clips them. + fun labelAlpha(fraction: Float) = ((fraction - 0.5f) / 0.5f).coerceIn(0f, 1f) } } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_menu.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_menu.xml new file mode 100644 index 000000000000..d483560d3785 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_menu.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_new_thread.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_new_thread.xml new file mode 100644 index 000000000000..109e695d0168 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_new_thread.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_settings.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_settings.xml new file mode 100644 index 000000000000..3c4dfa57233e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_settings.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 349eb8a1e9b8..a20120edd4d9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -133,7 +133,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.27.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.29.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 6d7ebcb9ffe7..16abe358e911 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -32,7 +32,9 @@ 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 { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle"; import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; +import { AndroidWorkspaceSettings } from "./features/workspace/AndroidWorkspaceSettings"; import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; import { PullRequestRouteScreen, @@ -219,6 +221,9 @@ const SettingsContentStack = createV5SheetStackNavigator({ linking: "", options: { title: "Settings", + // 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", }, }), SettingsEnvironments: createNativeStackScreen({ @@ -562,7 +567,12 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ ]); function workspaceLocationFromState(state: NavigationState) { - const routes = state.routes.filter((route) => !WORKSPACE_OVERLAY_ROUTES.has(route.name)); + // Android opens Settings as a workspace page rather than an overlay, so the layout sees it. + const routes = state.routes.filter( + (route) => + !WORKSPACE_OVERLAY_ROUTES.has(route.name) || + (Platform.OS === "android" && route.name === "SettingsSheet"), + ); const effectiveState = routes.length > 0 && routes.length !== state.routes.length ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) @@ -618,16 +628,18 @@ function RootStackLayout(props: { - - - {props.children} - - - + + + + {props.children} + + + + ); @@ -913,11 +925,14 @@ const RootStackConfig = createWorkspaceStackNavigator({ }), SettingsSheet: createNativeStackScreen({ screen: SettingsSheetStack, + layout: ({ children }) => {children}, linking: "settings", options: { ...NESTED_NAVIGATOR_OPTIONS, gestureEnabled: true, headerShown: false, + // Android opens Settings as a top-level tab, so it swaps like the other tabs. + ...(Platform.OS === "android" ? { animation: "none" as const } : null), }, }), SettingsLegal: createNativeStackScreen({ diff --git a/apps/mobile/src/components/ui/NativeAppBar.android.tsx b/apps/mobile/src/components/ui/NativeAppBar.android.tsx index b06b0908356e..2c028fded803 100644 --- a/apps/mobile/src/components/ui/NativeAppBar.android.tsx +++ b/apps/mobile/src/components/ui/NativeAppBar.android.tsx @@ -14,10 +14,13 @@ export function NativeAppBar(props: { const { colors } = useControlTheme(); const layout = useToolbarLayout(); return ( - + // The bar's height is fixed, so React Native lays out the screen below it from the first frame + // instead of at zero height until Compose has measured and reported the bar. + ({ - View: ({ children }: { children: ReactNode }) =>
{children}
, -})); -vi.mock("../../components/ui/AppText", () => ({ - AppText: ({ - accessibilityElementsHidden, - importantForAccessibility, - children, - }: { - accessibilityElementsHidden?: boolean; - importantForAccessibility?: string; - children: ReactNode; - }) => ( - - {children} - - ), - AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => ( - - ), -})); - -import { ConnectionFormField } from "./ConnectionFormField"; - -describe("ConnectionFormField accessibility", () => { - it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain(``); - expect(markup).toContain(``); - }); -}); diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index e8a29ce516c6..7c6fa8c6850f 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -36,10 +36,12 @@ export function AndroidHomeFabLayout(props: ComponentProps setLayoutWidth(event.nativeEvent.layout.width)}> {props.children} - {!props.disabled && state.hasConnections && layoutWidth !== null ? ( + {!props.disabled && !props.sidebar && state.hasConnections && layoutWidth !== null ? ( diff --git a/apps/mobile/src/features/home/ThreadListToolbar.tsx b/apps/mobile/src/features/home/ThreadListToolbar.tsx index 974f29b83b6b..9a66d1e28eca 100644 --- a/apps/mobile/src/features/home/ThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/ThreadListToolbar.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; import { useNavigation, useNavigationState } from "@react-navigation/native"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { useWorkspaceListNavigation } from "../workspace/useAndroidWorkspaceNavigation"; import { BackHandler, Keyboard, @@ -9,22 +8,16 @@ import { View, type LayoutChangeEvent, } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { MenuAction } from "@react-native-menu/menu"; import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader"; -import { CompactBrandTitle } from "../../components/CompactBrandTitle"; -import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import { AndroidAnchoredMenu } from "../../components/ui/AndroidAnchoredMenu"; import { Menu } from "../../components/ui/Menu"; import { SearchField } from "../../components/ui/SearchField"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; -import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; -import { useWorkspaceState } from "../../state/workspace"; -import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; +import { WorkspaceListToolbarRow } from "../workspace/WorkspaceListToolbarRow"; +import { useWorkspaceConnectionState } from "../../state/workspace"; import { useToolbarLayout } from "../../components/ui/useToolbarLayout"; -import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; -import { androidHomeFabPosition } from "./AndroidHomeFab.shared"; /** One toolbar height for the compact list and expanded sidebar, including search. */ export function ThreadListToolbar(props: { @@ -33,25 +26,20 @@ export function ThreadListToolbar(props: { readonly filterActions: MenuAction[]; readonly filterCustomized: boolean; readonly onFilterAction: NonNullable["onPressAction"]>; - readonly onOpenSettings: () => void; readonly onOpenEnvironments: () => void; readonly sidebar?: boolean; readonly onLayout?: (event: LayoutChangeEvent) => void; readonly onRequestVisibility?: () => void; }) { - const insets = useSafeAreaInsets(); const navigation = useNavigation(); const workspace = useWorkspaceTabs(); const isTabActive = workspace?.isTabActive; const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home"); const listFocused = homeFocused && (workspace === null || workspace.state.selectedTab === "threads"); - const { fabSize } = useAndroidControlSizing(); - const fab = androidHomeFabPosition(insets.bottom, props.sidebar); - const { height: toolbarHeight, ...headerPadding } = useToolbarLayout(); - const { state } = useWorkspaceState(); + const { paddingTop, paddingBottom } = useToolbarLayout(); + const connectionState = useWorkspaceConnectionState(); const { onRequestVisibility, onSearchQueryChange } = props; - const showPullRequests = useWorkspaceListNavigation("pullRequests"); const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; @@ -100,68 +88,34 @@ export function ThreadListToolbar(props: { onChangeText={onSearchQueryChange} /> ); + const filterButton = connectionState.hasConnections ? ( + + {(open) => ( + + )} + + ) : null; return ( - <> - - - {searching ? ( - <> - - {searchField} - - ) : ( - <> - - } - /> - - {props.sidebar && workspace ? ( - - ) : null} - - - - )} - - - {state.hasConnections ? ( - - - {(open) => ( - - )} - - - ) : null} - + + + ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index f1b9bfd5b926..ab22cd6e7d4d 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,5 +1,5 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; -import { useEffect, useState, type ReactNode } from "react"; +import { createContext, use, useEffect, useMemo, useState, type ReactNode } from "react"; import { Animated, Platform, Pressable, View } from "react-native"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; @@ -20,22 +20,47 @@ import { const STATUS_SHOW_DELAY_MS = 800; const FADE_IN_MS = 250; -function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | null { +type ConnectionStatus = WorkspaceConnectionStatusPresentation & { + /** Only while the status first appears. A header that shows it later shows it at once. */ + readonly fadeIn: boolean; +}; + +const ConnectionStatusContext = createContext(null); + +/** + * The delay hides brief reconnects, so it is the workspace's, not each header's: every header + * shows the status from the same moment. Headers read it from context instead of subscribing + * themselves, because a hidden Android tab drops its subscriptions and showed a stale status for + * a moment when it came back, while context still reaches it. + */ +export function WorkspaceConnectionStatusProvider(props: { readonly children: ReactNode }) { const { state } = useWorkspaceState(); const presentation = workspaceConnectionStatusPresentation(state); const hasStatus = presentation !== null; - const [visible, setVisible] = useState(false); + const [phase, setPhase] = useState<"waiting" | "fading" | "shown">("waiting"); useEffect(() => { - if (!hasStatus) { - setVisible(false); - return; - } - const timer = setTimeout(() => setVisible(true), STATUS_SHOW_DELAY_MS); - return () => clearTimeout(timer); + if (!hasStatus) return; + const timers = [ + setTimeout(() => setPhase("fading"), STATUS_SHOW_DELAY_MS), + setTimeout(() => setPhase("shown"), STATUS_SHOW_DELAY_MS + FADE_IN_MS), + ]; + return () => { + for (const timer of timers) clearTimeout(timer); + setPhase("waiting"); + }; }, [hasStatus]); - return visible ? presentation : null; + const label = presentation?.label; + const showsProgress = presentation?.showsProgress ?? false; + const status = useMemo( + () => + label === undefined || phase === "waiting" + ? null + : { label, showsProgress, fadeIn: phase === "fading" }, + [label, showsProgress, phase], + ); + return {props.children}; } /** @@ -46,12 +71,15 @@ function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | n */ function StatusFadeIn(props: { readonly children: ReactNode; + readonly animate: boolean; readonly grow?: boolean; readonly maxWidth?: number; }) { + const { animate } = props; const [opacity] = useState(() => new Animated.Value(0)); useEffect(() => { + if (!animate) return; const animation = Animated.timing(opacity, { duration: FADE_IN_MS, toValue: 1, @@ -59,12 +87,18 @@ function StatusFadeIn(props: { }); animation.start(); return () => animation.stop(); - }, [opacity]); + }, [animate, opacity]); return (