From bb28d6d20e36838cce5e2116b8e4222ade21feea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 1 Sep 2026 21:32:56 +0200 Subject: [PATCH 01/10] feat(mobile): add surface centering prototype --- .../android/build.gradle | 11 + .../android/src/main/AndroidManifest.xml | 1 + .../KiloSurfaceGeometryModule.kt | 273 +++++++++++++++ .../expo-module.config.json | 9 + .../ios/KiloSurfaceGeometry.podspec | 16 + .../ios/KiloSurfaceGeometryModule.swift | 324 ++++++++++++++++++ .../native-surface-geometry.test.ts | 170 +++++++++ .../centered-state-surface.mounted.test.tsx | 295 ++++++++++++++++ .../src/components/centered-state-surface.tsx | 245 +++++++++++++ .../centered-state.mounted.test.tsx | 212 ++++++++++++ apps/mobile/src/components/centered-state.tsx | 119 +++++++ .../src/lib/centered-state-layout.test.ts | 222 ++++++++++++ apps/mobile/src/lib/centered-state-layout.ts | 63 ++++ ...use-native-state-geometry.mounted.test.tsx | 178 ++++++++++ .../lib/hooks/use-native-state-geometry.ts | 105 ++++++ .../hooks/use-state-surface-measurement.ts | 104 ++++++ .../mobile/src/lib/native-surface-geometry.ts | 51 +++ apps/mobile/vitest.pure.config.ts | 1 + 18 files changed, 2399 insertions(+) create mode 100644 apps/mobile/modules/kilo-surface-geometry/android/build.gradle create mode 100644 apps/mobile/modules/kilo-surface-geometry/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt create mode 100644 apps/mobile/modules/kilo-surface-geometry/expo-module.config.json create mode 100644 apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometry.podspec create mode 100644 apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift create mode 100644 apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts create mode 100644 apps/mobile/src/components/centered-state-surface.mounted.test.tsx create mode 100644 apps/mobile/src/components/centered-state-surface.tsx create mode 100644 apps/mobile/src/components/centered-state.mounted.test.tsx create mode 100644 apps/mobile/src/components/centered-state.tsx create mode 100644 apps/mobile/src/lib/centered-state-layout.test.ts create mode 100644 apps/mobile/src/lib/centered-state-layout.ts create mode 100644 apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx create mode 100644 apps/mobile/src/lib/hooks/use-native-state-geometry.ts create mode 100644 apps/mobile/src/lib/hooks/use-state-surface-measurement.ts create mode 100644 apps/mobile/src/lib/native-surface-geometry.ts diff --git a/apps/mobile/modules/kilo-surface-geometry/android/build.gradle b/apps/mobile/modules/kilo-surface-geometry/android/build.gradle new file mode 100644 index 0000000000..8b7658cc07 --- /dev/null +++ b/apps/mobile/modules/kilo-surface-geometry/android/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.kilosurfacegeometry' +version = '1.0.0' + +android { + namespace 'expo.modules.kilosurfacegeometry' +} diff --git a/apps/mobile/modules/kilo-surface-geometry/android/src/main/AndroidManifest.xml b/apps/mobile/modules/kilo-surface-geometry/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..94cbbcfc39 --- /dev/null +++ b/apps/mobile/modules/kilo-surface-geometry/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt b/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt new file mode 100644 index 0000000000..c26d5309f5 --- /dev/null +++ b/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt @@ -0,0 +1,273 @@ +package expo.modules.kilosurfacegeometry + +import android.graphics.Matrix +import android.graphics.Rect +import android.graphics.RectF +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewTreeObserver +import android.view.WindowManager +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.functions.Queues +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicLong + +class KiloSurfaceGeometryModule : Module() { + private val observers = mutableMapOf>() + private val mainHandler = Handler(Looper.getMainLooper()) + private val generation = AtomicLong(0) + @Volatile private var destroyed = false + + override fun definition() = ModuleDefinition { + Name("KiloSurfaceGeometry") + Constant("isSupported") { true } + Events("onSurfaceGeometryChange") + + AsyncFunction("observeSurface") { tag: Int -> + val currentGeneration = generation.get() + if (destroyed) throw CodedException("The native surface observer is destroyed.") + val root = appContext.findView(tag) + ?: throw CodedException("The native surface view is not mounted.") + val existing = observers[tag]?.get() + val observer = if (existing != null && existing.generation == currentGeneration && existing.observes(root)) { + existing + } else { + observers.remove(tag)?.get()?.stop() + val observer = SurfaceGeometryObserver(root, tag, currentGeneration, { geometry -> + if (!destroyed && generation.get() == currentGeneration) { + sendEvent("onSurfaceGeometryChange", geometry) + } + }, { stopped -> + if (observers[tag]?.get() === stopped) observers.remove(tag) + }) + observers[tag] = WeakReference(observer) + observer.start() + observer + } + val geometry = observer.snapshot() + if (destroyed || generation.get() != currentGeneration) { + observer.stop() + throw CodedException("The native surface observer stopped.") + } + geometry + }.runOnQueue(Queues.MAIN) + + AsyncFunction("unobserveSurface") { tag: Int -> + observers.remove(tag)?.get()?.stop() + Unit + }.runOnQueue(Queues.MAIN) + + OnStopObserving { stopObserving() } + OnDestroy { + destroyed = true + stopObserving() + } + } + + private fun stopObserving() { + val retiredGeneration = generation.getAndIncrement() + val cleanup = Runnable { + observers.values.mapNotNull { it.get() }.filter { it.generation <= retiredGeneration }.forEach { it.stop() } + observers.entries.removeAll { it.value.get() == null } + } + if (Looper.myLooper() == Looper.getMainLooper()) cleanup.run() else mainHandler.post(cleanup) + } +} + +private class SurfaceGeometryObserver( + root: View, + private val tag: Int, + val generation: Long, + private val emit: (Map) -> Unit, + private val onStop: (SurfaceGeometryObserver) -> Unit +) : ViewTreeObserver.OnPreDrawListener, ViewTreeObserver.OnGlobalLayoutListener, + View.OnAttachStateChangeListener { + private val root = WeakReference(root) + private var tree: ViewTreeObserver? = null + private var previous: Map? = null + private var attached = false + private var stopped = false + + fun observes(view: View): Boolean = !stopped && root.get() === view + + fun start() { + val root = root.get() + if (stopped || root == null) { + stop() + return + } + root.addOnAttachStateChangeListener(this) + attached = root.isAttachedToWindow + if (attached) attachTree() + } + + private fun attachTree() { + val root = root.get() + if (stopped || root == null) { + stop() + return + } + if (!attached) return + detachTree() + tree = root.viewTreeObserver.also { + it.addOnPreDrawListener(this) + it.addOnGlobalLayoutListener(this) + } + } + + private fun detachTree() { + tree?.takeIf { it.isAlive }?.let { + it.removeOnPreDrawListener(this) + it.removeOnGlobalLayoutListener(this) + } + tree = null + } + + override fun onPreDraw(): Boolean { + if (!stopped && attached) snapshot() + return true + } + + override fun onGlobalLayout() { + if (!stopped && attached) snapshot() + } + + override fun onViewAttachedToWindow(view: View) { + if (!observes(view)) return + attached = true + attachTree() + snapshot() + } + + override fun onViewDetachedFromWindow(view: View) { + if (!observes(view)) return + attached = false + detachTree() + snapshot() + } + + fun snapshot(): Map { + val root = root.get() + if (stopped || root == null) { + stop() + return geometry(0f, 0f, 0f, 0f, 0f, 0f, 1.0) + } + val geometry = measure(root) + if (geometry != previous) { + previous = geometry + emit(geometry) + } + return geometry + } + + private fun measure(root: View): Map { + val density = root.resources.displayMetrics.density.toDouble() + val height = root.height.coerceAtLeast(0).toFloat() + val toLocal = Matrix() + val empty = geometry(0f, 0f, height, 0f, 0f, 0f, density) + if (!attached || !root.isAttachedToWindow) return empty + if (!localToScreen(root).invert(toLocal)) return empty + val windowRoot = root.rootView + val windowLocation = IntArray(2) + windowRoot.getLocationOnScreen(windowLocation) + val windowBounds = RectF( + windowLocation[0].toFloat(), windowLocation[1].toFloat(), + (windowLocation[0] + windowRoot.width).toFloat(), + (windowLocation[1] + windowRoot.height).toFloat() + ) + val insets = ViewCompat.getRootWindowInsets(root) ?: return empty + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + val dockedKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()) && ime.bottom > bars.bottom + val displayFrame = Rect() + windowRoot.getWindowVisibleDisplayFrame(displayFrame) + val safeWindow = RectF(windowBounds) + safeWindow.top += bars.top + if (!dockedKeyboard || windowBounds.bottom > displayFrame.bottom) { + safeWindow.bottom -= bars.bottom + } + toLocal.mapRect(safeWindow) + val safeTop = if (bars.top > 0) safeWindow.top.coerceIn(0f, height) else 0f + val safeBottom = if (bars.bottom > 0) (height - safeWindow.bottom).coerceIn(0f, height) else 0f + val invisible = geometry(0f, 0f, height, safeTop, safeBottom, 0f, density) + if (!root.isShown || root.windowVisibility != View.VISIBLE) return invisible + val globalVisible = Rect() + if (!root.getGlobalVisibleRect(globalVisible)) return invisible + globalVisible.offset(windowLocation[0], windowLocation[1]) + val visible = RectF(globalVisible) + if (!visible.intersect(windowBounds)) return invisible + var ancestor: View? = root + var alpha = 1f + while (ancestor != null) { + alpha *= ancestor.alpha + if (alpha <= 0.01f) return invisible + val clip = ancestor.clipBounds + if (clip != null) { + val screenClip = RectF(clip) + localToScreen(ancestor).mapRect(screenClip) + if (!visible.intersect(screenClip)) return invisible + } + ancestor = ancestor.parent as? View + } + val beforeKeyboard = RectF(visible) + if (dockedKeyboard) { + val mode = (windowRoot.layoutParams as? WindowManager.LayoutParams)?.softInputMode + val adjustsNothing = mode != null && + mode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST == WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + val keyboardTop = if (adjustsNothing) windowBounds.bottom - ime.bottom else displayFrame.bottom.toFloat() + visible.bottom = keyboardTop.coerceIn(visible.top, visible.bottom) + } + toLocal.mapRect(visible) + toLocal.mapRect(beforeKeyboard) + val top = visible.top.coerceIn(0f, height) + val bottom = visible.bottom.coerceIn(top, height) + val overlap = (beforeKeyboard.bottom.coerceIn(top, height) - bottom).coerceAtLeast(0f) + return geometry(top, bottom, height, safeTop, safeBottom, overlap, density) + } + + private fun localToScreen(view: View): Matrix { + val matrix = Matrix() + var ancestor: View? = view + while (ancestor != null) { + matrix.postConcat(ancestor.matrix) + matrix.postTranslate(ancestor.left.toFloat(), ancestor.top.toFloat()) + val parent = ancestor.parent as? View + if (parent != null) matrix.postTranslate(-parent.scrollX.toFloat(), -parent.scrollY.toFloat()) + ancestor = parent + } + val origin = floatArrayOf(0f, 0f) + matrix.mapPoints(origin) + val location = IntArray(2) + view.getLocationOnScreen(location) + matrix.postTranslate(location[0] - origin[0], location[1] - origin[1]) + return matrix + } + + private fun geometry( + top: Float, bottom: Float, height: Float, safeTop: Float, safeBottom: Float, + overlap: Float, density: Double + ): Map = mapOf( + "tag" to tag, + "visibleTop" to top / density, + "visibleBottom" to bottom / density, + "boundsHeight" to height / density, + "safeAreaTop" to safeTop / density, + "safeAreaBottom" to safeBottom / density, + "keyboardOverlap" to overlap / density + ) + + fun stop() { + if (stopped) return + stopped = true + attached = false + detachTree() + root.get()?.removeOnAttachStateChangeListener(this) + root.clear() + onStop(this) + } +} diff --git a/apps/mobile/modules/kilo-surface-geometry/expo-module.config.json b/apps/mobile/modules/kilo-surface-geometry/expo-module.config.json new file mode 100644 index 0000000000..b3501516e9 --- /dev/null +++ b/apps/mobile/modules/kilo-surface-geometry/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["KiloSurfaceGeometryModule"] + }, + "android": { + "modules": ["expo.modules.kilosurfacegeometry.KiloSurfaceGeometryModule"] + } +} diff --git a/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometry.podspec b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometry.podspec new file mode 100644 index 0000000000..3e6fb4edb8 --- /dev/null +++ b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometry.podspec @@ -0,0 +1,16 @@ +Pod::Spec.new do |s| + s.name = 'KiloSurfaceGeometry' + s.version = '1.0.0' + s.summary = 'Root-local visible surface geometry' + s.description = 'Local Expo module for native surface and docked keyboard geometry.' + s.license = { :type => 'Proprietary' } + s.author = 'Kilo' + s.homepage = 'https://github.com/Kilo-Org/cloud' + s.source = { :git => 'https://github.com/Kilo-Org/cloud.git' } + s.platforms = { :ios => '16.4' } + s.swift_version = '5.9' + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = '**/*.swift' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } +end diff --git a/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift new file mode 100644 index 0000000000..2559ea6771 --- /dev/null +++ b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift @@ -0,0 +1,324 @@ +import ExpoModulesCore +import UIKit + +public final class KiloSurfaceGeometryModule: Module { + private var probes: [Int: WeakSurfaceGeometryProbe] = [:] + private let lifecycleLock = NSLock() + private var generation = 0 + private var destroyed = false + + public func definition() -> ModuleDefinition { + Name("KiloSurfaceGeometry") + Constant("isSupported") { true } + Events("onSurfaceGeometryChange") + + AsyncFunction("observeSurface") { (tag: Int) -> [String: Any] in + guard let generation = self.currentGeneration(), + let root = self.appContext?.findView(withTag: tag, ofType: UIView.self) else { + throw SurfaceViewNotFoundException() + } + let probe: SurfaceGeometryProbe + if let existing = self.probes[tag]?.probe, existing.generation == generation, existing.observes(root) { + probe = existing + } else { + self.probes.removeValue(forKey: tag)?.probe?.stop() + probe = SurfaceGeometryProbe(root: root, tag: tag, generation: generation) { [weak self] geometry in + guard let self, self.currentGeneration() == generation else { return } + self.sendEvent("onSurfaceGeometryChange", geometry) + } onStop: { [weak self] probe in + if self?.probes[tag]?.probe === probe { + self?.probes.removeValue(forKey: tag) + } + } + self.probes[tag] = WeakSurfaceGeometryProbe(probe) + probe.start() + } + let geometry = probe.snapshot() + guard self.currentGeneration() == generation else { + probe.stop() + throw SurfaceViewNotFoundException() + } + return geometry + }.runOnQueue(.main) + + AsyncFunction("unobserveSurface") { (tag: Int) in + self.probes.removeValue(forKey: tag)?.probe?.stop() + }.runOnQueue(.main) + + OnStopObserving { + self.stopObserving() + } + OnDestroy { + self.stopObserving(destroying: true) + } + } + + private func currentGeneration() -> Int? { + lifecycleLock.withLock { destroyed ? nil : generation } + } + + private func stopObserving(destroying: Bool = false) { + let retiredGeneration = lifecycleLock.withLock { + let retired = generation + generation += 1 + destroyed = destroyed || destroying + return retired + } + let cleanup = { + let retired = self.probes.values.compactMap { $0.probe }.filter { $0.generation <= retiredGeneration } + retired.forEach { $0.stop() } + self.probes = self.probes.filter { $0.value.probe != nil } + } + if Thread.isMainThread { + cleanup() + } else { + DispatchQueue.main.async(execute: cleanup) + } + } +} + +private final class SurfaceViewNotFoundException: Exception { + override var reason: String { "The native surface view is not mounted." } +} + +private final class WeakSurfaceGeometryProbe { + weak var probe: SurfaceGeometryProbe? + + init(_ probe: SurfaceGeometryProbe) { + self.probe = probe + } +} + +private final class SurfaceGeometryProbe: UIView { + let generation: Int + private weak var root: UIView? + private let tag: Int + private let emit: ([String: Any]) -> Void + private let onStop: (SurfaceGeometryProbe) -> Void + private let keyboardEdge = UIView() + private var observations: [NSKeyValueObservation] = [] + private var ancestorIDs: [ObjectIdentifier] = [] + private var notifications: [NSObjectProtocol] = [] + private var previous: [String: Double]? + private var scheduled = false + private var observingWindow = false + private var attachmentGeneration = 0 + private var stopped = false + + init(root: UIView, tag: Int, generation: Int, emit: @escaping ([String: Any]) -> Void, + onStop: @escaping (SurfaceGeometryProbe) -> Void) { + self.root = root + self.tag = tag + self.generation = generation + self.emit = emit + self.onStop = onStop + super.init(frame: root.bounds) + isUserInteractionEnabled = false + accessibilityElementsHidden = true + backgroundColor = .clear + autoresizingMask = [.flexibleWidth, .flexibleHeight] + keyboardLayoutGuide.followsUndockedKeyboard = false + if #available(iOS 17.0, *) { + keyboardLayoutGuide.usesBottomSafeArea = false + } + keyboardEdge.translatesAutoresizingMaskIntoConstraints = false + addSubview(keyboardEdge) + NSLayoutConstraint.activate([ + keyboardEdge.leadingAnchor.constraint(equalTo: leadingAnchor), + keyboardEdge.topAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor), + keyboardEdge.widthAnchor.constraint(equalToConstant: 0), + keyboardEdge.heightAnchor.constraint(equalToConstant: 0) + ]) + } + + required init?(coder: NSCoder) { + return nil + } + + deinit { + observations.forEach { $0.invalidate() } + notifications.forEach { NotificationCenter.default.removeObserver($0) } + } + + func start() { + guard !stopped, let root else { + stop() + return + } + root.addSubview(self) + updateAttachment() + } + + private func updateAttachment() { + guard !stopped, let root, superview === root else { + stop() + return + } + guard window != nil else { + pause() + _ = snapshot() + return + } + if !observingWindow { + observingWindow = true + for name in [UIResponder.keyboardDidChangeFrameNotification, UIResponder.keyboardDidHideNotification, + UIApplication.didBecomeActiveNotification] { + notifications.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { + [weak self] _ in self?.schedule() + }) + } + } + observeAncestors() + layoutIfNeeded() + schedule() + } + + private func pause() { + observingWindow = false + attachmentGeneration += 1 + scheduled = false + observations.forEach { $0.invalidate() } + observations.removeAll() + ancestorIDs.removeAll() + notifications.forEach { NotificationCenter.default.removeObserver($0) } + notifications.removeAll() + } + + func observes(_ view: UIView) -> Bool { + !stopped && root === view && superview === view + } + + override func layoutSubviews() { + super.layoutSubviews() + schedule() + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + schedule() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + if superview == nil || superview !== root { + stop() + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + updateAttachment() + } + + private func schedule() { + guard !stopped, observingWindow, window != nil, !scheduled else { return } + scheduled = true + let generation = attachmentGeneration + DispatchQueue.main.async { [weak self] in + guard let self, !self.stopped, self.observingWindow, + self.attachmentGeneration == generation else { return } + self.scheduled = false + _ = self.snapshot() + } + } + + private func observeAncestors() { + var ancestors: [UIView] = [] + var ancestor = root + while let view = ancestor { + ancestors.append(view) + ancestor = view.superview + } + let ids = ancestors.map { ObjectIdentifier($0) } + guard ids != ancestorIDs else { return } + observations.forEach { $0.invalidate() } + observations.removeAll() + ancestorIDs = ids + for view in ancestors { + let layer = view.layer + observations.append(layer.observe(\.bounds) { [weak self] _, _ in self?.schedule() }) + observations.append(layer.observe(\.position) { [weak self] _, _ in self?.schedule() }) + observations.append(layer.observe(\.transform) { [weak self] _, _ in self?.schedule() }) + observations.append(layer.observe(\.sublayerTransform) { [weak self] _, _ in self?.schedule() }) + observations.append(layer.observe(\.masksToBounds) { [weak self] _, _ in self?.schedule() }) + observations.append(layer.observe(\.isHidden) { [weak self] _, _ in self?.schedule() }) + observations.append(layer.observe(\.opacity) { [weak self] _, _ in self?.schedule() }) + observations.append(layer.observe(\.superlayer) { [weak self] _, _ in self?.schedule() }) + } + } + + func snapshot() -> [String: Any] { + guard !stopped, let root, superview === root else { + stop() + return ["tag": tag, "visibleTop": 0.0, "visibleBottom": 0.0, "boundsHeight": 0.0, + "safeAreaTop": 0.0, "safeAreaBottom": 0.0, "keyboardOverlap": 0.0] + } + if observingWindow { + observeAncestors() + if frame != root.bounds { + frame = root.bounds + layoutIfNeeded() + } + } + let geometry = measure(root) + var event: [String: Any] = geometry + event["tag"] = tag + if previous != geometry { + previous = geometry + emit(event) + } + return event + } + + private func measure(_ root: UIView) -> [String: Double] { + let height = max(0, root.bounds.height) + let safeTop = min(height, max(0, root.safeAreaInsets.top)) + let safeBottom = min(height, max(0, root.safeAreaInsets.bottom)) + let empty = ["visibleTop": 0.0, "visibleBottom": 0.0, "boundsHeight": Double(height), + "safeAreaTop": Double(safeTop), "safeAreaBottom": Double(safeBottom), + "keyboardOverlap": 0.0] + guard observingWindow, let window = root.window, height > 0 else { return empty } + var visible = root.bounds.intersection(root.convert(window.bounds, from: window)) + var ancestor: UIView? = root + var alpha: CGFloat = 1 + while let view = ancestor { + alpha *= view.alpha + if view.isHidden || alpha <= 0.01 { return empty } + if view.clipsToBounds { + visible = visible.intersection(root.convert(view.bounds, from: view)) + } + ancestor = view.superview + } + guard !visible.isNull, !visible.isEmpty else { return empty } + let keyboard = root.convert(keyboardLayoutGuide.layoutFrame, from: self) + let idleKeyboardHeight: CGFloat + if #available(iOS 17.0, *) { + idleKeyboardHeight = 0 + } else { + idleKeyboardHeight = safeAreaInsets.bottom + } + let top = max(0, visible.minY - root.bounds.minY) + let bottom = min(height, visible.maxY - root.bounds.minY) + var visibleBottom = bottom + if keyboard.height > idleKeyboardHeight, keyboard.intersects(visible), keyboard.maxY >= visible.maxY { + visibleBottom = max(top, min(bottom, keyboard.minY - root.bounds.minY)) + } + return [ + "visibleTop": Double(top), + "visibleBottom": Double(visibleBottom), + "boundsHeight": Double(height), + "safeAreaTop": Double(safeTop), + "safeAreaBottom": Double(safeBottom), + "keyboardOverlap": Double(bottom - visibleBottom) + ] + } + + func stop() { + guard !stopped else { return } + stopped = true + pause() + removeFromSuperview() + root = nil + onStop(self) + } +} diff --git a/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts b/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts new file mode 100644 index 0000000000..33cf165b7d --- /dev/null +++ b/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type NativeSurfaceGeometry } from '../../src/lib/native-surface-geometry'; + +const native = vi.hoisted(() => ({ + requireOptionalNativeModule: vi.fn(), + addListener: + vi.fn< + (event: string, listener: (geometry: NativeSurfaceGeometry) => void) => { remove: () => void } + >(), + observeSurface: vi.fn(), + unobserveSurface: vi.fn(), +})); + +vi.mock('expo', () => ({ requireOptionalNativeModule: native.requireOptionalNativeModule })); + +const initial: NativeSurfaceGeometry = { + tag: 42, + visibleTop: 0, + visibleBottom: 400, + boundsHeight: 600, + safeAreaTop: 20, + safeAreaBottom: 20, + keyboardOverlap: 200, +}; + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + native.requireOptionalNativeModule.mockReturnValue({ ...native, isSupported: true }); + native.observeSurface.mockResolvedValue(initial); +}); + +describe('native surface geometry', () => { + it('reports absence without inventing geometry or throwing during import', async () => { + native.requireOptionalNativeModule.mockReturnValue(null); + const surface = await import('../../src/lib/native-surface-geometry'); + expect(native.requireOptionalNativeModule).toHaveBeenCalledWith('KiloSurfaceGeometry'); + expect(surface.isNativeSurfaceGeometryAvailable).toBe(false); + expect(surface.addSurfaceGeometryListener(vi.fn<() => void>())).toBeNull(); + await expect(surface.observeSurface(42)).rejects.toThrow('requires a rebuilt'); + await expect(surface.unobserveSurface(42)).resolves.toBeUndefined(); + }); + + it('reports unsupported native platforms as unavailable', async () => { + native.requireOptionalNativeModule.mockReturnValue({ ...native, isSupported: false }); + const surface = await import('../../src/lib/native-surface-geometry'); + expect(surface.isNativeSurfaceGeometryAvailable).toBe(false); + expect(surface.addSurfaceGeometryListener(vi.fn<() => void>())).toBeNull(); + await expect(surface.observeSurface(42)).rejects.toThrow('requires a rebuilt'); + expect(native.observeSurface).not.toHaveBeenCalled(); + }); + + it('returns the initial snapshot and exposes event and native cleanup', async () => { + const remove = vi.fn<() => void>(); + native.addListener.mockReturnValue({ remove }); + const surface = await import('../../src/lib/native-surface-geometry'); + const listener = vi.fn<(geometry: NativeSurfaceGeometry) => void>(); + const subscription = surface.addSurfaceGeometryListener(listener); + expect(surface.isNativeSurfaceGeometryAvailable).toBe(true); + expect(native.addListener).toHaveBeenCalledWith('onSurfaceGeometryChange', listener); + await expect(surface.observeSurface(42)).resolves.toEqual(initial); + expect(native.observeSurface).toHaveBeenCalledWith(42); + subscription?.remove(); + await surface.unobserveSurface(42); + expect(remove).toHaveBeenCalledOnce(); + expect(native.unobserveSurface).toHaveBeenCalledWith(42); + }); + + it.each([ + { + name: 'safe areas without clipping', + geometry: { ...initial, visibleBottom: 600, keyboardOverlap: 0 }, + }, + { + name: 'ancestor clipping independent of safe areas', + geometry: { ...initial, visibleTop: 50, visibleBottom: 550, keyboardOverlap: 0 }, + }, + { + name: 'docked keyboard overlap', + geometry: initial, + }, + { + name: 'a surface already resized above the keyboard', + geometry: { + ...initial, + boundsHeight: 400, + safeAreaBottom: 0, + keyboardOverlap: 0, + }, + }, + { + name: 'a floating surface above the keyboard', + geometry: { + ...initial, + boundsHeight: 400, + safeAreaTop: 0, + safeAreaBottom: 0, + keyboardOverlap: 0, + }, + }, + { + name: 'an invisible surface', + geometry: { ...initial, visibleBottom: 0, keyboardOverlap: 0 }, + }, + ])('preserves native snapshot and event fields for $name', async ({ geometry }) => { + native.observeSurface.mockResolvedValue(geometry); + const surface = await import('../../src/lib/native-surface-geometry'); + const listener = vi.fn<(geometry: NativeSurfaceGeometry) => void>(); + surface.addSurfaceGeometryListener(listener); + const nativeListener = native.addListener.mock.calls[0]?.[1]; + expect(nativeListener).toBe(listener); + nativeListener?.(geometry); + expect(listener).toHaveBeenCalledWith(geometry); + await expect(surface.observeSurface(42)).resolves.toBe(geometry); + }); + + it('keeps one subscription through pending attachment, detachment, and reattachment', async () => { + const detached: NativeSurfaceGeometry = { + ...initial, + visibleTop: 0, + visibleBottom: 0, + safeAreaTop: 0, + safeAreaBottom: 0, + keyboardOverlap: 0, + }; + const remove = vi.fn<() => void>(); + native.addListener.mockReturnValue({ remove }); + native.observeSurface.mockResolvedValue(detached); + const surface = await import('../../src/lib/native-surface-geometry'); + const listener = vi.fn<(geometry: NativeSurfaceGeometry) => void>(); + const subscription = surface.addSurfaceGeometryListener(listener); + await expect(surface.observeSurface(42)).resolves.toEqual(detached); + const publish = native.addListener.mock.calls[0]?.[1]; + expect(publish).toBe(listener); + publish?.(initial); + publish?.(detached); + publish?.(initial); + expect(listener.mock.calls).toEqual([[initial], [detached], [initial]]); + expect(native.observeSurface).toHaveBeenCalledExactlyOnceWith(42); + expect(native.addListener).toHaveBeenCalledOnce(); + expect(native.unobserveSurface).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + subscription?.remove(); + await surface.unobserveSurface(42); + expect(remove).toHaveBeenCalledOnce(); + expect(native.unobserveSurface).toHaveBeenCalledExactlyOnceWith(42); + }); + + it('accepts the largest positive signed 32-bit native tag', async () => { + const surface = await import('../../src/lib/native-surface-geometry'); + await surface.observeSurface(2_147_483_647); + expect(native.observeSurface).toHaveBeenCalledWith(2_147_483_647); + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])( + 'rejects invalid native tag %s before crossing the native boundary', + async tag => { + const surface = await import('../../src/lib/native-surface-geometry'); + await expect(surface.observeSurface(tag)).rejects.toThrow(RangeError); + expect(native.observeSurface).not.toHaveBeenCalled(); + } + ); + + it('preserves native errors instead of substituting a JS measurement', async () => { + native.observeSurface.mockRejectedValue(new Error('The native surface view is not mounted.')); + const surface = await import('../../src/lib/native-surface-geometry'); + await expect(surface.observeSurface(42)).rejects.toThrow('not mounted'); + }); +}); diff --git a/apps/mobile/src/components/centered-state-surface.mounted.test.tsx b/apps/mobile/src/components/centered-state-surface.mounted.test.tsx new file mode 100644 index 0000000000..8fbd270dd3 --- /dev/null +++ b/apps/mobile/src/components/centered-state-surface.mounted.test.tsx @@ -0,0 +1,295 @@ +import { + act, + type ComponentProps, + createElement, + createRef, + type ReactNode, + StrictMode, + useImperativeHandle, + useState, +} from 'react'; +import { type LayoutChangeEvent, type View } from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@/test/render-with-providers'; + +import { NativeStateSurface, StateSurface, useStateSurface } from './centered-state-surface'; + +type SurfaceProps = ComponentProps; +type Options = SurfaceProps['options']; +type NativeProps = NonNullable; +type Measurement = Parameters[0]; + +const geometryHook = vi.hoisted(() => vi.fn(() => ({ status: 'unavailable', geometry: null }))); +vi.mock('@/lib/hooks/use-native-state-geometry', () => ({ useNativeStateGeometry: geometryHook })); + +const platform = vi.hoisted(() => ({ OS: 'ios' })); +vi.mock('react-native', () => ({ + Platform: platform, + useWindowDimensions: () => ({ width: 400, height: 800 }), + View: 'View', +})); + +function createHarness(initialOptions: Options = {}, strict = false) { + const measurements: Measurement[] = []; + const nodeMock: Partial = { + measureInWindow: vi.fn((onMeasure: Measurement) => { + measurements.push(onMeasure); + }), + scrollTop: 0, + }; + const node = nodeMock as View; + const listeners = new Map void>(); + let updateOptions: ((options: Options) => void) | undefined = undefined; + let currentOptions = initialOptions; + let geometry: ReturnType = null; + const navigation = { + isFocused: () => true, + setOptions: vi.fn((options: Options) => { + updateOptions?.(options); + }), + addListener: vi.fn((event: string, listener: () => void) => { + listeners.set(event, listener); + return () => { + listeners.delete(event); + }; + }), + }; + function Probe() { + geometry = useStateSurface(); + return null; + } + function NativeScreen({ + children, + nativeProps, + }: { + children: ReactNode; + nativeProps?: NativeProps; + }): ReactNode { + useImperativeHandle(nativeProps?.ref, () => node); + return children; + } + function Harness() { + const [options, setOptions] = useState(initialOptions); + currentOptions = options; + updateOptions = next => { + setOptions(previous => ({ ...previous, ...next })); + }; + const navigationMock = navigation as Partial; + const props: Partial = { + options, + navigation: navigationMock as SurfaceProps['navigation'], + }; + return ( + + + + + + ); + } + return { + mount: async () => { + const mounted = await renderWithProviders(createElement('Root')); + await act(async () => { + await Promise.resolve(); + mounted.renderer.update( + createElement(strict ? StrictMode : 'Root', null, createElement(Harness)) + ); + }); + return mounted; + }, + node, + navigation, + listeners, + measurements, + geometry: () => geometry, + props: () => currentOptions.unstable_nativeProps, + update: async (options: Options) => { + await act(async () => { + await Promise.resolve(); + updateOptions?.(options); + }); + }, + settle: async (top = 0, height = 800) => { + await act(async () => { + await Promise.resolve(); + for (const onMeasure of measurements.splice(0)) { + onMeasure(0, top, 400, height); + } + }); + }, + }; +} + +beforeEach(() => { + geometryHook.mockClear(); + platform.OS = 'ios'; + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => + setTimeout(() => { + onFrame(0); + }, 16) + ); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('NativeStateSurface refs', () => { + it('observes native geometry only while a centered state is present', async () => { + const harness = createHarness(); + const mounted = await harness.mount(); + expect(geometryHook).toHaveBeenLastCalledWith(null); + let release: (() => void) | undefined = undefined; + act(() => { + release = harness.geometry()?.register(); + }); + expect(geometryHook).toHaveBeenLastCalledWith(harness.node); + act(() => { + release?.(); + }); + expect(geometryHook).toHaveBeenLastCalledWith(null); + mounted.unmount(); + }); + + it.each([false, true])( + 'preserves replacement props without a render loop in StrictMode %s', + async strict => { + const firstRef = createRef(); + const nextRef = createRef(); + const firstLayout = vi.fn>(); + const nextLayout = vi.fn>(); + const harness = createHarness( + { unstable_nativeProps: { ref: firstRef, onLayout: firstLayout } }, + strict + ); + const mounted = await harness.mount(); + await harness.settle(); + expect(firstRef.current).toBe(harness.node); + expect(harness.geometry()?.frame).toEqual({ top: 0, bottom: 800 }); + expect(harness.navigation.setOptions).toHaveBeenCalledTimes(1); + + const replacement = { ref: nextRef, onLayout: nextLayout, testID: 'replacement' }; + await harness.update({ unstable_nativeProps: replacement }); + expect(firstRef.current).toBeNull(); + expect(nextRef.current).toBe(harness.node); + expect(harness.geometry()?.frame).toBeNull(); + expect(harness.props()?.testID).toBe('replacement'); + expect(harness.navigation.setOptions).toHaveBeenCalledTimes(2); + const event: Partial = { + nativeEvent: { layout: { x: 0, y: 0, width: 400, height: 800 } }, + }; + harness.props()?.onLayout?.(event as LayoutChangeEvent); + expect(firstLayout).not.toHaveBeenCalled(); + expect(nextLayout).toHaveBeenCalledWith(event); + await harness.settle(300, 500); + expect(harness.geometry()?.frame).toEqual({ top: 300, bottom: 800 }); + + await act(async () => { + await Promise.resolve(); + harness.listeners.get('focus')?.(); + }); + expect(harness.navigation.setOptions).toHaveBeenCalledTimes(2); + expect(harness.props()?.testID).toBe('replacement'); + await harness.update({ unstable_nativeProps: replacement }); + expect(harness.navigation.setOptions).toHaveBeenCalledTimes(3); + expect(harness.props()?.ref).not.toBe(replacement.ref); + expect(nextRef.current).toBe(harness.node); + mounted.unmount(); + expect(nextRef.current).toBeNull(); + } + ); + + it('preserves external refs when a caller copies the installed props', async () => { + const ref = createRef(); + const onLayout = vi.fn>(); + const harness = createHarness({ unstable_nativeProps: { ref, onLayout } }, true); + const mounted = await harness.mount(); + await harness.settle(); + await harness.update({ unstable_nativeProps: { ...harness.props(), testID: 'copied' } }); + await harness.settle(); + expect(ref.current).toBe(harness.node); + expect(harness.navigation.setOptions).toHaveBeenCalledTimes(2); + expect(harness.props()?.testID).toBe('copied'); + mounted.unmount(); + expect(ref.current).toBeNull(); + }); + + it('invalidates detached measurements and accepts a fresh attachment', async () => { + const harness = createHarness(); + const mounted = await harness.mount(); + await harness.settle(); + const ref = harness.props()?.ref; + if (typeof ref !== 'function') { + throw new TypeError('Expected a measuring callback ref'); + } + act(() => { + ref(harness.node); + }); + const pending = harness.measurements.splice(0); + act(() => { + ref(null); + }); + expect(harness.geometry()?.frame).toBeNull(); + act(() => { + for (const onMeasure of pending) { + onMeasure(0, 100, 400, 700); + } + }); + expect(harness.geometry()?.frame).toBeNull(); + act(() => { + ref(harness.node); + }); + await harness.settle(300, 500); + expect(harness.geometry()?.frame).toEqual({ top: 300, bottom: 800 }); + mounted.unmount(); + }); + + it('clears invalid measurements and pairs callback ref cleanup', async () => { + const cleanup = vi.fn<() => void>(); + const ref = vi.fn((node: View | null) => (node ? cleanup : undefined)); + const harness = createHarness({ unstable_nativeProps: { ref } }, true); + const mounted = await harness.mount(); + await harness.settle(); + const assignments = () => ref.mock.calls.filter(([node]) => node !== null).length; + expect(assignments() - cleanup.mock.calls.length).toBe(1); + await harness.update({ unstable_nativeProps: undefined }); + expect(cleanup).toHaveBeenCalledTimes(assignments()); + await harness.settle(0, 0); + expect(harness.geometry()?.frame).toBeNull(); + mounted.unmount(); + }); +}); + +describe('native viewport policy', () => { + it.each([ + ['ios', 'formSheet', true], + ['ios', 'card', false], + ['ios', 'modal', false], + ['android', 'formSheet', false], + ] as const)('uses native fill only for %s %s', async (os, presentation, expected) => { + platform.OS = os; + const harness = createHarness({ presentation }); + const mounted = await harness.mount(); + expect(harness.geometry()?.nativeViewportFillsSurface).toBe(expected); + mounted.unmount(); + }); + + it('does not apply native fill to an explicit surface inside a modal', async () => { + let geometry: ReturnType = null; + function Probe() { + geometry = useStateSurface(); + return null; + } + const mounted = await renderWithProviders( + createElement(StateSurface, null, createElement(Probe)) + ); + expect(geometry).toMatchObject({ nativeViewportFillsSurface: false }); + mounted.unmount(); + }); +}); diff --git a/apps/mobile/src/components/centered-state-surface.tsx b/apps/mobile/src/components/centered-state-surface.tsx new file mode 100644 index 0000000000..7e077dd6b8 --- /dev/null +++ b/apps/mobile/src/components/centered-state-surface.tsx @@ -0,0 +1,245 @@ +import { + type ComponentProps, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { type LayoutChangeEvent, Platform, View, type ViewProps } from 'react-native'; +import { type Stack } from 'expo-router'; + +import { getStateSurfaceInsets } from '@/lib/centered-state-layout'; +import { + type SurfaceMeasurement, + useStateSurfaceMeasurement, +} from '@/lib/hooks/use-state-surface-measurement'; + +type SurfaceGeometry = SurfaceMeasurement & { + topInset: number; + bottomInset: number; + topReservation: number; + bottomReservation: number; + nativeViewportFillsSurface: boolean; + register: () => () => void; +}; + +type ScreenLayoutProps = Parameters['screenLayout']>>[0]; + +const StateSurfaceContext = createContext(null); + +export function useStateSurface() { + return useContext(StateSurfaceContext); +} + +function useSurfaceRegistration() { + const [count, setCount] = useState(0); + const register = useCallback(() => { + setCount(current => current + 1); + return () => { + setCount(current => current - 1); + }; + }, []); + return { active: count > 0, register }; +} + +function resolveSurfaceGeometry( + measurement: SurfaceMeasurement, + reservations: { + top: number; + bottom: number; + nativeViewportFillsSurface: boolean; + register: () => () => void; + } +): SurfaceGeometry { + const { frame, bounds, safeAreaTop, safeAreaBottom } = measurement; + const insets = + frame && bounds + ? getStateSurfaceInsets({ + surface: frame, + bounds, + top: Math.max(safeAreaTop, reservations.top), + bottom: Math.max(safeAreaBottom, reservations.bottom), + }) + : { topInset: 0, bottomInset: 0 }; + return { + frame, + bounds, + safeAreaTop, + safeAreaBottom, + source: measurement.source, + ...insets, + topReservation: reservations.top, + bottomReservation: reservations.bottom, + nativeViewportFillsSurface: reservations.nativeViewportFillsSurface, + register: reservations.register, + }; +} + +export function StateSurface({ + children, + onLayout, + topInset = 0, + bottomInset = 0, + ...props +}: ViewProps & { + topInset?: number; + bottomInset?: number; +}) { + const { active, register } = useSurfaceRegistration(); + const measurement = useStateSurfaceMeasurement(false, active); + const { capture, measure } = measurement; + const geometry = useMemo( + () => + resolveSurfaceGeometry(measurement, { + top: topInset, + bottom: bottomInset, + nativeViewportFillsSurface: false, + register, + }), + [measurement, topInset, bottomInset, register] + ); + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + measure(); + }, + [measure, onLayout] + ); + + return ( + + + {children} + + + ); +} + +export function NativeStateSurface({ children, navigation, options }: ScreenLayoutProps) { + const modal = options.presentation !== undefined && options.presentation !== 'card'; + const parent = useStateSurface(); + const { active, register } = useSurfaceRegistration(); + const measurement = useStateSurfaceMeasurement(Platform.OS === 'android' && modal, active); + const { capture, measure, node: nativeNode } = measurement; + const installedPropsRef = useRef(undefined); + const observedPropsRef = useRef(options.unstable_nativeProps); + const [propsState, setPropsState] = useState({ + incoming: options.unstable_nativeProps, + external: options.unstable_nativeProps, + }); + if ( + options.unstable_nativeProps !== installedPropsRef.current && + options.unstable_nativeProps !== propsState.incoming + ) { + const incoming = options.unstable_nativeProps; + const installed = installedPropsRef.current; + const external = incoming && { + ...incoming, + ref: installed && incoming.ref === installed.ref ? propsState.external?.ref : incoming.ref, + onLayout: + installed && incoming.onLayout === installed.onLayout + ? propsState.external?.onLayout + : incoming.onLayout, + }; + setPropsState({ incoming, external }); + } + const externalProps = propsState.external; + useImperativeHandle(externalProps?.ref, () => nativeNode, [nativeNode]); + const scheduledRef = useRef(null); + const schedule = useCallback(() => { + if (scheduledRef.current !== null) { + cancelAnimationFrame(scheduledRef.current); + } + scheduledRef.current = requestAnimationFrame(() => { + scheduledRef.current = null; + measure(); + }); + }, [measure]); + const nativeProps = useMemo( + () => ({ + ...externalProps, + ref: capture, + onLayout: (event: LayoutChangeEvent) => { + externalProps?.onLayout?.(event); + schedule(); + }, + }), + [capture, externalProps, schedule] + ); + + useLayoutEffect(() => { + const attach = () => { + const incomingChanged = observedPropsRef.current !== options.unstable_nativeProps; + observedPropsRef.current = options.unstable_nativeProps; + if ( + installedPropsRef.current !== nativeProps || + (incomingChanged && options.unstable_nativeProps !== nativeProps) + ) { + installedPropsRef.current = nativeProps; + navigation.setOptions({ unstable_nativeProps: nativeProps }); + } + schedule(); + }; + if (navigation.isFocused()) { + attach(); + } + return navigation.addListener('focus', attach); + }, [nativeProps, navigation, options.unstable_nativeProps, schedule]); + + useEffect(() => { + const removeTransition = navigation.addListener('transitionEnd', schedule); + const removeDetent = navigation.addListener('sheetDetentChange', schedule); + return () => { + removeTransition(); + removeDetent(); + if (scheduledRef.current !== null) { + cancelAnimationFrame(scheduledRef.current); + scheduledRef.current = null; + } + }; + }, [navigation, schedule]); + + const nativeViewportFillsSurface = Platform.OS === 'ios' && options.presentation === 'formSheet'; + const topReservation = modal ? 0 : (parent?.topReservation ?? 0); + const bottomReservation = modal ? 0 : (parent?.bottomReservation ?? 0); + const geometry = useMemo( + () => + resolveSurfaceGeometry(measurement, { + top: topReservation, + bottom: bottomReservation, + nativeViewportFillsSurface, + register, + }), + [measurement, topReservation, bottomReservation, nativeViewportFillsSurface, register] + ); + return {children}; +} + +export function StateSurfaceInsets({ + children, + bottomInset, +}: { + children: ReactNode; + bottomInset: number; +}) { + const surface = useStateSurface(); + const geometry = useMemo( + () => + surface + ? resolveSurfaceGeometry(surface, { + top: surface.topReservation, + bottom: Math.max(surface.bottomReservation, bottomInset), + nativeViewportFillsSurface: surface.nativeViewportFillsSurface, + register: surface.register, + }) + : null, + [surface, bottomInset] + ); + return {children}; +} diff --git a/apps/mobile/src/components/centered-state.mounted.test.tsx b/apps/mobile/src/components/centered-state.mounted.test.tsx new file mode 100644 index 0000000000..7f965469be --- /dev/null +++ b/apps/mobile/src/components/centered-state.mounted.test.tsx @@ -0,0 +1,212 @@ +import { + act, + type ComponentPropsWithRef, + createElement, + StrictMode, + useImperativeHandle, + useState, +} from 'react'; +import { + type LayoutChangeEvent, + ScrollView, + type ScrollViewProps, + type ViewProps, +} from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@/test/render-with-providers'; + +import { CenteredState } from './centered-state'; +import { type useStateSurface } from './centered-state-surface'; + +type ScrollNode = NonNullable>; +type Measurement = Parameters[0]; +const native = vi.hoisted(() => { + const surface: NonNullable> = { + frame: { top: 0, bottom: 500 }, + bounds: { top: 0, bottom: 500 }, + safeAreaTop: 0, + safeAreaBottom: 0, + source: 'layout', + topInset: 0, + bottomInset: 0, + topReservation: 0, + bottomReservation: 0, + nativeViewportFillsSurface: false, + register: vi.fn(() => vi.fn()), + }; + const measureInWindow = vi.fn<(onMeasure: Measurement) => void>(); + const node: Partial = { measureInWindow }; + const scroll: Partial = { getNativeScrollRef: () => node as ScrollNode }; + return { measurements: [] as Measurement[], surface, scroll, measureInWindow }; +}); + +vi.mock('@/components/centered-state-surface', () => ({ useStateSurface: () => native.surface })); +vi.mock('@/lib/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' ') })); +vi.mock('react-native', () => ({ + View: 'View', + ScrollView: (props: ComponentPropsWithRef) => { + const { ref, ...rest } = props; + useImperativeHandle(ref, () => native.scroll as ScrollView, []); + return createElement('ScrollView', rest); + }, +})); + +const contentLayout: Partial = { + nativeEvent: { layout: { x: 0, y: 0, width: 400, height: 700 } }, +}; + +async function mount() { + let rerender: (() => void) | undefined = undefined; + function Harness() { + const [, setVersion] = useState(0); + rerender = () => { + setVersion(version => version + 1); + }; + return {null}; + } + const mounted = await renderWithProviders( + createElement(StrictMode, null, createElement(Harness)) + ); + const content = () => + mounted.renderer.root.findByProps({ testID: 'state-content' }).props as ViewProps; + const scroll = () => mounted.renderer.root.findByType(ScrollView).props as ScrollViewProps; + act(() => { + content().onLayout?.(contentLayout as LayoutChangeEvent); + }); + return { + ...mounted, + content, + scroll, + rerender: () => { + act(() => { + rerender?.(); + }); + }, + settle: (top = 80, height = 340) => { + act(() => { + for (const onMeasure of native.measurements.splice(0)) { + onMeasure(0, top, 400, height); + } + }); + }, + }; +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + native.surface = { + frame: { top: 0, bottom: 500 }, + bounds: { top: 0, bottom: 500 }, + safeAreaTop: 0, + safeAreaBottom: 0, + source: 'layout', + topInset: 0, + bottomInset: 0, + topReservation: 0, + bottomReservation: 0, + nativeViewportFillsSurface: false, + register: vi.fn(() => vi.fn()), + }; + native.measurements = []; + native.measureInWindow.mockImplementation(onMeasure => { + native.measurements.push(onMeasure); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('CenteredState measurements', () => { + it.each([false, true])( + 'applies the provider native fill policy %s', + async nativeViewportFillsSurface => { + native.surface.nativeViewportFillsSurface = nativeViewportFillsSurface; + const mounted = await mount(); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + mounted.settle(); + expect(mounted.content().accessibilityElementsHidden).toBe(false); + expect(mounted.scroll().contentContainerStyle).toEqual({ + flexGrow: 1, + minHeight: nativeViewportFillsSurface ? 420 : 340, + paddingTop: 16, + paddingBottom: nativeViewportFillsSurface ? 96 : 16, + }); + mounted.unmount(); + } + ); + + it('rejects old viewport results after the surface changes', async () => { + const mounted = await mount(); + mounted.settle(); + act(() => { + mounted.scroll().onLayout?.(contentLayout as LayoutChangeEvent); + }); + const stale = native.measurements.splice(0); + native.surface.frame = { top: 300, bottom: 800 }; + mounted.rerender(); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + act(() => { + for (const onMeasure of stale) { + onMeasure(0, 80, 400, 340); + } + }); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + mounted.settle(380, 340); + expect(mounted.content().accessibilityElementsHidden).toBe(false); + mounted.unmount(); + }); + + it('clears readiness on detach and ignores the detached request', async () => { + const mounted = await mount(); + mounted.settle(); + act(() => { + mounted.scroll().onLayout?.(contentLayout as LayoutChangeEvent); + }); + const stale = native.measurements.splice(0); + const props = mounted.renderer.root.findByType(ScrollView).props as ComponentPropsWithRef< + typeof ScrollView + >; + if (typeof props.ref !== 'function') { + throw new TypeError('Expected a measuring callback ref'); + } + const ref = props.ref; + act(() => { + ref(null); + }); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + act(() => { + for (const onMeasure of stale) { + onMeasure(0, 80, 400, 340); + } + }); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + act(() => { + ref(native.scroll as ScrollView); + }); + mounted.settle(); + expect(mounted.content().accessibilityElementsHidden).toBe(false); + mounted.unmount(); + }); + + it('waits for a new valid measurement after the surface detaches', async () => { + const mounted = await mount(); + mounted.settle(); + const frame = native.surface.frame; + native.surface.frame = null; + mounted.rerender(); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + native.surface.frame = frame; + mounted.rerender(); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + mounted.settle(0, 0); + expect(mounted.content().accessibilityElementsHidden).toBe(true); + act(() => { + mounted.scroll().onLayout?.(contentLayout as LayoutChangeEvent); + }); + mounted.settle(); + expect(mounted.content().accessibilityElementsHidden).toBe(false); + mounted.unmount(); + }); +}); diff --git a/apps/mobile/src/components/centered-state.tsx b/apps/mobile/src/components/centered-state.tsx new file mode 100644 index 0000000000..a614820b69 --- /dev/null +++ b/apps/mobile/src/components/centered-state.tsx @@ -0,0 +1,119 @@ +import { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { type LayoutChangeEvent, ScrollView, type ScrollViewProps, View } from 'react-native'; + +import { useStateSurface } from '@/components/centered-state-surface'; +import { getCenteredStateLayout, type StateFrame } from '@/lib/centered-state-layout'; +import { cn } from '@/lib/utils'; + +type CenteredStateProps = { + children: ReactNode; + className?: string; + testID?: string; + refreshControl?: ScrollViewProps['refreshControl']; +}; + +type MeasuredViewport = { frame: StateFrame; surface: StateFrame }; + +export function CenteredState({ children, className, testID, refreshControl }: CenteredStateProps) { + const surface = useStateSurface(); + const frame = surface?.frame; + const scrollRef = useRef(null); + const requestRef = useRef(0); + const [viewport, setViewport] = useState(null); + const [contentHeight, setContentHeight] = useState(null); + + const register = surface?.register; + useLayoutEffect(() => register?.(), [register]); + + const measure = useCallback(() => { + requestRef.current += 1; + const request = requestRef.current; + const node = scrollRef.current?.getNativeScrollRef(); + if (!frame || !node) { + setViewport(null); + return; + } + node.measureInWindow((...bounds) => { + const [, top, , height] = bounds; + if (request !== requestRef.current) { + return; + } + const bottom = top + height; + if (height <= 0 || !Number.isFinite(top) || !Number.isFinite(bottom)) { + setViewport(null); + return; + } + setViewport(previous => + previous?.surface === frame && + previous.frame.top === top && + previous.frame.bottom === bottom + ? previous + : { surface: frame, frame: { top, bottom } } + ); + }); + }, [frame]); + + const capture = useCallback( + (node: ScrollView | null) => { + scrollRef.current = node; + measure(); + }, + [measure] + ); + + useLayoutEffect(() => { + measure(); + return () => { + requestRef.current += 1; + }; + }, [measure]); + + const measureContent = useCallback((event: LayoutChangeEvent) => { + setContentHeight(event.nativeEvent.layout.height); + }, []); + const layout = useMemo( + () => + surface?.frame && viewport?.surface === surface.frame && contentHeight !== null + ? getCenteredStateLayout({ + surface: surface.frame, + viewport: viewport.frame, + contentHeight, + topInset: surface.topInset, + bottomInset: surface.bottomInset, + nativeViewportFillsSurface: surface.nativeViewportFillsSurface, + nativeViewportBottom: surface.bounds?.bottom, + }) + : undefined, + [surface, viewport, contentHeight] + ); + const contentStyle = useMemo(() => ({ flexGrow: 1, ...layout }), [layout]); + const ready = layout !== undefined; + + if (!surface) { + throw new Error('CenteredState requires a StateSurface'); + } + + return ( + + + {children} + + + ); +} diff --git a/apps/mobile/src/lib/centered-state-layout.test.ts b/apps/mobile/src/lib/centered-state-layout.test.ts new file mode 100644 index 0000000000..15b04fd46b --- /dev/null +++ b/apps/mobile/src/lib/centered-state-layout.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; + +import { + getCenteredStateLayout, + getStateSurfaceInsets, + intersectStateFrames, +} from './centered-state-layout'; + +describe('getCenteredStateLayout', () => { + it.each([ + { title: 'no header or footer', viewport: { top: 0, bottom: 800 } }, + { title: 'header only', viewport: { top: 100, bottom: 800 } }, + { title: 'footer only', viewport: { top: 0, bottom: 700 } }, + { title: 'unequal header and footer', viewport: { top: 100, bottom: 760 } }, + { title: 'equal header and footer', viewport: { top: 100, bottom: 700 } }, + ])('centers on the surface with $title', ({ viewport }) => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 800 }, + viewport, + contentHeight: 200, + }); + expect(viewport.top + layout.paddingTop + 100).toBe(400); + expect(layout.minHeight).toBe(viewport.bottom - viewport.top); + }); + + it('centers inside a sheet rather than the application window', () => { + const layout = getCenteredStateLayout({ + surface: { top: 300, bottom: 800 }, + viewport: { top: 380, bottom: 800 }, + contentHeight: 100, + }); + expect(380 + layout.paddingTop + 50).toBe(550); + }); + + it('does not shift a short-sheet state when it fits at the exact center', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 320 }, + viewport: { top: 60, bottom: 320 }, + contentHeight: 180, + }); + expect(layout.paddingTop).toBe(10); + expect(60 + layout.paddingTop + 90).toBe(160); + }); + + it('keeps content below a tall header when the target is obstructed', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 800 }, + viewport: { top: 450, bottom: 800 }, + contentHeight: 200, + }); + expect(layout.paddingTop).toBe(16); + }); + + it('keeps content above an overlay without counting its inset twice', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 800 }, + viewport: { top: 100, bottom: 720 }, + contentHeight: 600, + bottomInset: 80, + }); + expect(layout.paddingTop).toBe(0); + expect(layout.paddingBottom).toBe(20); + }); + + it('gives tall content normal scrollable padding rather than a negative offset', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 400 }, + viewport: { top: 100, bottom: 400 }, + contentHeight: 700, + bottomInset: 40, + }); + expect(layout).toEqual({ minHeight: 300, paddingTop: 16, paddingBottom: 56 }); + }); + + it.each([false, true])( + 'keeps a flow footer reachable with native fill %s', + nativeViewportFillsSurface => { + const viewport = { top: 80, bottom: 420 }; + const contentHeight = 700; + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 500 }, + viewport, + contentHeight, + nativeViewportFillsSurface, + }); + const nativeHeight = nativeViewportFillsSurface ? 420 : 340; + const scrollRange = layout.paddingTop + contentHeight + layout.paddingBottom - nativeHeight; + expect(layout.minHeight).toBe(nativeHeight); + expect(layout.paddingBottom).toBe(nativeViewportFillsSurface ? 96 : 16); + expect(viewport.top + layout.paddingTop + contentHeight - scrollRange).toBe(404); + } + ); + + it.each([0, 80])('preserves a short state above a flow footer with inset %s', bottomInset => { + const layout = getCenteredStateLayout({ + surface: { top: 300, bottom: 800 }, + viewport: { top: 380, bottom: 720 }, + contentHeight: 120, + bottomInset, + nativeViewportFillsSurface: true, + }); + expect(layout).toEqual({ minHeight: 420, paddingTop: 110, paddingBottom: 190 }); + expect(380 + layout.paddingTop + 60).toBe(550); + }); + + it('does not add a second clearance for an overlay footer', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 500 }, + viewport: { top: 80, bottom: 500 }, + contentHeight: 700, + bottomInset: 80, + nativeViewportFillsSurface: true, + }); + expect(layout).toEqual({ minHeight: 420, paddingTop: 16, paddingBottom: 96 }); + }); + + it('fills the sheet without adding clearance when there is no footer', () => { + const layout = getCenteredStateLayout({ + surface: { top: 300, bottom: 800 }, + viewport: { top: 380, bottom: 800 }, + contentHeight: 120, + nativeViewportFillsSurface: true, + }); + expect(layout).toEqual({ minHeight: 420, paddingTop: 110, paddingBottom: 190 }); + }); + + it('uses the visible viewport after keyboard avoidance', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 500 }, + viewport: { top: 80, bottom: 440 }, + contentHeight: 120, + }); + expect(80 + layout.paddingTop + 60).toBe(250); + }); + + it('keeps a clipped native sheet within its visible surface', () => { + const layout = getCenteredStateLayout({ + surface: { top: 400, bottom: 800 }, + viewport: { top: 480, bottom: 1100 }, + contentHeight: 120, + }); + expect(480 + layout.paddingTop + 60).toBe(600); + expect(layout.paddingBottom).toBe(440); + }); +}); + +describe('native keyboard clipping', () => { + it('keeps the native scroll extent while centering above the keyboard', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 400 }, + viewport: { top: 80, bottom: 620 }, + contentHeight: 120, + nativeViewportFillsSurface: true, + nativeViewportBottom: 700, + }); + expect(layout).toEqual({ minHeight: 620, paddingTop: 60, paddingBottom: 440 }); + expect(80 + layout.paddingTop + 60).toBe(200); + }); + + it('keeps a long state action above the keyboard at the end of scrolling', () => { + const layout = getCenteredStateLayout({ + surface: { top: 0, bottom: 400 }, + viewport: { top: 80, bottom: 620 }, + contentHeight: 800, + nativeViewportFillsSurface: true, + nativeViewportBottom: 700, + }); + const scrollRange = layout.paddingTop + 800 + layout.paddingBottom - layout.minHeight; + expect(80 + layout.paddingTop + 800 - scrollRange).toBe(384); + }); +}); + +describe('getStateSurfaceInsets', () => { + it('does not reserve a tab bar that is behind the keyboard', () => { + expect( + getStateSurfaceInsets({ + surface: { top: 0, bottom: 500 }, + bounds: { top: 0, bottom: 800 }, + top: 60, + bottom: 100, + }) + ).toEqual({ topInset: 60, bottomInset: 0 }); + }); + + it('reserves a footer that moves with a resized root', () => { + expect( + getStateSurfaceInsets({ + surface: { top: 0, bottom: 500 }, + bounds: { top: 0, bottom: 500 }, + top: 60, + bottom: 100, + }) + ).toEqual({ topInset: 60, bottomInset: 100 }); + }); + + it('does not subtract an already-clipped safe area twice', () => { + expect( + getStateSurfaceInsets({ + surface: { top: 40, bottom: 760 }, + bounds: { top: 0, bottom: 800 }, + top: 40, + bottom: 40, + }) + ).toEqual({ topInset: 0, bottomInset: 0 }); + }); +}); + +describe('intersectStateFrames', () => { + it('clips an oversized sheet to its containing window', () => { + expect(intersectStateFrames({ top: 400, bottom: 1200 }, { top: 0, bottom: 800 })).toEqual({ + top: 400, + bottom: 800, + }); + }); + + it('returns a zero-height frame for a surface outside the window', () => { + expect(intersectStateFrames({ top: 900, bottom: 1200 }, { top: 0, bottom: 800 })).toEqual({ + top: 800, + bottom: 800, + }); + }); +}); diff --git a/apps/mobile/src/lib/centered-state-layout.ts b/apps/mobile/src/lib/centered-state-layout.ts new file mode 100644 index 0000000000..0a58ab040e --- /dev/null +++ b/apps/mobile/src/lib/centered-state-layout.ts @@ -0,0 +1,63 @@ +export type StateFrame = Readonly<{ top: number; bottom: number }>; + +type CenteredStateLayoutInput = { + surface: StateFrame; + viewport: StateFrame; + contentHeight: number; + topInset?: number; + bottomInset?: number; + nativeViewportFillsSurface?: boolean; + nativeViewportBottom?: number; +}; + +const STATE_GAP = 16; + +export function getStateSurfaceInsets({ + surface, + bounds, + top, + bottom, +}: { + surface: StateFrame; + bounds: StateFrame; + top: number; + bottom: number; +}) { + return { + topInset: Math.max(0, bounds.top + top - surface.top), + bottomInset: Math.max(0, surface.bottom - (bounds.bottom - bottom)), + }; +} + +export function intersectStateFrames(frame: StateFrame, clip: StateFrame): StateFrame { + const top = Math.max(clip.top, Math.min(frame.top, clip.bottom)); + return { top, bottom: Math.max(top, Math.min(frame.bottom, clip.bottom)) }; +} + +export function getCenteredStateLayout({ + surface, + viewport, + contentHeight, + topInset = 0, + bottomInset = 0, + nativeViewportFillsSurface = false, + nativeViewportBottom = surface.bottom, +}: CenteredStateLayoutInput) { + const viewportBottom = nativeViewportFillsSurface ? nativeViewportBottom : viewport.bottom; + const visible = intersectStateFrames(viewport, surface); + const top = Math.max(visible.top, surface.top + topInset); + const bottom = Math.min(visible.bottom, surface.bottom - bottomInset); + const idealTop = (surface.top + surface.bottom - contentHeight) / 2; + const centered = idealTop >= top && idealTop + contentHeight <= bottom; + const contentTop = centered + ? idealTop + : Math.max(top + STATE_GAP, Math.min(idealTop, bottom - STATE_GAP - contentHeight)); + + return { + minHeight: Math.max(0, viewportBottom - viewport.top), + paddingTop: Math.max(0, contentTop - viewport.top), + paddingBottom: centered + ? Math.max(0, viewportBottom - contentTop - contentHeight) + : Math.max(STATE_GAP, viewportBottom - bottom + STATE_GAP), + }; +} diff --git a/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx new file mode 100644 index 0000000000..5953d8f9d1 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx @@ -0,0 +1,178 @@ +import { act, createElement, useState } from 'react'; +import { type View } from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type NativeSurfaceGeometry } from '@/lib/native-surface-geometry'; +import { renderWithProviders } from '@/test/render-with-providers'; + +import { useNativeStateGeometry } from './use-native-state-geometry'; + +type Deferred = { + resolve: (geometry: NativeSurfaceGeometry) => void; + reject: (error: Error) => void; +}; + +const native = vi.hoisted(() => { + const pending = new Map(); + const listeners = new Set<(geometry: NativeSurfaceGeometry) => void>(); + return { + available: true, + pending, + listeners, + find: vi.fn<(node: View) => number | null>(), + observe: vi.fn(async (tag: number) => { + const result = await new Promise((resolve, reject) => { + pending.set(tag, { resolve, reject }); + }); + return result; + }), + unobserve: vi.fn(async (_tag: number) => { + await Promise.resolve(); + }), + }; +}); + +vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() })); +vi.mock('react-native', () => ({ findNodeHandle: native.find })); +vi.mock('@/lib/native-surface-geometry', () => ({ + get isNativeSurfaceGeometryAvailable() { + return native.available; + }, + addSurfaceGeometryListener: (listener: (geometry: NativeSurfaceGeometry) => void) => { + native.listeners.add(listener); + return { remove: () => native.listeners.delete(listener) }; + }, + observeSurface: native.observe, + unobserveSurface: native.unobserve, +})); + +const firstMock: Partial = {}; +const secondMock: Partial = {}; +const first = firstMock as View; +const second = secondMock as View; +const snapshot = (tag = 41): NativeSurfaceGeometry => ({ + tag, + visibleTop: 0, + visibleBottom: 800, + boundsHeight: 800, + safeAreaTop: 20, + safeAreaBottom: 34, + keyboardOverlap: 0, +}); + +async function mount() { + let observation: ReturnType | undefined = undefined; + let update: ((node: View) => void) | undefined = undefined; + function Probe() { + const [node, setNode] = useState(first); + update = setNode; + observation = useNativeStateGeometry(node); + return null; + } + const mounted = await renderWithProviders(createElement(Probe)); + return { + ...mounted, + read: () => observation, + update: (node: View) => { + act(() => { + update?.(node); + }); + }, + resolve: async (tag: number, geometry = snapshot(tag)) => { + await act(async () => { + native.pending.get(tag)?.resolve(geometry); + await Promise.resolve(); + }); + }, + emit: (geometry: NativeSurfaceGeometry) => { + act(() => { + for (const listener of native.listeners) { + listener(geometry); + } + }); + }, + }; +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.clearAllMocks(); + native.available = true; + native.pending.clear(); + native.listeners.clear(); + native.find.mockImplementation(node => (node === first ? 41 : 42)); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe('useNativeStateGeometry', () => { + it('reports an old client without pretending native measurements exist', async () => { + native.available = false; + const mounted = await mount(); + expect(mounted.read()).toMatchObject({ status: 'unavailable', geometry: null }); + expect(native.find).not.toHaveBeenCalled(); + expect(native.observe).not.toHaveBeenCalled(); + mounted.unmount(); + }); + + it('accepts its initial snapshot and ignores other native roots', async () => { + const mounted = await mount(); + expect(mounted.read()?.status).toBe('pending'); + await mounted.resolve(41); + expect(mounted.read()).toMatchObject({ status: 'ready', geometry: snapshot() }); + const previous = mounted.read(); + mounted.emit(snapshot(42)); + expect(mounted.read()).toBe(previous); + mounted.emit(snapshot()); + expect(mounted.read()).toBe(previous); + mounted.unmount(); + }); + + it('ignores an old snapshot after the root changes', async () => { + const mounted = await mount(); + mounted.update(second); + await mounted.resolve(41); + expect(mounted.read()).toMatchObject({ status: 'pending', geometry: null }); + await mounted.resolve(42); + expect(mounted.read()).toMatchObject({ status: 'ready', geometry: snapshot(42) }); + expect(native.unobserve).toHaveBeenCalledWith(41); + mounted.unmount(); + }); + + it('removes the listener and observer before a late snapshot resolves', async () => { + const mounted = await mount(); + mounted.unmount(); + expect(native.listeners.size).toBe(0); + expect(native.unobserve).toHaveBeenCalledWith(41); + await mounted.resolve(41); + expect(mounted.read()?.status).toBe('pending'); + }); + + it('exposes observation failure instead of marking it native-ready', async () => { + const mounted = await mount(); + await act(async () => { + native.pending.get(41)?.reject(new Error('View unavailable')); + await Promise.resolve(); + }); + expect(mounted.read()).toMatchObject({ status: 'failed', geometry: null }); + mounted.unmount(); + }); + + it('accepts detachment and reattachment without replacing the React ref', async () => { + const mounted = await mount(); + await mounted.resolve(41); + mounted.emit({ ...snapshot(), visibleBottom: 0 }); + expect(mounted.read()?.geometry?.visibleBottom).toBe(0); + mounted.emit({ ...snapshot(), visibleBottom: 500, keyboardOverlap: 300 }); + expect(mounted.read()?.geometry?.visibleBottom).toBe(500); + expect(native.observe).toHaveBeenCalledTimes(1); + mounted.unmount(); + }); + + it('rejects non-finite native bounds', async () => { + const mounted = await mount(); + await mounted.resolve(41, { ...snapshot(), visibleBottom: Number.NaN }); + expect(mounted.read()).toMatchObject({ status: 'failed', geometry: null }); + mounted.unmount(); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-native-state-geometry.ts b/apps/mobile/src/lib/hooks/use-native-state-geometry.ts new file mode 100644 index 0000000000..3ed886811b --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.ts @@ -0,0 +1,105 @@ +import { captureException } from '@sentry/react-native'; +import { useEffect, useState } from 'react'; +import { findNodeHandle, type View } from 'react-native'; + +import { + addSurfaceGeometryListener, + isNativeSurfaceGeometryAvailable, + type NativeSurfaceGeometry, + observeSurface, + unobserveSurface, +} from '@/lib/native-surface-geometry'; + +type Observation = { + node: View | null; + status: 'pending' | 'ready' | 'failed'; + geometry: NativeSurfaceGeometry | null; +}; + +function sameGeometry(left: NativeSurfaceGeometry | null, right: NativeSurfaceGeometry) { + return ( + left?.tag === right.tag && + left.visibleTop === right.visibleTop && + left.visibleBottom === right.visibleBottom && + left.boundsHeight === right.boundsHeight && + left.keyboardOverlap === right.keyboardOverlap && + left.safeAreaTop === right.safeAreaTop && + left.safeAreaBottom === right.safeAreaBottom + ); +} + +export function useNativeStateGeometry(node: View | null) { + const [observation, setObservation] = useState({ + node: null, + status: 'pending', + geometry: null, + }); + + useEffect(() => { + if (!isNativeSurfaceGeometryAvailable || !node) { + return undefined; + } + const tag = findNodeHandle(node); + if (tag === null) { + setObservation({ node, status: 'failed', geometry: null }); + return undefined; + } + let active = true; + const publish = (geometry: NativeSurfaceGeometry) => { + if (!active || geometry.tag !== tag) { + return; + } + const valid = + Number.isFinite(geometry.visibleTop) && + Number.isFinite(geometry.visibleBottom) && + Number.isFinite(geometry.boundsHeight) && + Number.isFinite(geometry.safeAreaTop) && + Number.isFinite(geometry.safeAreaBottom) && + geometry.visibleBottom >= geometry.visibleTop && + geometry.boundsHeight >= 0; + if (!valid) { + setObservation({ node, status: 'failed', geometry: null }); + return; + } + setObservation(previous => + previous.node === node && sameGeometry(previous.geometry, geometry) + ? previous + : { node, status: 'ready', geometry } + ); + }; + setObservation({ node, status: 'pending', geometry: null }); + const listener = addSurfaceGeometryListener(publish); + const start = async () => { + try { + publish(await observeSurface(tag)); + } catch { + if (active) { + setObservation({ node, status: 'failed', geometry: null }); + } + } + }; + const stop = async () => { + try { + await unobserveSurface(tag); + } catch (error) { + captureException(error, { + tags: { 'error.subsystem': 'surface_geometry', 'error.operation': 'unobserve' }, + }); + } + }; + void start(); + return () => { + active = false; + listener?.remove(); + void stop(); + }; + }, [node]); + + if (!isNativeSurfaceGeometryAvailable) { + return { status: 'unavailable' as const, geometry: null }; + } + if (observation.node !== node) { + return { status: 'pending' as const, geometry: null }; + } + return observation; +} diff --git a/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts b/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts new file mode 100644 index 0000000000..5fef3582e5 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts @@ -0,0 +1,104 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useWindowDimensions, type View } from 'react-native'; + +import { intersectStateFrames, type StateFrame } from '@/lib/centered-state-layout'; +import { useNativeStateGeometry } from '@/lib/hooks/use-native-state-geometry'; + +export type SurfaceMeasurement = { + frame: StateFrame | null; + bounds: StateFrame | null; + safeAreaTop: number; + safeAreaBottom: number; + source: 'native' | 'layout' | 'pending'; +}; + +export function useStateSurfaceMeasurement(androidSheet: boolean, nativeActive = true) { + const nodeRef = useRef(null); + const [nativeNode, setNode] = useState(null); + const native = useNativeStateGeometry(nativeActive ? nativeNode : null); + const [frame, setFrame] = useState(null); + const { width, height } = useWindowDimensions(); + const requestRef = useRef(0); + const mountedRef = useRef(true); + + const measure = useCallback(() => { + const node = nodeRef.current; + requestRef.current += 1; + const request = requestRef.current; + if (!node) { + setFrame(null); + return; + } + node.measureInWindow((...bounds) => { + const [, y, , measuredHeight] = bounds; + if (!mountedRef.current || request !== requestRef.current) { + return; + } + const top = y - (androidSheet ? node.scrollTop : 0); + const bottom = top + measuredHeight; + if ( + measuredHeight <= 0 || + !Number.isFinite(top) || + !Number.isFinite(bottom) || + bottom <= top + ) { + setFrame(null); + return; + } + setFrame(previous => + previous?.top === top && previous.bottom === bottom ? previous : { top, bottom } + ); + }); + }, [androidSheet]); + + const capture = useCallback( + (node: View | null) => { + nodeRef.current = node; + setNode(node); + measure(); + }, + [measure] + ); + + useLayoutEffect(() => { + mountedRef.current = true; + measure(); + return () => { + mountedRef.current = false; + requestRef.current += 1; + }; + }, [measure, width, height]); + + const measurement = useMemo(() => { + if (!frame || native.status === 'pending') { + return { frame: null, bounds: null, safeAreaTop: 0, safeAreaBottom: 0, source: 'pending' }; + } + if (native.geometry) { + const bounds = { top: frame.top, bottom: frame.top + native.geometry.boundsHeight }; + const visible = { + top: frame.top + native.geometry.visibleTop, + bottom: frame.top + native.geometry.visibleBottom, + }; + return { + frame: visible.bottom > visible.top ? visible : null, + bounds, + safeAreaTop: native.geometry.safeAreaTop, + safeAreaBottom: native.geometry.safeAreaBottom, + source: 'native', + }; + } + const visible = androidSheet ? intersectStateFrames(frame, { top: 0, bottom: height }) : frame; + return { + frame: visible.bottom > visible.top ? visible : null, + bounds: frame, + safeAreaTop: 0, + safeAreaBottom: 0, + source: 'layout', + }; + }, [frame, native.geometry, native.status, androidSheet, height]); + + return useMemo( + () => ({ ...measurement, node: nativeNode, capture, measure }), + [measurement, nativeNode, capture, measure] + ); +} diff --git a/apps/mobile/src/lib/native-surface-geometry.ts b/apps/mobile/src/lib/native-surface-geometry.ts new file mode 100644 index 0000000000..23ec085774 --- /dev/null +++ b/apps/mobile/src/lib/native-surface-geometry.ts @@ -0,0 +1,51 @@ +import { type NativeModule, requireOptionalNativeModule } from 'expo'; + +export type NativeSurfaceGeometry = Readonly<{ + tag: number; + visibleTop: number; + visibleBottom: number; + boundsHeight: number; + safeAreaTop: number; + safeAreaBottom: number; + keyboardOverlap: number; +}>; + +type SurfaceGeometryEvents = { + onSurfaceGeometryChange: (geometry: NativeSurfaceGeometry) => void; +}; + +type SurfaceGeometryModule = InstanceType> & { + isSupported: boolean; + observeSurface: (nativeViewTag: number) => Promise; + unobserveSurface: (nativeViewTag: number) => Promise; +}; + +const nativeModule = requireOptionalNativeModule('KiloSurfaceGeometry'); + +export const isNativeSurfaceGeometryAvailable = nativeModule?.isSupported === true; + +export function addSurfaceGeometryListener( + listener: (geometry: NativeSurfaceGeometry) => void +): { remove: () => void } | null { + if (!isNativeSurfaceGeometryAvailable) { + return null; + } + return nativeModule?.addListener('onSurfaceGeometryChange', listener) ?? null; +} + +export async function observeSurface(nativeViewTag: number): Promise { + if (!nativeModule || !isNativeSurfaceGeometryAvailable) { + throw new Error( + 'Native surface geometry requires a rebuilt iOS or Android development client.' + ); + } + if (!Number.isInteger(nativeViewTag) || nativeViewTag <= 0 || nativeViewTag > 2_147_483_647) { + throw new RangeError('The native view tag must be a positive 32-bit integer.'); + } + const geometry = await nativeModule.observeSurface(nativeViewTag); + return geometry; +} + +export async function unobserveSurface(nativeViewTag: number): Promise { + await nativeModule?.unobserveSurface(nativeViewTag); +} diff --git a/apps/mobile/vitest.pure.config.ts b/apps/mobile/vitest.pure.config.ts index 0fed5a21a6..2d4f33e510 100644 --- a/apps/mobile/vitest.pure.config.ts +++ b/apps/mobile/vitest.pure.config.ts @@ -42,6 +42,7 @@ export default defineProject({ // this directory holds both kinds, and a file in both projects runs twice. 'src/components/kiloclaw/**/!(*.mounted).test.tsx', 'src/lib/telemetry/**/*.test.ts', + 'modules/kilo-surface-geometry/*.test.ts', ], }, }); From 8440243deba1c54c03c6fac9f31776c975da577f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 1 Sep 2026 21:44:54 +0200 Subject: [PATCH 02/10] fix(mobile): avoid native observer UIView tag collision --- .../ios/KiloSurfaceGeometryModule.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift index 2559ea6771..44926d93a6 100644 --- a/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift +++ b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift @@ -77,7 +77,7 @@ public final class KiloSurfaceGeometryModule: Module { } } -private final class SurfaceViewNotFoundException: Exception { +private final class SurfaceViewNotFoundException: Exception, @unchecked Sendable { override var reason: String { "The native surface view is not mounted." } } @@ -92,7 +92,7 @@ private final class WeakSurfaceGeometryProbe { private final class SurfaceGeometryProbe: UIView { let generation: Int private weak var root: UIView? - private let tag: Int + private let observedTag: Int private let emit: ([String: Any]) -> Void private let onStop: (SurfaceGeometryProbe) -> Void private let keyboardEdge = UIView() @@ -108,7 +108,7 @@ private final class SurfaceGeometryProbe: UIView { init(root: UIView, tag: Int, generation: Int, emit: @escaping ([String: Any]) -> Void, onStop: @escaping (SurfaceGeometryProbe) -> Void) { self.root = root - self.tag = tag + self.observedTag = tag self.generation = generation self.emit = emit self.onStop = onStop @@ -250,7 +250,7 @@ private final class SurfaceGeometryProbe: UIView { func snapshot() -> [String: Any] { guard !stopped, let root, superview === root else { stop() - return ["tag": tag, "visibleTop": 0.0, "visibleBottom": 0.0, "boundsHeight": 0.0, + return ["tag": observedTag, "visibleTop": 0.0, "visibleBottom": 0.0, "boundsHeight": 0.0, "safeAreaTop": 0.0, "safeAreaBottom": 0.0, "keyboardOverlap": 0.0] } if observingWindow { @@ -262,7 +262,7 @@ private final class SurfaceGeometryProbe: UIView { } let geometry = measure(root) var event: [String: Any] = geometry - event["tag"] = tag + event["tag"] = observedTag if previous != geometry { previous = geometry emit(event) From ece9ada22e63d5dc31bb5568628a8af1e5db8c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 1 Sep 2026 22:57:50 +0200 Subject: [PATCH 03/10] fix(mobile): stabilize native surface mounting and placement --- .../src/components/centered-state-surface.tsx | 1 + .../src/lib/centered-state-layout.test.ts | 2 +- apps/mobile/src/lib/centered-state-layout.ts | 10 ++--- ...use-native-state-geometry.mounted.test.tsx | 37 +++++++++++++++++++ .../lib/hooks/use-native-state-geometry.ts | 31 +++++++++++++--- .../hooks/use-state-surface-measurement.ts | 5 ++- 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/components/centered-state-surface.tsx b/apps/mobile/src/components/centered-state-surface.tsx index 7e077dd6b8..4f9eac0d29 100644 --- a/apps/mobile/src/components/centered-state-surface.tsx +++ b/apps/mobile/src/components/centered-state-surface.tsx @@ -73,6 +73,7 @@ function resolveSurfaceGeometry( safeAreaTop, safeAreaBottom, source: measurement.source, + failure: measurement.failure, ...insets, topReservation: reservations.top, bottomReservation: reservations.bottom, diff --git a/apps/mobile/src/lib/centered-state-layout.test.ts b/apps/mobile/src/lib/centered-state-layout.test.ts index 15b04fd46b..c6cce43372 100644 --- a/apps/mobile/src/lib/centered-state-layout.test.ts +++ b/apps/mobile/src/lib/centered-state-layout.test.ts @@ -48,7 +48,7 @@ describe('getCenteredStateLayout', () => { viewport: { top: 450, bottom: 800 }, contentHeight: 200, }); - expect(layout.paddingTop).toBe(16); + expect(layout.paddingTop).toBe(0); }); it('keeps content above an overlay without counting its inset twice', () => { diff --git a/apps/mobile/src/lib/centered-state-layout.ts b/apps/mobile/src/lib/centered-state-layout.ts index 0a58ab040e..64194ff9f7 100644 --- a/apps/mobile/src/lib/centered-state-layout.ts +++ b/apps/mobile/src/lib/centered-state-layout.ts @@ -48,15 +48,15 @@ export function getCenteredStateLayout({ const top = Math.max(visible.top, surface.top + topInset); const bottom = Math.min(visible.bottom, surface.bottom - bottomInset); const idealTop = (surface.top + surface.bottom - contentHeight) / 2; - const centered = idealTop >= top && idealTop + contentHeight <= bottom; - const contentTop = centered - ? idealTop - : Math.max(top + STATE_GAP, Math.min(idealTop, bottom - STATE_GAP - contentHeight)); + const fits = contentHeight <= bottom - top; + const contentTop = fits + ? Math.max(top, Math.min(idealTop, bottom - contentHeight)) + : top + STATE_GAP; return { minHeight: Math.max(0, viewportBottom - viewport.top), paddingTop: Math.max(0, contentTop - viewport.top), - paddingBottom: centered + paddingBottom: fits ? Math.max(0, viewportBottom - contentTop - contentHeight) : Math.max(STATE_GAP, viewportBottom - bottom + STATE_GAP), }; diff --git a/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx index 5953d8f9d1..4b13afab50 100644 --- a/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx +++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx @@ -96,6 +96,11 @@ async function mount() { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => { + onFrame(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.clearAllMocks(); native.available = true; native.pending.clear(); @@ -106,6 +111,38 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); describe('useNativeStateGeometry', () => { + it('waits for the native mounting frame before observing', async () => { + let frame: FrameRequestCallback | undefined = undefined; + vi.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => { + frame = onFrame; + return 7; + }); + const mounted = await mount(); + expect(native.observe).not.toHaveBeenCalled(); + act(() => { + frame?.(0); + }); + expect(native.observe).toHaveBeenCalledWith(41); + await mounted.resolve(41); + expect(mounted.read()?.status).toBe('ready'); + mounted.unmount(); + }); + + it('cancels observation when the state disappears before its mounting frame', async () => { + let frame: FrameRequestCallback | undefined = undefined; + vi.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => { + frame = onFrame; + return 8; + }); + const mounted = await mount(); + mounted.unmount(); + act(() => { + frame?.(0); + }); + expect(cancelAnimationFrame).toHaveBeenCalledWith(8); + expect(native.observe).not.toHaveBeenCalled(); + }); + it('reports an old client without pretending native measurements exist', async () => { native.available = false; const mounted = await mount(); diff --git a/apps/mobile/src/lib/hooks/use-native-state-geometry.ts b/apps/mobile/src/lib/hooks/use-native-state-geometry.ts index 3ed886811b..00deea9780 100644 --- a/apps/mobile/src/lib/hooks/use-native-state-geometry.ts +++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.ts @@ -14,6 +14,7 @@ type Observation = { node: View | null; status: 'pending' | 'ready' | 'failed'; geometry: NativeSurfaceGeometry | null; + failure?: string; }; function sameGeometry(left: NativeSurfaceGeometry | null, right: NativeSurfaceGeometry) { @@ -41,7 +42,12 @@ export function useNativeStateGeometry(node: View | null) { } const tag = findNodeHandle(node); if (tag === null) { - setObservation({ node, status: 'failed', geometry: null }); + setObservation({ + node, + status: 'failed', + geometry: null, + failure: 'Native view tag unavailable', + }); return undefined; } let active = true; @@ -58,7 +64,12 @@ export function useNativeStateGeometry(node: View | null) { geometry.visibleBottom >= geometry.visibleTop && geometry.boundsHeight >= 0; if (!valid) { - setObservation({ node, status: 'failed', geometry: null }); + setObservation({ + node, + status: 'failed', + geometry: null, + failure: 'Invalid native surface geometry', + }); return; } setObservation(previous => @@ -72,9 +83,14 @@ export function useNativeStateGeometry(node: View | null) { const start = async () => { try { publish(await observeSurface(tag)); - } catch { + } catch (error) { if (active) { - setObservation({ node, status: 'failed', geometry: null }); + setObservation({ + node, + status: 'failed', + geometry: null, + failure: error instanceof Error ? error.message : 'Native observation failed', + }); } } }; @@ -87,9 +103,14 @@ export function useNativeStateGeometry(node: View | null) { }); } }; - void start(); + const startFrame = requestAnimationFrame(() => { + if (active) { + void start(); + } + }); return () => { active = false; + cancelAnimationFrame(startFrame); listener?.remove(); void stop(); }; diff --git a/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts b/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts index 5fef3582e5..a246890832 100644 --- a/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts +++ b/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts @@ -10,6 +10,7 @@ export type SurfaceMeasurement = { safeAreaTop: number; safeAreaBottom: number; source: 'native' | 'layout' | 'pending'; + failure?: string; }; export function useStateSurfaceMeasurement(androidSheet: boolean, nativeActive = true) { @@ -69,6 +70,7 @@ export function useStateSurfaceMeasurement(androidSheet: boolean, nativeActive = }; }, [measure, width, height]); + const failure = native.status === 'failed' ? native.failure : undefined; const measurement = useMemo(() => { if (!frame || native.status === 'pending') { return { frame: null, bounds: null, safeAreaTop: 0, safeAreaBottom: 0, source: 'pending' }; @@ -94,8 +96,9 @@ export function useStateSurfaceMeasurement(androidSheet: boolean, nativeActive = safeAreaTop: 0, safeAreaBottom: 0, source: 'layout', + failure, }; - }, [frame, native.geometry, native.status, androidSheet, height]); + }, [frame, native.geometry, native.status, androidSheet, height, failure]); return useMemo( () => ({ ...measurement, node: nativeNode, capture, measure }), From 2db81786017646ff985fdcbd72196e6ac7389ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 1 Sep 2026 23:00:28 +0200 Subject: [PATCH 04/10] fix(mobile): declare native geometry module Android version --- .../modules/kilo-surface-geometry/android/build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/mobile/modules/kilo-surface-geometry/android/build.gradle b/apps/mobile/modules/kilo-surface-geometry/android/build.gradle index 8b7658cc07..05204646b1 100644 --- a/apps/mobile/modules/kilo-surface-geometry/android/build.gradle +++ b/apps/mobile/modules/kilo-surface-geometry/android/build.gradle @@ -8,4 +8,9 @@ version = '1.0.0' android { namespace 'expo.modules.kilosurfacegeometry' + + defaultConfig { + versionCode 1 + versionName '1.0.0' + } } From 91875c5c20341fc496a9e8ee4469091ba81c84a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 2 Sep 2026 02:54:17 +0200 Subject: [PATCH 05/10] fix(mobile): simplify surface geometry and keep newer snapshots --- apps/mobile/.gitignore | 1 + .../KiloSurfaceGeometryModule.kt | 18 ++-- .../ios/KiloSurfaceGeometryModule.swift | 9 +- .../native-surface-geometry.test.ts | 94 +------------------ ...use-native-state-geometry.mounted.test.tsx | 24 ++++- .../lib/hooks/use-native-state-geometry.ts | 8 +- .../hooks/use-state-surface-measurement.ts | 5 +- .../mobile/src/lib/native-surface-geometry.ts | 9 +- 8 files changed, 43 insertions(+), 125 deletions(-) diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 80f323bd12..42c94b05f0 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -41,6 +41,7 @@ app-example # generated native folders /ios /android +/modules/*/android/build/ # prebuild cache stamp (written by the E2E workflow) .kilo-prebuild-key-* diff --git a/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt b/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt index c26d5309f5..c7fd26fe96 100644 --- a/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt +++ b/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt @@ -25,7 +25,6 @@ class KiloSurfaceGeometryModule : Module() { override fun definition() = ModuleDefinition { Name("KiloSurfaceGeometry") - Constant("isSupported") { true } Events("onSurfaceGeometryChange") AsyncFunction("observeSurface") { tag: Int -> @@ -155,7 +154,7 @@ private class SurfaceGeometryObserver( val root = root.get() if (stopped || root == null) { stop() - return geometry(0f, 0f, 0f, 0f, 0f, 0f, 1.0) + return geometry(0f, 0f, 0f, 0f, 0f, 1.0) } val geometry = measure(root) if (geometry != previous) { @@ -169,7 +168,7 @@ private class SurfaceGeometryObserver( val density = root.resources.displayMetrics.density.toDouble() val height = root.height.coerceAtLeast(0).toFloat() val toLocal = Matrix() - val empty = geometry(0f, 0f, height, 0f, 0f, 0f, density) + val empty = geometry(0f, 0f, height, 0f, 0f, density) if (!attached || !root.isAttachedToWindow) return empty if (!localToScreen(root).invert(toLocal)) return empty val windowRoot = root.rootView @@ -194,7 +193,7 @@ private class SurfaceGeometryObserver( toLocal.mapRect(safeWindow) val safeTop = if (bars.top > 0) safeWindow.top.coerceIn(0f, height) else 0f val safeBottom = if (bars.bottom > 0) (height - safeWindow.bottom).coerceIn(0f, height) else 0f - val invisible = geometry(0f, 0f, height, safeTop, safeBottom, 0f, density) + val invisible = geometry(0f, 0f, height, safeTop, safeBottom, density) if (!root.isShown || root.windowVisibility != View.VISIBLE) return invisible val globalVisible = Rect() if (!root.getGlobalVisibleRect(globalVisible)) return invisible @@ -214,7 +213,6 @@ private class SurfaceGeometryObserver( } ancestor = ancestor.parent as? View } - val beforeKeyboard = RectF(visible) if (dockedKeyboard) { val mode = (windowRoot.layoutParams as? WindowManager.LayoutParams)?.softInputMode val adjustsNothing = mode != null && @@ -223,11 +221,9 @@ private class SurfaceGeometryObserver( visible.bottom = keyboardTop.coerceIn(visible.top, visible.bottom) } toLocal.mapRect(visible) - toLocal.mapRect(beforeKeyboard) val top = visible.top.coerceIn(0f, height) val bottom = visible.bottom.coerceIn(top, height) - val overlap = (beforeKeyboard.bottom.coerceIn(top, height) - bottom).coerceAtLeast(0f) - return geometry(top, bottom, height, safeTop, safeBottom, overlap, density) + return geometry(top, bottom, height, safeTop, safeBottom, density) } private fun localToScreen(view: View): Matrix { @@ -249,16 +245,14 @@ private class SurfaceGeometryObserver( } private fun geometry( - top: Float, bottom: Float, height: Float, safeTop: Float, safeBottom: Float, - overlap: Float, density: Double + top: Float, bottom: Float, height: Float, safeTop: Float, safeBottom: Float, density: Double ): Map = mapOf( "tag" to tag, "visibleTop" to top / density, "visibleBottom" to bottom / density, "boundsHeight" to height / density, "safeAreaTop" to safeTop / density, - "safeAreaBottom" to safeBottom / density, - "keyboardOverlap" to overlap / density + "safeAreaBottom" to safeBottom / density ) fun stop() { diff --git a/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift index 44926d93a6..857c84d360 100644 --- a/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift +++ b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift @@ -9,7 +9,6 @@ public final class KiloSurfaceGeometryModule: Module { public func definition() -> ModuleDefinition { Name("KiloSurfaceGeometry") - Constant("isSupported") { true } Events("onSurfaceGeometryChange") AsyncFunction("observeSurface") { (tag: Int) -> [String: Any] in @@ -251,7 +250,7 @@ private final class SurfaceGeometryProbe: UIView { guard !stopped, let root, superview === root else { stop() return ["tag": observedTag, "visibleTop": 0.0, "visibleBottom": 0.0, "boundsHeight": 0.0, - "safeAreaTop": 0.0, "safeAreaBottom": 0.0, "keyboardOverlap": 0.0] + "safeAreaTop": 0.0, "safeAreaBottom": 0.0] } if observingWindow { observeAncestors() @@ -275,8 +274,7 @@ private final class SurfaceGeometryProbe: UIView { let safeTop = min(height, max(0, root.safeAreaInsets.top)) let safeBottom = min(height, max(0, root.safeAreaInsets.bottom)) let empty = ["visibleTop": 0.0, "visibleBottom": 0.0, "boundsHeight": Double(height), - "safeAreaTop": Double(safeTop), "safeAreaBottom": Double(safeBottom), - "keyboardOverlap": 0.0] + "safeAreaTop": Double(safeTop), "safeAreaBottom": Double(safeBottom)] guard observingWindow, let window = root.window, height > 0 else { return empty } var visible = root.bounds.intersection(root.convert(window.bounds, from: window)) var ancestor: UIView? = root @@ -308,8 +306,7 @@ private final class SurfaceGeometryProbe: UIView { "visibleBottom": Double(visibleBottom), "boundsHeight": Double(height), "safeAreaTop": Double(safeTop), - "safeAreaBottom": Double(safeBottom), - "keyboardOverlap": Double(bottom - visibleBottom) + "safeAreaBottom": Double(safeBottom) ] } diff --git a/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts b/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts index 33cf165b7d..38675750a9 100644 --- a/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts +++ b/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts @@ -21,13 +21,12 @@ const initial: NativeSurfaceGeometry = { boundsHeight: 600, safeAreaTop: 20, safeAreaBottom: 20, - keyboardOverlap: 200, }; beforeEach(() => { vi.resetModules(); vi.resetAllMocks(); - native.requireOptionalNativeModule.mockReturnValue({ ...native, isSupported: true }); + native.requireOptionalNativeModule.mockReturnValue(native); native.observeSurface.mockResolvedValue(initial); }); @@ -42,15 +41,6 @@ describe('native surface geometry', () => { await expect(surface.unobserveSurface(42)).resolves.toBeUndefined(); }); - it('reports unsupported native platforms as unavailable', async () => { - native.requireOptionalNativeModule.mockReturnValue({ ...native, isSupported: false }); - const surface = await import('../../src/lib/native-surface-geometry'); - expect(surface.isNativeSurfaceGeometryAvailable).toBe(false); - expect(surface.addSurfaceGeometryListener(vi.fn<() => void>())).toBeNull(); - await expect(surface.observeSurface(42)).rejects.toThrow('requires a rebuilt'); - expect(native.observeSurface).not.toHaveBeenCalled(); - }); - it('returns the initial snapshot and exposes event and native cleanup', async () => { const remove = vi.fn<() => void>(); native.addListener.mockReturnValue({ remove }); @@ -61,92 +51,14 @@ describe('native surface geometry', () => { expect(native.addListener).toHaveBeenCalledWith('onSurfaceGeometryChange', listener); await expect(surface.observeSurface(42)).resolves.toEqual(initial); expect(native.observeSurface).toHaveBeenCalledWith(42); + native.addListener.mock.calls[0]?.[1](initial); + expect(listener).toHaveBeenCalledExactlyOnceWith(initial); subscription?.remove(); await surface.unobserveSurface(42); expect(remove).toHaveBeenCalledOnce(); expect(native.unobserveSurface).toHaveBeenCalledWith(42); }); - it.each([ - { - name: 'safe areas without clipping', - geometry: { ...initial, visibleBottom: 600, keyboardOverlap: 0 }, - }, - { - name: 'ancestor clipping independent of safe areas', - geometry: { ...initial, visibleTop: 50, visibleBottom: 550, keyboardOverlap: 0 }, - }, - { - name: 'docked keyboard overlap', - geometry: initial, - }, - { - name: 'a surface already resized above the keyboard', - geometry: { - ...initial, - boundsHeight: 400, - safeAreaBottom: 0, - keyboardOverlap: 0, - }, - }, - { - name: 'a floating surface above the keyboard', - geometry: { - ...initial, - boundsHeight: 400, - safeAreaTop: 0, - safeAreaBottom: 0, - keyboardOverlap: 0, - }, - }, - { - name: 'an invisible surface', - geometry: { ...initial, visibleBottom: 0, keyboardOverlap: 0 }, - }, - ])('preserves native snapshot and event fields for $name', async ({ geometry }) => { - native.observeSurface.mockResolvedValue(geometry); - const surface = await import('../../src/lib/native-surface-geometry'); - const listener = vi.fn<(geometry: NativeSurfaceGeometry) => void>(); - surface.addSurfaceGeometryListener(listener); - const nativeListener = native.addListener.mock.calls[0]?.[1]; - expect(nativeListener).toBe(listener); - nativeListener?.(geometry); - expect(listener).toHaveBeenCalledWith(geometry); - await expect(surface.observeSurface(42)).resolves.toBe(geometry); - }); - - it('keeps one subscription through pending attachment, detachment, and reattachment', async () => { - const detached: NativeSurfaceGeometry = { - ...initial, - visibleTop: 0, - visibleBottom: 0, - safeAreaTop: 0, - safeAreaBottom: 0, - keyboardOverlap: 0, - }; - const remove = vi.fn<() => void>(); - native.addListener.mockReturnValue({ remove }); - native.observeSurface.mockResolvedValue(detached); - const surface = await import('../../src/lib/native-surface-geometry'); - const listener = vi.fn<(geometry: NativeSurfaceGeometry) => void>(); - const subscription = surface.addSurfaceGeometryListener(listener); - await expect(surface.observeSurface(42)).resolves.toEqual(detached); - const publish = native.addListener.mock.calls[0]?.[1]; - expect(publish).toBe(listener); - publish?.(initial); - publish?.(detached); - publish?.(initial); - expect(listener.mock.calls).toEqual([[initial], [detached], [initial]]); - expect(native.observeSurface).toHaveBeenCalledExactlyOnceWith(42); - expect(native.addListener).toHaveBeenCalledOnce(); - expect(native.unobserveSurface).not.toHaveBeenCalled(); - expect(remove).not.toHaveBeenCalled(); - subscription?.remove(); - await surface.unobserveSurface(42); - expect(remove).toHaveBeenCalledOnce(); - expect(native.unobserveSurface).toHaveBeenCalledExactlyOnceWith(42); - }); - it('accepts the largest positive signed 32-bit native tag', async () => { const surface = await import('../../src/lib/native-surface-geometry'); await surface.observeSurface(2_147_483_647); diff --git a/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx index 4b13afab50..d66c6be572 100644 --- a/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx +++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx @@ -57,7 +57,6 @@ const snapshot = (tag = 41): NativeSurfaceGeometry => ({ boundsHeight: 800, safeAreaTop: 20, safeAreaBottom: 34, - keyboardOverlap: 0, }); async function mount() { @@ -155,6 +154,8 @@ describe('useNativeStateGeometry', () => { it('accepts its initial snapshot and ignores other native roots', async () => { const mounted = await mount(); expect(mounted.read()?.status).toBe('pending'); + mounted.emit(snapshot(42)); + expect(mounted.read()?.status).toBe('pending'); await mounted.resolve(41); expect(mounted.read()).toMatchObject({ status: 'ready', geometry: snapshot() }); const previous = mounted.read(); @@ -165,6 +166,19 @@ describe('useNativeStateGeometry', () => { mounted.unmount(); }); + it('keeps a newer event when the startup snapshot resolves later', async () => { + const mounted = await mount(); + try { + const geometry = { ...snapshot(), visibleBottom: 500 }; + mounted.emit(geometry); + expect(mounted.read()).toMatchObject({ status: 'ready', geometry }); + await mounted.resolve(41); + expect(mounted.read()).toMatchObject({ status: 'ready', geometry }); + } finally { + mounted.unmount(); + } + }); + it('ignores an old snapshot after the root changes', async () => { const mounted = await mount(); mounted.update(second); @@ -200,10 +214,14 @@ describe('useNativeStateGeometry', () => { await mounted.resolve(41); mounted.emit({ ...snapshot(), visibleBottom: 0 }); expect(mounted.read()?.geometry?.visibleBottom).toBe(0); - mounted.emit({ ...snapshot(), visibleBottom: 500, keyboardOverlap: 300 }); + mounted.emit({ ...snapshot(), visibleBottom: 500 }); expect(mounted.read()?.geometry?.visibleBottom).toBe(500); - expect(native.observe).toHaveBeenCalledTimes(1); + expect(native.observe).toHaveBeenCalledExactlyOnceWith(41); + expect(native.listeners.size).toBe(1); + expect(native.unobserve).not.toHaveBeenCalled(); mounted.unmount(); + expect(native.listeners.size).toBe(0); + expect(native.unobserve).toHaveBeenCalledExactlyOnceWith(41); }); it('rejects non-finite native bounds', async () => { diff --git a/apps/mobile/src/lib/hooks/use-native-state-geometry.ts b/apps/mobile/src/lib/hooks/use-native-state-geometry.ts index 00deea9780..96d78670ac 100644 --- a/apps/mobile/src/lib/hooks/use-native-state-geometry.ts +++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.ts @@ -23,7 +23,6 @@ function sameGeometry(left: NativeSurfaceGeometry | null, right: NativeSurfaceGe left.visibleTop === right.visibleTop && left.visibleBottom === right.visibleBottom && left.boundsHeight === right.boundsHeight && - left.keyboardOverlap === right.keyboardOverlap && left.safeAreaTop === right.safeAreaTop && left.safeAreaBottom === right.safeAreaBottom ); @@ -51,10 +50,12 @@ export function useNativeStateGeometry(node: View | null) { return undefined; } let active = true; + let receivedGeometry = false; const publish = (geometry: NativeSurfaceGeometry) => { if (!active || geometry.tag !== tag) { return; } + receivedGeometry = true; const valid = Number.isFinite(geometry.visibleTop) && Number.isFinite(geometry.visibleBottom) && @@ -82,7 +83,10 @@ export function useNativeStateGeometry(node: View | null) { const listener = addSurfaceGeometryListener(publish); const start = async () => { try { - publish(await observeSurface(tag)); + const geometry = await observeSurface(tag); + if (!receivedGeometry) { + publish(geometry); + } } catch (error) { if (active) { setObservation({ diff --git a/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts b/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts index a246890832..a60565fb17 100644 --- a/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts +++ b/apps/mobile/src/lib/hooks/use-state-surface-measurement.ts @@ -20,7 +20,6 @@ export function useStateSurfaceMeasurement(androidSheet: boolean, nativeActive = const [frame, setFrame] = useState(null); const { width, height } = useWindowDimensions(); const requestRef = useRef(0); - const mountedRef = useRef(true); const measure = useCallback(() => { const node = nodeRef.current; @@ -32,7 +31,7 @@ export function useStateSurfaceMeasurement(androidSheet: boolean, nativeActive = } node.measureInWindow((...bounds) => { const [, y, , measuredHeight] = bounds; - if (!mountedRef.current || request !== requestRef.current) { + if (request !== requestRef.current) { return; } const top = y - (androidSheet ? node.scrollTop : 0); @@ -62,10 +61,8 @@ export function useStateSurfaceMeasurement(androidSheet: boolean, nativeActive = ); useLayoutEffect(() => { - mountedRef.current = true; measure(); return () => { - mountedRef.current = false; requestRef.current += 1; }; }, [measure, width, height]); diff --git a/apps/mobile/src/lib/native-surface-geometry.ts b/apps/mobile/src/lib/native-surface-geometry.ts index 23ec085774..ded61c6a10 100644 --- a/apps/mobile/src/lib/native-surface-geometry.ts +++ b/apps/mobile/src/lib/native-surface-geometry.ts @@ -7,7 +7,6 @@ export type NativeSurfaceGeometry = Readonly<{ boundsHeight: number; safeAreaTop: number; safeAreaBottom: number; - keyboardOverlap: number; }>; type SurfaceGeometryEvents = { @@ -15,26 +14,22 @@ type SurfaceGeometryEvents = { }; type SurfaceGeometryModule = InstanceType> & { - isSupported: boolean; observeSurface: (nativeViewTag: number) => Promise; unobserveSurface: (nativeViewTag: number) => Promise; }; const nativeModule = requireOptionalNativeModule('KiloSurfaceGeometry'); -export const isNativeSurfaceGeometryAvailable = nativeModule?.isSupported === true; +export const isNativeSurfaceGeometryAvailable = nativeModule !== null; export function addSurfaceGeometryListener( listener: (geometry: NativeSurfaceGeometry) => void ): { remove: () => void } | null { - if (!isNativeSurfaceGeometryAvailable) { - return null; - } return nativeModule?.addListener('onSurfaceGeometryChange', listener) ?? null; } export async function observeSurface(nativeViewTag: number): Promise { - if (!nativeModule || !isNativeSurfaceGeometryAvailable) { + if (!nativeModule) { throw new Error( 'Native surface geometry requires a rebuilt iOS or Android development client.' ); From f5eca23d677ca71bd0ed31457ae30cf08ebb468c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 2 Sep 2026 03:00:34 +0200 Subject: [PATCH 06/10] fix(mobile): center full-page and sheet states --- .../(1_kiloclaw)/chat/instance-picker.tsx | 5 +- .../app/(app)/(tabs)/(1_kiloclaw)/index.tsx | 26 +- .../[scope]/[platform]/(edit)/repos.tsx | 242 +++++++------- .../(3_profile)/organization/_layout.tsx | 4 +- .../security-agent/[scope]/_layout.tsx | 4 +- apps/mobile/src/app/(app)/(tabs)/_layout.tsx | 271 +++++++-------- .../agent-chat/[session-id].mounted.test.tsx | 1 + .../src/app/(app)/agent-chat/[session-id].tsx | 61 ++-- .../agent-chat/folder-picker.mounted.test.tsx | 47 ++- .../app/(app)/agent-chat/folder-picker.tsx | 152 ++++----- .../app/(app)/agent-chat/instance-picker.tsx | 38 +-- .../src/app/(app)/agent-chat/repo-picker.tsx | 162 ++++----- .../(app)/kiloclaw/[instance-id]/billing.tsx | 70 ++-- .../kiloclaw/[instance-id]/changelog.tsx | 26 +- .../kiloclaw/[instance-id]/dashboard.tsx | 16 +- .../[instance-id]/settings/channels.tsx | 26 +- .../[instance-id]/settings/device-pairing.tsx | 19 +- .../[instance-id]/settings/exec-policy.tsx | 14 +- .../[instance-id]/settings/google.tsx | 248 +++++++------- .../[instance-id]/settings/model-list.tsx | 96 +++--- .../[instance-id]/settings/secrets.tsx | 26 +- .../[instance-id]/settings/version-pin.tsx | 18 +- apps/mobile/src/app/(auth)/_layout.tsx | 6 +- apps/mobile/src/app/+not-found.tsx | 5 + apps/mobile/src/app/_layout.tsx | 18 +- .../agents/agents-tab-badge.test-helpers.ts | 4 + .../attachment-preview-strip.mounted.test.tsx | 21 +- .../agents/attachment-preview-strip.tsx | 27 +- ...ld-session-sheet-recovery.mounted.test.tsx | 3 + .../child-session-sheet.mounted.test.tsx | 12 +- .../components/agents/child-session-sheet.tsx | 15 +- .../file-part-renderer.mounted.test.tsx | 58 ++-- .../components/agents/file-part-renderer.tsx | 13 +- .../agents/instance-picker.mounted.test.tsx | 1 + .../agents/live-session-list-empty-state.tsx | 63 ++-- .../components/agents/markdown-table.test.ts | 38 +++ .../src/components/agents/markdown-table.tsx | 106 +++--- .../message-details-sheet.mounted.test.tsx | 1 + .../agents/model-picker-content.tsx | 138 ++++---- .../part-detail-images.mounted.test.tsx | 297 +++++++++++++++++ .../agents/part-detail-model.test.ts | 172 +++++++++- .../components/agents/part-detail-model.ts | 81 +++++ .../part-detail-sheet-host.mounted.test.tsx | 49 ++- .../agents/part-detail-sheet.mounted.test.tsx | 65 +++- .../components/agents/part-detail-sheet.tsx | 70 ++-- .../agents/picker-search.mounted.test.tsx | 142 ++++++++ .../agents/session-detail-content.test.ts | 31 ++ .../agents/session-detail-content.tsx | 89 ++--- .../agents/session-detail-queue.test.ts | 2 + .../session-list-body-empty.mounted.test.tsx | 1 + .../agents/session-list-body-empty.tsx | 90 +++-- .../session-list-content.mounted.test.tsx | 22 ++ .../agents/session-list-content.tsx | 42 +-- .../session-list-screen.mounted.test.tsx | 122 ++++--- .../components/agents/session-list-screen.tsx | 141 ++++---- .../session-page-sheet.mounted.test.tsx | 76 +++-- .../components/agents/session-page-sheet.tsx | 11 +- .../agents/session-row.mounted.test.tsx | 1 + ...ol-card-image-attachments.mounted.test.tsx | 21 ++ .../agents/tool-card-image-attachments.tsx | 33 +- .../agents/tool-part-detail-body.test.ts | 70 ++-- .../agents/tool-part-detail-body.tsx | 14 +- .../app-unlock-screen.layout.mounted.test.tsx | 8 +- .../app-unlock-screen.mounted.test.tsx | 23 -- .../app-unlock-screen.sheet.mounted.test.tsx | 37 +++ .../app-unlock-screen.test-assertions.ts | 27 ++ .../app-unlock-screen.test-helpers.tsx | 42 +-- .../src/components/app-unlock-screen.tsx | 36 +- .../src/components/bootstrap-error-screen.tsx | 35 +- .../centered-state.mounted.test.tsx | 64 ++++ apps/mobile/src/components/centered-state.tsx | 7 +- .../code-reviewer/bitbucket-overview.tsx | 61 ++-- .../full-surface-states.mounted.test.tsx | 200 +++++++++++ .../platform-overview-screen.mounted.test.tsx | 30 +- .../platform-overview-screen.tsx | 36 +- .../review-detail-screen.mounted.test.tsx | 35 +- .../code-reviewer/review-detail-screen.tsx | 18 +- .../code-reviewer/review-list-screen.tsx | 98 +++--- .../review-memory-screen.mounted.test.tsx | 5 + .../code-reviewer/review-memory-screen.tsx | 171 +++++----- .../code-reviewer/review-spectator.tsx | 15 +- .../device-sessions-screen.mounted.test.tsx | 138 ++++++++ .../src/components/device-sessions-screen.tsx | 84 ++--- apps/mobile/src/components/empty-state.tsx | 21 +- .../src/components/force-update-screen.tsx | 85 ++--- .../home/agent-sessions-section.test.ts | 1 + .../home/agent-sessions-section.tsx | 85 ++--- .../home/home-screen.mounted.test.tsx | 74 +++++ .../src/components/home/home-screen.test.ts | 4 + .../src/components/home/home-screen.tsx | 62 ++-- .../image-viewer-modal.mounted.test.tsx | 4 + .../src/components/image-viewer-modal.tsx | 16 +- .../src/components/invalid-route-state.tsx | 34 +- .../conversation-history-state-views.tsx | 1 - .../conversation-list-screen.mounted.test.tsx | 180 ++++++++++ .../kilo-chat/conversation-list-screen.tsx | 106 +++--- .../kilo-chat/empty-conversation-list.tsx | 32 +- .../kilo-pass-subscription-screen.test.ts | 25 ++ .../kilo-pass-subscription-screen.tsx | 84 ++--- .../kiloclaw/access-required-screen.tsx | 89 ++--- .../full-surface-states.mounted.test.tsx | 311 ++++++++++++++++++ .../kiloclaw/instance-context-boundary.tsx | 34 +- .../components/kiloclaw/onboarding-flow.tsx | 66 ++-- .../kiloclaw/onboarding/flow-body.tsx | 131 ++++---- .../full-surface-states.mounted.test.tsx | 248 ++++++++++++++ .../notifications-step.mounted.test.tsx | 23 +- .../kiloclaw/onboarding/provisioning-step.tsx | 141 ++++---- .../language-picker-sheet.mounted.test.tsx | 10 +- .../src/components/language-picker-sheet.tsx | 133 ++++---- ...guage-reload-error-screen.mounted.test.tsx | 1 + .../src/components/login-screen.test.ts | 1 + apps/mobile/src/components/login-screen.tsx | 29 +- .../offline-banner.mounted.test.tsx | 7 +- .../credit-activity-screen.mounted.test.tsx | 28 +- .../organization/credit-activity-screen.tsx | 11 +- .../invoices-screen.mounted.test.tsx | 28 +- .../organization/invoices-screen.tsx | 11 +- .../organization/low-balance-alert-sheet.tsx | 38 ++- .../member-limit-sheet.mounted.test.tsx | 128 +++++++ .../organization/member-limit-sheet.tsx | 47 ++- .../members-screen.mounted.test.tsx | 61 +++- .../organization/members-screen.tsx | 107 +++--- .../organization-boundary.mounted.test.tsx | 29 +- .../organization/organization-boundary.tsx | 6 +- .../organization/permission-denied.tsx | 34 +- apps/mobile/src/components/picker-sheet.tsx | 25 +- .../src/components/platform-error-screen.tsx | 18 +- .../pr-review/diff/pr-diff-file-list.test.tsx | 90 ++--- .../pr-review/diff/pr-diff-file-list.tsx | 47 +-- .../diff/pr-diff-file-navigator.test.tsx | 49 ++- .../pr-review/diff/pr-diff-file-navigator.tsx | 263 +++++++-------- .../pr-review/diff/pr-diff-hunk-rows.test.tsx | 103 +++--- .../pr-review/diff/pr-diff-hunk-rows.tsx | 70 ++-- .../discussion/reactions-row.test.tsx | 3 + .../full-surface-states.mounted.test.tsx | 175 ++++++++++ .../pr-review-comment-composer-screen.tsx | 12 +- .../pr-review-connect-gate-view.test.ts | 1 + .../pr-review/pr-review-connect-gate.tsx | 71 ++-- .../pr-review-discussion-tab.test.tsx | 94 +++--- .../pr-review/pr-review-discussion-tab.tsx | 86 +++-- .../pr-review-file-navigator-screen.tsx | 67 ++-- .../pr-review/pr-review-merge-screen.tsx | 12 +- .../pr-review/pr-review-overview.tsx | 46 +-- .../pr-review-review-submit-screen.tsx | 12 +- .../pr-review/pr-review-screen.test.tsx | 32 +- .../components/pr-review/pr-review-screen.tsx | 21 +- .../preferences-screen.mounted.test.tsx | 5 + apps/mobile/src/components/query-error.tsx | 4 + .../quick-chat-screen.mounted.test.tsx | 4 + .../audit-report-screen.mounted.test.tsx | 59 +++- .../security-agent/audit-report-screen.tsx | 59 ++-- .../dismiss-finding-screen.mounted.test.tsx | 121 ++++++- .../security-agent/dismiss-finding-screen.tsx | 19 +- .../finding-analysis-panel.mounted.test.tsx | 104 +++++- .../security-agent/finding-analysis-panel.tsx | 23 +- .../finding-detail-screen.mounted.test.tsx | 200 ++++++----- .../security-agent/finding-detail-screen.tsx | 86 ++--- .../finding-list-screen.mounted.test.tsx | 192 +++++++++++ .../security-agent/finding-list-screen.tsx | 78 +++-- ...finding-remediation-panel.mounted.test.tsx | 193 ++++++----- .../finding-remediation-panel.tsx | 21 +- .../security-agent/scope-entry-screen.test.ts | 64 ++-- .../security-agent/scope-entry-screen.tsx | 7 +- .../security-agent-setup.mounted.test.tsx | 66 ++++ .../security-agent/security-agent-setup.tsx | 38 +-- .../security-config.test-fixture.ts | 37 +++ .../settings-overview-screen.mounted.test.tsx | 12 +- .../settings-overview-screen.tsx | 215 ++++++------ .../share-destination-list.mounted.test.tsx | 201 +++++++++++ .../share/share-destination-list.tsx | 194 +++++------ .../share/share-gate-sheet.mounted.test.tsx | 7 +- .../src/components/share/share-gate-sheet.tsx | 43 +-- .../components/sheet-header.mounted.test.tsx | 2 +- .../src/components/trusted-hosts-screen.tsx | 120 +++---- 174 files changed, 7291 insertions(+), 3659 deletions(-) create mode 100644 apps/mobile/src/app/+not-found.tsx create mode 100644 apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx create mode 100644 apps/mobile/src/components/agents/picker-search.mounted.test.tsx create mode 100644 apps/mobile/src/components/app-unlock-screen.sheet.mounted.test.tsx create mode 100644 apps/mobile/src/components/app-unlock-screen.test-assertions.ts create mode 100644 apps/mobile/src/components/code-reviewer/full-surface-states.mounted.test.tsx create mode 100644 apps/mobile/src/components/device-sessions-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/kilo-chat/conversation-list-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/kiloclaw/full-surface-states.mounted.test.tsx create mode 100644 apps/mobile/src/components/kiloclaw/onboarding/full-surface-states.mounted.test.tsx create mode 100644 apps/mobile/src/components/organization/member-limit-sheet.mounted.test.tsx create mode 100644 apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx create mode 100644 apps/mobile/src/components/security-agent/finding-list-screen.mounted.test.tsx create mode 100644 apps/mobile/src/components/security-agent/security-agent-setup.mounted.test.tsx create mode 100644 apps/mobile/src/components/security-agent/security-config.test-fixture.ts create mode 100644 apps/mobile/src/components/share/share-destination-list.mounted.test.tsx diff --git a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx index d00148e37a..3987d3d0f6 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker.tsx @@ -76,6 +76,7 @@ export default function InstancePickerScreen() { return ( 0)} title={t('chat.instancePicker.switchInstance')} onDone={() => { router.back(); @@ -90,7 +91,6 @@ export default function InstancePickerScreen() { ) : null} {instancesQuery.isError ? ( { void instancesQuery.refetch(); @@ -99,7 +99,6 @@ export default function InstancePickerScreen() { ) : null} {showList && loadedInstances.length === 0 ? ( ) : null} - {showList ? ( + {showList && loadedInstances.length > 0 ? ( {loadedInstances.map(instance => ( - + { if (instancesQuery.isError) { @@ -130,11 +114,7 @@ export default function KiloClawTab() { ) : ( - + + ); + } else if (!reposLoading && bitbucketNotReady) { + repoState = ( + + + + {t('codeReviewer.repos.unavailable')} + + + + + ); + } else if (confirmedEmpty) { + repoState = ( + { + void (async () => { + if (platform === 'github') { + try { + const { token } = await trpcClient.githubApps.mintInstallState.mutate({ + organizationId: orgScope ?? undefined, + returnTo: '/cloud/sessions', + }); + await openExternalUrl( + getGitHubIntegrationUrl(WEB_BASE_URL, orgScope, token), + { label: t('codeReviewer.repos.repositoryAccess') } + ); + } catch { + toast.error(t('codeReviewer.repos.couldNotOpenGithubSettings')); + } + } else if (platform === 'gitlab') { + await openExternalUrl(getGitLabIntegrationUrl(WEB_BASE_URL, orgScope), { + label: t('codeReviewer.repos.repositoryAccess'), + }); + } + })(); + }} + > + {t('codeReviewer.repos.manageAccess')} + + ) : undefined + } + /> + ); + } + return ( - - {capabilities.selectionModePicker && ( - - {(['all', 'selected'] as const).map(option => ( - { - setMode(option); - }} - /> - ))} - - )} + {fullBodyState ? ( + repoState + ) : ( + + {capabilities.selectionModePicker && ( + + {(['all', 'selected'] as const).map(option => ( + { + setMode(option); + }} + /> + ))} + + )} - {(!capabilities.selectionModePicker || mode === 'selected') && ( - - - {t('codeReviewer.repos.title')} - - {reposLoading && ( - - - - - )} + {(!capabilities.selectionModePicker || mode === 'selected') && ( + + + {t('codeReviewer.repos.title')} + + {reposLoading && ( + + + + + )} - {!reposLoading && reposError && ( - - )} + {repoState} - {!reposLoading && !reposError && bitbucketNotReady && ( - - - {t('codeReviewer.repos.unavailable')} - - - - )} - - {confirmedEmpty && ( - { - void (async () => { - if (platform === 'github') { - try { - const { token } = await trpcClient.githubApps.mintInstallState.mutate( - { - organizationId: orgScope ?? undefined, - returnTo: '/cloud/sessions', - } - ); - await openExternalUrl( - getGitHubIntegrationUrl(WEB_BASE_URL, orgScope, token), - { label: t('codeReviewer.repos.repositoryAccess') } - ); - } catch { - toast.error(t('codeReviewer.repos.couldNotOpenGithubSettings')); - } - } else if (platform === 'gitlab') { - await openExternalUrl(getGitLabIntegrationUrl(WEB_BASE_URL, orgScope), { - label: t('codeReviewer.repos.repositoryAccess'), - }); - } - })(); - }} - > - {t('codeReviewer.repos.manageAccess')} - - ) : undefined - } - /> - )} - - {repoRows.map(repo => ( - { - toggleRepo(repo.id); - }} - /> - ))} - - )} - + /> + ))} + + )} + + )} ); } diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx index 53cfdb9729..428d756c9d 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/_layout.tsx @@ -4,8 +4,8 @@ import { appUnlockScreenLayout } from '@/components/app-unlock-screen'; import { privacyScreenLayout } from '@/components/privacy-cover-overlay'; import { useFormSheetDetents } from '@/lib/form-sheet'; -const screenLayout: typeof privacyScreenLayout = props => - appUnlockScreenLayout({ children: privacyScreenLayout(props) }); +const screenLayout: typeof appUnlockScreenLayout = props => + appUnlockScreenLayout({ ...props, children: privacyScreenLayout(props) }); export default function OrganizationLayout() { const { fullSheetDetent } = useFormSheetDetents(); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx index 81985bd51a..6fa1db7b2f 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/_layout.tsx @@ -7,8 +7,8 @@ import { privacyScreenLayout } from '@/components/privacy-cover-overlay'; import { useFormSheetDetents } from '@/lib/form-sheet'; import { parseParam } from '@/lib/route-params'; -const screenLayout: typeof privacyScreenLayout = props => - appUnlockScreenLayout({ children: privacyScreenLayout(props) }); +const screenLayout: typeof appUnlockScreenLayout = props => + appUnlockScreenLayout({ ...props, children: privacyScreenLayout(props) }); // Mounts exactly one command observer per scope alongside a headerless Stack, // so it stays mounted across Dashboard/Findings/Settings navigation without diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index 9b0e7aa63b..20b8fd27b3 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -6,6 +6,7 @@ import { Platform, useWindowDimensions, View, type ViewStyle } from 'react-nativ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; +import { StateSurfaceInsets } from '@/components/centered-state-surface'; import { BlurBar } from '@/components/ui/blur-bar'; import { Text } from '@/components/ui/text'; import { FEATURE_FLAG_QUICK_CHAT, useFeatureFlag } from '@/lib/analytics/posthog'; @@ -117,141 +118,143 @@ export default function TabsLayout() { }, [showQuickChatTab, onChatTab, router]); return ( - - , - tabBarIcon: ({ color, focused }) => ( - - ), - }} - listeners={{ - tabPress: () => { - void Haptics.selectionAsync(); - }, - }} - /> - ( - TAB_LABEL_WRAP_FONT_SCALE - ? t('tabs.kiloclawWrapped') - : t('tabs.kiloclaw') - } - focused={focused} - /> - ), - tabBarIcon: ({ color, focused }) => ( - - ), - }} - listeners={{ - tabPress: event => { - void Haptics.selectionAsync(); - event.preventDefault(); - router.navigate('/(app)/(tabs)/(1_kiloclaw)' as Href); - }, - }} - /> - , - tabBarIcon: ({ color, focused }) => ( - - ), - }} - listeners={{ - tabPress: () => { - void Haptics.selectionAsync(); - }, - }} - /> - , - tabBarIcon: ({ color, focused }) => ( - - ), - }} - listeners={{ - tabPress: () => { - void Haptics.selectionAsync(); - }, - }} - /> - , - tabBarIcon: ({ color, focused }) => ( - - ), - }} - listeners={{ - tabPress: event => { - void Haptics.selectionAsync(); - event.preventDefault(); - router.navigate(PROFILE_TAB_ROOT); + + - + > + , + tabBarIcon: ({ color, focused }) => ( + + ), + }} + listeners={{ + tabPress: () => { + void Haptics.selectionAsync(); + }, + }} + /> + ( + TAB_LABEL_WRAP_FONT_SCALE + ? t('tabs.kiloclawWrapped') + : t('tabs.kiloclaw') + } + focused={focused} + /> + ), + tabBarIcon: ({ color, focused }) => ( + + ), + }} + listeners={{ + tabPress: event => { + void Haptics.selectionAsync(); + event.preventDefault(); + router.navigate('/(app)/(tabs)/(1_kiloclaw)' as Href); + }, + }} + /> + , + tabBarIcon: ({ color, focused }) => ( + + ), + }} + listeners={{ + tabPress: () => { + void Haptics.selectionAsync(); + }, + }} + /> + , + tabBarIcon: ({ color, focused }) => ( + + ), + }} + listeners={{ + tabPress: () => { + void Haptics.selectionAsync(); + }, + }} + /> + , + tabBarIcon: ({ color, focused }) => ( + + ), + }} + listeners={{ + tabPress: event => { + void Haptics.selectionAsync(); + event.preventDefault(); + router.navigate(PROFILE_TAB_ROOT); + }, + }} + /> + + ); } diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx index 1e912dc7de..70711dec7d 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx @@ -73,6 +73,7 @@ const confirmationRequests = vi.hoisted(() => ({ })); const navigationRoutes = ['session-detail']; +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('react-native', () => ({ View: 'View', Pressable: 'Pressable', diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index c177f19167..c30a2b6e66 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -23,6 +23,7 @@ import { useIdentityConfirmation } from '@/components/agents/user-web-connection import { buildTerminalErrorCopyText } from '@/components/agents/session-terminal-error'; import { performCopy } from '@/components/agents/use-message-copy'; import { InvalidRouteState } from '@/components/invalid-route-state'; +import { CenteredState } from '@/components/centered-state'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; @@ -195,36 +196,38 @@ export default function SessionDetailScreen() { backFallback="/(app)/(tabs)/(2_agents)" /> - - - - - + + + + + + + - + ); } diff --git a/apps/mobile/src/app/(app)/agent-chat/folder-picker.mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/folder-picker.mounted.test.tsx index ca741843e3..565c02f916 100644 --- a/apps/mobile/src/app/(app)/agent-chat/folder-picker.mounted.test.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/folder-picker.mounted.test.tsx @@ -46,6 +46,7 @@ const flatListMock = vi.hoisted( vi.mock('react-native', () => ({ FlatList: flatListMock, Pressable: 'Pressable', + ScrollView: 'ScrollView', View: 'View', })); vi.mock('expo-router', () => ({ @@ -61,7 +62,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); -vi.mock('@/components/picker-sheet', () => ({ PickerSheet: 'PickerSheet' })); +vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/directional-icons', () => ({ DirectionalChevronRight: 'DirectionalChevronRight', @@ -142,11 +143,11 @@ describe('FolderPickerScreen body', () => { }); }); - it('renders one FlatList in the retryable phase, with the retry empty state', async () => { + it('replaces the list with the retryable state', async () => { listFn.mockResolvedValueOnce({ ok: false, reason: 'transport' }); const renderer = await mount(); - expect(findByType(renderer.root, 'FlatList')).toHaveLength(1); + expect(findByType(renderer.root, 'FlatList')).toHaveLength(0); const emptyState = findByType(renderer.root, 'EmptyState'); expect(emptyState).toHaveLength(1); expect(propOf(emptyState[0], 'action')).toBeTruthy(); @@ -157,11 +158,11 @@ describe('FolderPickerScreen body', () => { }); }); - it('renders one FlatList in the unsupported phase, with no retry action', async () => { + it('replaces the list with the unsupported state without a retry action', async () => { listFn.mockResolvedValueOnce({ ok: false, reason: 'unsupported' }); const renderer = await mount(); - expect(findByType(renderer.root, 'FlatList')).toHaveLength(1); + expect(findByType(renderer.root, 'FlatList')).toHaveLength(0); const emptyState = findByType(renderer.root, 'EmptyState'); expect(emptyState).toHaveLength(1); expect(propOf(emptyState[0], 'action')).toBeUndefined(); @@ -172,11 +173,43 @@ describe('FolderPickerScreen body', () => { }); }); - it('renders one FlatList in the ready-empty phase, with the empty state', async () => { + it('keeps the native header mounted when a child folder is empty', async () => { + listFn.mockResolvedValueOnce({ + ok: true, + path: '', + directories: [{ name: 'src', path: 'src' }], + }); + listFn.mockResolvedValueOnce({ ok: true, path: 'src', directories: [] }); + const renderer = await mount(); + const header = findByType(renderer.root, 'SheetHeader')[0]; + const folder = findByType(renderer.root, 'Pressable')[0]; + if (!header || !folder) { + throw new Error('Folder controls did not mount'); + } + const group = header.parent; + expect(group?.props.collapsable).toBe(false); + await act(async () => { + (folder.props.onPress as () => void)(); + await Promise.resolve(); + }); + expect(findByType(renderer.root, 'SheetHeader')[0]).toBe(header); + expect(header.parent).toBe(group); + expect(findByType(renderer.root, 'FlatList')).toHaveLength(0); + expect(findByType(renderer.root, 'EmptyState')).toHaveLength(1); + act(() => { + (header.props.onDone as () => void)(); + }); + expect(bridge.onSelect).toHaveBeenCalledWith('src'); + act(() => { + renderer.unmount(); + }); + }); + + it('replaces the list with the ready-empty state', async () => { listFn.mockResolvedValueOnce({ ok: true, path: '', directories: [] }); const renderer = await mount(); - expect(findByType(renderer.root, 'FlatList')).toHaveLength(1); + expect(findByType(renderer.root, 'FlatList')).toHaveLength(0); expect(findByType(renderer.root, 'EmptyState')).toHaveLength(1); expect(findByType(renderer.root, 'Skeleton')).toHaveLength(0); expect(findByType(renderer.root, 'Button')).toHaveLength(0); diff --git a/apps/mobile/src/app/(app)/agent-chat/folder-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/folder-picker.tsx index 82990e4f6c..eed7767354 100644 --- a/apps/mobile/src/app/(app)/agent-chat/folder-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/folder-picker.tsx @@ -108,11 +108,6 @@ export default function FolderPickerScreen() { const title = current?.title ?? bridge.projectName; const currentState = state?.path === current?.path ? state : null; - // One long-lived FlatList renders in every phase. Non-ready states go - // through ListEmptyComponent so the scroll view instance never changes: - // react-native-screens sizes a formSheet's scroll view once per bounds - // change and binds the correction to one instance, so swapping the body - // between a View and a FlatList breaks the header's frames. const data = currentState?.phase === 'ready' ? currentState.directories : []; const listContentStyle = { flexGrow: 1, paddingBottom: bottom } satisfies ViewStyle; let empty: ReactNode = null; @@ -128,47 +123,38 @@ export default function FolderPickerScreen() { ); } else if (currentState.phase === 'retryable') { empty = ( - - { - list(currentState.path); - }} - accessibilityLabel={t('common.retry')} - > - {t('common.retry')} - - } - /> - + { + list(currentState.path); + }} + accessibilityLabel={t('common.retry')} + > + {t('common.retry')} + + } + /> ); } else if (currentState.phase === 'unsupported') { empty = ( - - - + ); - } else { + } else if (data.length === 0) { empty = ( - - - + ); } @@ -180,47 +166,51 @@ export default function FolderPickerScreen() { cancelLabel={isNested ? t('common.back') : undefined} scrollable={false} > - entry.path} - contentContainerStyle={listContentStyle} - ListHeaderComponent={ - data.length > 0 ? ( - - - {t('agentChat.folderPicker.tapToListHint')} - - - ) : null - } - ListEmptyComponent={empty} - scrollEnabled={data.length > 0} - renderItem={({ item }) => ( - { - openChild(item); - }} - accessibilityRole="button" - accessibilityLabel={item.name} - accessibilityHint={t('agentChat.folderPicker.tapToListHint')} - > - - - {item.name} - - - entry.path} + contentContainerStyle={listContentStyle} + ListHeaderComponent={ + data.length > 0 ? ( + + + {t('agentChat.folderPicker.tapToListHint')} + + + ) : null + } + ListEmptyComponent={empty} + scrollEnabled={data.length > 0} + renderItem={({ item }) => ( + { + openChild(item); + }} + accessibilityRole="button" + accessibilityLabel={item.name} + accessibilityHint={t('agentChat.folderPicker.tapToListHint')} > - - - - )} - /> + + + {item.name} + + + + + + + )} + /> + )} ); } diff --git a/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx index 387a938bb8..26d01ab36c 100644 --- a/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx @@ -185,26 +185,24 @@ export default function InstancePickerScreen() { onDone={closePicker} scrollable={false} > - - { - void refetchInstances(); - }} - loading={isRefetching} - accessibilityLabel={t('common.retry')} - > - {t('common.retry')} - - } - /> - + { + void refetchInstances(); + }} + loading={isRefetching} + accessibilityLabel={t('common.retry')} + > + {t('common.retry')} + + } + /> ); } diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx index 0d2e3dd80e..d73d6a36dc 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -99,88 +99,92 @@ export default function RepoPickerScreen() { } return ( - - item.key} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - contentContainerStyle={{ paddingBottom: bottom }} - ListHeaderComponent={ - - - - - } - ListEmptyComponent={ - + + - } - renderItem={({ item }) => { - if (item.kind === 'header') { - return ( - - {t(item.titleKey)} - - ); + + } + > + {listItems.length === 0 ? ( + { - handleSelect(`${repo.platform}:${repo.fullName}`); - }} - accessibilityRole="button" - accessibilityLabel={rowLabel} - > - {repo.isPrivate ? ( - - ) : ( - - )} - + ) : ( + item.key} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerStyle={{ paddingBottom: bottom }} + renderItem={({ item }) => { + if (item.kind === 'header') { + return ( + + {t(item.titleKey)} + + ); + } + const repo = item.repo; + const platformName = t(REPO_PLATFORM_LABEL_KEYS[repo.platform]); + const rowLabel = `${platformName} ${repo.fullName}`; + return ( + { + handleSelect(`${repo.platform}:${repo.fullName}`); + }} + accessibilityRole="button" + accessibilityLabel={rowLabel} > - {platformName} - - - {repo.fullName} - - {bridge.currentValue === `${repo.platform}:${repo.fullName}` ? ( - - ) : null} - - ); - }} - /> + {repo.isPrivate ? ( + + ) : ( + + )} + + {platformName} + + + {repo.fullName} + + {bridge.currentValue === `${repo.platform}:${repo.fullName}` ? ( + + ) : null} + + ); + }} + /> + )} ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx index d6d808dd5f..7367156869 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/billing.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { toast } from 'sonner-native'; +import { CenteredState } from '@/components/centered-state'; import { DetailScreenScrollView } from '@/components/detail-screen'; import { EmptyState } from '@/components/empty-state'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; @@ -187,15 +188,7 @@ function PlanDetails({ ); } - return ( - - ); + return null; } export default function BillingScreen() { @@ -218,11 +211,11 @@ export default function BillingScreen() { return ( - - + + {t('kiloclaw.billing.managedByAdmin')} - + ); } @@ -260,14 +253,39 @@ export default function BillingScreen() { return ( - - { - void billingQuery.refetch(); - }} - /> - + { + void billingQuery.refetch(); + }} + /> + + ); + } + + const manageAction = ( + + ); + + if (!billing.subscription && !(billing.trial && !billing.trial.expired) && !billing.earlybird) { + return ( + + + ); } @@ -285,17 +303,7 @@ export default function BillingScreen() { - {/* Manage billing button */} - + {manageAction} diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx index 882231a230..838681f6ea 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/changelog.tsx @@ -60,26 +60,22 @@ export default function ChangelogScreen() { if (changelogQuery.isError) { return ( - - { - void changelogQuery.refetch(); - }} - /> - + { + void changelogQuery.refetch(); + }} + /> ); } if (!entries || entries.length === 0) { return ( - - - + ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx index 319317c098..a87dff8b5f 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/dashboard.tsx @@ -132,15 +132,13 @@ export default function DashboardScreen() { return ( - - { - void statusQuery.refetch(); - void billingQuery.refetch(); - }} - /> - + { + void statusQuery.refetch(); + void billingQuery.refetch(); + }} + /> ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx index 6c811ede5b..9072f48a21 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/channels.tsx @@ -43,26 +43,22 @@ export default function ChannelsScreen() { if (catalogQuery.isError) { return ( - - { - void catalogQuery.refetch(); - }} - /> - + { + void catalogQuery.refetch(); + }} + /> ); } if (catalogQuery.data.length === 0) { return ( - - - + ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx index c4fb5ec6c4..27a374a602 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx @@ -118,14 +118,12 @@ export default function DevicePairingScreen() { return ( - - { - void handleRefresh(); - }} - /> - + { + void handleRefresh(); + }} + /> ); } @@ -174,10 +172,7 @@ export default function DevicePairingScreen() { return ( - + - - { - void statusQuery.refetch(); - }} - /> - + { + void statusQuery.refetch(); + }} + /> ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx index dba5506a61..8a7877cc9d 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/google.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { useLocalSearchParams } from 'expo-router'; +import { CenteredState } from '@/components/centered-state'; import { DetailScreenScrollView } from '@/components/detail-screen'; import { GmailIcon, GoogleIcon } from '@/components/icons'; import { InstanceContextBoundary } from '@/components/kiloclaw/instance-context-boundary'; @@ -62,14 +63,12 @@ export default function GoogleScreen() { return ( - - { - void statusQuery.refetch(); - }} - /> - + { + void statusQuery.refetch(); + }} + /> ); } @@ -124,137 +123,136 @@ export default function GoogleScreen() { ]); } - return ( - - - + const body = ( + + {/* Connection status card */} + + + + {t('kiloclaw.google.title')} + + + {isConnected ? t('kiloclaw.google.connected') : t('kiloclaw.google.notConnected')} + + + + + + {!isConnected && ( - {/* Connection status card */} - - - - {t('kiloclaw.google.title')} - + {t('kiloclaw.google.disconnected')} + + + )} + + {t('kiloclaw.google.setupCommand')} + + + {t('kiloclaw.google.setupCommandHelp')} + + + {setupQuery.isPending && } + {setupQuery.isError && ( + + + {t('kiloclaw.google.failedToLoadCommand')} + - + )} + {setupQuery.isSuccess && ( + {setupQuery.data.command} + )} + + + )} - {!isConnected && ( - - {showRedeployPrompt && ( - - - {t('kiloclaw.google.disconnected')} - - - - )} - - {t('kiloclaw.google.setupCommand')} - - - {t('kiloclaw.google.setupCommandHelp')} + {isConnected && ( + + + + + + {t('kiloclaw.google.gmailNotifications')} - - {setupQuery.isPending && } - {setupQuery.isError && ( - - - {t('kiloclaw.google.failedToLoadCommand')} - - - - )} - {setupQuery.isSuccess && ( - - {setupQuery.data.command} - - )} - - - )} - - {isConnected && ( - - - - - - {t('kiloclaw.google.gmailNotifications')} - - - - + + - - - )} + - + )} + + ); + + return ( + + + {isConnected ? ( + + {body} + + ) : ( + {body} + )} ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx index 80a9bff1d7..898d98c93d 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/model-list.tsx @@ -186,58 +186,56 @@ export default function ModelListScreen() { )} {isError && ( - - { - void refetch(); - void configQuery.refetch(); - }} - /> - - )} - {!isLoading && !isError && ( - - item.type === 'header' ? `header-${item.title}` : `model-${item.model.id}-${index}` - } - contentContainerStyle={listContentContainerStyle} - ListEmptyComponent={ - - {t('kiloclaw.modelList.clearSearch')} - - ) : undefined - } - /> - } - renderItem={({ item }) => { - if (item.type === 'header') { - return ( - - - {item.title} - - - ); - } - return renderItem({ item: item.model }); + { + void refetch(); + void configQuery.refetch(); }} /> )} + {!isLoading && + !isError && + (sections.length === 0 ? ( + + {t('kiloclaw.modelList.clearSearch')} + + ) : undefined + } + /> + ) : ( + + item.type === 'header' ? `header-${item.title}` : `model-${item.model.id}-${index}` + } + contentContainerStyle={listContentContainerStyle} + renderItem={({ item }) => { + if (item.type === 'header') { + return ( + + + {item.title} + + + ); + } + return renderItem({ item: item.model }); + }} + /> + ))} ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx index 0320580d72..6b5e2b66fd 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/secrets.tsx @@ -43,26 +43,22 @@ export default function SecretsScreen() { if (catalogQuery.isError) { return ( - - { - void catalogQuery.refetch(); - }} - /> - + { + void catalogQuery.refetch(); + }} + /> ); } if (catalogQuery.data.length === 0) { return ( - - - + ); } diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx index ad388ebec3..d278fe1827 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/version-pin.tsx @@ -81,16 +81,14 @@ export default function VersionPinScreen() { return ( - - { - void myPinQuery.refetch(); - void latestVersionQuery.refetch(); - void availableVersionsQuery.refetch(); - }} - /> - + { + void myPinQuery.refetch(); + void latestVersionQuery.refetch(); + void availableVersionsQuery.refetch(); + }} + /> ); } diff --git a/apps/mobile/src/app/(auth)/_layout.tsx b/apps/mobile/src/app/(auth)/_layout.tsx index 171ce3bfa9..c082df734b 100644 --- a/apps/mobile/src/app/(auth)/_layout.tsx +++ b/apps/mobile/src/app/(auth)/_layout.tsx @@ -1,5 +1,6 @@ import { Stack } from 'expo-router'; +import { NativeStateSurface } from '@/components/centered-state-surface'; import { useFormSheetDetents } from '@/lib/form-sheet'; export const unstable_settings = { @@ -10,7 +11,10 @@ export default function AuthLayout() { const { fullSheetDetent } = useFormSheetDetents(); return ( - + } + screenOptions={{ headerShown: false }} + > ; +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index d18583102e..dba41ddeca 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -18,7 +18,7 @@ import * as Sentry from '@sentry/react-native'; import { reloadAppAsync } from 'expo'; import { loadAsync, useFonts } from 'expo-font'; import { - ErrorBoundary as ExpoRouterErrorBoundary, + type ErrorBoundaryProps, type Href, Slot, ThemeProvider, @@ -39,8 +39,10 @@ import { toast } from 'sonner-native'; import { AnimatedSplashOverlay } from '@/components/animated-splash-overlay'; import { AppRootProviders } from '@/components/app-root-providers'; import { BootstrapErrorScreen } from '@/components/bootstrap-error-screen'; +import { StateSurface } from '@/components/centered-state-surface'; import { LanguageReloadErrorScreen } from '@/components/language-reload-error-screen'; import { PrivacyCoverOverlay } from '@/components/privacy-cover-overlay'; +import { QueryError } from '@/components/query-error'; import { splashContentScale } from '@/components/splash-reveal'; import { announceForA11y, moveA11yFocus } from '@/lib/a11y/announce'; import { useAuth } from '@/lib/auth/auth-context'; @@ -964,7 +966,9 @@ function RootLayout() { - + + + @@ -973,6 +977,14 @@ function RootLayout() { ); } -export const ErrorBoundary = Sentry.wrapExpoRouterErrorBoundary(ExpoRouterErrorBoundary); +function RootErrorBoundary({ retry }: ErrorBoundaryProps) { + return ( + + void retry()} /> + + ); +} + +export const ErrorBoundary = Sentry.wrapExpoRouterErrorBoundary(RootErrorBoundary); export default Sentry.wrap(RootLayout); diff --git a/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts b/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts index a40cd9048d..2fe9232da6 100644 --- a/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts +++ b/apps/mobile/src/components/agents/agents-tab-badge.test-helpers.ts @@ -139,6 +139,10 @@ export function CountSurfaces() { ); } +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ + StateSurfaceInsets: 'StateSurfaceInsets', +})); vi.mock('@/lib/auth/auth-context', () => ({ useAuth: () => ({ token: 'account', diff --git a/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx b/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx index 821a1f266a..891abe22ed 100644 --- a/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx +++ b/apps/mobile/src/components/agents/attachment-preview-strip.mounted.test.tsx @@ -78,6 +78,8 @@ const a11yMock = vi.hoisted(() => ({ moveA11yFocus: vi.fn(), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'View' })); vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', Modal: 'Modal', @@ -690,11 +692,28 @@ describe('AttachmentPreviewStrip — text preview sheet surface', () => { expect(modals[0]?.props.animationType).toBe('slide'); expect(modals[0]?.props.presentationStyle).toBe('pageSheet'); expect(modals[0]?.props.transparent).toBeUndefined(); - expect(findByTestID(renderer.root, 'session-page-sheet-surface')).toHaveLength(0); + const surface = findByTestID(renderer.root, 'session-page-sheet-surface'); + expect(surface).toHaveLength(1); + expect(surface[0]?.props.style).toBeUndefined(); renderer.unmount(); }); + it('centers an empty preview outside the content scroller', async () => { + fileText.mockResolvedValueOnce(''); + const renderer = await openMarkdownPreview(); + const modal = nodesByType(renderer.root, 'Modal')[0]; + if (!modal) { + throw new Error('Modal not found'); + } + expect(nodesByType(modal, 'CenteredState')).toHaveLength(1); + expect(nodesByType(modal, 'ScrollView')).toHaveLength(0); + expect(nodesByType(modal, 'SheetHeader')).toHaveLength(1); + act(() => { + renderer.unmount(); + }); + }); + it('sizes the preview ScrollView to fill the sheet surface with flex-1', async () => { const renderer = await openMarkdownPreview(); diff --git a/apps/mobile/src/components/agents/attachment-preview-strip.tsx b/apps/mobile/src/components/agents/attachment-preview-strip.tsx index 768d9f2d67..5718b84272 100644 --- a/apps/mobile/src/components/agents/attachment-preview-strip.tsx +++ b/apps/mobile/src/components/agents/attachment-preview-strip.tsx @@ -21,7 +21,6 @@ import { toast } from 'sonner-native'; import { useActionSheet } from '@expo/react-native-action-sheet'; import { useTranslation } from 'react-i18next'; -import { i18n } from '@/i18n'; import { AlertCircle, File as FileIcon, RotateCcw, X } from '@/components/ui/icons'; import { moveA11yFocus } from '@/lib/a11y/announce'; @@ -34,6 +33,7 @@ import { type AttachmentMoveDirection, } from '@/lib/agent-attachments/use-agent-attachment-upload'; import { describeAttachmentChip } from '@/components/agents/attachment-chip-description'; +import { CenteredState } from '@/components/centered-state'; import { ImageViewerModal } from '@/components/image-viewer-modal'; import { SheetHeader } from '@/components/sheet-header'; import { SelectableText } from '@/components/ui/selectable-text'; @@ -111,13 +111,6 @@ export function dragTargetIndex( } function renderPreviewBody(preview: { mode: 'markdown' | 'text'; text: string }) { - if (preview.text === '') { - return ( - - {i18n.t('agentChat.filePart.fileEmpty')} - - ); - } if (preview.mode === 'markdown') { return ; } @@ -490,10 +483,20 @@ function AttachmentChip({ }} doneLabel={t('common.done')} /> - - {renderPreviewBody(textPreview)} - - + {textPreview.text === '' ? ( + + + {t('agentChat.filePart.fileEmpty')} + + + ) : ( + <> + + {renderPreviewBody(textPreview)} + + + + )} ) : null} diff --git a/apps/mobile/src/components/agents/child-session-sheet-recovery.mounted.test.tsx b/apps/mobile/src/components/agents/child-session-sheet-recovery.mounted.test.tsx index 9de5cff685..3d22ece961 100644 --- a/apps/mobile/src/components/agents/child-session-sheet-recovery.mounted.test.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet-recovery.mounted.test.tsx @@ -28,6 +28,9 @@ import { type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native' import { QueryError } from '@/components/query-error'; import { i18n } from '@/i18n'; +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'View' })); + async function mountRecovery(messages = [makeAssistantMessage()]) { const fetchPage = vi .fn>() diff --git a/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx index 5a1dca85c1..5211903768 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx @@ -21,6 +21,9 @@ import { QueryError } from '@/components/query-error'; import { i18n } from '@/i18n'; import { ChildSessionModelLabel } from './child-session-model-label'; +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'View' })); + describe('ChildSessionSheet title layout', () => { it.each([ { @@ -78,6 +81,9 @@ describe('ChildSessionSheet title layout', () => { expect(header.props).toMatchObject({ title: 'Inspect performance child 01' }); expect(header.props.onDone).toBe(props.onClose); expect(textValues(renderer.root)).toContain(state.expectedText); + expect(renderer.root.findAll(node => Object.is(node.type, 'CenteredState'))).toHaveLength( + state.messages.length === 0 ? 1 : 0 + ); expect( renderer.root.findAll( node => (node.type as string) === 'Pressable' && node.props.accessibilityLabel === 'Retry' @@ -101,6 +107,8 @@ describe('ChildSessionSheet mounted', () => { expect(textValues(renderer.root)).toContain('child text'); expect(renderer.root.findAllByType(QueryError)).toHaveLength(1); + expect(renderer.root.findAllByType(QueryError)[0]?.props.placement).toBe('top'); + expect(renderer.root.findAll(node => Object.is(node.type, 'CenteredState'))).toHaveLength(0); expect(textValues(renderer.root)).toContain('Failed'); expect(retryButton(renderer.root).props.accessibilityState).toEqual({ disabled: false, @@ -187,7 +195,9 @@ describe('ChildSessionSheet sheet surface', () => { expect(modalNode.props.onRequestClose).toBe(onClose); expect(modalNode.props.onDismiss).toBe(onDismiss); - expect(findByTestID(renderer.root, 'session-page-sheet-surface')).toHaveLength(0); + const surface = findByTestID(renderer.root, 'session-page-sheet-surface'); + expect(surface).toHaveLength(1); + expect(surface[0]?.props.style).toBeUndefined(); }); it('renders an opaque full-window Modal padded by the top inset on Android', async () => { diff --git a/apps/mobile/src/components/agents/child-session-sheet.tsx b/apps/mobile/src/components/agents/child-session-sheet.tsx index 6d94879cc8..2036eb5dee 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.tsx @@ -112,7 +112,8 @@ export function ChildSessionSheet({ message={hydrationError} onRetry={onRetry} isRetrying={hydrationState.status === 'loading'} - className="flex-none gap-3 border-b border-border py-3" + placement="top" + className="gap-3 border-b border-border py-3" /> ) : null} - - + ); } diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index e42bc847fe..a44ba005e9 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -128,6 +128,8 @@ vi.mock('@/lib/a11y/announce', () => ({ vi.mock('@/components/ui/icons', () => ({ AlertCircle: 'AlertCircle', File: 'File' })); vi.mock('@/components/image-viewer-modal', () => ({ ImageViewerModal: 'ImageViewerModal' })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); vi.mock('@/components/ui/image', () => ({ Image: 'Image' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ @@ -835,25 +837,33 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); - it('shows "This file is empty." for empty decoded text', async () => { - expoFileSystemMock.fileText.mockResolvedValue(''); - cacheFilePart('part-1', { - url: 'data:text/markdown;base64,QUJD', - mime: 'text/markdown', - filename: 'readme.md', - }); - const renderer = await mount( - makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: 'readme.md', url: '' }) - ); - const root = renderer.root; - - await press(first(pressableByLabel(root, 'Preview readme.md'))); - await flushAsync(); - - expect(texts(root)).toContain('This file is empty.'); + it.each([ + ['ios', 'text/markdown', 'readme.md'], + ['android', 'text/markdown', 'readme.md'], + ['ios', 'text/plain', 'file.txt'], + ['android', 'text/plain', 'file.txt'], + ])( + 'centers the empty %s %s preview outside its content scroller', + async (platform, mime, filename) => { + reactNativeMock.Platform.OS = platform; + expoFileSystemMock.fileText.mockResolvedValue(''); + cacheFilePart('part-1', { url: `data:${mime};base64,QUJD`, mime, filename }); + const renderer = await mount(makeFilePart({ id: 'part-1', mime, filename, url: '' })); + const root = renderer.root; + const markdown = mime === 'text/markdown'; + await press(first(pressableByLabel(root, `${markdown ? 'Preview' : 'Open'} ${filename}`))); + if (!markdown) { + await selectActionSheet(0); + } + await flushAsync(); - await unmount(renderer); - }); + expect(texts(root)).toContain('This file is empty.'); + expect(findByType(root, 'CenteredState')).toHaveLength(1); + expect(findByType(root, 'ScrollView')).toHaveLength(0); + expect(findByType(root, 'SheetHeader')).toHaveLength(1); + await unmount(renderer); + } + ); it('shares the source file from the header Share on an empty markdown preview', async () => { expoFileSystemMock.fileText.mockResolvedValue(''); @@ -910,6 +920,14 @@ describe('FilePartRenderer mounted', () => { expect(texts(root)).toContain('Could not load this file.'); expect(pressableByLabel(root, 'Retry loading file')).toHaveLength(1); + expect(findByType(root, 'CenteredState')).toHaveLength(1); + expect(findByType(root, 'ScrollView')).toHaveLength(0); + expoFileSystemMock.fileText.mockResolvedValue('Recovered'); + await press(first(pressableByLabel(root, 'Retry loading file'))); + await flushAsync(); + expect(findByType(root, 'CenteredState')).toHaveLength(0); + expect(findByType(root, 'ScrollView')).toHaveLength(1); + expect(first(findByType(root, 'ChatMarkdownText')).props.value).toBe('Recovered'); await unmount(renderer); }); @@ -1711,7 +1729,9 @@ describe('FilePartRenderer preview sheet surface', () => { expect(modals[0]?.props.animationType).toBe('slide'); expect(modals[0]?.props.presentationStyle).toBe('pageSheet'); expect(modals[0]?.props.transparent).toBeUndefined(); - expect(findByTestID(renderer.root, 'session-page-sheet-surface')).toHaveLength(0); + const surface = findByTestID(renderer.root, 'session-page-sheet-surface'); + expect(surface).toHaveLength(1); + expect(surface[0]?.props.style).toBeUndefined(); await unmount(renderer); }); diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index c40a8288b6..dff4cd529c 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; +import { CenteredState } from '@/components/centered-state'; import { ImageViewerModal } from '@/components/image-viewer-modal'; import { SheetHeader } from '@/components/sheet-header'; import { AccessibleStatus } from '@/components/ui/accessible-status'; @@ -510,9 +511,15 @@ function FilePreviewModal({ sharing={sharing} /> - - {renderBody()} - + {status === 'error' || (status === 'ready' && text === '') ? ( + + {renderBody()} + + ) : ( + + {renderBody()} + + )} ); diff --git a/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx b/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx index e27296b48c..06f2293bf3 100644 --- a/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx +++ b/apps/mobile/src/components/agents/instance-picker.mounted.test.tsx @@ -24,6 +24,7 @@ type ListProps = { ListEmptyComponent?: ReactNode; }; +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('react-native', () => ({ FlatList: (props: ListProps) => createElement( diff --git a/apps/mobile/src/components/agents/live-session-list-empty-state.tsx b/apps/mobile/src/components/agents/live-session-list-empty-state.tsx index 19c5cedcc4..3891823c7a 100644 --- a/apps/mobile/src/components/agents/live-session-list-empty-state.tsx +++ b/apps/mobile/src/components/agents/live-session-list-empty-state.tsx @@ -1,8 +1,6 @@ import { type Href, useRouter } from 'expo-router'; -import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { type ScrollViewProps } from 'react-native'; import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; import { EmptyState } from '@/components/empty-state'; @@ -13,52 +11,35 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; type LiveSessionListEmptyStateProps = { organizationId: string | null; - tabBarHeight: number; + refreshControl?: ScrollViewProps['refreshControl']; }; export function LiveSessionListEmptyState({ organizationId, - tabBarHeight, + refreshControl, }: Readonly) { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); - const { top } = useSafeAreaInsets(); - const [emptyBodyY, setEmptyBodyY] = useState(0); - const emptyStateSpacerStyle = useMemo( - () => ({ height: tabBarHeight + Math.max(0, emptyBodyY - top) }), - [emptyBodyY, tabBarHeight, top] - ); - return ( - { - setEmptyBodyY(event.nativeEvent.layout.y); - }} - > - { - router.push(getNewAgentSessionPath(organizationId) as Href); - }} - > - - {t('home.newCodingTask')} - - } - /> - - + { + router.push(getNewAgentSessionPath(organizationId) as Href); + }} + > + + {t('home.newCodingTask')} + + } + /> ); } diff --git a/apps/mobile/src/components/agents/markdown-table.test.ts b/apps/mobile/src/components/agents/markdown-table.test.ts index 5980b21cf2..b634efa909 100644 --- a/apps/mobile/src/components/agents/markdown-table.test.ts +++ b/apps/mobile/src/components/agents/markdown-table.test.ts @@ -140,6 +140,8 @@ vi.mock('@/components/ui/icons', () => ({ Table2: 'Table2', X: 'X', })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus', })); @@ -428,6 +430,42 @@ describe('MarkdownTable open path', () => { expect(status[0]?.props.message).toBe('This table has no rows.'); expect(status[0]?.props.tone).toBe('status'); expect(closeNode(renderer)).toBeTruthy(); + const centered = renderer.root.findAll(node => (node.type as string) === 'CenteredState'); + expect(centered).toHaveLength(1); + expect(renderer.root.findAll(node => (node.type as string) === 'ScrollView')).toHaveLength(0); + expect(renderer.root.findAll(node => (node.type as string) === 'GestureDetector')).toHaveLength( + 0 + ); + const surface = renderer.root.find(node => (node.type as string) === 'StateSurface'); + expect(surface.parent?.type).toBe('Modal'); + expect(surface.props.className).toBe('flex-1 bg-background'); + }); + + it('replaces the centered state with cells and retains cells across an empty parse', () => { + const renderer = renderTable({ rowCount: 0 }); + openTable(renderer); + vi.mocked(useMarkdown).mockReturnValue([ + createElement('View', { testID: 'retained-cells' }, 'row'), + ]); + act(() => { + renderer.update(createElement(MarkdownTable, defaultProps)); + }); + expect(renderer.root.findAll(node => (node.type as string) === 'CenteredState')).toHaveLength( + 0 + ); + expect(renderer.root.findAll(node => (node.type as string) === 'ScrollView')).toHaveLength(2); + vi.mocked(useMarkdown).mockReturnValue([]); + act(() => { + renderer.update(createElement(MarkdownTable, { ...defaultProps, rowCount: 0, raw: '' })); + }); + expect(renderer.root.findAll(node => node.props.testID === 'retained-cells')).toHaveLength(1); + expect(renderer.root.findAll(node => (node.type as string) === 'CenteredState')).toHaveLength( + 0 + ); + expect(renderer.root.findAll(node => (node.type as string) === 'ScrollView')).toHaveLength(2); + act(() => { + renderer.unmount(); + }); }); it('shows the loading wait before first cells and Close still works', () => { diff --git a/apps/mobile/src/components/agents/markdown-table.tsx b/apps/mobile/src/components/agents/markdown-table.tsx index a20622467c..7fbeddb2d1 100644 --- a/apps/mobile/src/components/agents/markdown-table.tsx +++ b/apps/mobile/src/components/agents/markdown-table.tsx @@ -3,6 +3,7 @@ import { Table2, X } from '@/components/ui/icons'; import { type ComponentRef, type ComponentType, + type ReactElement, type ReactNode, type RefObject, useCallback, @@ -42,6 +43,8 @@ import { formatNumber } from '@/lib/format'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { subscribePrivacyCover } from '@/lib/privacy-cover-events'; import { AccessibleStatus } from '@/components/ui/accessible-status'; +import { CenteredState } from '@/components/centered-state'; +import { StateSurface } from '@/components/centered-state-surface'; import { containsPressable, extractNodeText, linearRowLabel } from './markdown-a11y'; import { getMarkdownStyles, type MarkdownPalette } from './markdown-palette'; @@ -227,7 +230,25 @@ export function MarkdownTable({ const tableStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], + transformOrigin: 'top left', })); + const scrollContentStyle = { padding: 16, paddingBottom: insets.bottom + 16 }; + + function renderTableContent(cells: ReactNode) { + return ( + + + + + + {cells} + + + + + + ); + } return ( <> @@ -276,7 +297,7 @@ export function MarkdownTable({ setOpen(false); }} > - + {/* RNGH gestures need their own root inside an RN Modal — see image-viewer-modal.tsx. */} - - - - {/* Sizer: gives both scrollers the zoomed extent. The table keeps its - natural layout size (self-start) and is scaled from its top-left. */} - {/* eslint-disable-next-line react-native/no-inline-styles -- dynamic measured sizer dimensions */} - - - {raw !== undefined ? ( - - ) : ( - - )} - - - - - + {raw !== undefined ? ( + + {renderTableContent} + + ) : ( + renderTableContent( + + ) + )} - + ) : null} @@ -410,6 +412,7 @@ function MarkdownTableCells({ } type MarkdownTableBodyProps = { + children: (cells: ReactNode) => ReactElement; palette: MarkdownPalette; raw: string; columnCount: number; @@ -425,6 +428,7 @@ type MarkdownTableBodyProps = { // flashing the wait or empty states, and leaves the parent's chrome and zoom // untouched. function MarkdownTableBody({ + children, palette, raw, columnCount, @@ -476,20 +480,22 @@ function MarkdownTableBody({ const cells = elements.length > 0 ? elements : lastCellsRef.current; if (cells !== null) { - return <>{cells}; + return children(cells); } if (rowCount === 0) { return ( - + + + ); } - return ( + return children( diff --git a/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx index e478df04b7..b3e2ef30fb 100644 --- a/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/message-details-sheet.mounted.test.tsx @@ -54,6 +54,7 @@ vi.mock('@/lib/a11y/announcing-toast', () => ({ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'View' })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader', })); diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index a3dc3a28ba..11c44665df 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -155,77 +155,81 @@ export function ModelPickerContent() { } return ( - - item.key} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - contentContainerStyle={{ paddingBottom: bottom }} - ListHeaderComponent={ - - - - - - {favoritesError ? ( - - - {favoritesError} - - ) : null} + + + + - } - ListEmptyComponent={ - + + {favoritesError} + + ) : null} + + } + > + {rows.length === 0 ? ( + + ) : ( + item.key} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerStyle={{ paddingBottom: bottom }} + renderItem={({ item }) => { + if (item.type === 'header') { + return ( + + + {item.title} + + + ); } - /> - } - renderItem={({ item }) => { - if (item.type === 'header') { + return ( - - - {item.title} - - + ); - } - - return ( - - ); - }} - /> + }} + /> + )} ); } diff --git a/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx new file mode 100644 index 0000000000..f2534465e6 --- /dev/null +++ b/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx @@ -0,0 +1,297 @@ +import '@/i18n'; +import { type FilePart, type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { act, createElement } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { renderWithProviders } from '@/test/render-with-providers'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PartDetailSheet } from './part-detail-sheet'; + +type Renderer = Awaited>['renderer']; +type Instance = Renderer['root']; + +const cache = vi.hoisted(() => new Map()); +vi.mock('./tool-card-image-cache', () => ({ useToolCardImageUri: (id: string) => cache.get(id) })); +vi.mock('react-native', () => ({ + Modal: 'Modal', + View: 'View', + ScrollView: 'ScrollView', + Pressable: 'Pressable', + Platform: { OS: 'ios' }, + useWindowDimensions: () => ({ width: 390, height: 844 }), +})); +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), +})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); +vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); +vi.mock('@/components/ui/segmented-control', () => ({ SegmentedControl: 'SegmentedControl' })); +vi.mock('@/components/ui/image', () => ({ Image: 'Image' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/selectable-text', () => ({ SelectableText: 'SelectableText' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + ImageOff: 'ImageOff', + FileIcon: 'FileIcon', + Share2: 'Share2', + Eye: 'Eye', + Plug: 'Plug', +})); +vi.mock('@/components/image-viewer-modal', () => ({ ImageViewerModal: 'ImageViewerModal' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ background: '#000', mutedForeground: '#666' }), +})); +vi.mock('@/lib/share-remote-file', () => ({ + getShareRemoteFileReason: vi.fn(), + shareLocalFile: vi.fn(), + ShareRemoteFileError: Error, +})); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('./mono-scroll-block', () => ({ + MonoScrollBlock: 'MonoScrollBlock', + MonoScrollSheetProvider: 'MonoScrollSheetProvider', +})); +vi.mock('./chat-markdown-text', () => ({ ChatMarkdownText: 'ChatMarkdownText' })); +vi.mock('./code-block', () => ({ CodeBlock: 'CodeBlock' })); +vi.mock('./fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); +vi.mock('./tool-cards', async () => { + const { ReadToolCardBody } = await import('./tool-cards/read-tool-card'); + const { GenericToolCardBody } = await import('./tool-cards/generic-tool-card'); + return { + ReadToolCardBody, + GenericToolCardBody, + BashToolCardBody: 'BashToolCardBody', + EditToolCardBody: 'EditToolCardBody', + GlobToolCardBody: 'GlobToolCardBody', + GrepToolCardBody: 'GrepToolCardBody', + ListToolCardBody: 'ListToolCardBody', + PatchToolCardBody: 'PatchToolCardBody', + TaskToolCardBody: 'TaskToolCardBody', + TodoToolCardBody: 'TodoToolCardBody', + WebSearchToolCardBody: 'WebSearchToolCardBody', + WriteToolCardBody: 'WriteToolCardBody', + }; +}); + +const image: FilePart = { + id: 'image-1', + sessionID: 's1', + messageID: 'm1', + type: 'file', + mime: 'image/png', + url: '', +}; +const completedState: Extract = { + status: 'completed', + input: { filePath: 'image.png' }, + output: 'Image read successfully', + title: 'read', + metadata: {}, + time: { start: 1, end: 2 }, + attachments: [image], +}; +function makePart(state = completedState): ToolPart { + return { + id: 'part-1', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'call-1', + tool: 'read', + state, + }; +} +function sheet(part: ToolPart, visible = true) { + return createElement(PartDetailSheet, { part, visible, onClose: vi.fn<() => void>() }); +} +async function mount(part: ToolPart) { + const result = await renderWithProviders(sheet(part)); + return { + ...result, + update: (next: ToolPart, visible = true) => { + act(() => { + result.renderer.update( + createElement(QueryClientProvider, { client: result.queryClient }, sheet(next, visible)) + ); + }); + }, + }; +} +function nodes(root: Instance, type: string) { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} +function failImage(renderer: Renderer) { + const node = nodes(renderer.root, 'Image')[0]; + if (!node) { + throw new Error('image was not rendered'); + } + act(() => { + (node.props.onError as () => void)(); + }); +} +function unavailable(root: Instance) { + return nodes(root, 'Text').filter(node => node.props.children === 'Image unavailable'); +} + +beforeEach(() => { + cache.clear(); + cache.set('part-1', 'file:///image.png'); +}); + +describe('PartDetailSheet image failures', () => { + it.each([1, 2])( + 'centers all rendered failures with %i attachment records without resetting on wrapper changes', + async count => { + const part = makePart({ + ...completedState, + attachments: Array.from({ length: count }, (_, index) => ({ + ...image, + id: `image-${index}`, + })), + }); + const { renderer, update, unmount } = await mount(part); + expect(nodes(renderer.root, 'Image')).toHaveLength(1); + failImage(renderer); + expect(unavailable(renderer.root)).toHaveLength(1); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(1); + expect(nodes(renderer.root, 'ScrollView')).toHaveLength(0); + expect(nodes(renderer.root, 'Image')).toHaveLength(0); + update({ ...part }); + expect(unavailable(renderer.root)).toHaveLength(1); + expect(nodes(renderer.root, 'Image')).toHaveLength(0); + update({ ...part, tool: 'custom_tool' }); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(0); + expect(unavailable(renderer.root)).toHaveLength(1); + expect(nodes(renderer.root, 'Image')).toHaveLength(0); + update(part); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(1); + expect(unavailable(renderer.root)).toHaveLength(1); + expect(nodes(renderer.root, 'Image')).toHaveLength(0); + unmount(); + } + ); + + it('keeps the first successful image inline with additional uncached attachment records', async () => { + const { renderer, unmount } = await mount( + makePart({ ...completedState, attachments: [image, { ...image, id: 'uncached-image' }] }) + ); + const node = nodes(renderer.root, 'Image')[0]; + if (!node) { + throw new Error('image was not rendered'); + } + act(() => { + (node.props.onLoad as (event: { source: { width: number; height: number } }) => void)({ + source: { width: 100, height: 100 }, + }); + }); + expect(nodes(renderer.root, 'Image')).toHaveLength(1); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(0); + expect(nodes(renderer.root, 'ScrollView')).toHaveLength(1); + unmount(); + }); + + it('keeps partial success inline when an image fails but a cached file remains available', async () => { + const part = { + ...makePart({ + ...completedState, + input: {}, + output: '', + attachments: [ + image, + { ...image, id: 'file-1', mime: 'application/pdf', filename: 'report.pdf' }, + ], + }), + tool: 'send_file', + }; + const { renderer, unmount } = await mount(part); + failImage(renderer); + expect(unavailable(renderer.root)).toHaveLength(1); + expect(nodes(renderer.root, 'Text').some(node => node.props.children === 'report.pdf')).toBe( + true + ); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(0); + expect(nodes(renderer.root, 'ScrollView')).toHaveLength(1); + unmount(); + }); + + it.each(['markdown', 'output'])( + 'keeps substantive %s inline after image failure', + async content => { + const part = + content === 'markdown' + ? makePart({ + ...completedState, + input: { filePath: 'readme.md' }, + metadata: { + display: { + type: 'file', + path: 'readme.md', + text: '# Retained', + lineStart: 1, + lineEnd: 1, + totalLines: 1, + }, + }, + }) + : { + ...makePart({ ...completedState, input: {}, output: 'Retained output' }), + tool: 'custom_tool', + }; + const { renderer, unmount } = await mount(part); + failImage(renderer); + expect(unavailable(renderer.root)).toHaveLength(1); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(0); + expect(nodes(renderer.root, 'ScrollView')).toHaveLength(1); + if (content === 'markdown') { + expect(nodes(renderer.root, 'ChatMarkdownText')[0]?.props.value).toBe('# Retained'); + } else { + expect(nodes(renderer.root, 'MonoScrollBlock')[0]?.props.content).toBe('Retained output'); + } + unmount(); + } + ); + + it('ignores a late decode failure from a replaced URI', async () => { + const part = makePart(); + const { renderer, update, unmount } = await mount(part); + const imageNode = nodes(renderer.root, 'Image')[0]; + if (!imageNode) { + throw new Error('image was not rendered'); + } + const onError = imageNode.props.onError as () => void; + cache.set(part.id, 'file:///replacement.png'); + update(part); + act(onError); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(0); + expect(nodes(renderer.root, 'Image')).toHaveLength(1); + expect(unavailable(renderer.root)).toHaveLength(0); + unmount(); + }); + + it.each(['uri', 'part', 'reopen'])('resets the failure for a changed %s', async reset => { + const part = makePart(); + const { renderer, update, unmount } = await mount(part); + failImage(renderer); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(1); + if (reset === 'uri') { + cache.set(part.id, 'file:///replacement.png'); + update(part); + } else if (reset === 'part') { + cache.set('part-2', 'file:///image.png'); + update({ ...part, id: 'part-2' }); + } else { + update(part, false); + update(part); + } + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(0); + expect(nodes(renderer.root, 'Image')).toHaveLength(1); + expect(unavailable(renderer.root)).toHaveLength(0); + failImage(renderer); + expect(nodes(renderer.root, 'CenteredState')).toHaveLength(1); + expect(unavailable(renderer.root)).toHaveLength(1); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/agents/part-detail-model.test.ts b/apps/mobile/src/components/agents/part-detail-model.test.ts index 89b133f92b..e67548cc5e 100644 --- a/apps/mobile/src/components/agents/part-detail-model.test.ts +++ b/apps/mobile/src/components/agents/part-detail-model.test.ts @@ -6,7 +6,12 @@ import { } from '@kilocode/cloud-agent-sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { findPartById, getPartDetailTitle, shouldAutoFollowPartDetail } from './part-detail-model'; +import { + findPartById, + getPartDetailTitle, + shouldAutoFollowPartDetail, + shouldCenterPartDetail, +} from './part-detail-model'; const { getToolDisplay } = vi.hoisted(() => ({ getToolDisplay: vi.fn(), @@ -14,7 +19,7 @@ const { getToolDisplay } = vi.hoisted(() => ({ vi.mock('./tool-card-display', () => ({ getToolDisplay })); function makeToolPart( - overrides: { tool?: string; input?: Record } = {} + overrides: { tool?: string; input?: Record; state?: ToolPart['state'] } = {} ): ToolPart { return { id: 'tool-1', @@ -23,7 +28,7 @@ function makeToolPart( type: 'tool', callID: 'call-1', tool: overrides.tool ?? 'bash', - state: { + state: overrides.state ?? { status: 'completed', input: overrides.input ?? { command: 'echo hi' }, output: '', @@ -121,6 +126,167 @@ describe('getPartDetailTitle', () => { }); }); +const completedState: Extract = { + status: 'completed', + input: {}, + output: '', + title: '', + metadata: {}, + time: { start: 1, end: 2 }, +}; +const errorState: Extract = { + status: 'error', + input: {}, + error: 'Failed', + time: { start: 1, end: 2 }, +}; + +describe('shouldCenterPartDetail', () => { + it('centers missing details, but not reasoning', () => { + expect(shouldCenterPartDetail(null, false)).toBe(true); + expect(shouldCenterPartDetail(makeReasoningPart(''), false)).toBe(false); + }); + + it.each(['file.ts', 'file.md', 'file.mdx'])('centers only an empty read of %s', filePath => { + for (const text of ['', 'content', ' ']) { + const part = makeToolPart({ + tool: 'read', + state: { + ...completedState, + input: { filePath }, + output: 'raw envelope', + metadata: { + display: { + type: 'file', + path: filePath, + text, + lineStart: 1, + lineEnd: 0, + totalLines: 0, + }, + }, + }, + }); + expect(shouldCenterPartDetail(part, false)).toBe(text === ''); + } + }); + + it.each(['file.ts', 'file.md'])( + 'centers final empty writes of %s, including errors', + filePath => { + for (const state of [completedState, errorState]) { + for (const content of ['', undefined, 42, 'body', ' ']) { + const part = makeToolPart({ + tool: 'write', + state: { ...state, input: { filePath, content } }, + }); + expect(shouldCenterPartDetail(part, false)).toBe(!content || content === 42); + } + } + } + ); + + it.each(['todoread', 'todowrite'])('centers empty %s without a completed-status guard', tool => { + const states: ToolPart['state'][] = [ + completedState, + errorState, + { status: 'pending', input: {}, raw: '' }, + { status: 'running', input: {}, time: { start: 1 } }, + ]; + for (const state of states) { + for (const content of ['', ' ', 'Task']) { + const part = makeToolPart({ tool, state: { ...state, input: { todos: [{ content }] } } }); + expect(shouldCenterPartDetail(part, false)).toBe(content.trim() === ''); + } + expect(shouldCenterPartDetail(makeToolPart({ tool: 'write', state }), false)).toBe( + state.status === 'completed' || state.status === 'error' + ); + } + expect( + shouldCenterPartDetail( + makeToolPart({ tool, state: { ...completedState, output: 'raw fallback' } }), + false + ) + ).toBe(false); + }); + + it.each(['glob', 'grep'])('centers only status-only %s results', tool => { + for (const output of [ + 'No files found', + 'Found 0 files', + 'No files found\n(Results truncated)', + ]) { + expect( + shouldCenterPartDetail(makeToolPart({ tool, state: { ...completedState, output } }), false) + ).toBe(true); + } + for (const output of ['Found 1 file\nfile.ts', 'raw fallback', '', ' ']) { + expect( + shouldCenterPartDetail(makeToolPart({ tool, state: { ...completedState, output } }), false) + ).toBe(false); + } + }); + + it.each(['image/png', 'application/pdf'])( + 'centers only unavailable %s attachments without other content', + mime => { + const attachment = { + id: 'file-1', + sessionID: 's1', + messageID: 'm1', + type: 'file' as const, + mime, + url: '', + }; + const tool = mime === 'image/png' ? 'read' : 'send_file'; + const state = { + ...completedState, + attachments: [attachment], + output: tool === 'read' ? 'Image read successfully' : '', + }; + const part = makeToolPart({ tool, state }); + expect(shouldCenterPartDetail(part, false)).toBe(true); + expect(shouldCenterPartDetail(part, true)).toBe(false); + expect( + shouldCenterPartDetail( + makeToolPart({ tool: 'custom_tool', state: { ...state, input: { path: 'file' } } }), + false + ) + ).toBe(false); + expect( + shouldCenterPartDetail( + makeToolPart({ tool: 'send_file', state: { ...state, output: 'result' } }), + false + ) + ).toBe(false); + } + ); + + it('keeps an empty state inline beside available attachments', () => { + const attachment = { + id: 'file-1', + sessionID: 's1', + messageID: 'm1', + type: 'file' as const, + mime: 'image/png', + url: '', + }; + for (const tool of ['write', 'todoread', 'todowrite', 'glob', 'grep']) { + const part = makeToolPart({ + tool, + state: { + ...completedState, + input: { todos: [] }, + output: 'No files found', + attachments: [attachment], + }, + }); + expect(shouldCenterPartDetail(part, false)).toBe(true); + expect(shouldCenterPartDetail(part, true)).toBe(false); + } + }); +}); + describe('shouldAutoFollowPartDetail', () => { it('returns false for a null part', () => { expect(shouldAutoFollowPartDetail(null)).toBe(false); diff --git a/apps/mobile/src/components/agents/part-detail-model.ts b/apps/mobile/src/components/agents/part-detail-model.ts index 918ceaf320..fbacf424ba 100644 --- a/apps/mobile/src/components/agents/part-detail-model.ts +++ b/apps/mobile/src/components/agents/part-detail-model.ts @@ -1,9 +1,90 @@ import { type Part, type StoredMessage } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { i18n } from '@/i18n'; import { isPartStreaming, isReasoningPart, isToolPart } from './part-types'; +import { isMarkdownPath, resolveReadCodeBody } from './read-tool-markdown'; +import { getToolFileAttachments, getToolImageAttachments } from './tool-card-attachments'; import { getToolDisplay } from './tool-card-display'; +import { buildResultRowsModel, buildTodoListModel } from './tool-list-model'; + +const stringSchema = z.string(); + +export function shouldCenterPartDetail( + part: Part | null, + hasCachedAttachment: boolean, + imageFailed = false +): boolean { + if (part === null) { + return true; + } + if (!isToolPart(part)) { + return false; + } + + const hasImages = getToolImageAttachments(part).length > 0; + const hasFiles = getToolFileAttachments(part).length > 0; + const hasAttachments = hasImages || hasFiles; + if (hasCachedAttachment && (hasFiles || (hasImages && !imageFailed))) { + return false; + } + + const { state, tool } = part; + const { input } = state; + const output = state.status === 'completed' ? state.output : ''; + const hasState = hasAttachments || (state.status === 'error' && state.error.length > 0); + + switch (tool) { + case 'read': { + const body = resolveReadCodeBody(part); + const filePath = stringSchema.safeParse(input.filePath).data ?? ''; + if (body && (!hasImages || isMarkdownPath(filePath))) { + return body.text === ''; + } + return hasState && (hasImages || !output); + } + case 'write': { + const content = stringSchema.safeParse(input.content).data ?? ''; + return content === '' && (state.status === 'completed' || state.status === 'error'); + } + case 'todoread': + case 'todowrite': { + const model = buildTodoListModel(part); + return model ? model.tasks.length === 0 : hasState && !output; + } + case 'glob': + case 'grep': + case 'list': { + const model = output ? buildResultRowsModel(output, tool) : undefined; + return model + ? model.rows.length === 0 && (Boolean(model.caption) || model.truncated || hasAttachments) + : hasState; + } + case 'edit': { + return ( + hasState && + !stringSchema.safeParse(input.oldString).data && + !stringSchema.safeParse(input.newString).data + ); + } + case 'bash': { + return hasState && !stringSchema.safeParse(input.command).data && !output; + } + case 'task': + case 'websearch': + case 'codesearch': + case 'webfetch': { + return hasState && !output; + } + case 'suggest': { + return false; + } + default: { + return hasState && Object.keys(input).length === 0 && !output; + } + } +} /** * Resolve a part by id from a surface's live messages. The sheet host calls diff --git a/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx index 49cb4469e6..3fe13e9c0c 100644 --- a/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx +++ b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx @@ -29,6 +29,10 @@ vi.mock('react-native', () => ({ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); +const cachedUri = vi.hoisted(() => ({ value: undefined as string | undefined })); +vi.mock('./tool-card-image-cache', () => ({ useToolCardImageUri: () => cachedUri.value })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader', })); @@ -59,7 +63,7 @@ vi.mock('./tool-part-detail-body', () => ({ createElement( 'ToolPartDetailBody', props, - createElement(MonoScrollBlock, { content: LONG_LINE }) + props.part.tool === 'bash' ? createElement(MonoScrollBlock, { content: LONG_LINE }) : null ), })); @@ -223,6 +227,49 @@ describe('PartDetailSheetHost mounted', () => { propOf(node, 'children') === 'Details unavailable' ); expect(unavailable).toHaveLength(1); + expect(findByType(renderer.root, 'CenteredState')).toHaveLength(1); + expect(findByType(renderer.root, 'ScrollView')).toHaveLength(0); + }); + + it('switches from a centered state to inline content when an attachment becomes available', async () => { + const part: ToolPart = { + ...makeBashPart('read-1', '', true), + tool: 'read', + state: { + status: 'completed', + input: { filePath: 'image.png' }, + output: 'Image read successfully', + title: 'read', + metadata: {}, + time: { start: 1, end: 2 }, + attachments: [ + { + id: 'file-1', + sessionID: 's1', + messageID: 'm1', + type: 'file', + mime: 'image/png', + url: '', + }, + ], + }, + }; + const renderer = await mountHost([makeMessage('m1', [part])]); + act(() => { + capturedOpener?.('read-1'); + }); + expect(findByType(renderer.root, 'CenteredState')).toHaveLength(1); + expect(findByType(renderer.root, 'ScrollView')).toHaveLength(0); + act(() => { + cachedUri.value = 'file:///cached.png'; + renderer.update(hostElement([makeMessage('m1', [part])])); + }); + expect(findByType(renderer.root, 'CenteredState')).toHaveLength(0); + expect(findByType(renderer.root, 'ScrollView')).toHaveLength(1); + cachedUri.value = undefined; + act(() => { + renderer.unmount(); + }); }); it('renders full selectable reasoning text with the completed label', async () => { diff --git a/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx index 219954a1e7..9963a40b4a 100644 --- a/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx @@ -1,6 +1,11 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ /* eslint-disable max-lines -- cohesive mounted suite: mono-control presence and streaming auto-follow share one sheet harness */ -import { type Part, type ReasoningPart, type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { + type Part, + type ReasoningPart, + type StoredMessage, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; import { createElement, type ReactElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { describe, expect, it, type Mock, vi } from 'vitest'; @@ -9,6 +14,8 @@ import { describe, expect, it, type Mock, vi } from 'vitest'; // while the sheet module loads, so its binding must already be initialized. import { MonoScrollBlock } from './mono-scroll-block'; import { PartDetailSheet } from './part-detail-sheet'; +import { PartDetailSheetHost } from './part-detail-sheet-host'; +import { useOpenPartDetail } from './open-part-detail-context'; vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ background: '#000' }), @@ -24,6 +31,9 @@ vi.mock('react-native', () => ({ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0 }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); +vi.mock('./tool-card-image-cache', () => ({ useToolCardImageUri: () => undefined })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader', })); @@ -310,6 +320,59 @@ function contentSizeChange(scrollView: TestRenderer.ReactTestInstance, height: n } describe('PartDetailSheet mounted', () => { + it('centers state-only tool details independently in main and subagent hosts', () => { + const openers = new Map>(); + function CaptureOpener({ name }: { name: string }) { + openers.set(name, useOpenPartDetail()); + return null; + } + const messages: StoredMessage[] = [ + { + info: { + id: 'm1', + sessionID: 's1', + role: 'user', + time: { created: 1 }, + agent: 'test', + model: { providerID: 'kilo', modelID: 'test' }, + }, + parts: [{ ...makeGenericPart('write-1', {}, 'error'), tool: 'write' }], + }, + ]; + const holder: { renderer?: TestRenderer.ReactTestRenderer } = {}; + act(() => { + holder.renderer = TestRenderer.create( + + + + + + + ); + }); + const renderer = holder.renderer; + if (!renderer) { + throw new Error('renderer was not created'); + } + act(() => { + openers.get('main')?.('write-1'); + }); + expect(findByType(renderer.root, 'Modal').filter(node => propOf(node, 'visible'))).toHaveLength( + 1 + ); + act(() => { + openers.get('subagent')?.('write-1'); + }); + expect(findByType(renderer.root, 'Modal').filter(node => propOf(node, 'visible'))).toHaveLength( + 2 + ); + expect(findByType(renderer.root, 'CenteredState')).toHaveLength(2); + expect(findByType(renderer.root, 'ScrollView')).toHaveLength(0); + act(() => { + renderer.unmount(); + }); + }); + it('defaults to wrap: Wrap radio selected, block renders no inner scroller', async () => { const renderer = await mountSheet(makeBashPart('bash-1', 'echo hi')); diff --git a/apps/mobile/src/components/agents/part-detail-sheet.tsx b/apps/mobile/src/components/agents/part-detail-sheet.tsx index c8af71e13f..e41bfd38b0 100644 --- a/apps/mobile/src/components/agents/part-detail-sheet.tsx +++ b/apps/mobile/src/components/agents/part-detail-sheet.tsx @@ -5,6 +5,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { i18n } from '@/i18n'; +import { CenteredState } from '@/components/centered-state'; import { SheetHeader } from '@/components/sheet-header'; import { SelectableText } from '@/components/ui/selectable-text'; import { Text } from '@/components/ui/text'; @@ -13,8 +14,13 @@ import { SegmentedControl } from '@/components/ui/segmented-control'; import { MONO_SCROLL_TEXT_MODE_OPTIONS, type MonoScrollTextMode } from './mono-scroll-block-model'; import { MonoScrollSheetProvider } from './mono-scroll-block'; import { SessionPageSheet } from './session-page-sheet'; -import { getPartDetailTitle, shouldAutoFollowPartDetail } from './part-detail-model'; +import { + getPartDetailTitle, + shouldAutoFollowPartDetail, + shouldCenterPartDetail, +} from './part-detail-model'; import { isPartStreaming, isReasoningPart, isToolPart } from './part-types'; +import { useToolCardImageUri } from './tool-card-image-cache'; import { ToolPartDetailBody } from './tool-part-detail-body'; import { usePartDetailAutoScroll } from './use-part-detail-auto-scroll'; @@ -32,9 +38,6 @@ function renderPartContent(part: Part | null): ReactNode { ); } - if (isToolPart(part)) { - return ; - } if (isReasoningPart(part)) { // While streaming, plain Text instead of SelectableText: the read-only // TextInput re-lays-out the whole growing UITextView on every tick @@ -77,6 +80,7 @@ export function PartDetailSheet({ visible, part, onClose }: Readonly('wrap'); const [monoCount, setMonoCount] = useState(0); + const [failedImage, setFailedImage] = useState<{ partId: string; uri: string } | null>(null); const trackMonoBlock = useCallback(() => { setMonoCount(count => count + 1); return () => { @@ -86,6 +90,7 @@ export function PartDetailSheet({ visible, part, onClose }: Readonly { if (!visible) { setTextMode('wrap'); + setFailedImage(null); } }, [visible]); const sheetContext = useMemo( @@ -96,6 +101,25 @@ export function PartDetailSheet({ visible, part, onClose }: Readonly + {part && isToolPart(part) ? ( + { + setFailedImage({ partId: part.id, uri }); + }} + /> + ) : ( + renderPartContent(part) + )} + + ); return ( @@ -119,23 +143,27 @@ export function PartDetailSheet({ visible, part, onClose }: Readonly ) : null} - - - {renderPartContent(part)} - - + {centered ? ( + + {content} + + ) : ( + + {content} + + )} diff --git a/apps/mobile/src/components/agents/picker-search.mounted.test.tsx b/apps/mobile/src/components/agents/picker-search.mounted.test.tsx new file mode 100644 index 0000000000..68db9edcb7 --- /dev/null +++ b/apps/mobile/src/components/agents/picker-search.mounted.test.tsx @@ -0,0 +1,142 @@ +import { act, createElement, type EffectCallback, type ReactNode, useEffect } from 'react'; +import { renderWithProviders } from '@/test/render-with-providers'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; + +import RepoPickerScreen from '@/app/(app)/agent-chat/repo-picker'; +import { ModelPickerContent } from '@/components/agents/model-picker-content'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; +import { type RepoOption } from '@/lib/picker-bridge'; +import { modelPickerSlot, repoPickerSlot, UNFENCED_ROUTE_KEY } from '@/lib/route-registry'; +import '@/i18n'; + +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('react-native', () => ({ + FlatList: 'FlatList', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + TextInput: 'TextInput', + View: 'View', +})); +vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: vi.fn() }), + useLocalSearchParams: () => ({}), + useFocusEffect: (effect: EffectCallback) => { + useEffect(effect, [effect]); + }, +})); +vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + Check: 'Check', + Info: 'Info', + Lock: 'Lock', + Search: 'Search', + SearchX: 'SearchX', + Unlock: 'Unlock', +})); +vi.mock('@/components/agents/model-selector', () => ({ + ModelPickerOptionRow: 'ModelPickerOptionRow', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/hooks/use-model-preferences', () => ({ + useModelPreferences: () => ({ favorites: [], addFavorite: vi.fn(), removeFavorite: vi.fn() }), +})); + +const model: SessionModelOption = { + id: 'model-1', + displayId: 'model-1', + name: 'Test model', + variants: [], + isPreferred: false, + showGatewayMetadata: false, +}; +const repo: RepoOption = { platform: 'github', fullName: 'org/repo', isPrivate: false }; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + modelPickerSlot.set(UNFENCED_ROUTE_KEY, { + options: [model], + currentValue: '', + currentVariant: '', + selectionScope: { + sessionId: UNFENCED_ROUTE_KEY, + ownerConnectionId: null, + protocol: 'unknown', + catalogGenerationIdentity: null, + }, + isSelectionCurrent: () => true, + onSelect: vi.fn<() => void>(), + }); + repoPickerSlot.set(UNFENCED_ROUTE_KEY, { + repositories: [repo], + sections: [{ key: 'github', titleKey: 'agentChat.repoPicker.platformGithub', repos: [repo] }], + currentValue: '', + onSelect: vi.fn<() => void>(), + }); +}); + +async function mount(Component: () => ReactNode) { + const mounted = await renderWithProviders(createElement(Component)); + onTestFinished(mounted.unmount); + return mounted.renderer; +} + +function hosts(renderer: Awaited>, type: string) { + return renderer.root.findAll(node => node.type === type); +} + +describe.each([ + { name: 'model', Component: ModelPickerContent }, + { name: 'repository', Component: RepoPickerScreen }, +])('$name picker centering', ({ Component }) => { + it('keeps the search input and native header mounted when replacing the list', async () => { + const renderer = await mount(Component); + const input = hosts(renderer, 'TextInput')[0]; + const header = hosts(renderer, 'SheetHeader')[0]; + if (!input || !header) { + throw new Error('Picker controls did not mount'); + } + const group = header.parent; + expect(group?.props.collapsable).toBe(false); + expect(group?.findAll(node => node === input)).toHaveLength(1); + expect(hosts(renderer, 'FlatList')).toHaveLength(1); + expect(hosts(renderer, 'CenteredState')).toHaveLength(0); + + const changeSearch = input.props.onChangeText as (text: string) => void; + act(() => { + changeSearch('no matching choice'); + }); + expect(hosts(renderer, 'FlatList')).toHaveLength(0); + expect(hosts(renderer, 'ScrollView')).toHaveLength(0); + expect(hosts(renderer, 'CenteredState')).toHaveLength(1); + expect(hosts(renderer, 'TextInput')[0]).toBe(input); + expect(hosts(renderer, 'SheetHeader')[0]).toBe(header); + expect(header.parent).toBe(group); + + act(() => { + changeSearch(''); + }); + expect(hosts(renderer, 'FlatList')).toHaveLength(1); + expect(hosts(renderer, 'CenteredState')).toHaveLength(0); + expect(hosts(renderer, 'TextInput')[0]).toBe(input); + expect(header.parent).toBe(group); + }); + + it('centers an empty catalog without nesting a list', async () => { + const modelBridge = modelPickerSlot.get(UNFENCED_ROUTE_KEY); + const repoBridge = repoPickerSlot.get(UNFENCED_ROUTE_KEY); + if (!modelBridge || !repoBridge) { + throw new Error('Picker bridge is missing'); + } + modelPickerSlot.set(UNFENCED_ROUTE_KEY, { ...modelBridge, options: [] }); + repoPickerSlot.set(UNFENCED_ROUTE_KEY, { ...repoBridge, repositories: [], sections: [] }); + const renderer = await mount(Component); + expect(hosts(renderer, 'CenteredState')).toHaveLength(1); + expect(hosts(renderer, 'FlatList')).toHaveLength(0); + expect(hosts(renderer, 'ScrollView')).toHaveLength(0); + expect(hosts(renderer, 'TextInput')).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/components/agents/session-detail-content.test.ts b/apps/mobile/src/components/agents/session-detail-content.test.ts index f2478b122d..9c78822088 100644 --- a/apps/mobile/src/components/agents/session-detail-content.test.ts +++ b/apps/mobile/src/components/agents/session-detail-content.test.ts @@ -12,6 +12,7 @@ import { type KiloSessionId, type SessionManager, type SessionSnapshotPageOutcome, + type SessionStatusIndicator, type StoredMessage, type ToolPart, } from '@kilocode/cloud-agent-sdk'; @@ -49,6 +50,7 @@ vi.mock('@/components/agents/session-provider', () => ({ // Keep the actual detail/card/sheet/header callbacks and SDK. Replace native // rendering and unrelated composer, account, model-picker, and router dependencies. const navigationRoutes = vi.hoisted(() => ['session-detail']); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('react-native', () => ({ View: 'View', Pressable: 'Pressable', @@ -555,6 +557,31 @@ describe('SessionDetailContent display scope', () => { }); }); +describe('session detail status placement', () => { + it.each(['progress', 'info'] as const)( + 'centers a %s status without transcript rows', + async type => { + const view = await mountDetails([]); + act(() => { + view.store.set( + view.manager.atoms.statusIndicator, + { + type, + message: 'Session status', + timestamp: 0, + } + ); + }); + const centered = view.renderer.root.findAll(node => Object.is(node.type, 'CenteredState')); + expect(centered).toHaveLength(1); + expect( + centered[0]?.findAll(node => Object.is(node.type, 'SessionStatusIndicator')) + ).toHaveLength(1); + expect(view.renderer.root.findAllByType(EmptyState)).toHaveLength(0); + } + ); +}); + describe.each([true, false])('session detail return with history=%s', hasHistory => { beforeEach(() => { if (hasHistory) { @@ -606,6 +633,10 @@ describe.each([true, false])('session detail return with history=%s', hasHistory const error = view.renderer.root.findByType(QueryError).props as ComponentProps< typeof QueryError >; + expect( + view.renderer.root.findAll(node => Object.is(node.type, 'CenteredState')) + ).toHaveLength(1); + expect(error.placement).toBe('top'); expect(error.variant).toBe(code === 'UNAUTHORIZED' ? 'permission' : 'server'); expect(Boolean(error.onRetry)).toBe(code !== 'UNAUTHORIZED'); expect(renderedText(view.renderer.root)).toContain('Back to sessions'); diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 25433ffab5..9c7c473ea9 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -74,6 +74,7 @@ import { import { shouldKeepSessionAwake } from '@/components/agents/session-keep-awake'; import { shouldRefetchOnFocus } from '@/components/agents/session-focus-refetch'; import { TranscriptTimeMarker } from '@/components/agents/transcript-time-marker'; +import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; import { @@ -1171,8 +1172,6 @@ export function SessionDetailContent({ messageCount: messages.length, }); - const emptyStateText = statusIndicator ? null : t('agentChat.session.emptyTitle'); - const isSessionLoaded = fetchedData?.kiloSessionId === sessionId; const serverTitle = isSessionLoaded ? (fetchedData.title ?? undefined) : undefined; const rename = useSessionDetailRename({ @@ -1663,54 +1662,60 @@ export function SessionDetailContent({ detail: terminalError.detail, }); return ( - - { - void manager.switchSession(sessionId); - } - : undefined - } - isRetrying={isLoading} - /> - - - + + + { + void manager.switchSession(sessionId); + } + : undefined + } + isRetrying={isLoading} + /> + + + + - + ); } if (shouldBlockMessages) { return ; } if (visibleMessages.length === 0) { + if (statusIndicator) { + return ( + + + + + + ); + } return ( - - {statusIndicator ? : null} - {emptyStateText ? ( - - ) : null} - + ); } return ( diff --git a/apps/mobile/src/components/agents/session-detail-queue.test.ts b/apps/mobile/src/components/agents/session-detail-queue.test.ts index c734c6c880..72571fef22 100644 --- a/apps/mobile/src/components/agents/session-detail-queue.test.ts +++ b/apps/mobile/src/components/agents/session-detail-queue.test.ts @@ -42,6 +42,8 @@ const hoisted = vi.hoisted(() => { // Mock every RN / Expo / SDK side-effect import that `mobile-session-manager.ts` // and `session-detail-content.tsx` pull in transitively before loading either // module. +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); vi.mock('expo-secure-store', () => ({ getItemAsync: vi.fn(), })); diff --git a/apps/mobile/src/components/agents/session-list-body-empty.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-body-empty.mounted.test.tsx index 7c626cf555..2aa40ab15f 100644 --- a/apps/mobile/src/components/agents/session-list-body-empty.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-body-empty.mounted.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BodyEmpty } from './session-list-body-empty'; +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('react-native', () => ({ View: 'View', })); diff --git a/apps/mobile/src/components/agents/session-list-body-empty.tsx b/apps/mobile/src/components/agents/session-list-body-empty.tsx index 58a027b4a3..94b5f38012 100644 --- a/apps/mobile/src/components/agents/session-list-body-empty.tsx +++ b/apps/mobile/src/components/agents/session-list-body-empty.tsx @@ -1,8 +1,9 @@ import { History, SearchX } from '@/components/ui/icons'; import { type ReactNode } from 'react'; -import { View } from 'react-native'; +import { type ScrollViewProps, View } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; @@ -12,75 +13,60 @@ type BodyEmptyProps = { secondaryAction?: 'clear-search' | 'clear-filters' | 'none'; clearQueryAction: ReactNode; onRetry: () => void; + refreshControl?: ScrollViewProps['refreshControl']; }; -/** - * Renders the body empty-state for the Agents session list, switched on the - * `kind` returned by the body render model. Each branch is a compact - * `View` matching the design language of the rest of the list (icon + title - * + description). `no-past-sessions` carries no CTA: creation is offered by - * the FAB/tray on the live screen. - */ export function BodyEmpty({ kind, isSearching, secondaryAction, clearQueryAction, onRetry, + refreshControl, }: Readonly) { const { t } = useTranslation(); if (kind === 'filtered-empty') { - // Active search/filter narrowed the results to zero matches — never - // show the "create a task" CTA here, it's not the fix for a filter - // that's too narrow. return ( - - - + ); } if (kind === 'query-error-empty') { - // The query in error produced no rows to show — surface a retry for - // it (search or list, whichever `onRetry` targets). A Clear CTA is - // shown whenever the model reports an active query, choosing the - // label that matches the query type. return ( - - - {secondaryAction === 'clear-search' || secondaryAction === 'clear-filters' - ? clearQueryAction - : null} - + + + + {secondaryAction === 'clear-search' || secondaryAction === 'clear-filters' + ? clearQueryAction + : null} + + ); } - // 'no-past-sessions' — body is empty but the screen offers creation via - // the FAB/tray, so no CTA is rendered here. return ( - - - + ); } diff --git a/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx index 1801c8f49d..354fbc13d2 100644 --- a/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx @@ -29,6 +29,7 @@ const controls = vi.hoisted(() => ({ renameSession: vi.fn(), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('react-native', async () => { const React = await import('react'); // Virtualized cells reuse their renderer until its identity changes. This @@ -330,6 +331,27 @@ describe('AgentSessionListContent liveness', () => { expect(hosts(renderer, 'AccessibleStatus')).toHaveLength(0); }); + it.each([ + { hasAnySessions: false }, + { hasAnySessions: false, isError: true }, + { hasActiveQuery: true, isSearching: true }, + { hasActiveQuery: true, isSearching: false }, + { hasActiveQuery: true, isSearching: true, isError: true }, + { hasActiveQuery: true, isSearching: false, isError: true }, + ])('centers a refreshable body outside the list for %j', async overrides => { + const props = contentProps(overrides); + const renderer = mount(props); + const centered = hosts(renderer, 'CenteredState'); + expect(centered).toHaveLength(1); + expect(hosts(renderer, 'SectionList')).toHaveLength(0); + const refresh = centered[0]?.props.refreshControl as ReactElement<{ onRefresh: () => void }>; + await act(async () => { + refresh.props.onRefresh(); + await Promise.resolve(); + }); + expect(props.refetch).toHaveBeenCalledOnce(); + }); + it('keeps the loading skeletons instead of flashing empty history', () => { const renderer = mount(contentProps({ isLoading: true, hasAnySessions: false })); expect(hosts(renderer, 'Skeleton')).toHaveLength(8); diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 7bff002e85..668ef26f83 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -206,12 +206,12 @@ export function AgentSessionListContent({ // Gated on !isLoading so a cold-open load never flashes this surface. if (surface.kind === 'full-screen-error') { return ( - - + + } + /> ); } @@ -222,16 +222,13 @@ export function AgentSessionListContent({ // not flash this while queries run. if (surface.kind === 'history-empty') { return ( - + } /> ); @@ -252,16 +249,19 @@ export function AgentSessionListContent({ ); } else if (surface.listEmpty === 'body-empty' && bodyModel.kind !== 'render-list') { - emptyComponent = ( - + return ( + + } + /> + ); } diff --git a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx index 295be95cf9..95b987da38 100644 --- a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { i18n } from '@/i18n'; import type * as PlatformFilterModule from './platform-filter-modal'; import { AgentSessionListScreen } from './session-list-screen'; +import { StateSurfaceInsets } from '@/components/centered-state-surface'; import { EmptyState } from '@/components/empty-state'; import { ScreenHeader } from '@/components/screen-header'; import { Text } from '@/components/ui/text'; @@ -53,6 +54,10 @@ vi.mock('@/lib/auth/account-metadata-write', () => ({ vi.mock('sonner-native', () => ({ toast: { error: vi.fn() }, })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ + StateSurfaceInsets: ({ children }: { children: ReactNode }): ReactNode => children, +})); vi.mock('react-native', () => ({ I18nManager: { isRTL: false }, Platform: { OS: 'ios' }, @@ -411,7 +416,11 @@ describe('AgentSessionListScreen live presentation', () => { expect(text().includes('Updating')).toBe(Boolean(test.updating)); expect(text().includes('Loading…')).toBe(Boolean(test.skeleton)); expect(nodes('FlatList')).toHaveLength(test.rows ? 1 : 0); - expect(nodes('ScrollView')).toHaveLength(test.empty ? 1 : 0); + expect(nodes('ScrollView')).toHaveLength(0); + expect(nodes('CenteredState')).toHaveLength(test.empty || (test.error && !test.rows) ? 1 : 0); + expect(root().findByType(StateSurfaceInsets).props.bottomInset).toBe( + state.tabBarHeight + (test.empty ? 0 : 64) + ); expect(state.liveQuery).toHaveBeenLastCalledWith({ organizationId: null, enabled: true }); expect(headerAction().props.testID).toBe('agents-view-history'); expect(headerAction().props.accessibilityRole).toBe('button'); @@ -428,50 +437,29 @@ describe('AgentSessionListScreen live presentation', () => { expect(state.destination).toBe('/(app)/agent-chat/new'); }); - it('compensates the empty state for measured controls and keeps large text scrollable', async () => { + it('uses shared scrolling and refresh while preserving the large-text creation action', async () => { state.topInset = 44; await renderScreen(); - const scroll = requireNode('ScrollView'); + const viewport = requireNode('CenteredState'); const emptyState = root().findByType(EmptyState); - const spacer = scroll.findByProps({ pointerEvents: 'none' }); - const onLayout = scroll.props.onLayout as (event: { - nativeEvent: { layout: { y: number } }; - }) => void; - expect(scroll.parent?.parent).toBe(header().parent); expect(header().parent?.children[0]).toBe(header()); expect(header().props.className).toContain('px-[22px]'); expect(header().props.context).toBeUndefined(); - expect(scroll.props.className).toBe('flex-1'); - expect(scroll.props.contentContainerClassName).toBe('grow justify-center py-4'); - expect(scroll.props.contentContainerStyle).toBeUndefined(); - expect(scroll.props.scrollEnabled).not.toBe(false); - expect(emptyState.props.placement).toBe('top'); - expect(emptyState.props.className).toBe('shrink-0 pt-0'); - expect(spacer.props.className).toBe('shrink-0'); - expect(spacer.props.style).toEqual({ height: 60 }); - - for (const { y, height } of [ - { y: 180, height: 196 }, - { y: 260, height: 276 }, - { y: 20, height: 60 }, - ]) { - act(() => { - onLayout({ nativeEvent: { layout: { y } } }); - }); - expect(spacer.props.style).toEqual({ height }); - } + expect(emptyState.props.placement).toBeUndefined(); + expect(nodes('ScrollView')).toHaveLength(0); + expect(root().findByType(StateSurfaceInsets).props.bottomInset).toBe(60); + const refreshControl = viewport.props.refreshControl as { props: { onRefresh: () => void } }; + await act(async () => { + refreshControl.props.onRefresh(); + await Promise.resolve(); + }); + expect(state.refetch).toHaveBeenCalledTimes(1); state.fontScale = 2; state.tabBarHeight = 84; await renderScreen(); - act(() => { - onLayout({ nativeEvent: { layout: { y: 260 } } }); - }); - expect(spacer.props.style).toEqual({ height: 300 }); - state.topInset = 64; - await renderScreen(); - expect(spacer.props.style).toEqual({ height: 280 }); + expect(root().findByType(StateSurfaceInsets).props.bottomInset).toBe(84); const createAction = action('New coding task'); const label = createAction.findByType(Text); expect(createAction.props.className).toContain('max-w-full'); @@ -635,29 +623,29 @@ describe('AgentSessionListScreen live presentation', () => { } ); - it('keeps the retained error and Retry mounted as socket rows appear and disappear', async () => { + it('preserves error recovery when switching between centered feedback and socket rows', async () => { state.live.hasAcceptedSuccess = false; state.live.terminalError = failure; await renderScreen(); const message = 'Could not load active sessions'; - const retry = action('Retry'); - const status = nodes('Text').find(node => node.children.includes(message)); - expect(status).toBeDefined(); - expect(nodes('AlertCircle')).toHaveLength(1); + expect(text()).toContain(message); + expect(nodes('CenteredState')).toHaveLength(1); async function updateSocketRows(activeSessions: ActiveSession[]) { state.live.activeSessions = activeSessions; await renderScreen(); expect(nodes('RemoteSessionRow')).toHaveLength(activeSessions.length); - expect.soft(action('Retry') === retry).toBe(true); - expect - .soft(nodes('Text').find(node => node.children.includes(message)) === status) - .toBe(true); - expect.soft(state.announcements).toEqual([message]); - expect(nodes('AlertCircle')).toHaveLength(activeSessions.length === 0 ? 1 : 0); + expect(nodes('CenteredState')).toHaveLength(activeSessions.length === 0 ? 1 : 0); + expect(text()).toContain(message); + expect(state.announcements).toEqual([message]); + await act(async () => { + press('Retry'); + await Promise.resolve(); + }); } await updateSocketRows([row]); await updateSocketRows([]); + expect(state.refetch).toHaveBeenCalledTimes(2); }); it('does not invent internet or retry activity for an unknown paused connection', async () => { @@ -934,11 +922,16 @@ describe('AgentSessionListScreen live filtering', () => { const emptyState = renderer.root.findByType(EmptyState); expect(emptyState.props.description).toBe('Try a different search term.'); + expect(nodes('CenteredState')).toHaveLength(1); + expect(nodes('FlatList')).toHaveLength(0); + expect(requireNode('SessionListSearchHeader')).toBe(searchHeader); act(() => { (emptyState.props.action as { props: { onPress: () => void } }).props.onPress(); }); expect(nodes('FlatList')).toHaveLength(1); + expect(nodes('CenteredState')).toHaveLength(0); + expect(requireNode('SessionListSearchHeader')).toBe(searchHeader); expect(headerAction('agents-open-filters').props.activeCount).toBe(1); }); @@ -1044,7 +1037,7 @@ describe('AgentSessionListScreen live filtering', () => { const tree = renderer.toJSON() as TestRenderer.ReactTestRendererJSON; expect( tree.children?.slice(0, 4).map(child => (typeof child === 'string' ? child : child.type)) - ).toEqual(['View', 'View', 'SessionListSearchHeader', 'FlatList']); + ).toEqual(['View', 'SessionListSearchHeader', 'View', 'FlatList']); } }); @@ -1242,6 +1235,43 @@ describe('Live list admission and lifecycle', () => { expect(state.destination).toBe('/(app)/(tabs)/(2_agents)/history'); }); + it('refreshes the organization error through the context and resumes session refresh after recovery', async () => { + state.organization.organizationId = 'org-1'; + state.boundary.isError = true; + state.boundary.orgs = undefined; + const pending = Promise.withResolvers(); + state.boundaryRefetch.mockReturnValue(pending.promise); + await renderScreen(); + const refresh = () => + nodes('CenteredState')[0]?.props.refreshControl as { + props: { refreshing: boolean; onRefresh: () => void }; + }; + act(() => { + refresh().props.onRefresh(); + }); + expect(state.boundaryRefetch).toHaveBeenCalledOnce(); + expect(state.refetch).not.toHaveBeenCalled(); + expect(refresh().props.refreshing).toBe(true); + await act(async () => { + pending.resolve(undefined); + await pending.promise; + }); + expect(refresh().props.refreshing).toBe(false); + + state.organization.organizationId = null; + state.live.activeSessions = [row]; + await renderScreen(); + const readyRefresh = nodes('FlatList')[0]?.props.refreshControl as { + props: { onRefresh: () => void }; + }; + await act(async () => { + readyRefresh.props.onRefresh(); + await Promise.resolve(); + }); + expect(state.refetch).toHaveBeenCalledOnce(); + expect(state.boundaryRefetch).toHaveBeenCalledOnce(); + }); + it('recovers membership through boundary Retry and revokes admission on an unresolved organization change', async () => { state.organization.organizationId = 'org-1'; state.boundary.isError = true; diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index 01330155a1..7055822ea9 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -13,6 +13,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { Bot, Plus } from '@/components/ui/icons'; +import { StateSurfaceInsets } from '@/components/centered-state-surface'; import { EmptyState } from '@/components/empty-state'; import { liveSessionContent, @@ -56,16 +57,16 @@ export function AgentSessionListScreen() { ); const context = useLiveSessionContext(); - const { organizationId } = context; + const { organizationId, isError: isContextError, refetch: refetchContext } = context; const sessions = useLiveAgentSessions({ organizationId, enabled: context.isReady }); const { activeSessions, refetch } = sessions; const content = liveSessionContent(context, sessions); const hasLiveRows = content === 'rows'; + const showFab = context.isReady && content !== 'empty'; const query = useLiveSessionQuery(activeSessions); const { visibleSessions, isSearching } = query; const [showFilterModal, setShowFilterModal] = useState(false); - const hasVisibleRows = visibleSessions.length > 0; const refetchRef = useRef(refetch); useEffect(() => { @@ -145,12 +146,12 @@ export function AgentSessionListScreen() { setRefreshing(true); try { // LiveSessionFeedback retains and announces failures without a duplicate toast. - await refetch(); + await (isContextError ? refetchContext() : refetch()); } finally { setRefreshing(false); } })(); - }, [refetch]); + }, [isContextError, refetchContext, refetch]); const renderItem = useCallback( ({ item }: { item: ActiveSession }) => ( @@ -164,8 +165,6 @@ export function AgentSessionListScreen() { [navigateToSession, organizationId] ); - const keyExtractor = useCallback((item: ActiveSession) => item.id, []); - // The tab bar is an absolutely-positioned overlay, so scrollable content // must clear it. The FAB adds its own inset when it shows so the last row // scrolls clear of the button too. @@ -198,11 +197,12 @@ export function AgentSessionListScreen() { ))} ); - } else if (hasLiveRows && !hasVisibleRows) { + } else if (hasLiveRows && visibleSessions.length === 0) { body = ( } description={ isSearching ? t('agents.sessionList.tryDifferentSearch') @@ -222,7 +222,10 @@ export function AgentSessionListScreen() { ); } else if (content === 'empty') { body = ( - + } + /> ); } else if (hasLiveRows) { body = ( @@ -230,7 +233,7 @@ export function AgentSessionListScreen() { ref={listRef} data={visibleSessions} renderItem={renderItem} - keyExtractor={keyExtractor} + keyExtractor={item => item.id} extraData={attentionFocusRevision} contentContainerStyle={listPadding} refreshControl={} @@ -240,65 +243,69 @@ export function AgentSessionListScreen() { } return ( - - - - + + + {hasLiveRows || isSearching ? ( + 0} + showSearchBusy={false} + showInlineError={false} + onChangeText={query.handleSearchChange} + onClearSearch={query.handleClearSearch} + /> + ) : null} + + } + /> + + {body} + {/* Empty content owns its creation action; other admitted states keep the FAB. */} + {showFab && ( + { + router.push(getNewAgentSessionPath(organizationId) as Href); + }} + className="absolute items-center justify-center rounded-full bg-primary shadow-lg shadow-[#00000040] active:opacity-80" + style={fabStyle} + > + + + )} + {showFilterModal && ( + { + setShowFilterModal(false); + }} + onApply={query.handleApplyFilters} + /> + )} - {hasLiveRows || isSearching ? ( - 0} - showSearchBusy={false} - showInlineError={false} - onChangeText={query.handleSearchChange} - onClearSearch={query.handleClearSearch} - /> - ) : null} - {body} - {/* Empty content owns its creation action; other admitted states keep the FAB. */} - {context.isReady && content !== 'empty' && ( - { - router.push(getNewAgentSessionPath(organizationId) as Href); - }} - className="absolute items-center justify-center rounded-full bg-primary shadow-lg shadow-[#00000040] active:opacity-80" - style={fabStyle} - > - - - )} - {showFilterModal && ( - { - setShowFilterModal(false); - }} - onApply={query.handleApplyFilters} - /> - )} - + ); } diff --git a/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx index 282769e64c..7c910235d1 100644 --- a/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx @@ -46,6 +46,7 @@ vi.mock('react-native', () => ({ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: safeAreaMock.useSafeAreaInsets, })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader', })); @@ -125,20 +126,27 @@ describe('SessionPageSheet mounted', () => { expect(modalNode.props.onRequestClose).toBe(onClose); expect(modalNode.props.onDismiss).toBe(onDismiss); - // No Android surface on iOS. - expect(findByTestID(renderer.root, 'session-page-sheet-surface')).toHaveLength(0); + const surface = findByTestID(renderer.root, 'session-page-sheet-surface'); + expect(surface).toHaveLength(1); + expect(surface[0]?.props.style).toBeUndefined(); renderer.unmount(); }); - it('renders children inside the iOS surface View', async () => { + it.each(['ios', 'android'])('uses a StateSurface as the %s Modal root', async platform => { + reactNativeMock.Platform.OS = platform; const renderer = await mountSheet({ children: createElement('SheetHeader', { title: 'Details' }), }); - - expect(findByType(renderer.root, 'SheetHeader')).toHaveLength(1); - - renderer.unmount(); + const surfaces = findByType(renderer.root, 'StateSurface'); + expect(surfaces).toHaveLength(1); + expect(surfaces[0]?.parent).toBe(modal(renderer.root)); + expect(surfaces[0]?.props.className).toBe('flex-1 bg-background'); + expect(findByType(renderer.root, 'SheetHeader')[0]?.parent).toBe(surfaces[0]); + expect(findByType(renderer.root, 'View')).toHaveLength(0); + act(() => { + renderer.unmount(); + }); }); it('renders an opaque full-window Modal on Android', async () => { @@ -185,31 +193,35 @@ describe('SessionPageSheet mounted', () => { renderer.unmount(); }); - it('force-closes the Modal on the privacy cover even when onClose leaves it open', async () => { - // MessageDetailsSheet passes a stacked closer that only pops its inner - // select-text view, so `visible` stays true and the native Modal would - // otherwise stay in the Recents snapshot. - const onClose = vi.fn<() => void>(); - const renderer = await mountSheet({ onClose }); - expect(modal(renderer.root).props.visible).toBe(true); - - await act(async () => { - await Promise.resolve(); - emitPrivacyCover(); - }); - expect(onClose).toHaveBeenCalledTimes(1); - expect(modal(renderer.root).props.visible).toBe(false); - - // The forced close releases on the next foreground, so the caller is not - // left holding a sheet that can never show again. - await act(async () => { - await Promise.resolve(); - reactNativeMock.emitAppState('active'); - }); - expect(modal(renderer.root).props.visible).toBe(true); - - renderer.unmount(); - }); + it.each(['ios', 'android'])( + 'force-closes the %s Modal on privacy cover and restores it on foreground', + async platform => { + reactNativeMock.Platform.OS = platform; + // MessageDetailsSheet passes a stacked closer that only pops its inner + // select-text view, so `visible` stays true and the native Modal would + // otherwise stay in the Recents snapshot. + const onClose = vi.fn<() => void>(); + const renderer = await mountSheet({ onClose }); + expect(modal(renderer.root).props.visible).toBe(true); + + await act(async () => { + await Promise.resolve(); + emitPrivacyCover(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(modal(renderer.root).props.visible).toBe(false); + + // The forced close releases on the next foreground, so the caller is not + // left holding a sheet that can never show again. + await act(async () => { + await Promise.resolve(); + reactNativeMock.emitAppState('active'); + }); + expect(modal(renderer.root).props.visible).toBe(true); + + renderer.unmount(); + } + ); it('keeps SheetHeader as the first surface child and routes Done to onClose', async () => { reactNativeMock.Platform.OS = 'android'; diff --git a/apps/mobile/src/components/agents/session-page-sheet.tsx b/apps/mobile/src/components/agents/session-page-sheet.tsx index be1e09062c..d89e40a933 100644 --- a/apps/mobile/src/components/agents/session-page-sheet.tsx +++ b/apps/mobile/src/components/agents/session-page-sheet.tsx @@ -1,7 +1,8 @@ import { type ReactNode, useEffect, useState } from 'react'; -import { AppState, Modal, Platform, View } from 'react-native'; +import { AppState, Modal, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StateSurface } from '@/components/centered-state-surface'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { subscribePrivacyCover } from '@/lib/privacy-cover-events'; @@ -74,7 +75,9 @@ export function SessionPageSheet({ onRequestClose={onClose} onDismiss={onDismiss} > - {children} + + {children} + ); } @@ -86,13 +89,13 @@ export function SessionPageSheet({ animationType="slide" onRequestClose={onClose} > - {children} - + ); } diff --git a/apps/mobile/src/components/agents/session-row.mounted.test.tsx b/apps/mobile/src/components/agents/session-row.mounted.test.tsx index 1515c5cdcd..aea26a5c7a 100644 --- a/apps/mobile/src/components/agents/session-row.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-row.mounted.test.tsx @@ -69,6 +69,7 @@ vi.mock('@/components/rename-modal', () => ({ RenameModal: 'RenameModal' })); vi.mock('@/components/destination-option-row', () => ({ DestinationOptionRow: 'DestinationOptionRow', })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/agents/session-list-section-header', () => ({ SessionListSectionHeader: 'SessionListSectionHeader', diff --git a/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx b/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx index a079472d32..f389f040aa 100644 --- a/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx +++ b/apps/mobile/src/components/agents/tool-card-image-attachments.mounted.test.tsx @@ -237,6 +237,27 @@ describe('ToolCardImageAttachments mounted', () => { await unmount(renderer); }); + it('clears a standalone decode failure when the cached URI changes', async () => { + seedImageCache(); + const renderer = await mount(makeToolPart([makeAttachment('att-1', 'image/png', '')])); + const image = renderer.root.find(node => (node.type as string) === 'Image'); + act(() => { + (image.props.onError as () => void)(); + }); + expect(texts(renderer.root)).toContain('Image unavailable'); + act(() => { + __resetToolCardImageCacheForTests(); + cacheToolAttachment('part-1', { + mime: 'image/png', + dataUrl: 'data:image/png;base64,QUJD', + filename: 'replacement.png', + }); + }); + expect(previewButtons(renderer.root)).toHaveLength(1); + expect(texts(renderer.root)).not.toContain('Image unavailable'); + await unmount(renderer); + }); + it('flips the unavailable row to a preview when the cache write lands after mount', async () => { const renderer = await mount(makeToolPart([makeAttachment('att-1', 'image/png', '')])); const root = renderer.root; diff --git a/apps/mobile/src/components/agents/tool-card-image-attachments.tsx b/apps/mobile/src/components/agents/tool-card-image-attachments.tsx index 8c8c81343a..c4a3dcabc3 100644 --- a/apps/mobile/src/components/agents/tool-card-image-attachments.tsx +++ b/apps/mobile/src/components/agents/tool-card-image-attachments.tsx @@ -37,17 +37,23 @@ function UnavailableRow({ ); } +type ToolCardImageAttachmentsProps = { + part: ToolPart; + imageFailed?: boolean; + onImageError?: (uri: string) => void; +}; + function ToolCardImageAttachment({ part, label, -}: Readonly<{ - part: ToolPart; - label: string; -}>) { + imageFailed, + onImageError, +}: Readonly) { const { t } = useTranslation(); const uri = useToolCardImageUri(part.id); const [aspectRatio, setAspectRatio] = useState(undefined); - const [failed, setFailed] = useState(false); + const [failedUri, setFailedUri] = useState(undefined); + const failed = imageFailed ?? failedUri === uri; const [viewerVisible, setViewerVisible] = useState(false); if (uri === undefined) { @@ -86,7 +92,8 @@ function ToolCardImageAttachment({ setAspectRatio(resolveImagePreviewAspectRatio(event.source.width, event.source.height)); }} onError={() => { - setFailed(true); + setFailedUri(uri); + onImageError?.(uri); }} /> @@ -104,7 +111,11 @@ function ToolCardImageAttachment({ ); } -export function ToolCardImageAttachments({ part }: Readonly<{ part: ToolPart }>) { +export function ToolCardImageAttachments({ + part, + imageFailed, + onImageError, +}: Readonly) { const attachments = getToolImageAttachments(part); if (attachments.length === 0) { return null; @@ -120,7 +131,13 @@ export function ToolCardImageAttachments({ part }: Readonly<{ part: ToolPart }>) return ( - + ); } diff --git a/apps/mobile/src/components/agents/tool-part-detail-body.test.ts b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts index c23b779d23..f79c31fe58 100644 --- a/apps/mobile/src/components/agents/tool-part-detail-body.test.ts +++ b/apps/mobile/src/components/agents/tool-part-detail-body.test.ts @@ -5,6 +5,7 @@ import type * as ReactI18next from 'react-i18next'; import { ToolPartDetailBody } from './tool-part-detail-body'; import { SuggestToolCardBody } from './suggest-tool-card'; +import { shouldCenterPartDetail } from './part-detail-model'; import { BashToolCardBody, EditToolCardBody, @@ -151,25 +152,8 @@ function findByType(node: unknown, type: string | ToolBody): React.ReactElement[ return findAll(node, el => el.type === type); } -function orderedTypes(node: unknown): (string | React.ComponentType)[] { - const types: (string | React.ComponentType)[] = []; - function walk(value: unknown): void { - if (value == null || typeof value === 'string' || typeof value === 'number') { - return; - } - if (Array.isArray(value)) { - for (const child of value) { - walk(child); - } - return; - } - if (React.isValidElement(value)) { - types.push(value.type as string | React.ComponentType); - walk((value.props as Record).children); - } - } - walk(node); - return types; +function orderedTypes(node: unknown): React.ReactElement['type'][] { + return findAll(node, () => true).map(el => el.type); } const textChildren = (el: React.ReactElement): unknown => @@ -223,17 +207,24 @@ describe('ToolPartDetailBody routing', () => { expect(findByType(root, GenericToolCardBody)).toHaveLength(1); }); - it('renders attachments above the body when present', () => { + it('renders attachments above the body and forwards decode-failure props', () => { getToolImageAttachments.mockReturnValue([makeFilePart('img-1', 'image/png')]); getToolFileAttachments.mockReturnValue([makeFilePart('file-1', 'application/pdf')]); + const part = makeToolPart('bash', completedState); + const onImageError = vi.fn<(uri: string) => void>(); // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call - const root = ToolPartDetailBody({ part: makeToolPart('bash', completedState) }); + const root = ToolPartDetailBody({ part, imageFailed: true, onImageError }); expect(orderedTypes(root)).toEqual([ 'View', 'ToolCardImageAttachments', 'ToolCardFileAttachments', 'BashToolCardBody', ]); + expect(findByType(root, 'ToolCardImageAttachments')[0]?.props).toEqual({ + part, + imageFailed: true, + onImageError, + }); }); }); @@ -289,6 +280,43 @@ describe('ToolPartDetailBody status line', () => { }); }); +describe('ToolPartDetailBody error placement', () => { + beforeEach(() => { + getToolImageAttachments.mockReturnValue([]); + getToolFileAttachments.mockReturnValue([]); + }); + + it.each(routingTable.filter(([tool]) => tool !== 'suggest'))( + 'centers an error-only %s body', + tool => { + expect(shouldCenterPartDetail(makeToolPart(tool, errorState), false)).toBe(true); + } + ); + + it('centers an error-only generic body', () => { + expect(shouldCenterPartDetail(makeToolPart('custom_tool', errorState), false)).toBe(true); + }); + + it.each([ + ['bash', { command: 'pwd' }], + ['edit', { oldString: 'old' }], + ['edit', { newString: 'new' }], + ['patch', { patchText: 'patch' }], + ['apply_patch', { patchText: 'patch' }], + ['custom_tool', { query: 'data' }], + ['write', { content: 'file' }], + ['todoread', { todos: [{ content: 'Task' }] }], + ['todowrite', { todos: [{ content: 'Task' }] }], + ] satisfies [string, Record][])( + 'keeps a %s error inline with content', + (tool, input) => { + expect(shouldCenterPartDetail(makeToolPart(tool, { ...errorState, input }), false)).toBe( + false + ); + } + ); +}); + describe('BashToolCardBody streaming contract', () => { it('renders the $ command block while running with a short command', () => { // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call diff --git a/apps/mobile/src/components/agents/tool-part-detail-body.tsx b/apps/mobile/src/components/agents/tool-part-detail-body.tsx index 169691389c..0919f1e885 100644 --- a/apps/mobile/src/components/agents/tool-part-detail-body.tsx +++ b/apps/mobile/src/components/agents/tool-part-detail-body.tsx @@ -71,7 +71,11 @@ function renderToolBody(part: ToolPart): React.ReactNode { } } -export function ToolPartDetailBody({ part }: Readonly<{ part: ToolPart }>) { +export function ToolPartDetailBody({ + part, + imageFailed, + onImageError, +}: Readonly<{ part: ToolPart; imageFailed?: boolean; onImageError?: (uri: string) => void }>) { const { t } = useTranslation(); const status = part.state.status; @@ -83,7 +87,13 @@ export function ToolPartDetailBody({ part }: Readonly<{ part: ToolPart }>) { {status === 'running' ? ( {t('agentChat.partDetail.running')} ) : null} - {getToolImageAttachments(part).length > 0 ? : null} + {getToolImageAttachments(part).length > 0 ? ( + + ) : null} {getToolFileAttachments(part).length > 0 ? : null} {renderToolBody(part)} diff --git a/apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx b/apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx index f817211eca..0e9dc64d32 100644 --- a/apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx +++ b/apps/mobile/src/components/app-unlock-screen.layout.mounted.test.tsx @@ -19,12 +19,8 @@ it('centers the unlock content and keeps a separate gap before Retry', async () const copy = heading.parent; const content = copy?.parent; expect(content?.props.className).toContain('gap-8'); - expect(content?.parent?.props.contentContainerStyle).toMatchObject({ - flexGrow: 1, - justifyContent: 'center', - paddingTop: 48, - paddingBottom: 36, - }); + expect(content?.parent?.type).toBe('CenteredState'); + expect(content?.props.style).toEqual({ paddingLeft: 24, paddingRight: 24 }); expect(content?.findAll(node => node === retry())).toHaveLength(1); expect(copy?.findAll(node => node === retry())).toHaveLength(0); expect(retry()?.props.accessibilityLabel).toBe('Retry'); diff --git a/apps/mobile/src/components/app-unlock-screen.mounted.test.tsx b/apps/mobile/src/components/app-unlock-screen.mounted.test.tsx index ca1c72027e..46fafb4012 100644 --- a/apps/mobile/src/components/app-unlock-screen.mounted.test.tsx +++ b/apps/mobile/src/components/app-unlock-screen.mounted.test.tsx @@ -18,12 +18,8 @@ import { text, unmountUnlock, } from '@/components/app-unlock-screen.test-helpers'; -import { appUnlockScreenLayout } from '@/components/app-unlock-screen'; -import { PickerSheet } from '@/components/picker-sheet'; import { PreferencesScreen } from '@/components/preferences-screen'; -import { SheetHeader } from '@/components/sheet-header'; import { type ElementType } from 'react'; -import { ScrollView } from 'react-native'; import { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { i18n } from '@/i18n'; @@ -125,25 +121,6 @@ it.each([false, true])( } ); -it.each([true, false])('keeps native sheet siblings; scrollable=%s', async scrollable => { - const children = ( - void>()} scrollable={scrollable}> - {scrollable ? null : } - - ); - await mount(appUnlockScreenLayout({ children })); - const sheet = root().findByType(PickerSheet); - expect(sheet.parent?.props).toEqual({ - children, - className: 'flex-1', - pointerEvents: 'auto', - accessibilityElementsHidden: false, - importantForAccessibility: 'auto', - }); - expect(sheet.parent?.parent?.type).not.toBe('View'); - expect(sheet.children).toMatchObject([{ type: SheetHeader }, { type: 'ScrollView' }]); -}); - it.each([null, 'disabled'])('shows the scene without a prompt for %s', async raw => { storage.getItemAsync.mockResolvedValue(raw); await mount(); diff --git a/apps/mobile/src/components/app-unlock-screen.sheet.mounted.test.tsx b/apps/mobile/src/components/app-unlock-screen.sheet.mounted.test.tsx new file mode 100644 index 0000000000..d337b56a64 --- /dev/null +++ b/apps/mobile/src/components/app-unlock-screen.sheet.mounted.test.tsx @@ -0,0 +1,37 @@ +import { + mount, + resetUnlockMocks, + unlockRoot, + unlockScene, + unmountUnlock, +} from '@/components/app-unlock-screen.test-helpers'; +import { PickerSheet } from '@/components/picker-sheet'; +import { SheetHeader } from '@/components/sheet-header'; +import { ScrollView } from 'react-native'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +beforeEach(resetUnlockMocks); +afterEach(unmountUnlock); + +it.each([true, false])('keeps native sheet siblings; scrollable=%s', async scrollable => { + const children = ( + void>()} scrollable={scrollable}> + {scrollable ? null : } + + ); + await mount(unlockScene(children)); + const sheet = unlockRoot().findByType(PickerSheet); + expect(sheet.parent?.props).toEqual({ + children, + className: 'flex-1', + pointerEvents: 'auto', + accessibilityElementsHidden: false, + importantForAccessibility: 'auto', + }); + expect(sheet.parent?.parent?.type).not.toBe('View'); + expect(sheet.children).toMatchObject([ + { type: 'View', props: { collapsable: false } }, + { type: 'ScrollView' }, + ]); + expect(sheet.findByType(SheetHeader).parent?.props.collapsable).toBe(false); +}); diff --git a/apps/mobile/src/components/app-unlock-screen.test-assertions.ts b/apps/mobile/src/components/app-unlock-screen.test-assertions.ts new file mode 100644 index 0000000000..ebbda32476 --- /dev/null +++ b/apps/mobile/src/components/app-unlock-screen.test-assertions.ts @@ -0,0 +1,27 @@ +import { type ElementType } from 'react'; +import { expect } from 'vitest'; +import { type unlockRoot } from './app-unlock-screen.test-helpers'; + +type Root = ReturnType; + +export function text(root: Root) { + const texts = root.findAllByType('Text' as ElementType); + return texts.map(node => node.props.children).join('\n'); +} + +export function expectHidden(root: Root, hidden: boolean) { + const scenes = root.findAllByType('Scene' as ElementType); + expect(scenes.length).toBeGreaterThan(0); + for (const scene of scenes) { + const wrapper = scene.find( + node => (node.type as string) === 'View' && node.props.pointerEvents !== undefined + ); + expect(wrapper.props).toMatchObject({ + pointerEvents: hidden ? 'none' : 'auto', + accessibilityElementsHidden: hidden, + importantForAccessibility: hidden ? 'no-hide-descendants' : 'auto', + }); + expect((wrapper.props.className as string).includes('opacity-0')).toBe(hidden); + expect(wrapper.findAllByType('Draft' as ElementType)).toHaveLength(1); + } +} diff --git a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx index 08ac8feb41..b0cd6dc884 100644 --- a/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx +++ b/apps/mobile/src/components/app-unlock-screen.test-helpers.tsx @@ -91,6 +91,11 @@ vi.mock('react-native-reanimated', () => ({ useAnimatedStyle: (build: () => unknown) => build(), })); vi.mock('expo-screen-capture', () => ({})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ + NativeStateSurface: ({ children }: { children: ReactElement }) => children, + StateSurface: 'StateSurface', +})); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/icons', () => ({ Bell: 'Icon', @@ -110,7 +115,7 @@ vi.mock('expo-router', () => ({ // One mounted descriptor per navigator exercises its production callback. Stack: Object.assign( ({ screenLayout }: { screenLayout: typeof appUnlockScreenLayout }) => - createElement('Scene', null, screenLayout({ children: })), + createElement('Scene', null, unlockScene(, screenLayout)), { Screen: 'StackScreen' } ), useRouter: () => ({ push: vi.fn() }), @@ -259,31 +264,7 @@ export async function flush(update?: () => void) { }); } -export function text(root: ReactTestInstance) { - const texts = root.findAllByType('Text' as ElementType); - return texts.map(node => node.props.children).join('\n'); -} - -export function expectHidden(root: ReactTestInstance, hidden: boolean) { - const scenes = root.findAllByType('Scene' as ElementType); - expect(scenes.length).toBeGreaterThan(0); - for (const scene of scenes) { - const wrapper = scene.find( - node => (node.type as string) === 'View' && node.props.pointerEvents !== undefined - ); - expect(wrapper.props).toMatchObject({ - pointerEvents: hidden ? 'none' : 'auto', - accessibilityElementsHidden: hidden, - importantForAccessibility: hidden ? 'no-hide-descendants' : 'auto', - }); - expect((wrapper.props.className as string).includes('opacity-0')).toBe(hidden); - expect(wrapper.findAllByType('Draft' as ElementType)).toHaveLength(1); - } -} - -export function nestedUnlockScenes(children: ReactElement) { - return appUnlockScreenLayout({ children: appUnlockScreenLayout({ children }) }); -} +export { expectHidden, text } from './app-unlock-screen.test-assertions'; function isHidden(node: ReactTestInstance): boolean { for (let parent = node.parent; parent; parent = parent.parent) { @@ -308,3 +289,12 @@ export function expectFeedback(root: ReactTestInstance, message: string, copies: platform.OS === 'android' ? 'polite' : undefined ); } + +export function unlockScene(children: ReactElement, layout = appUnlockScreenLayout) { + const props = { children }; + return layout(props as Parameters[0]); +} + +export function nestedUnlockScenes(children: ReactElement) { + return unlockScene(unlockScene(children)); +} diff --git a/apps/mobile/src/components/app-unlock-screen.tsx b/apps/mobile/src/components/app-unlock-screen.tsx index 0fa486e0be..f2292ea032 100644 --- a/apps/mobile/src/components/app-unlock-screen.tsx +++ b/apps/mobile/src/components/app-unlock-screen.tsx @@ -1,8 +1,10 @@ -import { type ReactElement } from 'react'; -import { Platform, ScrollView, View } from 'react-native'; +import { type ComponentProps, type ReactElement } from 'react'; +import { Platform, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { type EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { CenteredState } from '@/components/centered-state'; +import { NativeStateSurface, StateSurface } from '@/components/centered-state-surface'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -65,15 +67,8 @@ export function AppUnlockFeedback({ outcome }: Readonly<{ outcome: UnlockOutcome return feedback; } -function contentPadding({ top, bottom, left, right }: EdgeInsets) { - return { - flexGrow: 1, - justifyContent: 'center' as const, - paddingTop: top + 24, - paddingBottom: bottom + 24, - paddingLeft: left + 24, - paddingRight: right + 24, - }; +function contentPadding({ left, right }: EdgeInsets) { + return { paddingLeft: left + 24, paddingRight: right + 24 }; } function AppUnlockScene({ children }: Readonly<{ children: ReactElement }>) { @@ -95,9 +90,9 @@ function AppUnlockScene({ children }: Readonly<{ children: ReactElement }>) { {children} {hidden ? ( - - - + + + {t('preferences.biometricUnlock')} @@ -122,8 +117,8 @@ function AppUnlockScene({ children }: Readonly<{ children: ReactElement }>) { )} - - + + ) : null} ); @@ -132,6 +127,11 @@ function AppUnlockScene({ children }: Readonly<{ children: ReactElement }>) { /** Presentation only: one provider owns authentication across every native Stack scene. */ export function appUnlockScreenLayout({ children, -}: Readonly<{ children: ReactElement }>): ReactElement { - return {children}; + ...props +}: ComponentProps): ReactElement { + return ( + + {children} + + ); } diff --git a/apps/mobile/src/components/bootstrap-error-screen.tsx b/apps/mobile/src/components/bootstrap-error-screen.tsx index 9d8061d097..9c2440785c 100644 --- a/apps/mobile/src/components/bootstrap-error-screen.tsx +++ b/apps/mobile/src/components/bootstrap-error-screen.tsx @@ -1,6 +1,6 @@ -import { ScrollView, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -25,14 +25,9 @@ export function BootstrapErrorScreen({ secondaryAccessibilityLabel, onSecondaryPress, }: BootstrapErrorScreenProps) { - const { top, bottom } = useSafeAreaInsets(); return ( - - + + {title} {description} @@ -50,25 +45,7 @@ export function BootstrapErrorScreen({ {secondaryLabel} - - + + ); } - -type Insets = { readonly top: number; readonly bottom: number }; - -const VERTICAL_GUTTER = 24; -const HORIZONTAL_GUTTER = 24; -const CONTENT_GAP = 16; - -function makeContentContainerStyle({ top, bottom }: Insets) { - return { - flexGrow: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - gap: CONTENT_GAP, - paddingHorizontal: HORIZONTAL_GUTTER, - paddingTop: top + VERTICAL_GUTTER, - paddingBottom: bottom + VERTICAL_GUTTER, - }; -} diff --git a/apps/mobile/src/components/centered-state.mounted.test.tsx b/apps/mobile/src/components/centered-state.mounted.test.tsx index 7f965469be..417a6f9c0c 100644 --- a/apps/mobile/src/components/centered-state.mounted.test.tsx +++ b/apps/mobile/src/components/centered-state.mounted.test.tsx @@ -15,9 +15,13 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '@/test/render-with-providers'; +import { SearchX } from '@/components/ui/icons'; import { CenteredState } from './centered-state'; import { type useStateSurface } from './centered-state-surface'; +import { EmptyState } from './empty-state'; +import { InvalidRouteState } from './invalid-route-state'; +import { QueryError } from './query-error'; type ScrollNode = NonNullable>; type Measurement = Parameters[0]; @@ -52,6 +56,22 @@ vi.mock('react-native', () => ({ }, })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: () => null, + Lock: () => null, + SearchX: () => null, + ServerCrash: () => null, + WifiOff: () => null, +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#777777' }), +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('expo-router', () => ({ useRouter: () => ({ replace: vi.fn<() => void>() }) })); + const contentLayout: Partial = { nativeEvent: { layout: { x: 0, y: 0, width: 400, height: 700 } }, }; @@ -118,6 +138,50 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe('Shared state placement', () => { + it.each([undefined, 'center', 'top'] as const)( + 'owns one scroller only for centered placement: %s', + async placement => { + const mounted = await renderWithProviders( + + ); + expect(mounted.renderer.root.findAllByType(CenteredState)).toHaveLength( + placement === 'top' ? 0 : 1 + ); + expect(mounted.renderer.root.findAllByType(ScrollView)).toHaveLength( + placement === 'top' ? 0 : 1 + ); + mounted.unmount(); + } + ); + + it('keeps an invalid route state directly scrollable beneath a native sheet header', async () => { + const mounted = await renderWithProviders(); + expect(mounted.renderer.toJSON()).toMatchObject({ type: 'ScrollView' }); + mounted.unmount(); + }); + + it('keeps retry and refresh on the centered error body', async () => { + const onRetry = vi.fn<() => void>(); + const refreshControl = createElement('RefreshControl', { + refreshing: false, + onRefresh: vi.fn(), + }); + const mounted = await renderWithProviders( + + ); + expect(mounted.renderer.root.findAllByType(ScrollView)).toHaveLength(1); + expect(mounted.renderer.root.findByType(ScrollView).props.refreshControl).toBe(refreshControl); + const retry = mounted.renderer.root.findByProps({ accessibilityLabel: 'common.retry' }) + .props as { + onPress: () => void; + }; + retry.onPress(); + expect(onRetry).toHaveBeenCalledOnce(); + mounted.unmount(); + }); +}); + describe('CenteredState measurements', () => { it.each([false, true])( 'applies the provider native fill policy %s', diff --git a/apps/mobile/src/components/centered-state.tsx b/apps/mobile/src/components/centered-state.tsx index a614820b69..1477a18485 100644 --- a/apps/mobile/src/components/centered-state.tsx +++ b/apps/mobile/src/components/centered-state.tsx @@ -14,7 +14,12 @@ type CenteredStateProps = { type MeasuredViewport = { frame: StateFrame; surface: StateFrame }; -export function CenteredState({ children, className, testID, refreshControl }: CenteredStateProps) { +export function CenteredState({ + children, + className, + testID = 'centered-state', + refreshControl, +}: CenteredStateProps) { const surface = useStateSurface(); const frame = surface?.frame; const scrollRef = useRef(null); diff --git a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx index f467a3643a..1a407454ef 100644 --- a/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx +++ b/apps/mobile/src/components/code-reviewer/bitbucket-overview.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Switch, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import { CenteredState } from '@/components/centered-state'; import { openModelPicker } from '@/components/agents/model-selector'; import { BitbucketConnectForm } from '@/components/code-reviewer/bitbucket-connect-form'; import { @@ -16,7 +17,7 @@ import { Button } from '@/components/ui/button'; import { ConfigureRow } from '@/components/ui/configure-row'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; -import { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { PLATFORM_CAPABILITIES } from '@/lib/code-reviewer-config'; import { WEB_BASE_URL } from '@/lib/config'; import { openExternalUrl } from '@/lib/external-link'; @@ -47,7 +48,6 @@ export function BitbucketOverview({ }>) { const router = useRouter(); const { t } = useTranslation(); - const paddingBottom = useTabBarBottomPadding(); const readiness = useBitbucketReadiness(scope); const save = useSaveReviewConfig(scope, 'bitbucket'); const { models, isLoading: modelsLoading } = useAvailableModels(scope); @@ -64,14 +64,12 @@ export function BitbucketOverview({ return ( - - { - providerState.refetch(); - }} - isRetrying={providerState.isRetrying} - /> - + { + providerState.refetch(); + }} + isRetrying={providerState.isRetrying} + /> ); } @@ -87,14 +85,29 @@ export function BitbucketOverview({ return ( - - { - void config.refetch(); - }} - isRetrying={config.isFetching} - /> - + { + void config.refetch(); + }} + isRetrying={config.isFetching} + /> + + ); + } + + if (!isLoading && !connected) { + return ( + + + + {canEdit ? ( + + ) : ( + + {t('codeReviewer.bitbucket.notConnectedReadOnly')} + + )} + ); } @@ -152,18 +165,6 @@ export function BitbucketOverview({ )} - {!isLoading && !connected && ( - - {canEdit ? ( - - ) : ( - - {t('codeReviewer.bitbucket.notConnectedReadOnly')} - - )} - - )} - {!isLoading && connected && config.data != null && rows != null && ( {readiness.data?.ready === false && ( diff --git a/apps/mobile/src/components/code-reviewer/full-surface-states.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/full-surface-states.mounted.test.tsx new file mode 100644 index 0000000000..f59814a3a8 --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/full-surface-states.mounted.test.tsx @@ -0,0 +1,200 @@ +import { createElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import ReposRoute from '@/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos'; +import { ReviewListScreen } from './review-list-screen'; +import { renderWithProviders } from '@/test/render-with-providers'; + +const state = vi.hoisted(() => ({ + platform: 'gitlab', + connected: false, + repositories: { + data: { repositories: [] } as unknown, + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + }, + bitbucket: { + data: { repositoryCache: { status: 'available', repositories: [] } } as unknown, + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + }, + reviews: { + data: { success: true, reviews: [] } as unknown, + isLoading: false, + isError: false, + isFetching: false, + error: { data: { code: 'INTERNAL_SERVER_ERROR' } }, + refetch: vi.fn(), + }, + push: vi.fn(), +})); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ scope: 'personal', platform: state.platform }), + useRouter: () => ({ push: state.push }), +})); +vi.mock('react-native', () => ({ View: 'View', Pressable: 'Pressable' })); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: vi.fn() }, + FadeOut: { duration: vi.fn() }, + LinearTransition: {}, +})); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); +vi.mock('@/components/repo-toggle-row', () => ({ RepoToggleRow: 'RepoToggleRow' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/choice-row', () => ({ ChoiceRow: 'ChoiceRow' })); +vi.mock('@/components/ui/radio-group', () => ({ RadioGroup: 'RadioGroup' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ + FolderGit2: 'FolderGit2', + GitPullRequest: 'GitPullRequest', +})); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://example.test' })); +vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() })); +vi.mock('@/lib/trpc', () => ({ trpcClient: {} })); +vi.mock('@/lib/hooks/use-code-reviewer', () => ({ + PERSONAL_SCOPE: 'personal', + useGitHubStatus: () => ({ data: { connected: state.connected } }), + useGitLabStatus: () => ({ data: { connected: false } }), + useReviewConfig: () => ({ + data: { repositorySelectionMode: 'selected', selectedRepositoryIds: [] }, + }), + useSaveReviewConfig: () => ({ mutate: vi.fn() }), + useGitHubRepositories: () => state.repositories, + useGitLabRepositories: () => state.repositories, + useBitbucketReadiness: () => state.bitbucket, +})); +vi.mock('@/lib/hooks/use-code-reviewer-repo-selection', () => ({ + useRepoSelectionToggle: () => vi.fn(), +})); +vi.mock('@/lib/hooks/use-code-reviews', () => ({ useReviewList: () => state.reviews })); +vi.mock('@/lib/hooks/use-route-foreground-refresh', () => ({ useRouteForegroundRefresh: vi.fn() })); + +beforeEach(() => { + state.platform = 'gitlab'; + state.connected = false; + state.repositories.data = { repositories: [] }; + state.repositories.isError = false; + state.bitbucket.data = { repositoryCache: { status: 'available', repositories: [] } }; + state.bitbucket.isError = false; + state.reviews.data = { success: true, reviews: [] }; + state.reviews.isError = false; + state.reviews.error.data.code = 'INTERNAL_SERVER_ERROR'; + vi.clearAllMocks(); +}); + +describe('Reviewer repository bodies', () => { + it.each(['gitlab', 'bitbucket'])( + 'centers the %s empty body outside its scroller', + async platform => { + state.platform = platform; + const { renderer, unmount } = await renderWithProviders(createElement(ReposRoute)); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + expect(renderer.root.find(node => String(node.type) === 'EmptyState').props.placement).toBe( + 'center' + ); + unmount(); + } + ); + + it('keeps the GitHub empty state beside its selection controls', async () => { + state.platform = 'github'; + const { renderer, unmount } = await renderWithProviders(createElement(ReposRoute)); + const scroll = renderer.root.find(node => String(node.type) === 'ScrollView'); + expect(scroll.find(node => String(node.type) === 'RadioGroup')).toBeDefined(); + expect(scroll.find(node => String(node.type) === 'EmptyState').props.placement).toBe('top'); + unmount(); + }); + + it('centers missing Bitbucket setup', async () => { + state.platform = 'bitbucket'; + state.bitbucket.data = { repositoryCache: { status: 'unavailable' } }; + const { renderer, unmount } = await renderWithProviders(createElement(ReposRoute)); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + expect(renderer.root.find(node => String(node.type) === 'CenteredState')).toBeDefined(); + unmount(); + }); + + it('keeps cached repositories beside a partial error', async () => { + state.repositories.isError = true; + state.repositories.data = { repositories: [{ id: 1, fullName: 'org/repo', private: false }] }; + const { renderer, unmount } = await renderWithProviders(createElement(ReposRoute)); + const scroll = renderer.root.find(node => String(node.type) === 'ScrollView'); + expect(scroll.find(node => String(node.type) === 'RepoToggleRow')).toBeDefined(); + expect(scroll.find(node => String(node.type) === 'QueryError').props.placement).toBe('top'); + unmount(); + }); +}); + +describe('Recent review bodies', () => { + it.each([false, true])( + 'keeps the correct empty action with provider connection %s', + async connected => { + state.connected = connected; + const { renderer, unmount } = await renderWithProviders( + createElement(ReviewListScreen, { scope: 'personal' }) + ); + const empty = renderer.root.find(node => String(node.type) === 'EmptyState'); + expect(empty.props.placement).toBeUndefined(); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + const action = empty.props.action as React.ReactElement<{ onPress: () => void }>; + action.props.onPress(); + expect(state.push).toHaveBeenCalledWith( + `/(app)/(tabs)/(3_profile)/code-reviewer/personal${connected ? '/manual-review' : ''}` + ); + unmount(); + } + ); + + it.each(['INTERNAL_SERVER_ERROR', 'FORBIDDEN', 'NOT_FOUND', 'UNAUTHORIZED'])( + 'keeps the %s retry policy outside the scroller', + async code => { + state.reviews.data = undefined; + state.reviews.isError = true; + state.reviews.error.data.code = code; + const { renderer, unmount } = await renderWithProviders( + createElement(ReviewListScreen, { scope: 'personal' }) + ); + const error = renderer.root.find(node => String(node.type) === 'QueryError'); + expect(error.props.placement).toBeUndefined(); + expect(Boolean(error.props.onRetry)).toBe(code === 'INTERNAL_SERVER_ERROR'); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + unmount(); + } + ); + + it('keeps cached reviews after a background failure', async () => { + state.reviews.isError = true; + state.reviews.data = { + success: true, + reviews: [ + { + id: 'r1', + pr_title: 'Saved review', + repo_full_name: 'org/repo', + pr_number: 1, + status: 'completed', + created_at: '2026-09-01T00:00:00Z', + }, + ], + }; + const { renderer, unmount } = await renderWithProviders( + createElement(ReviewListScreen, { scope: 'personal' }) + ); + expect(renderer.root.findByProps({ children: 'Saved review' })).toBeDefined(); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/platform-overview-screen.mounted.test.tsx index eb73217681..1dcc050f39 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.mounted.test.tsx @@ -23,6 +23,10 @@ const config = vi.hoisted(() => ({ refetch: vi.fn(), })); +const provider = vi.hoisted(() => ({ status: 'connected' })); +const centeredState = vi.hoisted(() => vi.fn()); +const scrollView = vi.hoisted(() => vi.fn()); + const permission = vi.hoisted(() => ({ status: 'ready', canEdit: true, @@ -45,6 +49,12 @@ vi.mock('expo-router', () => ({ vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn(), })); +vi.mock('@/components/centered-state', () => ({ + CenteredState: (props: { children?: unknown }) => { + centeredState(props); + return props.children; + }, +})); vi.mock('@/components/agents/model-selector', () => ({ openModelPicker: vi.fn(), })); @@ -70,7 +80,10 @@ vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: () => null })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/tab-screen', () => ({ - TabScreenScrollView: ({ children }: { children?: unknown }) => children, + TabScreenScrollView: (props: { children?: unknown }) => { + scrollView(props); + return props.children; + }, })); vi.mock('@/lib/code-reviewer-config', () => ({ PLATFORM_CAPABILITIES: { @@ -83,7 +96,7 @@ vi.mock('@/lib/hooks/use-available-models', () => ({ useAvailableModels: () => ({ models: [], isLoading: false }), })); vi.mock('@/lib/hooks/use-code-reviewer', () => ({ - classifyProviderState: () => ({ status: 'connected' }), + classifyProviderState: () => provider, PERSONAL_SCOPE: 'personal', useGitHubStatus: () => ({ isLoading: false, @@ -175,6 +188,19 @@ beforeEach(() => { config.refetch.mockClear(); permission.status = 'ready'; permission.canEdit = true; + provider.status = 'connected'; + centeredState.mockClear(); + scrollView.mockClear(); +}); + +describe('PlatformOverviewScreen disconnected setup', () => { + it.each([false, true])('centers setup with edit permission %s', async canEdit => { + provider.status = 'disconnected'; + permission.canEdit = canEdit; + await renderScreen(); + expect(centeredState).toHaveBeenCalledOnce(); + expect(scrollView).not.toHaveBeenCalled(); + }); }); describe('PlatformOverviewScreen actionRequired banner', () => { diff --git a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx index ff9c8f9215..0f331d769e 100644 --- a/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-overview-screen.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Switch, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import { CenteredState } from '@/components/centered-state'; import { openModelPicker } from '@/components/agents/model-selector'; import { BitbucketOverview } from '@/components/code-reviewer/bitbucket-overview'; import { @@ -131,6 +132,24 @@ export function PlatformOverviewScreen({ ); } + const handleConnected = status.refetch; + if (!isLoading && !connected) { + return ( + + + + {canEdit ? ( + + ) : ( + + {t('codeReviewer.notConnectedReadOnly', { platform: capabilities.label })} + + )} + + + ); + } + const pushField = (field: string) => { router.push(`/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/${platform}/${field}` as Href); }; @@ -190,23 +209,6 @@ export function PlatformOverviewScreen({ )} - {!isLoading && !connected && ( - - {canEdit ? ( - status.refetch()} - /> - ) : ( - - {t('codeReviewer.notConnectedReadOnly', { platform: capabilities.label })} - - )} - - )} - {!isLoading && connected && config.data != null && rows != null && ( {platform === 'gitlab' && hasWebhookSyncWarning && ( diff --git a/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx index 02128686a5..cbc17fc7e9 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.mounted.test.tsx @@ -111,6 +111,8 @@ vi.mock('@/components/code-reviewer/review-list-screen', () => ({ className: 'text-good', }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ StateSurface: 'StateSurface' })); vi.mock('@/components/query-error', () => ({ QueryError: (props: { variant?: string; @@ -695,7 +697,7 @@ describe('ReviewDetailScreen spectator transcript', () => { ); expect(spectatorError).toBeDefined(); expect(spectatorError?.onRetry).toBeDefined(); - expect(spectatorError?.placement).toBe('top'); + expect(spectatorError?.placement).toBeUndefined(); }); it('fills the sheet with the transcript and clears the bottom safe area', () => { @@ -729,6 +731,31 @@ describe('ReviewDetailScreen spectator transcript', () => { expect(sessionListRenders.list[0]?.contentBottomInset).toBe(34); }); + it.each([false, true])( + 'keeps cached history after stream info fails with cached metadata %s', + hasMetadata => { + spectatorQueries.streamInfo.data = hasMetadata + ? makeStreamInfo({ status: 'completed' }) + : { success: false, error: 'failed' }; + spectatorQueries.streamInfo.isError = hasMetadata; + spectatorQueries.sessionMessages.data = { + success: true, + entries: [{ timestamp: 't1', message: 'Saved transcript', eventType: 'text' }], + }; + detail.data = { + success: true, + review: makeReview({ status: 'completed' }), + tokenUsage: { input: 0, output: 0 }, + }; + + renderScreen(true); + + const items = sessionListRenders.list.at(-1)?.items as { message: string }[]; + expect(items).toEqual([expect.objectContaining({ message: 'Saved transcript' })]); + expect(queryErrors.errors).toHaveLength(0); + } + ); + it('shows QueryError plus Retry when the session snapshot fails', () => { spectatorQueries.streamInfo.data = makeStreamInfo({ status: 'completed' }); spectatorQueries.sessionMessages.data = { success: false }; @@ -745,7 +772,7 @@ describe('ReviewDetailScreen spectator transcript', () => { ); expect(snapshotError).toBeDefined(); expect(snapshotError?.onRetry).toBeDefined(); - expect(snapshotError?.placement).toBe('top'); + expect(snapshotError?.placement).toBeUndefined(); act(() => { snapshotError?.onRetry?.(); @@ -868,7 +895,7 @@ describe('ReviewDetailScreen spectator transcript', () => { expect(collectText(renderer.toJSON())).not.toContain('No transcript for this review.'); }); - it('shows a top-aligned QueryError when the live stream errors before any row', () => { + it('shows a centered QueryError when the live stream errors before any row', () => { const captured: { onError?: () => void } = {}; spectatorStream.createReviewSpectatorStream.mockImplementation( (input: { onError: () => void }) => { @@ -902,7 +929,7 @@ describe('ReviewDetailScreen spectator transcript', () => { error => error.title === 'Could not load the review transcript.' ); expect(liveErrorState).toBeDefined(); - expect(liveErrorState?.placement).toBe('top'); + expect(liveErrorState?.placement).toBeUndefined(); expect(liveErrorState?.onRetry).toBeDefined(); }); }); diff --git a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx index 85e12a7d8a..e08009b514 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-screen.tsx @@ -102,23 +102,19 @@ export function ReviewDetailScreen({ return ( - - - + ); } return ( - - void refetch()} - isRetrying={isFetching} - /> - + void refetch()} + isRetrying={isFetching} + /> ); } diff --git a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx index 531085768b..04d773139c 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -1,5 +1,6 @@ import { type Href, useRouter } from 'expo-router'; import { GitPullRequest } from '@/components/ui/icons'; +import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -78,9 +79,43 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { errorVariant = errorCode === 'NOT_FOUND' ? 'not-found' : 'permission'; } - return ( - - + let body: ReactNode = null; + if (!isLoading && data?.success && data.reviews.length === 0) { + body = ( + { + router.push( + (hasConnectedProvider + ? `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/manual-review` + : `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}`) as Href + ); + }} + > + + {hasConnectedProvider + ? t('codeReviewer.reviewList.startManualReview') + : t('codeReviewer.reviewList.configureProvider')} + + + } + /> + ); + } else if (!isLoading && ((isError && !data) || (data && !data.success))) { + body = ( + void refetch()} + isRetrying={isFetching} + /> + ); + } else { + body = ( {isLoading && ( @@ -91,56 +126,6 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { )} - {/* Only a full-screen error when there's no usable data yet — a transient - background poll failure with stale data should keep showing that data, - not hide it behind a retry banner. */} - {!isLoading && isError && !data && ( - void refetch()} - isRetrying={isFetching} - /> - )} - - {!isLoading && data && !data.success && ( - void refetch()} - isRetrying={isFetching} - /> - )} - - {!isLoading && data?.success && data.reviews.length === 0 && ( - { - router.push( - (hasConnectedProvider - ? `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/manual-review` - : `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}`) as Href - ); - }} - > - - {hasConnectedProvider - ? t('codeReviewer.reviewList.startManualReview') - : t('codeReviewer.reviewList.configureProvider')} - - - } - /> - )} - {!isLoading && data?.success && data.reviews.length > 0 && ( // no pagination, limit 50 — add offset paging if lists outgrow it @@ -179,6 +164,13 @@ export function ReviewListScreen({ scope }: Readonly<{ scope: string }>) { )} + ); + } + + return ( + + + {body} ); } diff --git a/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx index d63c1b9d77..14c28584d6 100644 --- a/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.mounted.test.tsx @@ -124,6 +124,8 @@ vi.mock('@shopify/flash-list', () => ({ }, })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); + vi.mock('@/components/empty-state', () => ({ EmptyState: ({ title }: { title: string }) => `EMPTY:${title}`, })); @@ -201,6 +203,7 @@ describe('ReviewMemoryScreen retryable errors', () => { renderScreen(); + expect(flashList.onEndReached).toBeNull(); expect(queryErrors.errors).toHaveLength(1); expect(queryErrors.errors[0]?.variant).toBe('server'); expect(queryErrors.errors[0]?.onRetry).toBeDefined(); @@ -212,6 +215,7 @@ describe('ReviewMemoryScreen retryable errors', () => { renderScreen(); + expect(flashList.onEndReached).toBeNull(); expect(queryErrors.errors).toHaveLength(1); expect(queryErrors.errors[0]?.variant).toBe('server'); expect(queryErrors.errors[0]?.onRetry).toBeDefined(); @@ -279,6 +283,7 @@ describe('ReviewMemoryScreen proposals', () => { const renderer = renderScreen(); expect(collectText(renderer.toJSON())).toContain('EMPTY:No proposals'); + expect(flashList.onEndReached).toBeNull(); }); it('renders the paginated proposal list', () => { diff --git a/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx index 8550dee567..9ee6a3d3c8 100644 --- a/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-memory-screen.tsx @@ -1,9 +1,10 @@ import { FlashList } from '@shopify/flash-list'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; @@ -90,14 +91,83 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { ); } - return ( - - + + + + + ); + } else if (summaryError) { + body = ( + void summaryQuery.refetch()} + isRetrying={summaryQuery.isFetching} /> + ); + } else if (disabled) { + body = ( + + + + {t('codeReviewer.reviewMemory.off')} + + + {t('codeReviewer.reviewMemory.offDescription')} + + {readOnly ? ( + + {t('codeReviewer.reviewMemory.readOnlyDescription')} + + ) : ( + + )} + + + ); + } else if (firstPageError) { + body = ( + void proposalsQuery.refetch()} + isRetrying={proposalsQuery.isFetching} + /> + ); + } else if (empty) { + body = ( + <> + + {footer} + + ); + } else if (happy) { + body = ( proposal.id} renderItem={({ item }) => ( @@ -109,83 +179,6 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { )} - ListEmptyComponent={ - - {summaryLoading && ( - - - - - - )} - - {summaryError && ( - void summaryQuery.refetch()} - isRetrying={summaryQuery.isFetching} - /> - )} - - {disabled && ( - - - {t('codeReviewer.reviewMemory.off')} - - - {t('codeReviewer.reviewMemory.offDescription')} - - {readOnly ? ( - - {t('codeReviewer.reviewMemory.readOnlyDescription')} - - ) : ( - - )} - - )} - - {proposalsLoading && ( - - - - - - )} - - {firstPageError && ( - void proposalsQuery.refetch()} - isRetrying={proposalsQuery.isFetching} - /> - )} - - {empty && ( - - )} - - } ListFooterComponent={footer} onEndReached={() => { if (proposalsQuery.hasNextPage && !proposalsQuery.isFetchingNextPage) { @@ -194,6 +187,16 @@ export function ReviewMemoryScreen({ scope }: Readonly<{ scope: string }>) { }} onEndReachedThreshold={0.5} /> + ); + } + + return ( + + + {body} ); } diff --git a/apps/mobile/src/components/code-reviewer/review-spectator.tsx b/apps/mobile/src/components/code-reviewer/review-spectator.tsx index db2db5fdcc..b836653eb7 100644 --- a/apps/mobile/src/components/code-reviewer/review-spectator.tsx +++ b/apps/mobile/src/components/code-reviewer/review-spectator.tsx @@ -21,6 +21,7 @@ import { createReviewSpectatorStream, } from '@/components/code-reviewer/review-spectator-stream'; import { useRefetchSessionMessagesOnTerminal } from '@/components/code-reviewer/review-spectator-terminal-refetch'; +import { CenteredState } from '@/components/centered-state'; import { QueryError } from '@/components/query-error'; import { Text } from '@/components/ui/text'; import { useTRPC } from '@/lib/trpc'; @@ -43,11 +44,11 @@ const renderSpectatorRow: ListRenderItem = ({ item }) => ( function SpectatorCopy({ message }: Readonly<{ message: string }>) { return ( - - + + {message} - + ); } @@ -195,7 +196,8 @@ export function ReviewSpectator({ [sessionMessages.data] ); - const transcriptRows = shouldLoadHistory ? historicalRows : liveRows; + const transcriptRows = + shouldLoadHistory || (info === null && liveRows.length === 0) ? historicalRows : liveRows; function renderRowsWithRetry(onRetry: () => void) { return ( @@ -246,7 +248,7 @@ export function ReviewSpectator({ return ; } if (streamInfo.isError || (streamInfo.data && !streamInfo.data.success)) { - if (liveRows.length > 0) { + if (transcriptRows.length > 0) { return renderRowsWithRetry(() => { void streamInfo.refetch(); }); @@ -255,7 +257,6 @@ export function ReviewSpectator({ { void streamInfo.refetch(); }} @@ -277,7 +278,6 @@ export function ReviewSpectator({ { void sessionMessages.refetch(); }} @@ -307,7 +307,6 @@ export function ReviewSpectator({ { setLiveError(false); setRetryNonce(count => count + 1); diff --git a/apps/mobile/src/components/device-sessions-screen.mounted.test.tsx b/apps/mobile/src/components/device-sessions-screen.mounted.test.tsx new file mode 100644 index 0000000000..73b88605a8 --- /dev/null +++ b/apps/mobile/src/components/device-sessions-screen.mounted.test.tsx @@ -0,0 +1,138 @@ +import type * as ReactQuery from '@tanstack/react-query'; +import { createElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type DeviceSession } from '@/lib/device-sessions'; +import { renderWithProviders } from '@/test/render-with-providers'; +import { DeviceSessionsScreen } from './device-sessions-screen'; +import { TrustedHostsScreen } from './trusted-hosts-screen'; + +const query = vi.hoisted(() => ({ + data: undefined as DeviceSession[] | undefined, + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), +})); +const hosts = vi.hoisted(() => ({ trustedHosts: [] as string[], hasLoaded: true })); +vi.mock('@tanstack/react-query', async importOriginal => ({ + ...(await importOriginal()), + useQuery: () => query, + useMutation: () => ({ isPending: false, mutate: vi.fn() }), +})); +vi.mock('expo-router', () => ({ useRouter: () => ({ back: vi.fn() }) })); +vi.mock('react-native', () => ({ + Alert: { alert: vi.fn() }, + View: 'View', + Pressable: 'Pressable', +})); +vi.mock('sonner-native', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/components/detail-screen', () => ({ DetailScreenScrollView: 'DetailScreenScrollView' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/icons', () => ({ + LogOut: 'LogOut', + Smartphone: 'Smartphone', + Shield: 'Shield', + X: 'X', +})); +vi.mock('@/lib/auth/auth-context', () => ({ + useAuth: () => ({ token: 'test-token', signOut: vi.fn() }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/hooks/use-trusted-hosts', () => ({ + useTrustedHosts: () => hosts, + revokeHost: vi.fn(), +})); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + user: { + listDeviceSessions: { queryOptions: () => ({}) }, + revokeDeviceSessionById: { mutationOptions: () => ({}) }, + }, + }), +})); +vi.mock('@/lib/utils', () => ({ parseTimestamp: (value: string) => new Date(value) })); +vi.mock('@/lib/format', () => ({ formatDate: () => 'Date' })); + +beforeEach(() => { + query.data = undefined; + query.isLoading = false; + query.isError = false; + query.refetch.mockClear(); + hosts.hasLoaded = true; + hosts.trustedHosts = []; +}); + +describe('account surface states', () => { + it.each(['empty', 'error'] as const)( + 'lifts the device %s state outside the scroller', + async state => { + query.isError = state === 'error'; + const { renderer, unmount } = await renderWithProviders(createElement(DeviceSessionsScreen)); + expect( + renderer.root.findAll(node => String(node.type) === 'DetailScreenScrollView') + ).toHaveLength(0); + const body = renderer.root.find( + node => String(node.type) === (state === 'error' ? 'QueryError' : 'EmptyState') + ); + const props = body.props as { placement?: string; onRetry?: () => void }; + expect(props.placement).not.toBe('top'); + if (state === 'error') { + props.onRetry?.(); + expect(query.refetch).toHaveBeenCalledOnce(); + } + unmount(); + } + ); + + it('keeps cached device records after a refetch failure', async () => { + query.isError = true; + query.data = [ + { + id: 'device-1', + user_agent: 'Kilo/1', + isCurrent: false, + created_at: '2026-01-01T00:00:00Z', + last_seen_at: '2026-01-01T00:00:00Z', + }, + ]; + const { renderer, unmount } = await renderWithProviders(createElement(DeviceSessionsScreen)); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + expect(renderer.root.findAll(node => String(node.type) === 'EmptyState')).toHaveLength(0); + expect(renderer.root.findAll(node => String(node.type) === 'Pressable')).toHaveLength(1); + unmount(); + }); + + it('keeps device loading ahead of error and empty states', async () => { + query.isLoading = true; + query.isError = true; + const { renderer, unmount } = await renderWithProviders(createElement(DeviceSessionsScreen)); + expect(renderer.root.findAll(node => String(node.type) === 'Skeleton')).toHaveLength(12); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + expect(renderer.root.findAll(node => String(node.type) === 'EmptyState')).toHaveLength(0); + unmount(); + }); + + it('lifts trusted host emptiness outside the scroller', async () => { + const { renderer, unmount } = await renderWithProviders(createElement(TrustedHostsScreen)); + expect(renderer.root.findAll(node => String(node.type) === 'TabScreenScrollView')).toHaveLength( + 0 + ); + expect(renderer.root.findAll(node => String(node.type) === 'EmptyState')).toHaveLength(1); + unmount(); + }); + + it('does not show trusted host emptiness before storage loads', async () => { + hosts.hasLoaded = false; + const { renderer, unmount } = await renderWithProviders(createElement(TrustedHostsScreen)); + expect(renderer.root.findAll(node => String(node.type) === 'EmptyState')).toHaveLength(0); + expect(renderer.root.findAll(node => String(node.type) === 'Skeleton')).toHaveLength(4); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/device-sessions-screen.tsx b/apps/mobile/src/components/device-sessions-screen.tsx index 9723b67002..64fde9ce8a 100644 --- a/apps/mobile/src/components/device-sessions-screen.tsx +++ b/apps/mobile/src/components/device-sessions-screen.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; +import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Pressable, View } from 'react-native'; import { toast } from 'sonner-native'; @@ -88,7 +89,11 @@ export function DeviceSessionsScreen() { enabled: token != null, }); - const state = classifyDeviceSessionsState({ isLoading, isError, data }); + const state = classifyDeviceSessionsState({ + isLoading, + isError: isError && data === undefined, + data, + }); const sessions = sortDeviceSessions(data ?? []); const revokeSession = useMutation( @@ -137,15 +142,43 @@ export function DeviceSessionsScreen() { ); }; - return ( - - + let body: ReactNode = null; + if (state === 'error') { + body = ( + void refetch()} + isRetrying={isFetching} + /> + ); + } else if (state === 'empty') { + body = ( + { + router.back(); + }} + > + {t('deviceSessions.viewProfile')} + + } + /> + ); + } else { + body = ( - {state === 'loading' && ( + {state === 'loading' ? ( {[0, 1, 2].map(index => ( @@ -158,39 +191,7 @@ export function DeviceSessionsScreen() { ))} - )} - - {state === 'error' && ( - void refetch()} - isRetrying={isFetching} - /> - )} - - {state === 'empty' && ( - { - router.back(); - }} - > - {t('deviceSessions.viewProfile')} - - } - /> - )} - - {(state === 'happy' || state === 'no-current') && ( + ) : ( {sessions.map(session => ( )} + ); + } + + return ( + + + {body} ); } diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx index 30b698fb38..9ac735e18e 100644 --- a/apps/mobile/src/components/empty-state.tsx +++ b/apps/mobile/src/components/empty-state.tsx @@ -1,7 +1,8 @@ import { type LucideIcon } from '@/components/ui/icons'; import { type ReactNode } from 'react'; -import { View } from 'react-native'; +import { type ScrollViewProps, View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; @@ -16,6 +17,7 @@ type EmptyStateProps = { className?: string; action?: ReactNode; placement?: 'center' | 'top'; + refreshControl?: ScrollViewProps['refreshControl']; /** Overrides the icon bubble's container classes (size/shape/background). Defaults to the card-style bubble. */ iconContainerClassName?: string; iconSize?: number; @@ -31,6 +33,7 @@ export function EmptyState({ className, action, placement = 'center', + refreshControl, iconContainerClassName = DEFAULT_ICON_CONTAINER_CLASS, iconSize = 24, iconStrokeWidth = 1.5, @@ -38,14 +41,8 @@ export function EmptyState({ }: Readonly) { const colors = useThemeColors(); - return ( - + const content = ( + @@ -67,4 +64,10 @@ export function EmptyState({ {action} ); + + return placement === 'center' ? ( + {content} + ) : ( + content + ); } diff --git a/apps/mobile/src/components/force-update-screen.tsx b/apps/mobile/src/components/force-update-screen.tsx index ac3bb5cba5..4a412d0731 100644 --- a/apps/mobile/src/components/force-update-screen.tsx +++ b/apps/mobile/src/components/force-update-screen.tsx @@ -1,9 +1,9 @@ import { Download } from '@/components/ui/icons'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Linking, Platform, ScrollView, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Linking, Platform, View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { openExternalUrl } from '@/lib/external-link'; @@ -15,26 +15,9 @@ const STORE_URL = ? 'https://apps.apple.com/app/id6761193135' : 'https://play.google.com/store/apps/details?id=com.kilocode.kiloapp'; -const VERTICAL_GUTTER = 32; -const HORIZONTAL_GUTTER = 32; - -type Insets = { readonly top: number; readonly bottom: number }; - -function makeContentContainerStyle({ top, bottom }: Insets) { - return { - flexGrow: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - paddingHorizontal: HORIZONTAL_GUTTER, - paddingTop: top + VERTICAL_GUTTER, - paddingBottom: bottom + VERTICAL_GUTTER, - }; -} - export function ForceUpdateScreen() { const colors = useThemeColors(); const { t } = useTranslation(); - const { top, bottom } = useSafeAreaInsets(); const [storeOpenFailed, setStoreOpenFailed] = useState(false); const openStore = async () => { @@ -47,39 +30,37 @@ export function ForceUpdateScreen() { }; return ( - - - {t('forceUpdate.title')} - - {t('forceUpdate.description')} - - + + + + {t('forceUpdate.title')} + + {t('forceUpdate.description')} + + - {storeOpenFailed && ( - - - {t('forceUpdate.couldNotOpenStore')} - - - - - )} - + {storeOpenFailed && ( + + + {t('forceUpdate.couldNotOpenStore')} + + + + + )} + + ); } diff --git a/apps/mobile/src/components/home/agent-sessions-section.test.ts b/apps/mobile/src/components/home/agent-sessions-section.test.ts index ec44c5dee8..fe4b0303bf 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.test.ts +++ b/apps/mobile/src/components/home/agent-sessions-section.test.ts @@ -17,6 +17,7 @@ const queryClient = new ReactQuery.QueryClient(); vi.mock('expo-router', () => ({ useRouter: () => ({ navigate: navigateSpy, dismissTo: dismissToSpy }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('react-native', () => ({ View: 'View', Pressable: 'Pressable', Platform: { OS: 'ios' } })); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); vi.mock('@expo/react-native-action-sheet', () => ({ diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx index 28c2b063d4..a9e7f67012 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.tsx +++ b/apps/mobile/src/components/home/agent-sessions-section.tsx @@ -1,7 +1,9 @@ import { type Href, useRouter } from 'expo-router'; import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { View } from 'react-native'; +import { Platform, type ScrollViewProps, View } from 'react-native'; + +import { CenteredState } from '@/components/centered-state'; import { RemoteSessionRow } from '@/components/agents/remote-session-row'; import { useAgentSessionNavigator } from '@/components/agents/use-agent-session-navigator'; @@ -12,6 +14,7 @@ import { AccessibleStatus } from '@/components/ui/accessible-status'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; +import { useStatusAnnouncement } from '@/lib/a11y/status-announcement'; import { useAuth } from '@/lib/auth/auth-context'; import { type useLiveAgentSessions } from '@/lib/hooks/use-agent-sessions'; import { useCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state'; @@ -22,7 +25,6 @@ import { createSubmitLock } from '@/lib/submit-lock'; import { readTrpcErrorField } from '@/lib/trpc-error'; import { cn } from '@/lib/utils'; -const HOME_LIVE_SLOT_MIN_CLASS = 'min-h-[72px]'; // The trailing slash pins the index route. const AGENTS_INDEX_HREF = '/(app)/(tabs)/(2_agents)/' as const; const MAX_ROWS = 3; @@ -85,7 +87,13 @@ export function LiveSessionFeedback({ context, sessions, failureLabel, -}: LiveSessionProps & { failureLabel: string }) { + centered = false, + refreshControl, +}: LiveSessionProps & { + failureLabel: string; + centered?: boolean; + refreshControl?: ScrollViewProps['refreshControl']; +}) { const { t } = useTranslation(); const router = useRouter(); const internet = useCommittedConnectivityStatus(); @@ -93,9 +101,7 @@ export function LiveSessionFeedback({ const connection = useUserWebConnection(); const wasConnected = useRef(false); useEffect(() => { - if (isConnected) { - wasConnected.current = true; - } + wasConnected.current ||= isConnected; }, [isConnected]); const retryLock = useMemo(createSubmitLock, []); const [retrying, setRetrying] = useState(false); @@ -114,6 +120,9 @@ export function LiveSessionFeedback({ })(); }; const content = liveSessionContent(context, sessions); + useStatusAnnouncement( + context.isReady && sessions.terminalError?.kind === 'retryable' ? failureLabel : null + ); const denied = context.isReady && sessions.terminalError?.kind === 'non-retryable'; const unavailable = !context.isResolving && !context.isReady && !context.isError; let failure: ReactNode = null; @@ -121,6 +130,7 @@ export function LiveSessionFeedback({ failure = ( - + - } - /> - + { + router.replace(backTo); + }} + > + {t('invalidRoute.goBack')} + + } + /> ); } diff --git a/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx b/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx index 8c53b44e5c..b9602dec81 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-history-state-views.tsx @@ -42,7 +42,6 @@ export function ConversationHistoryErrorView({ diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.mounted.test.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.mounted.test.tsx new file mode 100644 index 0000000000..aaa29edf42 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.mounted.test.tsx @@ -0,0 +1,180 @@ +import { createElement, type ElementType, type ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CenteredState } from '@/components/centered-state'; +import { QueryError } from '@/components/query-error'; +import { renderWithProviders } from '@/test/render-with-providers'; +import { ConversationListScreen } from './conversation-list-screen'; +import { + ConversationHistoryErrorView, + ConversationInlineRetryBanner, +} from './conversation-history-state-views'; + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + refresh: vi.fn(), + refetch: vi.fn<() => void>(), + create: vi.fn(), + push: vi.fn(), +})); + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + RefreshControl: 'RefreshControl', + View: 'View', + Platform: { OS: 'android' }, + useWindowDimensions: () => ({ fontScale: 1 }), +})); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView' }, + FadeIn: { duration: vi.fn() }, +})); +vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock('@shopify/flash-list', () => ({ FlashList: 'FlashList' })); +vi.mock('@kilocode/kilo-chat-hooks', () => ({ + useBotStatus: vi.fn(), + useEventServiceClient: vi.fn(), +})); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('expo-localization', () => ({ getCalendars: () => [{ firstWeekday: 1 }] })); +vi.mock('expo-router', () => ({ useRouter: () => ({ push: mocks.push }) })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key } })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/ui/icons', () => ({ + Plus: 'Plus', + Settings2: 'Settings2', + MessageSquarePlus: 'MessageSquarePlus', + AlertCircle: 'AlertCircle', + Lock: 'Lock', + SearchX: 'SearchX', + ServerCrash: 'ServerCrash', + WifiOff: 'WifiOff', +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#666666' }), +})); +vi.mock('@/lib/hooks/use-manual-refresh', () => ({ + useManualRefresh: () => [false, mocks.refresh], +})); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + CONVERSATION_CREATED_EVENT: 'created', +})); +vi.mock('./conversation-row', () => ({ ConversationRow: 'ConversationRow' })); +vi.mock('./conversation-header', () => ({ ConversationHeader: 'ConversationHeader' })); +vi.mock('./app-aware-keyboard-padding', () => ({ AppAwareKeyboardPaddingView: 'KeyboardPadding' })); +vi.mock('./hooks/use-kilo-chat-client', () => ({ useKiloChatClient: vi.fn() })); +vi.mock('./hooks/use-instance-presence', () => ({ useInstancePresence: vi.fn() })); +vi.mock('./hooks/use-app-active-and-focused', () => ({ useAppActiveAndFocused: () => true })); +vi.mock('./hooks/use-now-ticker', () => ({ useNowTicker: () => 1_800_000_000_000 })); +vi.mock('./hooks/use-conversations', () => ({ + useConversations: mocks.list, + useCreateConversation: () => ({ mutate: mocks.create, isPending: false }), + useLeaveConversation: () => ({ mutate: vi.fn() }), +})); + +const mounted: Awaited>[] = []; +async function mountList() { + const result = await renderWithProviders( + createElement(ConversationListScreen, { sandboxId: 'instance-1', sandboxLabel: 'Assistant' }) + ); + mounted.push(result); + return result.renderer.root; +} + +function press(node: { props: unknown }) { + (node.props as { onPress: () => void }).onPress(); +} + +function refresh(node: { props: unknown }) { + const { refreshControl } = node.props as { + refreshControl: ReactElement<{ onRefresh: () => void }>; + }; + refreshControl.props.onRefresh(); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.list.mockReturnValue({ + data: { conversations: [] }, + isPending: false, + isError: false, + refetch: mocks.refetch, + hasNextPage: false, + isFetchingNextPage: false, + }); +}); + +afterEach(() => { + for (const result of mounted.splice(0)) { + result.unmount(); + } +}); + +describe('Kilo Chat full-body states', () => { + it('centers the empty list outside FlashList and preserves refresh and creation', async () => { + const root = await mountList(); + expect(root.findAllByType('FlashList' as ElementType)).toHaveLength(0); + const centered = root.findByType(CenteredState); + refresh(centered); + expect(mocks.refresh).toHaveBeenCalledOnce(); + press(root.findByType('Button' as ElementType)); + expect(mocks.create).toHaveBeenCalledWith({ sandboxId: 'instance-1' }, expect.any(Object)); + expect(root.findAllByType('Plus' as ElementType)).toHaveLength(0); + expect(root.findAllByType('ScreenHeader' as ElementType)).toHaveLength(1); + }); + + it('keeps loaded conversations in FlashList with refresh and one creation button', async () => { + mocks.list.mockReturnValue({ + data: { conversations: [{ conversationId: 'conversation-1', joinedAt: 1_800_000_000_000 }] }, + isPending: false, + isError: false, + refetch: mocks.refetch, + }); + const root = await mountList(); + expect(root.findAllByType(CenteredState)).toHaveLength(0); + const list = root.findByType('FlashList' as ElementType); + expect(list.props.ListEmptyComponent).toBeUndefined(); + refresh(list); + expect(mocks.refresh).toHaveBeenCalledOnce(); + expect(root.findAllByType('Plus' as ElementType)).toHaveLength(1); + }); + + it('centers list failures without rendering the empty-list creation action', async () => { + mocks.list.mockReturnValue({ + data: undefined, + isPending: false, + isError: true, + refetch: mocks.refetch, + }); + const root = await mountList(); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findAllByType('FlashList' as ElementType)).toHaveLength(0); + expect(root.findAllByType(QueryError)).toHaveLength(1); + press(root.findByType('Button' as ElementType)); + expect(mocks.refetch).toHaveBeenCalledOnce(); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it('centers initial-history errors but keeps retained-history errors inline', async () => { + const error = await renderWithProviders( + createElement(ConversationHistoryErrorView, { onRetry: mocks.refetch }) + ); + mounted.push(error); + expect(error.renderer.root.findAllByType(CenteredState)).toHaveLength(1); + const inline = await renderWithProviders( + createElement(ConversationInlineRetryBanner, { message: 'Retry', onRetry: mocks.refetch }) + ); + mounted.push(inline); + expect(inline.renderer.root.findAllByType(CenteredState)).toHaveLength(0); + press(inline.renderer.root.findByType('Pressable' as ElementType)); + expect(mocks.refetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx index 908730098b..925f4cb5f0 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -204,13 +204,8 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { return ( - + { void listQuery.refetch(); @@ -223,6 +218,14 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { const conversations = listQuery.data?.conversations ?? []; const entries = flattenConversationGroups(conversations, now); + const refreshControl = ( + + ); return ( @@ -244,56 +247,49 @@ export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { } /> - - entry.kind === 'header' ? `header:${entry.label}` : entry.conversation.conversationId - } - renderItem={({ item }) => - item.kind === 'header' ? ( - - {item.label} - - ) : ( - - - - ) - } - ListEmptyComponent={ - - } - ListFooterComponent={ - isFetchingNextPage ? ( - - - - ) : null - } - onEndReached={fetchMoreConversations} - onEndReachedThreshold={0.5} - refreshControl={ - - } - /> + {entries.length === 0 ? ( + + ) : ( + + entry.kind === 'header' ? `header:${entry.label}` : entry.conversation.conversationId + } + renderItem={({ item }) => + item.kind === 'header' ? ( + + {item.label} + + ) : ( + + + + ) + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + onEndReached={fetchMoreConversations} + onEndReachedThreshold={0.5} + refreshControl={refreshControl} + /> + )} - {/* The empty state below already renders its own "Create conversation" CTA — - only one creation affordance should be visible at a time. */} {entries.length > 0 && ( void; isStarting: boolean; + refreshControl?: ScrollViewProps['refreshControl']; }; -export function EmptyConversationList({ onStart, isStarting }: Props) { +export function EmptyConversationList({ onStart, isStarting, refreshControl }: Props) { const { t } = useTranslation(); return ( - - - - {isStarting ? t('chat.conversationList.starting') : t('chat.conversationList.create')} - - - } - /> - + + + {isStarting ? t('chat.conversationList.starting') : t('chat.conversationList.create')} + + + } + /> ); } diff --git a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.test.ts b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.test.ts index 817264630d..ae5a7632fa 100644 --- a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.test.ts +++ b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.test.ts @@ -68,6 +68,8 @@ vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); + vi.mock('@/components/detail-screen', () => ({ DetailScreenScrollView: 'DetailScreenScrollView', })); @@ -273,6 +275,29 @@ describe('KiloPassSubscriptionScreen', () => { mocks.routerPush.mockReset(); }); + it.each(['web_management', 'unavailable'])( + 'centers %s outside the purchase scroller', + async kind => { + mocks.presentation.data = { kind, webUrl: null }; + const renderer = await renderScreen(); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + expect( + renderer.root.findAll(node => String(node.type) === 'DetailScreenScrollView') + ).toHaveLength(0); + expect(mocks.ownerMount).not.toHaveBeenCalled(); + renderer.unmount(); + } + ); + + it('keeps a cached presentation after a refetch failure', async () => { + mocks.presentation.data = { kind: 'web_management', webUrl: null }; + mocks.presentation.isError = true; + const renderer = await renderScreen(); + expect(allText(renderer)).toContain('This Kilo Pass is managed on the web.'); + expect(allText(renderer)).not.toContain("Couldn't load Kilo Pass."); + renderer.unmount(); + }); + it('happy: native_iap with products and an allowed preflight enables tiles and starts purchase', async () => { setNativeIapPresentation(); mocks.nativeIap.products = [product]; diff --git a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx index 63a38db3bd..921b5c24c5 100644 --- a/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx +++ b/apps/mobile/src/components/kilo-pass/kilo-pass-subscription-screen.tsx @@ -7,6 +7,7 @@ import { ActivityIndicator, Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { CenteredState } from '@/components/centered-state'; import { DetailScreenScrollView } from '@/components/detail-screen'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; @@ -87,17 +88,23 @@ function KiloPassPresentationErrorScreen({ onRetry }: { onRetry: () => void }) { return ( - - - {t('kiloPass.unavailable')} - - - {t('kiloPass.couldNotLoad')} - - - + + + + {t('kiloPass.unavailable')} + + + {t('kiloPass.couldNotLoad')} + + + + ); } @@ -121,38 +128,31 @@ function KiloPassUnavailableScreen({ return ( - - - + + + {t('kiloPass.subscriptionHeaderDescription')} - - {KILO_PASS_TITLE} - {description} - {isWebManagement && presentation.webUrl ? ( - - ) : null} - - - + {KILO_PASS_TITLE} + {description} + {isWebManagement && presentation.webUrl ? ( + + ) : null} + + ); } @@ -461,7 +461,7 @@ export function KiloPassSubscriptionScreen() { return ; } - if (presentationQuery.isError) { + if (!presentationQuery.data) { return ( { diff --git a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx index e804689eb5..9c62a6c60a 100644 --- a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Linking, Platform, View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; import { Button, type ButtonProps } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { toneColor, type ToneKey } from '@/lib/agent-color'; @@ -119,58 +120,62 @@ export function AccessRequiredScreen({ subcase }: Readonly + + + + + + + + {t('kiloclaw.accessRequired.iosTitle')} + + + {t('kiloclaw.accessRequired.iosBody')} + + + {t('kiloclaw.accessRequired.iosContact')} + + + + + ); + } + + return ( + + - + - - {t('kiloclaw.accessRequired.iosTitle')} - - - {t('kiloclaw.accessRequired.iosBody')} - + {t(content.titleKey)} - {t('kiloclaw.accessRequired.iosContact')} + {t(content.bodyKey)} + - ); - } - - return ( - - - - - - {t(content.titleKey)} - - {t(content.bodyKey)} - - - - + ); } diff --git a/apps/mobile/src/components/kiloclaw/full-surface-states.mounted.test.tsx b/apps/mobile/src/components/kiloclaw/full-surface-states.mounted.test.tsx new file mode 100644 index 0000000000..87fd5b117b --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/full-surface-states.mounted.test.tsx @@ -0,0 +1,311 @@ +import type * as ReactQuery from '@tanstack/react-query'; +import { + act, + type ComponentProps, + createElement, + type ElementType, + type ReactElement, +} from 'react'; +import { ScrollView } from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import BillingScreen from '@/app/(app)/kiloclaw/[instance-id]/billing'; +import ChannelsScreen from '@/app/(app)/kiloclaw/[instance-id]/settings/channels'; +import GoogleScreen from '@/app/(app)/kiloclaw/[instance-id]/settings/google'; +import ModelListScreen from '@/app/(app)/kiloclaw/[instance-id]/settings/model-list'; +import SecretsScreen from '@/app/(app)/kiloclaw/[instance-id]/settings/secrets'; +import InstancePickerScreen from '@/app/(app)/(tabs)/(1_kiloclaw)/chat/instance-picker'; +import { CenteredState } from '@/components/centered-state'; +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { renderWithProviders } from '@/test/render-with-providers'; +import { InstanceContextBoundary } from './instance-context-boundary'; + +const mocks = vi.hoisted(() => ({ + context: vi.fn(), + billing: vi.fn(), + catalog: vi.fn(), + status: vi.fn(), + setup: vi.fn(), + config: vi.fn(), + models: vi.fn(), + instances: vi.fn(), + copy: vi.fn(), + refetch: vi.fn<() => void>(), + replace: vi.fn(), + push: vi.fn(), + openURL: vi.fn(), + mutations: { + updateModel: { isPending: false, mutate: vi.fn() }, + restartMachine: { isPending: false, mutate: vi.fn() }, + setGmailNotifications: { isPending: false, mutate: vi.fn() }, + disconnectGoogle: { isPending: false, mutate: vi.fn() }, + }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + ScrollView: 'ScrollView', + FlatList: 'FlatList', + TextInput: 'TextInput', + Pressable: 'Pressable', + ActivityIndicator: 'ActivityIndicator', + Alert: { alert: vi.fn() }, + Platform: { OS: 'android' }, + Linking: { openURL: mocks.openURL }, +})); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView' }, + FadeIn: { duration: vi.fn() }, + FadeOut: { duration: vi.fn() }, + LinearTransition: {}, +})); +vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key, language: 'en' } })); +vi.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ 'instance-id': 'instance-1', currentId: 'instance-1' }), + useRouter: () => ({ + replace: mocks.replace, + push: mocks.push, + back: vi.fn(), + dismissAll: vi.fn(), + }), +})); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('expo-clipboard', () => ({ setStringAsync: mocks.copy })); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@tanstack/react-query', async importOriginal => ({ + ...(await importOriginal()), + useQuery: mocks.models, +})); +vi.mock('@/components/centered-state', () => ({ + CenteredState: ({ children, refreshControl }: ComponentProps) => + createElement(ScrollView, { refreshControl }, children), +})); +vi.mock('@/components/detail-screen', () => ({ DetailScreenScrollView: 'DetailScreenScrollView' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/sheet-header', () => ({ SheetHeader: 'SheetHeader' })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + ExternalLink: 'ExternalLink', + Lock: 'Lock', + SearchX: 'SearchX', + ServerCrash: 'ServerCrash', + WifiOff: 'WifiOff', + CreditCard: 'CreditCard', + Check: 'Check', + Eye: 'Eye', + Search: 'Search', + RefreshCw: 'RefreshCw', + Unplug: 'Unplug', + MessageSquare: 'MessageSquare', + KeyRound: 'KeyRound', + Server: 'Server', +})); +vi.mock('@/components/icons', () => ({ GoogleIcon: 'GoogleIcon', GmailIcon: 'GmailIcon' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/kiloclaw/settings-card', () => ({ SettingsCard: 'SettingsCard' })); +vi.mock('@/components/kiloclaw/status-badge', () => ({ StatusBadge: 'StatusBadge' })); +vi.mock('@/lib/hooks/use-instance-context', () => ({ + useInstanceContext: mocks.context, + instanceOrgId: () => null, + useAllKiloClawInstances: mocks.instances, +})); +vi.mock('@/lib/hooks/use-kiloclaw-queries', () => ({ + useKiloClawBillingStatus: mocks.billing, + useKiloClawChannelCatalog: mocks.catalog, + useKiloClawSecretCatalog: mocks.catalog, + useKiloClawStatus: mocks.status, + useKiloClawGoogleSetup: mocks.setup, + useKiloClawConfig: mocks.config, + useKiloClawMutations: () => mocks.mutations, +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#000000', mutedForeground: '#666666' }), +})); +vi.mock('@/lib/hooks/use-language-preference', () => ({ getResolvedLanguage: () => 'en' })); +vi.mock('@/lib/screen-insets', () => ({ useDetailScreenBottomPadding: () => 0 })); +vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ models: { list: { queryOptions: vi.fn() } } }) })); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://example.test' })); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + INSTANCE_ACTION_EVENT: 'action', +})); + +const mounted: Awaited>[] = []; +async function mount(ui: ReactElement) { + const result = await renderWithProviders(ui); + mounted.push(result); + return result.renderer.root; +} + +function press(node: { props: unknown }) { + (node.props as { onPress: () => void }).onPress(); +} + +function query(data: unknown, isError = false) { + return { + data, + isError, + isPending: false, + isLoading: false, + isSuccess: !isError, + refetch: mocks.refetch, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.context.mockReturnValue({ status: 'ready', isOrg: false }); + mocks.billing.mockReturnValue(query({ subscription: null, trial: null, earlybird: null })); + mocks.catalog.mockReturnValue(query([])); + mocks.status.mockReturnValue(query({ googleConnected: false })); + mocks.setup.mockReturnValue(query({ command: 'connect-google' })); + mocks.config.mockReturnValue(query({})); + mocks.models.mockReturnValue(query([])); + mocks.instances.mockReturnValue(query([])); +}); + +afterEach(() => { + for (const result of mounted.splice(0)) { + result.unmount(); + } + vi.useRealTimers(); +}); + +describe('KiloClaw full-body states', () => { + it('centers no-plan billing with its manage action outside the loaded scroller', async () => { + const root = await mount(createElement(BillingScreen)); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findAllByType('DetailScreenScrollView' as ElementType)).toHaveLength(0); + expect(root.findByType(EmptyState).props.title).toBe('kiloclaw.billing.noActivePlan'); + press(root.findByType('Button' as ElementType)); + expect(mocks.openURL).toHaveBeenCalledWith('https://example.test/claw'); + }); + + it.each([ + { trial: { expired: false, daysRemaining: 2, endsAt: '2026-09-03T00:00:00Z' } }, + { earlybird: { daysRemaining: 0, expiresAt: '2026-09-01T00:00:00Z' } }, + ])('keeps existing billing details in the loaded scroller', async billing => { + mocks.billing.mockReturnValue(query(billing)); + const root = await mount(createElement(BillingScreen)); + expect(root.findAllByType(CenteredState)).toHaveLength(0); + expect(root.findAllByType('DetailScreenScrollView' as ElementType)).toHaveLength(1); + }); + + it('treats an expired trial without another plan as empty billing', async () => { + mocks.billing.mockReturnValue(query({ trial: { expired: true } })); + const root = await mount(createElement(BillingScreen)); + expect(root.findByType(EmptyState).props.title).toBe('kiloclaw.billing.noActivePlan'); + }); + + it('keeps organization billing read-only', async () => { + mocks.context.mockReturnValue({ status: 'ready', isOrg: true }); + const root = await mount(createElement(BillingScreen)); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findAllByType('Button' as ElementType)).toHaveLength(0); + }); + + it('centers disconnected Google setup and preserves copying the command', async () => { + vi.useFakeTimers(); + const root = await mount(createElement(GoogleScreen)); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findAllByType('DetailScreenScrollView' as ElementType)).toHaveLength(0); + await act(async () => { + press(root.findByType('Button' as ElementType)); + await Promise.resolve(); + }); + expect(mocks.copy).toHaveBeenCalledWith('connect-google'); + }); + + it('keeps command failures inside the centered Google setup', async () => { + mocks.setup.mockReturnValue(query(undefined, true)); + const root = await mount(createElement(GoogleScreen)); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + const [retry, copy] = root.findAllByType('Button' as ElementType); + if (!retry || !copy) { + throw new Error('Expected retry and copy buttons'); + } + press(retry); + expect(mocks.refetch).toHaveBeenCalledOnce(); + expect(copy.props.disabled).toBe(true); + }); + + it('preserves connected Google spacing and settings actions in one scroller', async () => { + mocks.status.mockReturnValue(query({ googleConnected: true })); + const root = await mount(createElement(GoogleScreen)); + expect(root.findAllByType(CenteredState)).toHaveLength(0); + const scroller = root.findByType('DetailScreenScrollView' as ElementType); + expect(scroller.props.contentContainerClassName).toBe('px-4 pt-4 gap-4'); + press(scroller.findByProps({ size: 'sm' })); + expect(mocks.mutations.setGmailNotifications.mutate).toHaveBeenCalledWith({ enabled: true }); + }); + + it.each([ChannelsScreen, SecretsScreen, ModelListScreen, InstancePickerScreen])( + 'renders an empty body outside the list and picker scrollers for %s', + async Screen => { + const root = await mount(createElement(Screen)); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findAllByType('FlatList' as ElementType)).toHaveLength(0); + expect(root.findAllByType(ScrollView)).toHaveLength(1); + } + ); + + it('keeps model search visible and clears an empty search without nesting the state', async () => { + mocks.models.mockReturnValue(query([{ id: 'model-1', name: 'Model one', isPreferred: false }])); + const root = await mount(createElement(ModelListScreen)); + const search = root.findByType('TextInput' as ElementType).props as { + onChangeText: (text: string) => void; + }; + act(() => { + search.onChangeText('missing'); + }); + expect(root.findAllByType('FlatList' as ElementType)).toHaveLength(0); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findByType(EmptyState).props.title).toBe('kiloclaw.modelList.noMatches'); + act(() => { + press(root.findByType('Button' as ElementType)); + }); + expect(root.findAllByType(CenteredState)).toHaveLength(0); + expect(root.findAllByType('FlatList' as ElementType)).toHaveLength(1); + }); + + it('keeps one picker scroller and preserves setup and retry actions', async () => { + const empty = await mount(createElement(InstancePickerScreen)); + expect(empty.findAllByType(ScrollView)).toHaveLength(1); + press(empty.findByType('Button' as ElementType)); + expect(mocks.push).toHaveBeenCalledWith('/(app)/onboarding'); + mocks.instances.mockReturnValue(query(undefined, true)); + const error = await mount(createElement(InstancePickerScreen)); + expect(error.findAllByType(CenteredState)).toHaveLength(1); + expect(error.findAllByType(ScrollView)).toHaveLength(1); + press(error.findByType('Button' as ElementType)); + expect(mocks.refetch).toHaveBeenCalledOnce(); + }); + + it('preserves the instance-boundary retry and missing-instance action', async () => { + const error = await mount( + createElement(InstanceContextBoundary, { + title: 'Instance', + context: { status: 'error', refetch: mocks.refetch }, + }) + ); + expect(error.findAllByType(CenteredState)).toHaveLength(1); + expect(error.findAllByType(QueryError)).toHaveLength(1); + press(error.findByType('Button' as ElementType)); + expect(mocks.refetch).toHaveBeenCalledOnce(); + const missing = await mount( + createElement(InstanceContextBoundary, { + title: 'Instance', + context: { status: 'not_found' }, + }) + ); + expect(missing.findAllByType(CenteredState)).toHaveLength(1); + press(missing.findByType('Button' as ElementType)); + expect(mocks.replace).toHaveBeenCalledWith('/(app)/(tabs)/(1_kiloclaw)'); + }); +}); diff --git a/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx b/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx index c97a5e35d4..8bc924aa6d 100644 --- a/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-context-boundary.tsx @@ -15,37 +15,21 @@ type Props = { context: InstanceContextResult; }; -/** - * Renders the full-screen shell (background + `ScreenHeader`) for the - * terminal states of `useInstanceContext`: an error with retry, or an - * "instance not found" empty state (destroyed instance / stale deep link). - * Callers only reach this for `error`/`not_found` — `loading`/`ready` are - * handled by the screen itself. - */ export function InstanceContextBoundary({ title, context }: Readonly) { const router = useRouter(); const { t } = useTranslation(); - if (context.status === 'error') { - return ( - - - - { - context.refetch(); - }} - /> - - - ); - } - return ( - + {context.status === 'error' ? ( + { + context.refetch(); + }} + /> + ) : ( ) { } /> - + )} ); } diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index 99b4dc3d32..1382f52f04 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -19,6 +19,7 @@ import { ActivityIndicator, Pressable, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; import { z } from 'zod'; +import { CenteredState } from '@/components/centered-state'; import { AccessRequiredScreen } from '@/components/kiloclaw/access-required-screen'; import { resolveAccessRequiredSubcase } from '@/components/kiloclaw/empty-state-content'; import { FlowBody } from '@/components/kiloclaw/onboarding/flow-body'; @@ -353,31 +354,29 @@ export function OnboardingFlow() { ); - if (onboardingQuery.isPending || !state.onboardingStateLoaded) { + if (onboardingQuery.isError) { return ( - - - - - + { + void onboardingQuery.refetch(); + }} + /> ); } - if (onboardingQuery.isError) { + if (onboardingQuery.isPending || !state.onboardingStateLoaded) { return ( - - { - void onboardingQuery.refetch(); - }} - /> - + + + + + ); } @@ -387,23 +386,27 @@ export function OnboardingFlow() { ? resolveAccessRequiredSubcase(onboardingQuery.data) : null; let unavailableContent: ReactNode = ( - - - - {t('kiloclaw.onboarding.flow.finishingSetup')} - - + + + + + {t('kiloclaw.onboarding.flow.finishingSetup')} + + + ); if (onboardingQuery.data?.state === 'signup_unavailable') { unavailableContent = ( - - - {t('kiloclaw.onboarding.unavailableTitle')} - - - {t('kiloclaw.onboarding.unavailableDescription')} - - + + + + {t('kiloclaw.onboarding.unavailableTitle')} + + + {t('kiloclaw.onboarding.unavailableDescription')} + + + ); } else if (subcase) { unavailableContent = ; @@ -412,10 +415,7 @@ export function OnboardingFlow() { return ( - + {unavailableContent} diff --git a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx index 000e744c20..30c6c0f3e1 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/flow-body.tsx @@ -3,6 +3,7 @@ import { View } from 'react-native'; import { useTranslation } from 'react-i18next'; import Animated, { FadeIn } from 'react-native-reanimated'; +import { CenteredState } from '@/components/centered-state'; import { CompleteStep } from '@/components/kiloclaw/onboarding/complete-step'; import { IdentityStep } from '@/components/kiloclaw/onboarding/identity-step'; import { NotificationsStep } from '@/components/kiloclaw/onboarding/notifications-step'; @@ -45,79 +46,75 @@ export function FlowBody(props: Readonly) { if (errorCategory === 'access_conflict') { const warn = toneColor('warn'); return ( - - - - - - - {t('kiloclaw.onboarding.flow.review')} - - - {t('kiloclaw.onboarding.flow.manualReviewTitle')} - - - {t('kiloclaw.onboarding.flow.manualReviewBody')} - - - - + + + + + + + + {t('kiloclaw.onboarding.flow.review')} + + + {t('kiloclaw.onboarding.flow.manualReviewTitle')} + + + {t('kiloclaw.onboarding.flow.manualReviewBody')} + + + + + ); } if (errorCategory === 'generic') { const danger = toneColor('danger'); return ( - - - - - - - {t('kiloclaw.onboarding.flow.provisioning')} - - - {t('kiloclaw.onboarding.flow.somethingWentWrong')} - - - {t('kiloclaw.onboarding.flow.genericErrorBody')} - - - - + + + + + + + + {t('kiloclaw.onboarding.flow.provisioning')} + + + {t('kiloclaw.onboarding.flow.somethingWentWrong')} + + + {t('kiloclaw.onboarding.flow.genericErrorBody')} + + + + + ); } diff --git a/apps/mobile/src/components/kiloclaw/onboarding/full-surface-states.mounted.test.tsx b/apps/mobile/src/components/kiloclaw/onboarding/full-surface-states.mounted.test.tsx new file mode 100644 index 0000000000..469a7b00bf --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/onboarding/full-surface-states.mounted.test.tsx @@ -0,0 +1,248 @@ +import { act, createElement, type ElementType, type ReactElement } from 'react'; +import { Platform } from 'react-native'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CenteredState } from '@/components/centered-state'; +import { AccessRequiredScreen, type AccessRequiredSubcase } from '../access-required-screen'; +import { EmptyStateContent } from '../empty-state-content'; +import { OnboardingFlow } from '../onboarding-flow'; +import { INITIAL_STATE } from '@/lib/onboarding'; +import { renderWithProviders } from '@/test/render-with-providers'; +import { CompleteStep } from './complete-step'; +import { FlowBody } from './flow-body'; +import { ProvisioningStep } from './provisioning-step'; + +const mocks = vi.hoisted(() => ({ + onboarding: vi.fn(), + retry: vi.fn<() => void>(), + background: vi.fn<() => void>(), + mutations: { + start: { mutate: vi.fn() }, + provision: { mutate: vi.fn() }, + patchBotIdentity: { mutate: vi.fn() }, + patchExecPreset: { mutate: vi.fn() }, + }, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Pressable: 'Pressable', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'android' }, + Linking: { openURL: vi.fn() }, +})); +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView' }, + FadeIn: { duration: vi.fn() }, + FadeOut: { duration: vi.fn() }, + LinearTransition: {}, + ZoomIn: { springify: () => ({ damping: () => ({ stiffness: vi.fn() }) }) }, + useAnimatedStyle: vi.fn(), + useSharedValue: (value: number) => ({ value }), + useReducedMotion: () => true, + withDelay: vi.fn(), + withSequence: vi.fn(), + withTiming: vi.fn(), +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/i18n', () => ({ i18n: { t: (key: string) => key, language: 'en' } })); +vi.mock('expo-router', () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn() }), +})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/ui/icons', () => ({ + AlertCircle: 'AlertCircle', + AlertTriangle: 'AlertTriangle', + Clock: 'Clock', + ExternalLink: 'ExternalLink', + LifeBuoy: 'LifeBuoy', + PauseCircle: 'PauseCircle', + ShieldAlert: 'ShieldAlert', + Lock: 'Lock', + SearchX: 'SearchX', + ServerCrash: 'ServerCrash', + WifiOff: 'WifiOff', + Server: 'Server', + X: 'X', + Plus: 'Plus', +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/ui/status-dot', () => ({ StatusDot: 'StatusDot' })); +vi.mock('@/components/kiloclaw/bot-avatar', () => ({ BotAvatar: 'BotAvatar' })); +vi.mock('./identity-step', () => ({ IdentityStep: 'IdentityStep' })); +vi.mock('./notifications-step', () => ({ NotificationsStep: 'NotificationsStep' })); +vi.mock('@/lib/hooks/use-kiloclaw-queries', () => ({ + useKiloClawStatus: () => ({ data: undefined, isError: false }), + useKiloClawMutations: () => mocks.mutations, + useKiloClawMobileOnboardingState: mocks.onboarding, + useKiloClawGatewayReady: () => ({ data: undefined, isError: false }), +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ foreground: '#000000', mutedForeground: '#666666' }), +})); +vi.mock('@/lib/trpc', () => ({ useTRPC: () => ({ kiloclaw: {} }) })); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://example.test' })); +vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() })); +vi.mock('@/lib/appsflyer', () => ({ trackEvent: vi.fn() })); + +const mounted: Awaited>[] = []; +async function mount(ui: ReactElement) { + const result = await renderWithProviders(ui); + mounted.push(result); + return result.renderer.root; +} + +function press(node: { props: unknown }) { + (node.props as { onPress: () => void }).onPress(); +} + +beforeEach(() => { + vi.clearAllMocks(); + Platform.OS = 'android'; + mocks.onboarding.mockReturnValue({ + data: undefined, + isError: true, + isPending: false, + refetch: mocks.retry, + }); +}); + +afterEach(() => { + for (const result of mounted.splice(0)) { + result.unmount(); + } + vi.useRealTimers(); +}); + +describe('KiloClaw onboarding full-body states', () => { + it('shows the initial onboarding error instead of an indefinite skeleton', async () => { + const root = await mount(createElement(OnboardingFlow)); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + press(root.findByType('Button' as ElementType)); + expect(mocks.retry).toHaveBeenCalledOnce(); + }); + + it.each(['pending_settlement', 'signup_unavailable'] as const)( + 'centers %s in the tab and onboarding without nesting states', + async state => { + mocks.onboarding.mockReturnValue({ + data: { state, instanceId: null }, + isError: false, + isPending: false, + }); + const onboarding = await mount(createElement(OnboardingFlow)); + expect(onboarding.findAllByType(CenteredState)).toHaveLength(1); + const tab = await mount( + createElement(EmptyStateContent, { + state: { state, instanceId: null }, + foregroundColor: '#000000', + onCreate: mocks.retry, + }) + ); + expect(tab.findAllByType(CenteredState)).toHaveLength(1); + } + ); + + it.each([ + 'trial_expired', + 'subscription_canceled', + 'subscription_past_due', + 'quarantined', + 'multiple_current_conflict', + 'non_canonical_earlybird', + ])('centers access state %s and preserves the iOS action restriction', async subcase => { + const android = await mount(createElement(AccessRequiredScreen, { subcase })); + expect(android.findAllByType(CenteredState)).toHaveLength(1); + expect(android.findAllByType('Button' as ElementType)).toHaveLength(1); + Platform.OS = 'ios'; + const ios = await mount(createElement(AccessRequiredScreen, { subcase })); + expect(ios.findAllByType(CenteredState)).toHaveLength(1); + expect(ios.findAllByType('Button' as ElementType)).toHaveLength(0); + }); + + it.each(['access_conflict', 'generic'] as const)( + 'centers the %s onboarding terminal', + async errorCategory => { + const root = await mount( + createElement(FlowBody, { + state: { ...INITIAL_STATE, errorCategory }, + onIdentityContinue: mocks.retry, + onNotificationsComplete: mocks.retry, + onProvisioningComplete: mocks.retry, + onRetry: mocks.retry, + onGraceElapsed: mocks.retry, + onContinueInBackground: mocks.background, + onOpenInstance: mocks.retry, + }) + ); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + } + ); + + it.each([{ queryErrored: true }, { instanceStatus: 'stopped' }, { gateway502Expired: true }])( + 'centers provisioning failures and preserves both actions', + async state => { + const root = await mount( + createElement(ProvisioningStep, { + state: { ...INITIAL_STATE, ...state }, + onComplete: mocks.retry, + onGraceElapsed: mocks.retry, + onRetry: mocks.retry, + onContinueInBackground: mocks.background, + }) + ); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + const [retry, background] = root.findAllByType('Button' as ElementType); + if (!retry || !background) { + throw new Error('Expected retry and background buttons'); + } + press(retry); + press(background); + expect(mocks.retry).toHaveBeenCalledOnce(); + expect(mocks.background).toHaveBeenCalledOnce(); + } + ); + + it('centers provisioning waits and timeout actions', async () => { + vi.useFakeTimers(); + const root = await mount( + createElement(ProvisioningStep, { + state: INITIAL_STATE, + onComplete: mocks.retry, + onGraceElapsed: mocks.retry, + onRetry: mocks.retry, + onContinueInBackground: mocks.background, + }) + ); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findAllByType('Button' as ElementType)).toHaveLength(0); + act(() => { + vi.advanceTimersByTime(150_000); + }); + expect(root.findAllByType(CenteredState)).toHaveLength(1); + expect(root.findAllByType('Button' as ElementType)).toHaveLength(2); + }); + + it('preserves the successful completion layout and action through the flow body', async () => { + const root = await mount( + createElement(FlowBody, { + state: { ...INITIAL_STATE, step: 'done', provisionSuccess: true }, + onIdentityContinue: mocks.retry, + onNotificationsComplete: mocks.retry, + onProvisioningComplete: mocks.retry, + onRetry: mocks.retry, + onGraceElapsed: mocks.retry, + onContinueInBackground: mocks.background, + onOpenInstance: mocks.retry, + }) + ); + expect(root.findAllByType(CenteredState)).toHaveLength(0); + expect(root.findByType(CompleteStep).parent?.props.className).toBe('flex-1'); + press(root.findByType('Button' as ElementType)); + expect(mocks.retry).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.mounted.test.tsx b/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.mounted.test.tsx index 56b40c7918..b737ce7a87 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.mounted.test.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/notifications-step.mounted.test.tsx @@ -5,7 +5,7 @@ // locale sends English push copy, and a null app_version drops the Android // channel id, so omitting either field breaks a device enrolled from here. -import { createElement } from 'react'; +import { createElement, type ElementType } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { NotificationsStep } from './notifications-step'; @@ -30,6 +30,7 @@ vi.mock('react-native', () => ({ })); vi.mock('expo-secure-store', () => ({ setItemAsync: vi.fn() })); vi.mock('expo-application', () => ({ nativeApplicationVersion: '1.0.5' })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/ui/directional-icons', () => ({ DirectionalChevronRight: () => null })); @@ -67,6 +68,26 @@ describe('NotificationsStep push registration', () => { registerTokenMutationFn.mockResolvedValue({ success: true }); }); + it('preserves the permission spinner and the notification form scroller', async () => { + const permission = Promise.withResolvers<'undetermined'>(); + getNotificationPermissionStatus.mockReturnValue(permission.promise); + const { renderer, unmount } = await renderWithProviders( + createElement(NotificationsStep, { onComplete: vi.fn<() => void>(), botIdentity: null }) + ); + expect(renderer.root.findAllByType('CenteredState' as ElementType)).toHaveLength(0); + expect( + renderer.root.findByType('ActivityIndicator' as ElementType).parent?.props.className + ).toBe('flex-1 items-center justify-center gap-3 px-6'); + expect(renderer.root.findAllByType('ScrollView' as ElementType)).toHaveLength(0); + permission.resolve('undetermined'); + await waitFor(() => renderer.root.findAllByType('ScrollView' as ElementType).length === 1); + expect( + renderer.root.findByType('ScrollView' as ElementType).props.contentContainerClassName + ).toBe('p-4 gap-6'); + expect(renderer.root.findAllByType('Button' as ElementType)).toHaveLength(2); + unmount(); + }); + it('registers the token with the active locale and the app version', async () => { await renderWithProviders( createElement(NotificationsStep, { onComplete: vi.fn<() => void>(), botIdentity: null }) diff --git a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx index 549701dabb..68a140a4a4 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx @@ -21,6 +21,7 @@ import Animated, { withTiming, } from 'react-native-reanimated'; +import { CenteredState } from '@/components/centered-state'; import { Button } from '@/components/ui/button'; import { BotAvatar } from '@/components/kiloclaw/bot-avatar'; import { Text } from '@/components/ui/text'; @@ -172,85 +173,83 @@ export function ProvisioningStep({ const danger = toneColor('danger'); const content = TERMINAL_CONTENT[terminalReason]; return ( - - - - - - - {t('kiloclaw.onboarding.flow.provisioning')} - - {t(content.titleKey)} - - {t(content.bodyKey, { - name: botName, - displayMinutes: formatNumber(OVERALL_TIMEOUT_MS / 60_000, i18n.language), - })} - - - - - - - + {t(content.titleKey)} + + {t(content.bodyKey, { + name: botName, + displayMinutes: formatNumber(OVERALL_TIMEOUT_MS / 60_000, i18n.language), + })} + + + + + + + + ); } return ( - - - - - - - - - {t('kiloclaw.onboarding.flow.provisioning')} - - - {t('kiloclaw.onboarding.provisioning.settingUp', { name: botName })} - - - + + - - {stageMessage} - + - - - {t('kiloclaw.onboarding.provisioning.backgroundHint')} - - + + + + {t('kiloclaw.onboarding.flow.provisioning')} + + + {t('kiloclaw.onboarding.provisioning.settingUp', { name: botName })} + + + + + + {stageMessage} + + + + + + {t('kiloclaw.onboarding.provisioning.backgroundHint')} + + + ); } diff --git a/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx b/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx index 60cb07d8e0..4e83bbf3cc 100644 --- a/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/language-picker-sheet.mounted.test.tsx @@ -84,7 +84,11 @@ vi.mock('@/i18n/apply-language', async importOriginal => { }; }); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); -vi.mock('@/components/picker-sheet', () => ({ PickerSheet: 'PickerSheet' })); +vi.mock('@/components/picker-sheet', () => ({ + PickerSheet: (props: { headerContent?: ReactNode; children?: ReactNode }) => + createElement('PickerSheet', props, props.headerContent, props.children), +})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/ui/choice-row', () => ({ ChoiceRow: 'ChoiceRow' })); vi.mock('@/components/ui/icons', () => ({ SearchX: 'SearchX' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); @@ -213,11 +217,13 @@ describe('LanguagePickerSheet apply', () => { const emptyState = findByType(renderer.root, 'EmptyState')[0]; expect(emptyState?.props).toMatchObject({ icon: 'SearchX', - placement: 'top', title: 'No languages match', description: 'Try a different search term.', }); expect(findByType(renderer.root, 'ChoiceRow')).toHaveLength(0); + expect(findByType(renderer.root, 'FlatList')).toHaveLength(0); + expect(findByType(renderer.root, 'TextInput')[0]).toBe(input); + expect(emptyState?.props.placement).toBeUndefined(); act(() => { (input.props.onChangeText as (value: string) => void)(''); diff --git a/apps/mobile/src/components/language-picker-sheet.tsx b/apps/mobile/src/components/language-picker-sheet.tsx index 04e6912cf1..b245628558 100644 --- a/apps/mobile/src/components/language-picker-sheet.tsx +++ b/apps/mobile/src/components/language-picker-sheet.tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, FlatList, I18nManager, TextInput, View } from 'react import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; +import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { LanguagePickerRow } from '@/components/language-picker-row'; import { PickerSheet } from '@/components/picker-sheet'; @@ -163,11 +164,13 @@ export function LanguagePickerSheet({ disabled={busy} scrollable={false} > - - - {t('language.languageSaved')} - - + + + + {t('language.languageSaved')} + + + ); } @@ -185,12 +188,14 @@ export function LanguagePickerSheet({ disabled scrollable={false} > - - - - {t('language.restarting')} - - + + + + + {t('language.restarting')} + + + ); } @@ -204,61 +209,61 @@ export function LanguagePickerSheet({ doneLabel={t('common.done')} disabled={busy} scrollable={false} + headerContent={ + + + + } > - item.key} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - contentContainerClassName="px-4 pb-4" - ListFooterComponent={} - ListHeaderComponent={ - - + ) : ( + item.key} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + contentContainerClassName="px-4 pb-4" + ListFooterComponent={} + renderItem={({ item, index }) => ( + - - } - ListEmptyComponent={ - - } - renderItem={({ item, index }) => ( - - )} - /> + )} + /> + )} ); } diff --git a/apps/mobile/src/components/language-reload-error-screen.mounted.test.tsx b/apps/mobile/src/components/language-reload-error-screen.mounted.test.tsx index 7738b9d522..0b8215204c 100644 --- a/apps/mobile/src/components/language-reload-error-screen.mounted.test.tsx +++ b/apps/mobile/src/components/language-reload-error-screen.mounted.test.tsx @@ -15,6 +15,7 @@ vi.mock('react-native', () => ({ vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); diff --git a/apps/mobile/src/components/login-screen.test.ts b/apps/mobile/src/components/login-screen.test.ts index 957005ef05..d87481c73b 100644 --- a/apps/mobile/src/components/login-screen.test.ts +++ b/apps/mobile/src/components/login-screen.test.ts @@ -59,6 +59,7 @@ vi.mock('@/components/kilo-chat/app-aware-keyboard-padding-state', () => ({ resolveAppAwareKeyboardPadding: vi.fn(), resolveKeyboardPaddingEventsForPlatform: () => null, })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/login/idle-auth', () => ({ IdleAuth: 'IdleAuth' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/image', () => ({ Image: 'Image' })); diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx index d8ba32d1dc..ea2f7a79dd 100644 --- a/apps/mobile/src/components/login-screen.tsx +++ b/apps/mobile/src/components/login-screen.tsx @@ -20,6 +20,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; import logo from '@/../assets/images/logo.png'; +import { CenteredState } from '@/components/centered-state'; import { resolveAppAwareKeyboardPadding, resolveKeyboardPaddingEventsForPlatform, @@ -175,19 +176,21 @@ export function LoginScreen() { if (status === 'approved') { if (persistError) { return ( - - {persistError} - - + + + {persistError} + + + ); } return ( diff --git a/apps/mobile/src/components/offline-banner.mounted.test.tsx b/apps/mobile/src/components/offline-banner.mounted.test.tsx index 35b1aa8f15..c1142ef179 100644 --- a/apps/mobile/src/components/offline-banner.mounted.test.tsx +++ b/apps/mobile/src/components/offline-banner.mounted.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts React/RN trees without a DOM */ -import { createElement, type ReactElement, type ReactNode, useSyncExternalStore } from 'react'; +import { type ReactElement, useSyncExternalStore } from 'react'; import { type MobileRouter } from '@kilocode/trpc/mobile'; import { onlineManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createTRPCClient, httpLink } from '@trpc/client'; @@ -87,15 +87,12 @@ vi.mock('@/lib/hooks/use-security-agent-mutations', () => ({ vi.mock('@/components/security-agent/audit-report-button', () => ({ AuditReportButton: 'AuditReportButton', })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); -vi.mock('@/components/empty-state', () => ({ - EmptyState: ({ description, action }: { description?: ReactNode; action?: ReactNode }) => - createElement('EmptyState', null, description, action), -})); vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView', useTabBarBottomPadding: () => 0, diff --git a/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx index 2f5b0e3dca..b59bcafe20 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.mounted.test.tsx @@ -110,7 +110,8 @@ vi.mock('@/lib/utils', () => ({ })); vi.mock('@/components/empty-state', () => ({ - EmptyState: ({ title }: { title: string }) => `EMPTY_STATE:${title}`, + EmptyState: (props: { title: string; placement?: string }) => + createElement('EmptyState', props, `EMPTY_STATE:${props.title}`), })); vi.mock('@/components/query-error', () => ({ @@ -160,7 +161,7 @@ vi.mock('react-native', () => ({ }) => { const data = props.data ?? []; if (data.length === 0) { - return props.ListEmptyComponent ?? null; + return createElement('FlatList', null, props.ListEmptyComponent, props.ListFooterComponent); } return createElement( 'View', @@ -273,14 +274,33 @@ describe('OrganizationCreditActivityScreen empty', () => { it('renders the empty state when the first page has no entries', async () => { pageQuery.data = { pages: [{ entries: [], nextCursor: null, hasMore: false }] }; - const texts = await renderScreen(); + const { renderer, unmount } = await renderWithProviders( + createElement(OrganizationCreditActivityScreen) + ); - expect(texts).toContain('EMPTY_STATE:No credit activity'); + expect(collectText(renderer.toJSON())).toContain('EMPTY_STATE:No credit activity'); + expect(renderer.root.findAll(node => String(node.type) === 'FlatList')).toHaveLength(0); + expect( + ( + renderer.root.find(node => String(node.type) === 'EmptyState').props as { + placement?: string; + } + ).placement + ).not.toBe('top'); expect(queryErrors.errors).toHaveLength(0); + unmount(); }); }); describe('OrganizationCreditActivityScreen pagination', () => { + it('keeps pagination available when a loaded page has no visible entries', async () => { + pageQuery.data = { pages: [{ entries: [], nextCursor: 1, hasMore: true }] }; + pageHook.hasMore = true; + const texts = await renderScreen(); + expect(texts).toContain('EMPTY_STATE:No credit activity'); + expect(buttons.rendered.some(button => button.accessibilityLabel === 'Load more')).toBe(true); + }); + it('renders the truncated footer with Load more when hasMore is true', async () => { pageQuery.data = { pages: [{ entries: [TRANSACTION], nextCursor: 1, hasMore: true }] }; pageHook.entries = [TRANSACTION]; diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index e70debe2be..a1cf66a808 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -196,7 +196,7 @@ export function OrganizationCreditActivityScreen() { ); } else if (isFirstPageError) { body = ( - + void query.refetch()} @@ -204,6 +204,14 @@ export function OrganizationCreditActivityScreen() { /> ); + } else if (transactions.length === 0 && !hasMore && !isLaterPageError) { + body = ( + + ); } else { const footer = ( @@ -252,6 +260,7 @@ export function OrganizationCreditActivityScreen() { contentContainerClassName="grow gap-3 px-6 pt-4" ListEmptyComponent={ ({ })); vi.mock('@/components/empty-state', () => ({ - EmptyState: ({ title }: { title: string }) => `EMPTY_STATE:${title}`, + EmptyState: (props: { title: string; placement?: string }) => + createElement('EmptyState', props, `EMPTY_STATE:${props.title}`), })); vi.mock('@/components/query-error', () => ({ @@ -146,7 +147,7 @@ vi.mock('react-native', () => ({ }) => { const data = props.data ?? []; if (data.length === 0) { - return props.ListEmptyComponent ?? null; + return createElement('FlatList', null, props.ListEmptyComponent, props.ListFooterComponent); } return createElement( 'View', @@ -260,14 +261,33 @@ describe('OrganizationInvoicesScreen empty', () => { it('renders the empty state when the first page has no entries', async () => { pageQuery.data = { pages: [{ entries: [], nextCursor: null, hasMore: false }] }; - const texts = await renderScreen(); + const { renderer, unmount } = await renderWithProviders( + createElement(OrganizationInvoicesScreen) + ); - expect(texts).toContain('EMPTY_STATE:No invoices'); + expect(collectText(renderer.toJSON())).toContain('EMPTY_STATE:No invoices'); + expect(renderer.root.findAll(node => String(node.type) === 'FlatList')).toHaveLength(0); + expect( + ( + renderer.root.find(node => String(node.type) === 'EmptyState').props as { + placement?: string; + } + ).placement + ).not.toBe('top'); expect(queryErrors.errors).toHaveLength(0); + unmount(); }); }); describe('OrganizationInvoicesScreen pagination', () => { + it('keeps pagination available when a loaded page has no visible entries', async () => { + pageQuery.data = { pages: [{ entries: [], nextCursor: 'next', hasMore: true }] }; + pageHook.hasMore = true; + const texts = await renderScreen(); + expect(texts).toContain('EMPTY_STATE:No invoices'); + expect(buttons.rendered.some(button => button.accessibilityLabel === 'Load more')).toBe(true); + }); + it('renders the truncated footer with Load more when hasMore is true', async () => { pageQuery.data = { pages: [{ entries: [INVOICE], nextCursor: 'inv-1', hasMore: true }] }; pageHook.entries = [INVOICE]; diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index a5d75d071d..f465afbe5b 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -229,7 +229,7 @@ export function OrganizationInvoicesScreen() { ); } else if (isFirstPageError) { body = ( - + void query.refetch()} @@ -237,6 +237,14 @@ export function OrganizationInvoicesScreen() { /> ); + } else if (invoices.length === 0 && !hasMore && !isLaterPageError) { + body = ( + + ); } else { const footer = ( @@ -285,6 +293,7 @@ export function OrganizationInvoicesScreen() { contentContainerClassName="grow gap-3 px-6 pt-4" ListEmptyComponent={ ; } - let body: ReactNode = null; - if (orgWithMembers.data) { - body = ( - - ); - } else if (orgWithMembers.isError) { - body = ( - void orgWithMembers.refetch()} - isRetrying={orgWithMembers.isFetching} - placement="top" - /> + if (orgWithMembers.isError && !orgWithMembers.data) { + return ( + <> + + + {t('organization.lowBalanceAlert.title')} + + + void orgWithMembers.refetch()} + isRetrying={orgWithMembers.isFetching} + /> + ); } @@ -223,7 +222,12 @@ export function LowBalanceAlertSheet() { {t('organization.lowBalanceAlert.title')} - {body} + {orgWithMembers.data && ( + + )} ); } diff --git a/apps/mobile/src/components/organization/member-limit-sheet.mounted.test.tsx b/apps/mobile/src/components/organization/member-limit-sheet.mounted.test.tsx new file mode 100644 index 0000000000..41748a093a --- /dev/null +++ b/apps/mobile/src/components/organization/member-limit-sheet.mounted.test.tsx @@ -0,0 +1,128 @@ +import { createElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@/test/render-with-providers'; +import { InviteMemberSheet } from './invite-member-sheet'; +import { LowBalanceAlertSheet } from './low-balance-alert-sheet'; +import { MemberLimitSheet } from './member-limit-sheet'; + +const boundary = vi.hoisted(() => ({ + organizationId: 'org-1' as string | null, + role: 'owner', + org: null as { organizationId: string } | null, + isResolving: false, +})); +const query = vi.hoisted(() => ({ + data: undefined as { members: { id: string; status: string }[]; settings: object } | undefined, + isLoading: false, + isPending: false, + isError: false, + isFetching: false, + refetch: vi.fn(), +})); +vi.mock('expo-router', () => ({ useRouter: () => ({ back: vi.fn() }) })); +vi.mock('expo-haptics', () => ({ selectionAsync: vi.fn() })); +vi.mock('react-native', () => ({ View: 'View', ScrollView: 'ScrollView', Pressable: 'Pressable' })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/organization/organization-boundary', () => ({ + OrganizationBoundary: 'OrganizationBoundary', +})); +vi.mock('@/components/organization/member-row', () => ({ roleLabel: String })); +vi.mock('@/components/organization/invited-member-row-state', () => ({ + getInviteSuccessMessage: () => '', +})); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/form-field', () => ({ FormField: 'FormField' })); +vi.mock('@/components/ui/radio-group', () => ({ + RadioGroup: 'RadioGroup', + radioItemA11y: () => ({}), +})); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/icons', () => ({ Check: 'Check', Lock: 'Lock' })); +vi.mock('@/lib/a11y/announcing-toast', () => ({ announcingToast: { success: vi.fn() } })); +vi.mock('@/lib/analytics/posthog', () => ({ + captureEvent: vi.fn(), + ORGANIZATION_MEMBER_INVITED_EVENT: 'invited', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/hooks/use-organization-queries', () => ({ + useOrgBoundary: () => boundary, + useOrgWithMembers: () => query, + isActiveOrgMember: (member: { status: string }) => member.status === 'active', + isMoneyRole: (role: string) => role === 'owner' || role === 'billing_manager', +})); +vi.mock('@/lib/hooks/use-organization-mutations', () => ({ useOrganizationMutations: () => ({}) })); +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ email: 'test@example.com' }), +})); + +beforeEach(() => { + boundary.organizationId = 'org-1'; + boundary.org = { organizationId: 'org-1' }; + boundary.role = 'owner'; + boundary.isResolving = false; + query.data = undefined; + query.isLoading = false; + query.isPending = false; + query.isError = false; + query.refetch.mockClear(); +}); + +const sheets = [ + { name: 'member limit', element: createElement(MemberLimitSheet, { memberId: 'member-1' }) }, + { name: 'low balance alert', element: createElement(LowBalanceAlertSheet) }, +]; + +describe('organization sheet surfaces', () => { + it.each(sheets)('lifts the $name error outside the form scroller', async ({ element }) => { + query.isError = true; + const { renderer, unmount } = await renderWithProviders(element); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + const error = renderer.root.find(node => String(node.type) === 'QueryError'); + const props = error.props as { placement?: string; onRetry: () => void }; + expect(props.placement).not.toBe('top'); + props.onRetry(); + expect(query.refetch).toHaveBeenCalledOnce(); + expect(renderer.toJSON()).toHaveLength(2); + unmount(); + }); + + it('centers a missing member without nesting the state in a scroller', async () => { + query.data = { members: [], settings: {} }; + const { renderer, unmount } = await renderWithProviders( + createElement(MemberLimitSheet, { memberId: 'missing' }) + ); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + unmount(); + }); + + it.each([...sheets, { name: 'invite', element: createElement(InviteMemberSheet) }])( + 'keeps the $name context boundary outside a scroller', + async ({ element }) => { + boundary.organizationId = null; + boundary.org = null; + const { renderer, unmount } = await renderWithProviders(element); + expect( + renderer.root.findAll(node => String(node.type) === 'OrganizationBoundary') + ).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + unmount(); + } + ); + + it.each([...sheets, { name: 'invite', element: createElement(InviteMemberSheet) }])( + 'keeps $name permission denial outside a scroller', + async ({ element }) => { + boundary.role = 'member'; + const { renderer, unmount } = await renderWithProviders(element); + expect(renderer.root.findAll(node => String(node.type) === 'EmptyState')).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + unmount(); + } + ); +}); diff --git a/apps/mobile/src/components/organization/member-limit-sheet.tsx b/apps/mobile/src/components/organization/member-limit-sheet.tsx index 5ef95af9a1..a5d6e69c71 100644 --- a/apps/mobile/src/components/organization/member-limit-sheet.tsx +++ b/apps/mobile/src/components/organization/member-limit-sheet.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; import { OrganizationBoundary } from '@/components/organization/organization-boundary'; import { limitError, parseLimit } from '@/components/organization/member-limit-validators'; import { PermissionDenied } from '@/components/organization/permission-denied'; @@ -133,31 +134,29 @@ export function MemberLimitSheet({ memberId }: Readonly<{ memberId: string }>) { return ; } - if (orgWithMembers.isError && !orgWithMembers.data) { + const loadFailed = orgWithMembers.isError && !orgWithMembers.data; + if (loadFailed || !member) { return ( - - - {t('organization.memberLimit.title')} - - void orgWithMembers.refetch()} - isRetrying={orgWithMembers.isFetching} - placement="top" - /> - - ); - } - - if (!member) { - return ( - - - {t('organization.memberLimit.title')} - - - {t('organization.memberLimit.memberNotFound')} - - + <> + + + {t('organization.memberLimit.title')} + + + {loadFailed ? ( + void orgWithMembers.refetch()} + isRetrying={orgWithMembers.isFetching} + /> + ) : ( + + + {t('organization.memberLimit.memberNotFound')} + + + )} + ); } diff --git a/apps/mobile/src/components/organization/members-screen.mounted.test.tsx b/apps/mobile/src/components/organization/members-screen.mounted.test.tsx index e42a466e52..b2a44c9ebd 100644 --- a/apps/mobile/src/components/organization/members-screen.mounted.test.tsx +++ b/apps/mobile/src/components/organization/members-screen.mounted.test.tsx @@ -6,7 +6,7 @@ // error selector are unit-tested separately; this proves the loading → error → // empty precedence in the screen JSX itself. -import { type ComponentType, createElement, type ReactElement } from 'react'; +import { type ComponentType, createElement, type ReactElement, type ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '@/test/render-with-providers'; @@ -25,6 +25,8 @@ const withMembersQuery = vi.hoisted(() => ({ vi.mock('@/lib/hooks/use-organization-queries', () => ({ isMoneyRole: () => true, + isActiveOrgMember: (member: { status: string }) => member.status === 'active', + isInvitedOrgMember: (member: { status: string }) => member.status === 'invited', useOrgBoundary: () => ({ organizationId: 'org-1', role: 'owner', @@ -38,16 +40,22 @@ vi.mock('@shopify/flash-list', () => ({ FlashList: (props: { data?: unknown[]; ListEmptyComponent?: ComponentType | ReactElement | null; + renderItem?: (info: { item: unknown; index: number }) => ReactElement; }) => { const data = props.data ?? []; - if (data.length === 0) { - const Empty = props.ListEmptyComponent; - if (typeof Empty === 'function') { - return createElement(Empty); - } - return Empty ?? null; + const Empty = props.ListEmptyComponent; + if (data.length > 0) { + return createElement( + 'FlashList', + null, + data.map((item, index) => props.renderItem?.({ item, index })) + ); } - return null; + return createElement( + 'FlashList', + null, + typeof Empty === 'function' ? createElement(Empty) : Empty + ); }, })); @@ -61,7 +69,8 @@ vi.mock('@/components/ui/icons', () => ({ })); vi.mock('@/components/empty-state', () => ({ - EmptyState: ({ title }: { title: string }) => `EMPTY_STATE:${title}`, + EmptyState: (props: { title: string; placement?: string; action?: ReactNode }) => + createElement('EmptyState', props, `EMPTY_STATE:${props.title}`, props.action), })); vi.mock('@/components/organization/invited-member-row', () => ({ @@ -161,10 +170,40 @@ describe('OrganizationMembersScreen empty-state precedence', () => { expect(texts).not.toContain('No members yet'); }); - it('renders "No members yet" when there is no error and both member arrays are empty', async () => { - const texts = await renderScreen(); + it('renders "No members yet" outside the list when both member arrays are empty', async () => { + const { renderer, unmount } = await renderWithProviders( + createElement(OrganizationMembersScreen) + ); + const texts = collectText(renderer.toJSON()); expect(texts).not.toContain('QUERY_ERROR'); expect(texts).toContain('EMPTY_STATE:No members yet'); + expect(renderer.root.findAll(node => String(node.type) === 'FlashList')).toHaveLength(0); + unmount(); + }); + + it('keeps the empty member notice inline above cached invitations after a refetch failure', async () => { + withMembersQuery.isError = true; + withMembersQuery.data = { + settings: {}, + members: [{ status: 'invited', inviteId: 'invite-1', inviteDate: null }], + }; + const { renderer, unmount } = await renderWithProviders( + createElement(OrganizationMembersScreen) + ); + const list = renderer.root.find(node => String(node.type) === 'FlashList'); + expect(list.find(node => String(node.type) === 'EmptyState').props).toMatchObject({ + placement: 'top', + }); + expect(collectText(renderer.toJSON())).not.toContain('QUERY_ERROR'); + unmount(); + }); + + it('keeps the loading skeleton ahead of error and empty states', async () => { + withMembersQuery.isLoading = true; + withMembersQuery.isError = true; + const texts = await renderScreen(); + expect(texts).not.toContain('QUERY_ERROR'); + expect(texts).not.toContain('EMPTY_STATE:No members yet'); }); }); diff --git a/apps/mobile/src/components/organization/members-screen.tsx b/apps/mobile/src/components/organization/members-screen.tsx index ee400d95d0..65ef768093 100644 --- a/apps/mobile/src/components/organization/members-screen.tsx +++ b/apps/mobile/src/components/organization/members-screen.tsx @@ -1,7 +1,7 @@ import { FlashList } from '@shopify/flash-list'; import { type Href, useRouter } from 'expo-router'; import { UserPlus, Users } from '@/components/ui/icons'; -import { useMemo } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, View, type ViewStyle } from 'react-native'; @@ -103,7 +103,7 @@ export function OrganizationMembersScreen() { const emptyState = ( 0 ? 'top' : 'center'} title={t('organization.members.emptyTitle')} description={ canInvite @@ -126,32 +126,6 @@ export function OrganizationMembersScreen() { /> ); - // Loading, error, and empty are mutually exclusive and evaluated in this - // order. An error leaves both member arrays empty, so it must be checked - // before the empty branch — otherwise a 500 renders "No members yet". - const renderListEmpty = () => { - if (isLoading) { - return ( - - - - - - ); - } - if (errorView) { - return ( - void orgWithMembers.refetch() : undefined} - isRetrying={orgWithMembers.isFetching} - placement="top" - /> - ); - } - return emptyState; - }; - const renderItem = ({ item, index }: { item: MembersListItem; index: number }) => { switch (item.kind) { case 'section': { @@ -210,6 +184,19 @@ export function OrganizationMembersScreen() { } }; + let emptyBody: ReactNode = null; + if (!isLoading && errorView) { + emptyBody = ( + void orgWithMembers.refetch() : undefined} + isRetrying={orgWithMembers.isFetching} + /> + ); + } else if (!isLoading && items.length === 0) { + emptyBody = emptyState; + } + return ( - { - switch (item.kind) { - case 'section': { - return `section:${item.title}`; - } - case 'members-empty': { - return 'members-empty'; - } - case 'member': { - return item.member.id; - } - case 'invite': { - return item.invite.inviteId; - } - default: { - const _exhaustive: never = item; - return _exhaustive; + {emptyBody ?? ( + { + switch (item.kind) { + case 'section': { + return `section:${item.title}`; + } + case 'members-empty': { + return 'members-empty'; + } + case 'member': { + return item.member.id; + } + case 'invite': { + return item.invite.inviteId; + } + default: { + const _exhaustive: never = item; + return _exhaustive; + } } + }} + getItemType={item => item.kind} + ListEmptyComponent={ + + + + + } - }} - getItemType={item => item.kind} - ListEmptyComponent={renderListEmpty} - ListFooterComponent={} - showsVerticalScrollIndicator={false} - contentContainerStyle={listContentContainerStyle} - /> + ListFooterComponent={} + showsVerticalScrollIndicator={false} + contentContainerStyle={listContentContainerStyle} + /> + )} ); } diff --git a/apps/mobile/src/components/organization/organization-boundary.mounted.test.tsx b/apps/mobile/src/components/organization/organization-boundary.mounted.test.tsx index 9d38dab164..22bab6377e 100644 --- a/apps/mobile/src/components/organization/organization-boundary.mounted.test.tsx +++ b/apps/mobile/src/components/organization/organization-boundary.mounted.test.tsx @@ -82,12 +82,18 @@ function boundaryState(overrides: Partial = {}): BoundaryState { }; } -const Boundary = OrganizationBoundary as ComponentType<{ organizationIdOverride?: string }>; - -function mountBoundary(organizationIdOverride?: string): TestRenderer.ReactTestRenderer { +const Boundary = OrganizationBoundary as ComponentType<{ + organizationIdOverride?: string; + title?: string; +}>; + +function mountBoundary( + organizationIdOverride?: string, + title?: string +): TestRenderer.ReactTestRenderer { const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; act(() => { - ref.current = TestRenderer.create(createElement(Boundary, { organizationIdOverride })); + ref.current = TestRenderer.create(createElement(Boundary, { organizationIdOverride, title })); }); if (!ref.current) { throw new Error('boundary did not render'); @@ -120,6 +126,21 @@ beforeEach(() => { }); describe('OrganizationBoundary settled states', () => { + it.each([undefined, 'Organization'])( + 'renders the state without a scroller for title %s', + title => { + useOrgBoundaryMock.mockReturnValue(boundaryState()); + const renderer = mountBoundary(undefined, title); + const state = renderer.root.find(node => String(node.type) === 'EmptyStateMock'); + expect(findByType(renderer.root, 'ScreenHeaderMock')).toHaveLength(title ? 1 : 0); + expect(findByType(renderer.root, 'View')).toHaveLength(title ? 1 : 0); + expect(state).toBeDefined(); + act(() => { + renderer.unmount(); + }); + } + ); + it('renders a spinner while the org context is resolving', () => { useOrgBoundaryMock.mockReturnValue(boundaryState({ isResolving: true })); diff --git a/apps/mobile/src/components/organization/organization-boundary.tsx b/apps/mobile/src/components/organization/organization-boundary.tsx index f32b081933..847c6d7cc1 100644 --- a/apps/mobile/src/components/organization/organization-boundary.tsx +++ b/apps/mobile/src/components/organization/organization-boundary.tsx @@ -106,9 +106,13 @@ export function OrganizationBoundary({ } } + if (title == null) { + return content; + } + return ( - {title != null && } + {content} ); diff --git a/apps/mobile/src/components/organization/permission-denied.tsx b/apps/mobile/src/components/organization/permission-denied.tsx index e47cbf3f71..2bc1666695 100644 --- a/apps/mobile/src/components/organization/permission-denied.tsx +++ b/apps/mobile/src/components/organization/permission-denied.tsx @@ -1,7 +1,6 @@ import { useRouter } from 'expo-router'; import { Lock } from '@/components/ui/icons'; import { useTranslation } from 'react-i18next'; -import { View } from 'react-native'; import { EmptyState } from '@/components/empty-state'; import { Button } from '@/components/ui/button'; @@ -21,22 +20,21 @@ export function PermissionDenied({ description }: PermissionDeniedProps) { const { t } = useTranslation(); return ( - - { - router.back(); - }} - > - {t('organization.permissionDenied.back')} - - } - /> - + { + router.back(); + }} + > + {t('organization.permissionDenied.back')} + + } + /> ); } diff --git a/apps/mobile/src/components/picker-sheet.tsx b/apps/mobile/src/components/picker-sheet.tsx index 494823ad86..42ab734c2b 100644 --- a/apps/mobile/src/components/picker-sheet.tsx +++ b/apps/mobile/src/components/picker-sheet.tsx @@ -1,7 +1,7 @@ import { Info } from '@/components/ui/icons'; import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView } from 'react-native'; +import { ScrollView, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { EmptyState } from '@/components/empty-state'; @@ -14,6 +14,7 @@ export function PickerSheet({ doneLabel, cancelLabel, children, + headerContent, expired = false, scrollable = true, disabled = false, @@ -25,6 +26,7 @@ export function PickerSheet({ /** Overrides the leading control's text and accessibility label (nested Back). */ cancelLabel?: string; children?: ReactNode; + headerContent?: ReactNode; /** Set when the caller's data source (picker bridge) is gone — renders the standard "Options expired" empty state instead of children. */ expired?: boolean; /** @@ -54,15 +56,18 @@ export function PickerSheet({ // pinning the scroll view to the full sheet, painting it over the header. return ( <> - - {scrollable ? ( + + + {headerContent} + + {scrollable && !expired ? ( {body} ) : ( body diff --git a/apps/mobile/src/components/platform-error-screen.tsx b/apps/mobile/src/components/platform-error-screen.tsx index 14959dc30f..87818621a0 100644 --- a/apps/mobile/src/components/platform-error-screen.tsx +++ b/apps/mobile/src/components/platform-error-screen.tsx @@ -2,7 +2,6 @@ import { View } from 'react-native'; import { QueryError, type QueryErrorVariant } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; -import { useTabBarBottomPadding } from '@/components/tab-screen'; /** * Full-screen "load failed" state: a ScreenHeader over a centered QueryError, @@ -31,19 +30,16 @@ export function PlatformErrorScreen({ onRetry?: () => void; isRetrying?: boolean; }>) { - const paddingBottom = useTabBarBottomPadding(); return ( - - - + ); } diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx index 538ab64448..a8b16de647 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.test.tsx @@ -1,5 +1,6 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as screen-header.mounted.test.tsx) */ -import { createElement } from 'react'; +import { createElement, type ReactElement } from 'react'; +import { type RefreshControlProps } from 'react-native'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -23,6 +24,7 @@ const listQueryState = vi.hoisted(() => ({ vi.mock('react-native', () => ({ View: 'View', + RefreshControl: 'RefreshControl', })); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => insetsState, @@ -30,6 +32,7 @@ vi.mock('react-native-safe-area-context', () => ({ vi.mock('@shopify/flash-list', () => ({ FlashList: 'FlashList', })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ PrReviewReconnectNotice: 'PrReviewReconnectNotice', @@ -112,10 +115,12 @@ const BASE_PROPS = { changedFiles: 1, }; -function mountList(): TestRenderer.ReactTestRenderer { +function mountList(changedFiles = BASE_PROPS.changedFiles): TestRenderer.ReactTestRenderer { const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; act(() => { - ref.current = TestRenderer.create(createElement(PrReviewFileList, BASE_PROPS)); + ref.current = TestRenderer.create( + createElement(PrReviewFileList, { ...BASE_PROPS, changedFiles }) + ); }); const renderer = ref.current; if (!renderer) { @@ -147,57 +152,62 @@ function resetState(): void { listQueryState.firstPageErrorState = null; } -describe('PrReviewFileList first-page chrome bottom inset (plan §6)', () => { +describe('PrReviewFileList full-body states', () => { beforeEach(() => { insetsState.bottom = 0; resetState(); }); - it('pads the reconnect chrome by the detail-screen padding at a zero inset', () => { + it('centers the reconnect notice without local bottom padding', () => { listQueryState.firstPageErrorState = { kind: 'reconnect' }; const renderer = mountList(); - - const views = bottomPaddedViews(renderer); - expect(views).toHaveLength(1); - const view = views[0]; - if (!view) { - throw new Error('expected a padded View'); - } - expect((view.props.style as { paddingBottom?: number }).paddingBottom).toBe(32); + const centered = renderer.root.find(node => String(node.type) === 'CenteredState'); + expect(centered.find(node => String(node.type) === 'PrReviewReconnectNotice')).toBeDefined(); + expect(bottomPaddedViews(renderer)).toHaveLength(0); }); - it('pads the retryable chrome by the detail-screen padding at a zero inset', () => { + it('lets QueryError own the retryable body and retry action', () => { listQueryState.firstPageErrorState = { kind: 'retryable' }; const renderer = mountList(); - - const views = bottomPaddedViews(renderer); - expect(views).toHaveLength(1); - const view = views[0]; - if (!view) { - throw new Error('expected a padded View'); - } - expect((view.props.style as { paddingBottom?: number }).paddingBottom).toBe(32); + const error = renderer.root.find(node => String(node.type) === 'QueryError'); + expect(error.props.placement).toBeUndefined(); + expect(bottomPaddedViews(renderer)).toHaveLength(0); + act(() => { + (error.props.onRetry as () => void)(); + }); + expect(listQueryState.query.refetch).toHaveBeenCalled(); }); - it('grows the reconnect and retryable padding with a nonzero system inset', () => { - insetsState.bottom = 34; + it.each([false, true])('refreshes the waiting body with fetching state %s', isFetching => { + listQueryState.query.isFetching = isFetching; + listQueryState.query.refetch.mockClear(); + const renderer = mountList(); + const empty = renderer.root.find(node => String(node.type) === 'EmptyFilesView'); + const refreshControl = empty.props.refreshControl as ReactElement; + expect(refreshControl.type).toBe('RefreshControl'); + expect(refreshControl.props.refreshing).toBe(isFetching); + expect( + renderer.root.findAll(node => + ['FlashList', 'ScrollView', 'CenteredState'].includes(String(node.type)) + ) + ).toHaveLength(0); + act(() => { + refreshControl.props.onRefresh?.(); + }); + expect(listQueryState.query.refetch).toHaveBeenCalledOnce(); + }); - listQueryState.firstPageErrorState = { kind: 'reconnect' }; - const reconnectViews = bottomPaddedViews(mountList()); - expect(reconnectViews).toHaveLength(1); - const reconnectView = reconnectViews[0]; - if (!reconnectView) { - throw new Error('expected a padded View'); - } - expect((reconnectView.props.style as { paddingBottom?: number }).paddingBottom).toBe(50); + it('keeps the confirmed empty state unchanged', () => { + const renderer = mountList(0); + const empty = renderer.root.find(node => String(node.type) === 'EmptyFilesView'); + expect(empty.props.refreshControl).toBeUndefined(); + }); - listQueryState.firstPageErrorState = { kind: 'retryable' }; - const retryableViews = bottomPaddedViews(mountList()); - expect(retryableViews).toHaveLength(1); - const retryableView = retryableViews[0]; - if (!retryableView) { - throw new Error('expected a padded View'); - } - expect((retryableView.props.style as { paddingBottom?: number }).paddingBottom).toBe(50); + it('keeps cached files after a later page fails', () => { + listQueryState.files = [{ path: 'src/file.ts' }]; + listQueryState.query.isError = true; + const renderer = mountList(); + expect(renderer.root.findAll(node => String(node.type) === 'FlashList')).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); }); }); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index 8a8153dcf9..562fe73282 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -26,7 +26,7 @@ import { FlashList, type FlashListRef } from '@shopify/flash-list'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { View, type ViewStyle } from 'react-native'; +import { RefreshControl, View, type ViewStyle } from 'react-native'; import { QueryError } from '@/components/query-error'; import { @@ -55,7 +55,7 @@ import { } from '@/lib/pr-review/diff/pr-review-file-list-state'; import { usePrDiffListScroll } from '@/lib/pr-review/diff/use-pr-diff-list-scroll'; import { clearDiffSelection } from '@/lib/pr-review/diff-selection-bridge'; -import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; +import { CenteredState } from '@/components/centered-state'; import { useIsTablet } from '@/lib/hooks/use-is-tablet'; type PrReviewFileListProps = { @@ -101,9 +101,6 @@ export function PrReviewFileList({ }); const { viewMode, setViewMode } = useDiffViewMode(); const isTablet = useIsTablet(); - // Bottom clearance for the reconnect and retryable first-page chrome, which - // render without the floating bar (so no list reserve applies). - const bottomPadding = useDetailScreenBottomPadding(); const { selection, selectionView, handleLineTap, clearSelection } = useDiffSelection({ owner, repo, @@ -281,31 +278,41 @@ export function PrReviewFileList({ } if (firstPageErrorState?.kind === 'reconnect') { return ( - + - + ); } if (firstPageErrorState?.kind === 'retryable') { return ( - - { - void query.refetch(); - }} - isRetrying={query.isFetching} - /> - + { + void query.refetch(); + }} + isRetrying={query.isFetching} + /> ); } } if (!query.isLoading && files.length === 0) { - return ; + return ( + 0 ? ( + { + void query.refetch(); + }} + /> + ) : undefined + } + /> + ); } const isTruncated = query.hasNextPage || Boolean(fetchToCompletion.error); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx index ed2fbf130c..30bb7ad0bc 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.test.tsx @@ -55,6 +55,7 @@ vi.mock('expo-router', () => ({ })); vi.mock('@/components/ui/icons', () => ({ Search: 'Search' })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); @@ -105,7 +106,7 @@ type ListQueryResult = { refetch: () => unknown; }; files: PrReviewFile[]; - firstPageErrorState: null; + firstPageErrorState: { kind: 'permission' | 'not-found' | 'retryable' | 'reconnect' } | null; laterPageError: boolean; }; @@ -288,6 +289,52 @@ describe('PrDiffFileNavigator stable row callbacks (finding 2)', () => { }; }); + it('keeps the search input mounted while the list becomes empty and returns', async () => { + const { renderer } = await mountNavigator(); + const input = findSearchInput(renderer); + typeSearch(renderer, 'missing'); + expect(findSearchInput(renderer)).toBe(input); + const centered = renderer.root.find(node => String(node.type) === 'CenteredState'); + expect(centered.findByProps({ children: 'No files match "missing"' })).toBeDefined(); + const header = renderer.root.findByProps({ collapsable: false }); + expect(header.findByProps({ accessibilityLabel: 'Filter files by path' })).toBe(input); + expect(header.parent).toBe(centered.parent); + typeSearch(renderer, 'src'); + expect(findSearchInput(renderer)).toBe(input); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(0); + }); + + it.each(['permission', 'not-found', 'retryable', 'reconnect'] as const)( + 'centers the %s body outside the list', + async kind => { + listQueryResult.files = []; + listQueryResult.firstPageErrorState = { kind }; + const { renderer } = await mountNavigator(); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + expect(flashListProps.current).toBeNull(); + expect(findSearchInput(renderer)).toBeDefined(); + const retries = renderer.root.findAllByProps({ accessibilityLabel: 'Retry loading files' }); + if (kind === 'retryable' || kind === 'reconnect') { + expect(retries).toHaveLength(1); + const retry = renderer.root.findByProps({ accessibilityLabel: 'Retry loading files' }); + act(() => { + (retry.props.onPress as () => void)(); + }); + expect(listQueryResult.query.refetch).toHaveBeenCalledOnce(); + } else { + expect(retries).toHaveLength(0); + } + } + ); + + it('renders the empty state without mounting a list', async () => { + listQueryResult.files = []; + const { renderer } = await mountNavigator(); + const empty = renderer.root.find(node => String(node.type) === 'EmptyState'); + expect(empty.props.placement).toBeUndefined(); + expect(flashListProps.current).toBeNull(); + }); + it('does not re-render the memoized row on a search keystroke', async () => { const { renderer } = await mountNavigator(); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx index 6af073f1e6..ee72b48dcb 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx @@ -22,7 +22,7 @@ import { FlashList } from '@shopify/flash-list'; import { useRouter } from 'expo-router'; import { Search } from '@/components/ui/icons'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, @@ -34,6 +34,7 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { NavigatorFileRow } from '@/components/pr-review/diff/pr-diff-navigator-file-row'; import { Skeleton } from '@/components/ui/skeleton'; @@ -67,6 +68,7 @@ type PrDiffFileNavigatorProps = { /** Overview `changedFiles` count: the authoritative total for progress + truncation. */ readonly changedFiles: number; readonly onDismiss?: () => void; + readonly header?: ReactNode; }; function countViewed(files: PrReviewFile[], isViewed: (path: string) => boolean): number { @@ -86,6 +88,7 @@ export function PrDiffFileNavigator({ headSha, changedFiles, onDismiss, + header, }: PrDiffFileNavigatorProps) { const router = useRouter(); const colors = useThemeColors(); @@ -211,39 +214,38 @@ export function PrDiffFileNavigator({ [viewed, getRowCallbacks] ); + let body: ReactNode = null; if (firstPageErrorState?.kind === 'not-found') { - return ( - - - + body = ( + + + {t('prReview.pullRequestUnavailable')} {t('prReview.pullRequestUnavailableDescription')} - + ); - } - if (firstPageErrorState?.kind === 'permission') { - return ( - - - + } else if (firstPageErrorState?.kind === 'permission') { + body = ( + + + {t('prReview.accessDenied')} {t('prReview.accessDeniedDescription')} - + ); - } - if (firstPageErrorState?.kind === 'retryable' || firstPageErrorState?.kind === 'reconnect') { - return ( - - - + } else if (firstPageErrorState) { + body = ( + + + {t('prReview.fileNavigator.couldNotLoadFiles')} @@ -260,49 +262,56 @@ export function PrDiffFileNavigator({ {t('common.retry')} - + ); - } - - if (query.isLoading && files.length === 0) { - return ( - - - - - - - {[0, 1, 2, 3, 4].map(index => ( - - - - - - + } else if (query.isLoading && files.length === 0) { + body = ( + + {[0, 1, 2, 3, 4].map(index => ( + + + + + - ))} - + + ))} ); - } - - if (!query.isLoading && files.length === 0) { - return ( - - - + } else if (files.length === 0) { + body = ( + + ); + } else if (filtered.length === 0) { + body = ( + + + + {t('prReview.fileNavigator.noMatches', { query: searchRef.current })} + + + + ); + } else { + body = ( + file.path} + keyboardShouldPersistTaps="handled" + automaticallyAdjustKeyboardInsets + contentContainerStyle={listContentStyle} + onEndReached={() => { + if (!hasActiveSearch && query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + /> ); } @@ -323,88 +332,72 @@ export function PrDiffFileNavigator({ const isTruncated = query.hasNextPage || Boolean(fetchAll.error) || changedFiles > files.length; return ( - - - - { - searchRef.current = value; - setSearchVersion(version => version + 1); - }} - className="flex-1 text-sm leading-[normal] text-foreground" - returnKeyType="search" - autoCorrect={false} - autoCapitalize="none" - clearButtonMode="while-editing" - /> - + <> + + {header} + + + 0} + placeholder={t('prReview.fileNavigator.filterPlaceholder')} + placeholderTextColor={colors.mutedForeground} + accessibilityLabel={t('prReview.fileNavigator.filterPlaceholder')} + onChangeText={value => { + searchRef.current = value; + setSearchVersion(version => version + 1); + }} + className="flex-1 text-sm leading-[normal] text-foreground" + returnKeyType="search" + autoCorrect={false} + autoCapitalize="none" + clearButtonMode="while-editing" + /> + - - - {isTruncated - ? t('prReview.fileNavigator.viewedOfListed', { - viewed: formatNumber(viewedCount, i18n.language), - total: formatNumber(files.length, i18n.language), - }) - : t('prReview.fileNavigator.viewedCount', { - viewed: formatNumber(viewedCount, i18n.language), - total: formatNumber(files.length, i18n.language), - })} - - {fetchAll.isRunning ? ( - - + {files.length > 0 && ( + - {t('prReview.fileNavigator.loadingFiles', { - loaded: formatNumber(fetchAll.loadedFiles, i18n.language), - total: formatNumber(changedFiles, i18n.language), - })} + {isTruncated + ? t('prReview.fileNavigator.viewedOfListed', { + viewed: formatNumber(viewedCount, i18n.language), + total: formatNumber(files.length, i18n.language), + }) + : t('prReview.fileNavigator.viewedCount', { + viewed: formatNumber(viewedCount, i18n.language), + total: formatNumber(files.length, i18n.language), + })} + {fetchAll.isRunning ? ( + + + + {t('prReview.fileNavigator.loadingFiles', { + loaded: formatNumber(fetchAll.loadedFiles, i18n.language), + total: formatNumber(changedFiles, i18n.language), + })} + + + ) : null} - ) : null} - - - {showRetry ? ( - - {retryMessage} - void retryAction()} - className="rounded-md border border-border bg-card px-3 py-1 active:opacity-70" - accessibilityRole="button" - accessibilityLabel={retryLabel} - > - {t('common.retry')} - - - ) : null} + )} - file.path} - keyboardShouldPersistTaps="handled" - automaticallyAdjustKeyboardInsets - contentContainerStyle={listContentStyle} - onEndReached={() => { - // During a search, fetch-to-completion loads pages; a scroll fetch would race it. - if (!hasActiveSearch && query.hasNextPage && !query.isFetchingNextPage) { - void query.fetchNextPage(); - } - }} - onEndReachedThreshold={0.5} - ListEmptyComponent={ - - - {t('prReview.fileNavigator.noMatches', { query: searchRef.current })} - + {showRetry ? ( + + {retryMessage} + void retryAction()} + className="rounded-md border border-border bg-card px-3 py-1 active:opacity-70" + accessibilityRole="button" + accessibilityLabel={retryLabel} + > + {t('common.retry')} + - } - /> - + ) : null} + + {body} + ); } diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.test.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.test.tsx index 62b77431b0..fb72b33dd3 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.test.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.test.tsx @@ -1,20 +1,18 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as screen-header.mounted.test.tsx) */ import { createElement } from 'react'; +import { RefreshControl } from 'react-native'; import TestRenderer, { act } from 'react-test-renderer'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import '@/i18n'; import { EmptyFilesView, TabStateMessage } from './pr-diff-hunk-rows'; -const insetsState = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); - vi.mock('react-native', () => ({ View: 'View', Pressable: 'Pressable', + RefreshControl: 'RefreshControl', })); -vi.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => insetsState, -})); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/ui/icons', () => ({ Check: 'Check', @@ -32,77 +30,62 @@ function mountNode(node: React.ReactElement): TestRenderer.ReactTestRenderer { act(() => { ref.current = TestRenderer.create(node); }); - const renderer = ref.current; - if (!renderer) { + if (!ref.current) { throw new Error('renderer was not created'); } - return renderer; -} - -function rootView(renderer: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance { - const views = renderer.root.findAll( - node => typeof node.type === 'string' && (node.type as string) === 'View' - ); - const root = views[0]; - if (!root) { - throw new Error('root View not found'); - } - return root; + return ref.current; } -function paddingBottom(renderer: TestRenderer.ReactTestRenderer): number | undefined { - return (rootView(renderer).props.style as { paddingBottom?: number } | undefined)?.paddingBottom; +function centeredContent(renderer: TestRenderer.ReactTestRenderer) { + return renderer.root.find(node => String(node.type) === 'CenteredState'); } -describe('TabStateMessage bottom inset (plan §6)', () => { - beforeEach(() => { - insetsState.bottom = 0; - }); - - it('pads the terminal message by the detail-screen padding at a zero inset', () => { - const renderer = mountNode( - createElement(TabStateMessage, { title: 'Access denied', message: 'No access.' }) - ); - - expect(paddingBottom(renderer)).toBe(32); - }); - - it('grows the terminal message padding with a nonzero system inset', () => { - insetsState.bottom = 34; +describe('Files pane full-body states', () => { + it('centers the terminal message without local bottom padding', () => { const renderer = mountNode( createElement(TabStateMessage, { title: 'Access denied', message: 'No access.' }) ); - - expect(paddingBottom(renderer)).toBe(50); - }); -}); - -describe('EmptyFilesView bottom inset (plan §6)', () => { - beforeEach(() => { - insetsState.bottom = 0; + expect(centeredContent(renderer).findByProps({ children: 'No access.' })).toBeDefined(); + expect( + renderer.root.findAll( + node => + (node.props.style as { paddingBottom?: number } | undefined)?.paddingBottom !== undefined + ) + ).toHaveLength(0); }); - it('pads the empty state by the detail-screen padding at a zero inset', () => { - const renderer = mountNode(createElement(EmptyFilesView, { changedFiles: 0 })); - - expect(paddingBottom(renderer)).toBe(32); + it.each([0, 2])('centers the empty or waiting body for %s reported files', changedFiles => { + const renderer = mountNode(createElement(EmptyFilesView, { changedFiles })); + expect(centeredContent(renderer)).toBeDefined(); + const texts = renderer.root.findAll(node => String(node.type) === 'Text'); + expect( + texts.some(node => + String(node.props.children).includes(changedFiles === 0 ? 'No files' : 'loading') + ) + ).toBe(true); }); - it('grows the empty state padding with a nonzero system inset', () => { - insetsState.bottom = 34; - const renderer = mountNode(createElement(EmptyFilesView, { changedFiles: 0 })); - - expect(paddingBottom(renderer)).toBe(50); + it('passes refresh to the single centered scroller in the waiting body', () => { + const refreshControl = createElement(RefreshControl, { refreshing: false }); + const renderer = mountNode(createElement(EmptyFilesView, { changedFiles: 2, refreshControl })); + const centered = centeredContent(renderer); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + expect(centered.props.refreshControl).toBe(refreshControl); + expect( + centered.findByProps({ children: 'Files are still loading. Pull to refresh.' }) + ).toBeDefined(); }); - it('keeps the Overview CTA inside the padded empty state', () => { - const onRequestOverview = vi.fn(() => undefined); + it('keeps the Overview action inside the centered body', () => { + const onRequestOverview = vi.fn<() => void>(); const renderer = mountNode( createElement(EmptyFilesView, { changedFiles: 0, onRequestOverview }) ); - - expect(paddingBottom(renderer)).toBe(32); - const cta = renderer.root.findByProps({ accessibilityLabel: 'Go to Overview tab' }); - expect(cta).toBeTruthy(); + const cta = centeredContent(renderer).findByProps({ accessibilityLabel: 'Go to Overview tab' }); + act(() => { + (cta.props.onPress as () => void)(); + }); + expect(onRequestOverview).toHaveBeenCalledOnce(); }); }); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx index 010f9e8a4c..c592db7706 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx @@ -2,13 +2,13 @@ import { Check, ChevronDown, File, GitCommit, X } from '@/components/ui/icons'; import { useTranslation } from 'react-i18next'; -import { Pressable, View } from 'react-native'; +import { Pressable, type ScrollViewProps, View } from 'react-native'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; import { formatNumber } from '@/lib/format'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; +import { CenteredState } from '@/components/centered-state'; import { type ExpandSeparatorItem } from '@/lib/pr-review/diff/pr-diff-list-items'; const DEFAULT_EXPAND_WINDOW = 20; @@ -259,52 +259,52 @@ export function PaginationRow({ } export function TabStateMessage({ title, message }: { title: string; message: string }) { - const bottomPadding = useDetailScreenBottomPadding(); return ( - - {title} - - {message} - - + + + {title} + + {message} + + + ); } export function EmptyFilesView({ changedFiles, onRequestOverview, + refreshControl, }: { changedFiles: number; onRequestOverview?: () => void; + refreshControl?: ScrollViewProps['refreshControl']; }) { const colors = useThemeColors(); const { t } = useTranslation(); - const bottomPadding = useDetailScreenBottomPadding(); return ( - - - {t('prReview.noFilesChanged')} - - {changedFiles === 0 - ? t('prReview.noFilesChangedDescription') - : t('prReview.hunkRows.filesStillLoading')} - - {onRequestOverview ? ( - - {t('prReview.hunkRows.goToOverview')} - - ) : null} - + + + + + {t('prReview.noFilesChanged')} + + + {changedFiles === 0 + ? t('prReview.noFilesChangedDescription') + : t('prReview.hunkRows.filesStillLoading')} + + {onRequestOverview ? ( + + {t('prReview.hunkRows.goToOverview')} + + ) : null} + + ); } diff --git a/apps/mobile/src/components/pr-review/discussion/reactions-row.test.tsx b/apps/mobile/src/components/pr-review/discussion/reactions-row.test.tsx index e43c8bf36d..c6f3424904 100644 --- a/apps/mobile/src/components/pr-review/discussion/reactions-row.test.tsx +++ b/apps/mobile/src/components/pr-review/discussion/reactions-row.test.tsx @@ -98,6 +98,9 @@ describe('ReactionsRow picker dismissal focus', () => { it('restores focus to the trigger after the backdrop closes the picker', async () => { const renderer = await openPicker(); expect(modalProps(renderer).visible).toBe(true); + const surface = renderer.root.findByProps({ className: 'flex-1 justify-end bg-[#00000066]' }); + expect(String(surface.type)).toBe('View'); + expect(String(surface.parent?.type)).toBe('Modal'); // The backdrop is the labelled pressable without a button role. press( diff --git a/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx b/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx new file mode 100644 index 0000000000..d94e963bdb --- /dev/null +++ b/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx @@ -0,0 +1,175 @@ +import type * as ReactQuery from '@tanstack/react-query'; +import { createElement } from 'react'; +import { RefreshControl } from 'react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import { PrReviewOverview } from './pr-review-overview'; +import { PrReviewCommentComposerScreen } from './pr-review-comment-composer-screen'; +import { PrReviewReviewSubmitScreen } from './pr-review-review-submit-screen'; +import { PrReviewMergeScreen } from './pr-review-merge-screen'; +import { PrReviewFileNavigatorScreen } from './pr-review-file-navigator-screen'; +import { renderWithProviders } from '@/test/render-with-providers'; + +const query = vi.hoisted(() => ({ + data: undefined as unknown, + isLoading: false, + isError: true, + isFetching: false, + error: { data: { code: 'INTERNAL_SERVER_ERROR' } }, + refetch: vi.fn(), +})); + +vi.mock('@tanstack/react-query', async importOriginal => ({ + ...(await importOriginal()), + useQuery: () => query, +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ back: vi.fn(), push: vi.fn() }), + useLocalSearchParams: () => ({ + owner: 'org', + repo: 'repo', + number: '1', + path: 'src/a.ts', + line: '1', + side: 'RIGHT', + }), +})); +vi.mock('react-native', () => ({ + View: 'View', + ActivityIndicator: 'ActivityIndicator', + RefreshControl: 'RefreshControl', + Alert: { alert: vi.fn() }, +})); +vi.mock('expo-web-browser', () => ({ openBrowserAsync: vi.fn() })); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/invalid-route-state', () => ({ InvalidRouteState: 'InvalidRouteState' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/detail-screen', () => ({ DetailScreenScrollView: 'ScrollView' })); +vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({ + PrFormSheetHeader: 'PrFormSheetHeader', +})); +vi.mock('@/components/pr-review/pr-review-comment-composer', () => ({ + PrReviewCommentComposer: 'PrReviewCommentComposer', +})); +vi.mock('@/components/pr-review/pr-review-submit', () => ({ PrReviewSubmit: 'PrReviewSubmit' })); +vi.mock('@/components/pr-review/merge/pr-merge-sheet', () => ({ PrMergeSheet: 'PrMergeSheet' })); +vi.mock('@/components/pr-review/diff/pr-diff-file-navigator', () => ({ + PrDiffFileNavigator: 'PrDiffFileNavigator', +})); +vi.mock('@/components/pr-review/merge/pr-merge-section', () => ({ + PrMergeSection: 'PrMergeSection', +})); +vi.mock('@/components/pr-review/pr-review-checks-section', () => ({ + PrReviewChecksSection: 'PrReviewChecksSection', +})); +vi.mock('@/components/agents/markdown-text', () => ({ MarkdownText: 'MarkdownText' })); +vi.mock('@/components/pr-review/pr-review-overview-parts', () => ({ + describePrState: () => ({}), + formatPrCounts: () => '', + PrAuthorRow: 'PrAuthorRow', + PrCountsLine: 'PrCountsLine', + PrRefsRow: 'PrRefsRow', + PrStateChip: 'PrStateChip', +})); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ + CheckCheck: 'CheckCheck', + GitPullRequest: 'GitPullRequest', +})); +vi.mock('@/lib/config', () => ({ WEB_BASE_URL: 'https://example.test' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/pr-review/pending-review-provider', () => ({ + usePendingReview: () => ({ items: [] }), +})); +vi.mock('@/lib/trpc', () => ({ + trpcClient: {}, + useTRPC: () => ({ + githubPrReview: { getPullRequest: { queryOptions: () => ({}) } }, + githubApps: { getUserAuthorization: { queryKey: () => [] } }, + }), +})); + +beforeEach(() => { + query.data = undefined; + query.isError = true; + query.isLoading = false; + query.error.data.code = 'INTERNAL_SERVER_ERROR'; + vi.clearAllMocks(); +}); + +const overviewProps = { + owner: 'org', + repo: 'repo', + number: 1, + isActive: true, + refreshControl: createElement(RefreshControl, { refreshing: false }), +}; + +describe('PR Overview full-body states', () => { + it.each(['INTERNAL_SERVER_ERROR', 'FORBIDDEN', 'NOT_FOUND', 'PRECONDITION_FAILED'])( + 'centers %s outside the scroller and preserves refresh', + async code => { + query.error.data.code = code; + const { renderer, unmount } = await renderWithProviders( + createElement(PrReviewOverview, overviewProps) + ); + const body = renderer.root.find(node => + ['EmptyState', 'QueryError'].includes(String(node.type)) + ); + expect(body.props.refreshControl).toBe(overviewProps.refreshControl); + expect(body.props.placement).toBeUndefined(); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + if (code === 'FORBIDDEN') { + expect(body.props.action).toBeUndefined(); + } + if (code === 'INTERNAL_SERVER_ERROR') { + (body.props.onRetry as () => void)(); + expect(query.refetch).toHaveBeenCalledOnce(); + } + unmount(); + } + ); + + it('keeps cached overview content and its refresh control after a transient failure', async () => { + query.data = { title: 'Saved title', headSha: '1234567', counts: {}, bodyMarkdown: '' }; + const { renderer, unmount } = await renderWithProviders( + createElement(PrReviewOverview, overviewProps) + ); + const scroll = renderer.root.find(node => String(node.type) === 'ScrollView'); + expect(scroll.props.refreshControl).toBe(overviewProps.refreshControl); + expect(scroll.findByProps({ children: 'Saved title' })).toBeDefined(); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); + unmount(); + }); +}); + +describe.each([ + ['composer', PrReviewCommentComposerScreen], + ['submit', PrReviewReviewSubmitScreen], + ['merge', PrReviewMergeScreen], + ['navigator', PrReviewFileNavigatorScreen], +] as const)('%s sheet body', (_name, Screen) => { + it('lets QueryError own scrolling and retry', async () => { + const { renderer, unmount } = await renderWithProviders(createElement(Screen)); + const error = renderer.root.find(node => String(node.type) === 'QueryError'); + expect(error.props.placement).toBeUndefined(); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + (error.props.onRetry as () => void)(); + expect(query.refetch).toHaveBeenCalledOnce(); + unmount(); + }); + + it('centers the waiting body without a second scroller', async () => { + query.isError = false; + query.isLoading = true; + const { renderer, unmount } = await renderWithProviders(createElement(Screen)); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'ScrollView')).toHaveLength(0); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx index 5a13060416..d03bfb59b1 100644 --- a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx @@ -2,7 +2,9 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { type ReactNode, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Alert, ScrollView, View } from 'react-native'; +import { ActivityIndicator, Alert } from 'react-native'; + +import { CenteredState } from '@/components/centered-state'; import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { PrReviewCommentComposer } from '@/components/pr-review/pr-review-comment-composer'; @@ -120,9 +122,9 @@ export function PrReviewCommentComposerScreen() { body = null; } else if (pr.isLoading) { body = ( - + - + ); } else { body = ( @@ -140,9 +142,7 @@ export function PrReviewCommentComposerScreen() { return ( <> - - {body} - + {body} ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts b/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts index fd622a2c99..fd304a7bec 100644 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts +++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate-view.test.ts @@ -138,6 +138,7 @@ vi.mock('@/components/ui/icons', () => ({ ShieldAlert: 'ShieldAlert', })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx index 39210cce9d..031ea7dd80 100644 --- a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx @@ -3,7 +3,7 @@ import { PlugZap, RefreshCcw, ShieldAlert } from '@/components/ui/icons'; import { type ReactNode, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Platform, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { CenteredState } from '@/components/centered-state'; import { toast } from 'sonner-native'; import { EmptyState } from '@/components/empty-state'; @@ -44,7 +44,6 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); const colors = useThemeColors(); - const insets = useSafeAreaInsets(); const { t } = useTranslation(); const authorization = useQuery(trpc.githubApps.getUserAuthorization.queryOptions()); const connect = useMutation( @@ -130,57 +129,43 @@ export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { return ( - + - + ); } if (view === 'connect' || view === 'reconnect') { const revoked = view === 'reconnect'; - // Geometry (R1 on iPhone 17 Pro): EmptyState flex-centers its full stack - // (icon→CTA), but AC measures the title…CTA cluster — half the icon block - // (~36pt) below true center. The screen root also extends under the home - // indicator, so the safe region under the header is shorter than flex-1. - // Fix without touching shared EmptyState: (1) pad the body by the bottom - // safe-area inset so centering uses header-bottom → safe-bottom; (2) add - // pb-[72px] (= h-14 icon bubble + gap-4) inside EmptyState so justify-center - // lifts the stack by half that amount and the title…CTA cluster lands on - // the safe-region midpoint (±24pt). return ( - - { - void handleConnect(); - }} - > - {connecting ? ( - - ) : ( - - )} - - {revoked ? t('prReview.connect.reconnectTitle') : t('prReview.connect.title')} - - - } - /> - + { + void handleConnect(); + }} + > + {connecting ? ( + + ) : ( + + )} + + {revoked ? t('prReview.connect.reconnectTitle') : t('prReview.connect.title')} + + + } + /> ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx index b5330832ae..c29a18c02d 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx @@ -35,6 +35,7 @@ vi.mock('@/lib/pr-review/discussion/use-pr-review-discussion-threads', () => ({ vi.mock('@/lib/a11y/motion', () => ({ useMotionPolicy: () => ({ scrollAnimated: false }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/pr-review/pr-review-reconnect-notice', () => ({ PrReviewReconnectNotice: 'PrReviewReconnectNotice', @@ -101,75 +102,70 @@ function resetState(): void { discussionState.laterPageError = false; } -describe('PrReviewDiscussionTab chrome bottom inset (plan §6)', () => { +describe('PrReviewDiscussionTab full-body states', () => { beforeEach(() => { insetsState.bottom = 0; resetState(); }); - it('pads the permission chrome by the detail-screen padding at a zero inset', () => { - discussionState.firstPageErrorState = { kind: 'permission' }; - const renderer = mountTab(); - - expectSinglePadding(renderer, 32); - }); - - it('pads the not-found chrome by the detail-screen padding at a zero inset', () => { - discussionState.firstPageErrorState = { kind: 'not-found' }; + it.each(['permission', 'not-found', 'retryable'])('lets QueryError own the %s body', kind => { + discussionState.firstPageErrorState = { kind }; const renderer = mountTab(); - - expectSinglePadding(renderer, 32); + const error = renderer.root.find(node => String(node.type) === 'QueryError'); + expect(error.props.placement).toBeUndefined(); + expect(bottomPaddedViews(renderer)).toHaveLength(0); + if (kind === 'retryable') { + act(() => { + (error.props.onRetry as () => void)(); + }); + expect(discussionState.query.refetch).toHaveBeenCalled(); + } else { + expect(error.props.onRetry).toBeUndefined(); + } }); - it('pads the reconnect chrome by the detail-screen padding at a zero inset', () => { + it('centers the reconnect notice', () => { discussionState.firstPageErrorState = { kind: 'reconnect' }; const renderer = mountTab(); - - expectSinglePadding(renderer, 32); + const centered = renderer.root.find(node => String(node.type) === 'CenteredState'); + expect(centered.find(node => String(node.type) === 'PrReviewReconnectNotice')).toBeDefined(); + expect(bottomPaddedViews(renderer)).toHaveLength(0); }); - it('pads the retryable chrome by the detail-screen padding at a zero inset', () => { - discussionState.firstPageErrorState = { kind: 'retryable' }; - const renderer = mountTab(); - - expectSinglePadding(renderer, 32); + it('keeps the loading skeleton padding', () => { + discussionState.query.isPending = true; + expectSinglePadding(mountTab(), 32); }); - it('pads the loading chrome by the detail-screen padding at a zero inset', () => { - discussionState.query.isPending = true; + it('lets EmptyState own the empty body and keeps its Files action', () => { const renderer = mountTab(); - - expectSinglePadding(renderer, 32); + const empty = renderer.root.find(node => String(node.type) === 'EmptyState'); + expect(empty.props.placement).toBeUndefined(); + expect((empty.props.action as React.ReactElement<{ onPress: () => void }>).props.onPress).toBe( + BASE_PROPS.onRequestFiles + ); + expect(bottomPaddedViews(renderer)).toHaveLength(0); }); - it('pads the empty chrome by the detail-screen padding at a zero inset', () => { + it('keeps retained comments and a retry action after a transient first-page failure', () => { + discussionState.conversation = [{ nodeId: 'c1', createdAt: null }]; + discussionState.firstPageErrorState = { kind: 'retryable' }; const renderer = mountTab(); - - expectSinglePadding(renderer, 32); + const list = renderer.root.find(node => String(node.type) === 'PrReviewDiscussionList'); + expect(list.props.laterPageError).toBe(true); + expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0); }); - it('grows every chrome padding with a nonzero system inset', () => { - insetsState.bottom = 34; - const states: { kind: string }[] = [ - { kind: 'permission' }, - { kind: 'not-found' }, - { kind: 'reconnect' }, - { kind: 'retryable' }, - ]; - for (const state of states) { - resetState(); - discussionState.firstPageErrorState = state; - const renderer = mountTab(); - // Math.max(34, 16) + 16 - expectSinglePadding(renderer, 50); - } - - resetState(); - discussionState.query.isPending = true; - expectSinglePadding(mountTab(), 50); - - resetState(); - expectSinglePadding(mountTab(), 50); + it('keeps permission denial ahead of retained comments', () => { + discussionState.conversation = [{ nodeId: 'c1', createdAt: null }]; + discussionState.firstPageErrorState = { kind: 'permission' }; + const renderer = mountTab(); + expect(renderer.root.find(node => String(node.type) === 'QueryError').props.variant).toBe( + 'permission' + ); + expect( + renderer.root.findAll(node => String(node.type) === 'PrReviewDiscussionList') + ).toHaveLength(0); }); it('renders the happy list without a chrome wrapper', () => { diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx index ae8414b3b2..baca0fc176 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -49,6 +49,7 @@ import { Platform, View } from 'react-native'; import { PrReviewDiscussionList } from '@/components/pr-review/discussion/pr-review-discussion-list'; import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; +import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { Button } from '@/components/ui/button'; @@ -208,52 +209,45 @@ export function PrReviewDiscussionTab({ }; // ── First-page error / terminal states ───────────────────────────── + const isEmpty = isDiscussionEmpty(threads, conversation); + const retainedContentError = !isEmpty && firstPageErrorState?.kind === 'retryable'; const view = selectDiscussionTabView({ - firstPageErrorState, - isPending: query.isPending, - isEmpty: isDiscussionEmpty(threads, conversation), + firstPageErrorState: retainedContentError ? null : firstPageErrorState, + isPending: query.isPending && isEmpty, + isEmpty, }); if (view.kind === 'permission') { return ( - - - + ); } if (view.kind === 'not-found') { return ( - - - + ); } if (view.kind === 'reconnect') { return ( - + - + ); } if (view.kind === 'retryable') { return ( - - { - void query.refetch(); - }} - isRetrying={query.isFetching} - /> - + { + void query.refetch(); + }} + isRetrying={query.isFetching} + /> ); } @@ -281,24 +275,22 @@ export function PrReviewDiscussionTab({ // ── Empty (neither threads nor conversation comments) ────────────── if (view.kind === 'empty') { return ( - - - {t('prReview.discussion.reviewFiles')} - - ) : null - } - /> - + + {t('prReview.discussion.reviewFiles')} + + ) : null + } + /> ); } @@ -320,7 +312,7 @@ export function PrReviewDiscussionTab({ onScrollBeginDrag={invalidateSettle} hasNextPage={query.hasNextPage} isFetchingNextPage={query.isFetchingNextPage} - laterPageError={laterPageError} + laterPageError={laterPageError || retainedContentError} onLoadMore={() => { void query.fetchNextPage(); }} diff --git a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx index c796e02f01..1201218e85 100644 --- a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx @@ -1,9 +1,10 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; + import { PrDiffFileNavigator } from '@/components/pr-review/diff/pr-diff-file-navigator'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; @@ -40,28 +41,19 @@ export function PrReviewFileNavigatorScreen() { ) ); - let content: ReactNode = null; - if (pr.isLoading) { - content = ( - - - - ); - } else if (pr.isError || !pr.data) { - content = ( - - { - void pr.refetch(); - }} - isRetrying={pr.isFetching} - /> - - ); - } else { - content = ( + const header = ( + { + router.back(); + }} + /> + ); + + if (pr.data) { + return ( { router.back(); }} + header={header} /> ); } return ( - - { - router.back(); - }} - /> - {content} - + <> + {header} + {pr.isLoading ? ( + + + + ) : ( + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + )} + ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx index c05fb79f7f..b11bfebafd 100644 --- a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx @@ -2,7 +2,9 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, ScrollView, View } from 'react-native'; +import { ActivityIndicator } from 'react-native'; + +import { CenteredState } from '@/components/centered-state'; import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { QueryError } from '@/components/query-error'; @@ -84,9 +86,9 @@ export function PrReviewMergeScreen() { } const body: ReactNode = pr.isLoading ? ( - + - + ) : ( - - {body} - + {body} ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx index 58adfa7be0..42b734f38f 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx @@ -4,9 +4,10 @@ import * as WebBrowser from 'expo-web-browser'; import { CheckCheck, GitPullRequest } from '@/components/ui/icons'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { View } from 'react-native'; +import { type ScrollViewProps, View } from 'react-native'; import { toast } from 'sonner-native'; +import { DetailScreenScrollView } from '@/components/detail-screen'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { MarkdownText } from '@/components/agents/markdown-text'; @@ -42,6 +43,7 @@ type PrReviewOverviewProps = { * inner `listChecks` consumer in `PrReviewChecksSection`. */ readonly isActive: boolean; + readonly refreshControl?: ScrollViewProps['refreshControl']; }; function OverviewSkeleton() { @@ -71,6 +73,7 @@ export function PrReviewOverview({ repo, number, isActive: _isActive, + refreshControl, }: PrReviewOverviewProps) { const trpc = useTRPC(); const connection = useCheckGitHubConnection(); @@ -101,17 +104,12 @@ export function PrReviewOverview({ })(); }, [t]); - if (pr.isLoading) { - return ; - } - - if (pr.isError) { - const state = classifyPrReviewQueryState(pr.error); - + const state = pr.isError ? classifyPrReviewQueryState(pr.error) : null; + if (state && (!pr.data || state.kind !== 'retryable')) { if (state.kind === 'not-found') { return ( { @@ -171,12 +169,15 @@ export function PrReviewOverview({ const data = pr.data; if (!data) { - // Belt-and-suspenders guard for TS — the isLoading + isError branches - // above already cover the runtime cases. If we got here, tanstack is - // reporting neither loading nor error but also has no data (e.g. - // enabled=false with no cached value). Render the skeleton rather - // than dereferencing an undefined DTO. - return ; + return ( + + + + ); } const chip = describePrState({ state: data.state, @@ -185,7 +186,12 @@ export function PrReviewOverview({ }); return ( - + @@ -254,6 +260,6 @@ export function PrReviewOverview({ sha: data.headSha.slice(0, 7), })} - + ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx index 222d3dc161..67a9b661a3 100644 --- a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx @@ -2,7 +2,9 @@ import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, ScrollView, View } from 'react-native'; +import { ActivityIndicator } from 'react-native'; + +import { CenteredState } from '@/components/centered-state'; import { PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; import { PrReviewSubmit } from '@/components/pr-review/pr-review-submit'; @@ -55,9 +57,9 @@ export function PrReviewReviewSubmitScreen() { } const body: ReactNode = pr.isLoading ? ( - + - + ) : ( - - {body} - + {body} ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx index c65b887f46..f1b18ae573 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx @@ -330,7 +330,7 @@ describe('PrReviewScreen share action', () => { }); }); -describe('PrReviewScreen Overview bottom inset (plan §6)', () => { +describe('PrReviewScreen Overview scrolling', () => { beforeEach(() => { prQueryResult = { data: undefined, @@ -351,25 +351,25 @@ describe('PrReviewScreen Overview bottom inset (plan §6)', () => { }); } - it('renders the Overview body inside DetailScreenScrollView', () => { - expect(findOverviewScroll()).not.toBeNull(); + it('does not wrap the Overview in a second scroller', () => { + expect(findOverviewScroll()).toBeNull(); }); - it('drops the fixed pb-12 clearance from the Overview scroll container', () => { - // eslint-disable-next-line new-cap - const element = PrReviewScreen({ owner: 'octocat', repo: 'hello', number: 7 }); - const scroll = findElement({ + it('passes the refresh control to the Overview', () => { + const renderScreen = PrReviewScreen; + const element = renderScreen({ owner: 'octocat', repo: 'hello', number: 7 }); + const overview = findElement({ node: element, - type: 'DetailScreenScrollView', - prop: 'contentContainerClassName', - value: 'gap-5 px-4', + type: 'PrReviewOverview', + prop: 'isActive', + value: true, }); - expect(scroll).not.toBeNull(); - if (!scroll) { - throw new Error('Overview scroll not found'); + expect(overview).not.toBeNull(); + if (!overview) { + throw new Error('Overview not found'); } - const className = (scroll.props as { contentContainerClassName?: string }) - .contentContainerClassName; - expect(className).not.toContain('pb-12'); + const refresh = (overview.props as { refreshControl: React.ReactElement }).refreshControl; + expect(refresh.type).toBe('RefreshControl'); + expect((refresh.props as { onRefresh: unknown }).onRefresh).toEqual(expect.any(Function)); }); }); diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index 7590c7686e..49085a0b77 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -5,7 +5,6 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next'; import { Pressable, RefreshControl, Share, View } from 'react-native'; -import { DetailScreenScrollView } from '@/components/detail-screen'; import { PrMergePartialSuccessBanner } from '@/components/pr-review/merge/pr-merge-partial-success-banner'; import { PrReviewDiscussionTab } from '@/components/pr-review/pr-review-discussion-tab'; import { PrReviewFilesTab } from '@/components/pr-review/pr-review-files-tab'; @@ -164,21 +163,19 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { })(); }, [queryClient, trpc, owner, repo, number, pr.data?.headSha]); - // Each tab owns its own scroll: Overview is a DetailScreenScrollView with - // pull-to-refresh; the Files tab hosts a virtualized FlashList and must - // NOT be nested inside a ScrollView. let body: ReactNode = null; if (tab === 'overview') { body = ( - } - > + <> {partialMergeReason ? : null} - - + } + /> + ); } else if (tab === 'files') { body = ( diff --git a/apps/mobile/src/components/preferences-screen.mounted.test.tsx b/apps/mobile/src/components/preferences-screen.mounted.test.tsx index 976dfad412..150a1c519e 100644 --- a/apps/mobile/src/components/preferences-screen.mounted.test.tsx +++ b/apps/mobile/src/components/preferences-screen.mounted.test.tsx @@ -27,6 +27,11 @@ vi.mock('expo-secure-store', () => storage); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/centered-state-surface', () => ({ + NativeStateSurface: 'NativeStateSurface', + StateSurface: 'StateSurface', +})); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('react-native', () => ({ Switch: 'Switch', diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index 3f10daa48b..03c07f12bc 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/icons'; import { type TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; +import { type ScrollViewProps } from 'react-native'; import { EmptyState } from '@/components/empty-state'; import { AccessibleStatus } from '@/components/ui/accessible-status'; @@ -51,6 +52,7 @@ type QueryErrorProps = { isRetrying?: boolean; className?: string; placement?: 'center' | 'top'; + refreshControl?: ScrollViewProps['refreshControl']; }; export function QueryError({ @@ -64,6 +66,7 @@ export function QueryError({ isRetrying = false, className, placement = 'center', + refreshControl, }: Readonly) { const { t } = useTranslation(); const meta = variantMeta(t, variant); @@ -79,6 +82,7 @@ export function QueryError({ } className={className} placement={placement} + refreshControl={refreshControl} iconContainerClassName="rounded-full bg-muted p-4" iconSize={32} iconStrokeWidth={2} diff --git a/apps/mobile/src/components/quick-chat/quick-chat-screen.mounted.test.tsx b/apps/mobile/src/components/quick-chat/quick-chat-screen.mounted.test.tsx index 595c3ed68a..3b8ca5296e 100644 --- a/apps/mobile/src/components/quick-chat/quick-chat-screen.mounted.test.tsx +++ b/apps/mobile/src/components/quick-chat/quick-chat-screen.mounted.test.tsx @@ -26,6 +26,10 @@ import { renderWithProviders, waitFor } from '@/test/render-with-providers'; import { type QuickChatRow } from './quick-chat-messages'; import { QuickChatScreen } from './quick-chat-screen'; +vi.mock('@/components/centered-state-surface', () => ({ + StateSurfaceInsets: 'StateSurfaceInsets', +})); + const listMessagesQueryFn = vi.hoisted(() => vi.fn()); const getOrCreateThreadMutate = vi.hoisted(() => vi.fn()); const listMessagesQuery = vi.hoisted(() => vi.fn()); diff --git a/apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx index c6efa34bb0..2dd83f3254 100644 --- a/apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/audit-report-screen.mounted.test.tsx @@ -57,7 +57,7 @@ vi.mock('@/components/security-agent/collapsible-section', () => ({ vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/tab-screen', () => ({ - TabScreenScrollView: (props: { children?: unknown }) => props.children, + TabScreenScrollView: 'TabScreenScrollView', })); type R = TestRenderer.ReactTestRenderer; @@ -173,11 +173,7 @@ function isInstance(child: I | string): child is I { function firstChildTypeOfScreenRoot(root: I): string | undefined { const screenView = root.children.find(isInstance); const first = screenView?.children.find(isInstance); - if (!first) { - return undefined; - } - const type = first.type; - return typeof type === 'string' ? type : undefined; + return first && typeof first.type === 'string' ? first.type : undefined; } function useQueryEnabledFlags(): boolean[] { @@ -244,18 +240,8 @@ describe('AuditReportScreen states', () => { expect(findByType(root.root, 'EmptyState')).toHaveLength(0); }); - it('renders a non-retryable explanation without Retry on FORBIDDEN', () => { - setQueryState({ isError: true, error: { data: { code: 'FORBIDDEN' } } }); - const root = renderScreen('org-123'); - - const empty = findByType(root.root, 'EmptyState'); - expect(empty).toHaveLength(1); - expect(empty[0]?.props.title).toBe('Audit report unavailable'); - expect(findByType(root.root, 'QueryError')).toHaveLength(0); - }); - - it('treats the org billing-gate UNAUTHORIZED denial as non-retryable too', () => { - setQueryState({ isError: true, error: { data: { code: 'UNAUTHORIZED' } } }); + it.each(['FORBIDDEN', 'UNAUTHORIZED'])('renders a non-retryable explanation for %s', code => { + setQueryState({ isError: true, error: { data: { code } } }); const root = renderScreen('org-123'); const empty = findByType(root.root, 'EmptyState'); @@ -287,6 +273,43 @@ describe('AuditReportScreen states', () => { const empty = findByType(root.root, 'EmptyState'); expect(empty).toHaveLength(1); expect(empty[0]?.props.title).toBe('No recorded activity'); + expect(empty[0]?.props.placement).not.toBe('top'); + expect(findByType(root.root, 'TabScreenScrollView')).toHaveLength(0); + }); + + it('retains a cached report with an inline retry after a transient failure', () => { + setQueryState({ + isError: true, + error: { data: { code: 'INTERNAL_SERVER_ERROR' } }, + data: { status: 'ok', report: makeReport() }, + }); + const tree = renderScreen('personal'); + expect(findByType(tree.root, 'CollapsibleSection')).toHaveLength(1); + expect(findByType(tree.root, 'QueryError')[0]?.props.placement).toBe('top'); + expect(findByType(tree.root, 'TabScreenScrollView')).toHaveLength(1); + }); + + it('shows a full-body failure when the cached report has no activity', () => { + setQueryState({ + isError: true, + data: { status: 'ok', report: makeReport({ findings: [] }) }, + }); + const tree = renderScreen('personal'); + expect(findByType(tree.root, 'QueryError')).toHaveLength(1); + expect(findByType(tree.root, 'QueryError')[0]?.props.placement).not.toBe('top'); + expect(findByType(tree.root, 'TabScreenScrollView')).toHaveLength(0); + expect(findByType(tree.root, 'EmptyState')).toHaveLength(0); + }); + + it.each(['FORBIDDEN', 'UNAUTHORIZED'])('does not retain a report after %s', code => { + setQueryState({ + isError: true, + error: { data: { code } }, + data: { status: 'ok', report: makeReport() }, + }); + const tree = renderScreen('org-123'); + expect(findByType(tree.root, 'TabScreenScrollView')).toHaveLength(0); + expect(findByType(tree.root, 'EmptyState')).toHaveLength(1); }); it('renders one section per finding group for a non-empty report', () => { diff --git a/apps/mobile/src/components/security-agent/audit-report-screen.tsx b/apps/mobile/src/components/security-agent/audit-report-screen.tsx index 75c293bd16..355eba9a81 100644 --- a/apps/mobile/src/components/security-agent/audit-report-screen.tsx +++ b/apps/mobile/src/components/security-agent/audit-report-screen.tsx @@ -156,7 +156,10 @@ function FindingSection({ finding }: Readonly<{ finding: SecurityFindingAuditSec ); } -function AuditReportView({ report }: Readonly<{ report: SecurityAgentAuditReport }>) { +function AuditReportView({ + report, + onRetry, +}: Readonly<{ report: SecurityAgentAuditReport; onRetry?: () => void }>) { const { t } = useTranslation(); if (report.findings.length === 0) { const start = formatDate(parseTimestamp(report.period.start), i18n.language, { @@ -177,6 +180,13 @@ function AuditReportView({ report }: Readonly<{ report: SecurityAgentAuditReport return ( + {onRetry ? ( + + ) : null} {report.findings.map(finding => ( @@ -190,6 +200,7 @@ export function AuditReportScreen({ scope }: Readonly<{ scope: string }>) { const { t } = useTranslation(); const query = useSecurityAgentAuditReport(scope); const errorCode = query.error?.data?.code; + const hasReport = query.data?.status === 'ok' && query.data.report.findings.length > 0; // The org procedure is `organizationBillingProcedure`, which rejects // viewers without the owner/billing_manager role. That denial is // non-retryable: retrying cannot change the viewer's role. @@ -213,37 +224,37 @@ export function AuditReportScreen({ scope }: Readonly<{ scope: string }>) { /> )} - {!query.isLoading && query.isError && !forbidden && ( - - void query.refetch()} - /> - + {!query.isLoading && query.isError && !forbidden && !hasReport && ( + void query.refetch()} + /> )} {!query.isLoading && !query.isError && query.data?.status === 'query_failed' && ( - - void query.refetch()} - /> - + void query.refetch()} + /> )} {query.isPending && query.isPaused && ( - - void query.refetch()} - /> - + void query.refetch()} + /> )} - {!query.isLoading && !query.isError && query.data?.status === 'ok' && ( - - )} + {!query.isLoading && + !forbidden && + (!query.isError || hasReport) && + query.data?.status === 'ok' && ( + void query.refetch() : undefined} + /> + )} ); } diff --git a/apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx index 73129fcf20..a1a8418451 100644 --- a/apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/dismiss-finding-screen.mounted.test.tsx @@ -12,6 +12,11 @@ import { createElement } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ScrollView } from 'react-native'; +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { type SecurityFinding } from '@/lib/security-agent'; +import { SettingsRecoveryStatus } from './settings-recovery-status'; import { DismissFindingScreen } from './dismiss-finding-screen'; const PERSISTENCE_FAILED_MESSAGE = vi.hoisted( @@ -34,15 +39,18 @@ const dismiss = vi.hoisted(() => ({ })); const capability = vi.hoisted(() => ({ canManage: true, + status: 'allowed' as 'allowed' | 'denied' | 'error' | 'loading', isLoading: false, + isFetching: false, isError: false, refetch: vi.fn(), })); const finding = vi.hoisted(() => ({ isLoading: false, + isFetching: false, isError: false, error: null as unknown, - data: { status: 'open' }, + data: undefined as Pick | undefined, refetch: vi.fn(), })); const pillGroup = vi.hoisted(() => ({ @@ -97,8 +105,11 @@ vi.mock('@/lib/hooks/use-security-agent-mutations', () => ({ }, })); vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); -vi.mock('@/components/empty-state', () => ({ EmptyState: () => null })); -vi.mock('@/components/query-error', () => ({ QueryError: () => null })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/security-agent/settings-recovery-status', () => ({ + SettingsRecoveryStatus: 'SettingsRecoveryStatus', +})); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); vi.mock('@/components/security-agent/settings-pill-group', () => ({ PillGroup: (props: { onChange: (value: string) => void }) => { @@ -175,17 +186,121 @@ describe('DismissFindingScreen dismissal CTA states', () => { dismiss.isError = false; dismiss.error = null; capability.canManage = true; + capability.status = 'allowed'; capability.isLoading = false; + capability.isFetching = false; capability.isError = false; + capability.refetch.mockReset(); finding.isLoading = false; + finding.isFetching = false; finding.isError = false; + finding.error = null; finding.data = { status: 'open' }; + finding.refetch.mockReset(); dismissDraft.draft = null; dismissDraft.hydrated = true; dismissDraft.persist.mockClear(); dismissDraft.clear.mockClear(); }); + it.each(['finding', 'permissions'] as const)( + 'keeps the draft mounted and retries a cached %s failure without submitting', + source => { + const query = source === 'finding' ? finding : capability; + const otherQuery = source === 'finding' ? capability : finding; + const tree = renderScreen(); + selectReason(); + const input = findCommentInput(tree.root); + act(() => { + (input.props.onChangeText as (value: string) => void)('Keep this comment'); + }); + const update = () => { + tree.update( + createElement(DismissFindingScreen, { scope: 'personal', findingId: 'finding-1' }) + ); + }; + query.isError = true; + finding.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + act(update); + const scroll = tree.root.findByType(ScrollView); + const retry = scroll.findByType(SettingsRecoveryStatus); + expect(tree.root.findAllByType(ScrollView)).toHaveLength(1); + expect(tree.root.findAllByType(QueryError)).toHaveLength(0); + expect(tree.root.findAllByType(EmptyState)).toHaveLength(0); + act(retry.props.onRetry as () => void); + expect(query.refetch).toHaveBeenCalledOnce(); + expect(otherQuery.refetch).not.toHaveBeenCalled(); + expect(dismiss.mutate).not.toHaveBeenCalled(); + query.isFetching = true; + act(update); + expect(retry.props.isRetrying).toBe(true); + expect(findCommentInput(tree.root)).toBe(input); + query.isFetching = false; + query.isError = false; + act(update); + expect(tree.root.findByType(ScrollView)).toBe(scroll); + expect(findCommentInput(tree.root)).toBe(input); + expect(tree.root.findAllByType(SettingsRecoveryStatus)).toHaveLength(0); + expect(buttonDisabled(tree.root)).toBe(false); + act(findDismissButton(tree.root).props.onPress as () => void); + expect(dismiss.mutate).toHaveBeenCalledWith( + { findingId: 'finding-1', reason: 'not_used', comment: 'Keep this comment' }, + expect.any(Object) + ); + } + ); + + it.each(['finding', 'permissions'] as const)( + 'keeps an uncached %s failure outside the form', + source => { + const query = source === 'finding' ? finding : capability; + query.isError = true; + if (source === 'finding') { + finding.data = undefined; + } else { + capability.status = 'error'; + } + const tree = renderScreen(); + expect(tree.root.findAllByType(ScrollView)).toHaveLength(0); + expect(tree.root.findAllByType(SettingsRecoveryStatus)).toHaveLength(0); + const error = tree.root.findByType(QueryError); + expect(error.props.placement).not.toBe('top'); + act(error.props.onRetry as () => void); + expect(query.refetch).toHaveBeenCalledOnce(); + } + ); + + it.each([ + ['denied', 'open'], + ['allowed', 'fixed'], + ['allowed', 'dismissed'], + ] as const)( + 'keeps %s permissions and a %s finding blocked after refetch failures', + (status, findingStatus) => { + capability.status = status; + capability.canManage = status === 'allowed'; + capability.isError = true; + finding.data = { status: findingStatus }; + finding.isError = true; + finding.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + const tree = renderScreen(); + expect(tree.root.findAllByType(ScrollView)).toHaveLength(0); + expect(tree.root.findAllByType(EmptyState)).toHaveLength(1); + expect(tree.root.findAllByType(SettingsRecoveryStatus)).toHaveLength(0); + expect(tree.root.findAllByType(QueryError)).toHaveLength(0); + } + ); + + it.each(['NOT_FOUND', 'FORBIDDEN'])('keeps the full-body %s state outside the form', code => { + finding.isError = true; + finding.error = { data: { code } }; + const tree = renderScreen(); + expect(tree.root.findAllByType(ScrollView)).toHaveLength(0); + const empty = tree.root.findAllByType(EmptyState); + expect(empty).toHaveLength(1); + expect(empty[0]?.props.placement).not.toBe('top'); + }); + it('keeps the dismissal CTA enabled once a reason is chosen', () => { const root = renderScreen(); selectReason(); diff --git a/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx b/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx index f279e03a1d..21bb4ac7db 100644 --- a/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx +++ b/apps/mobile/src/components/security-agent/dismiss-finding-screen.tsx @@ -8,6 +8,7 @@ import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { PillGroup } from '@/components/security-agent/settings-pill-group'; +import { SettingsRecoveryStatus } from '@/components/security-agent/settings-recovery-status'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; @@ -154,7 +155,7 @@ export function DismissFindingScreen({ scope, findingId }: Readonly @@ -167,7 +168,7 @@ export function DismissFindingScreen({ scope, findingId }: Readonly @@ -232,6 +233,20 @@ export function DismissFindingScreen({ scope, findingId }: Readonly + {findingQuery.isError ? ( + void findingQuery.refetch()} + /> + ) : null} + {capability.isError ? ( + void capability.refetch()} + /> + ) : null} ({ diff --git a/apps/mobile/src/components/security-agent/finding-analysis-panel.mounted.test.tsx b/apps/mobile/src/components/security-agent/finding-analysis-panel.mounted.test.tsx index 5b32754562..1b95750100 100644 --- a/apps/mobile/src/components/security-agent/finding-analysis-panel.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/finding-analysis-panel.mounted.test.tsx @@ -7,7 +7,14 @@ // ("Exploitable") and triage ("Triage confidence") evidence blocks in those // states even when `analysis.analysis` still carries old data. -import { createElement } from 'react'; +import { type ComponentProps, createElement } from 'react'; +import { CenteredState } from '@/components/centered-state'; +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { TabScreenScrollView } from '@/components/tab-screen'; +import { MarkdownText } from '@/components/agents/markdown-text'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -15,7 +22,7 @@ import { FindingAnalysisPanel } from './finding-analysis-panel'; import { type SecurityAnalysis } from '@/lib/security-agent'; const capacity = vi.hoisted(() => ({ - runningCount: 0, + runningCount: 0 as number | undefined, concurrencyLimit: 3, isLoading: false, isError: false, @@ -56,15 +63,17 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ mutedForeground: '#666', }), })); -vi.mock('@/components/agents/markdown-text', () => ({ MarkdownText: () => null })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' })); +vi.mock('@/components/agents/markdown-text', () => ({ MarkdownText: 'MarkdownText' })); vi.mock('@/components/security-agent/collapsible-section', () => ({ - CollapsibleSection: () => null, + CollapsibleSection: 'CollapsibleSection', })); vi.mock('@/components/security-agent/finding-status-badge', () => ({ FindingStatusBadge: () => null, })); -vi.mock('@/components/empty-state', () => ({ EmptyState: () => null })); -vi.mock('@/components/query-error', () => ({ QueryError: () => null })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/kv-row', () => ({ KvRow: (props: { label: string }) => { @@ -77,7 +86,7 @@ vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); type R = TestRenderer.ReactTestRenderer; -function staleAnalysis(): Record { +function staleAnalysis() { return { sandboxAnalysis: { extractionStatus: 'succeeded', @@ -116,7 +125,10 @@ function analysisFixture(overrides: Record = {}): SecurityAnaly } as unknown as SecurityAnalysis; } -function renderPanel(analysis: SecurityAnalysis): R { +function renderPanel( + analysis: SecurityAnalysis | undefined, + props: Partial> = {} +): R { const ref: { current: R | undefined } = { current: undefined }; act(() => { ref.current = TestRenderer.create( @@ -127,6 +139,7 @@ function renderPanel(analysis: SecurityAnalysis): R { isLoading: false, isError: false, onRetry: () => undefined, + ...props, }) ); }); @@ -148,6 +161,81 @@ describe('FindingAnalysisPanel completion-copy gate', () => { startAnalysis.mutate.mockClear(); }); + it.each([null, 'completed', 'pending', 'running', 'failed'])( + 'centers a contentless %s analysis', + status => { + const tree = renderPanel(analysisFixture({ status })); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(1); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + expect(tree.root.findAllByType(EmptyState)).toHaveLength(0); + } + ); + + it.each([ + { triage: staleAnalysis().triage }, + { sandboxAnalysis: { ...staleAnalysis().sandboxAnalysis, rawMarkdown: undefined } }, + { rawMarkdown: 'Retained technical report' }, + ])('keeps substantive evidence in the report scroller: %j', analysis => { + const tree = renderPanel(analysisFixture({ analysis }), { isError: true }); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + expect(tree.root.findAllByType(QueryError)).toHaveLength(0); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(1); + expect(tree.root.findAllByType(MarkdownText)).toHaveLength('rawMarkdown' in analysis ? 1 : 0); + }); + + it('keeps a Markdown report after a failed retry', () => { + const tree = renderPanel(analysisFixture({ status: 'failed', analysis: staleAnalysis() })); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + expect(tree.root.findByType(MarkdownText).props.value).toBe('stale report'); + }); + + it('centers the absent response without another container', () => { + const tree = renderPanel(undefined); + expect(tree.root.findByType(EmptyState).props.placement).not.toBe('top'); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + }); + + it('keeps loading ahead of an absent response failure', () => { + const tree = renderPanel(undefined, { isLoading: true, isError: true }); + expect(tree.root.findAllByType(Skeleton)).toHaveLength(2); + expect(tree.root.findAllByType(QueryError)).toHaveLength(0); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + }); + + it('keeps Retry in a full-body absent response failure', () => { + const onRetry = vi.fn<() => void>(); + const tree = renderPanel(undefined, { isError: true, onRetry }); + const error = tree.root.findByType(QueryError); + expect(error.props.placement).not.toBe('top'); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + act(error.props.onRetry as () => void); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it.each(['loading', 'error', 'full'] as const)( + 'keeps analysis disabled when capacity is %s', + state => { + capacity.isLoading = state === 'loading'; + capacity.isError = state === 'error'; + capacity.runningCount = state === 'full' ? 3 : undefined; + const tree = renderPanel(analysisFixture({ status: null })); + expect(tree.root.findByType(Button).props.disabled).toBe(true); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(1); + } + ); + + it('keeps the analysis action in the centered state', () => { + const tree = renderPanel(analysisFixture({ status: null })); + const button = tree.root.findByType(Button); + expect(button.props.disabled).toBe(false); + act(button.props.onPress as () => void); + expect(startAnalysis.mutate).toHaveBeenCalledWith({ + findingId: 'finding-1', + retrySandboxOnly: false, + }); + }); + it('hides stale sandbox and triage evidence while the analysis is running', () => { renderPanel(analysisFixture({ status: 'running', analysis: staleAnalysis() })); diff --git a/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx b/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx index 7d7d0865de..2e41bee804 100644 --- a/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx +++ b/apps/mobile/src/components/security-agent/finding-analysis-panel.tsx @@ -9,6 +9,8 @@ import { ActivityIndicator, Alert, Pressable, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { MarkdownText } from '@/components/agents/markdown-text'; +import { CenteredState } from '@/components/centered-state'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { CollapsibleSection } from '@/components/security-agent/collapsible-section'; import { formatExploitable, @@ -59,7 +61,7 @@ export function FindingAnalysisPanel({ if (isLoading && !analysis) { return ( - + @@ -67,18 +69,13 @@ export function FindingAnalysisPanel({ } if (isError && !analysis) { - return ( - - - - ); + return ; } if (!analysis) { return ( @@ -158,8 +155,14 @@ export function FindingAnalysisPanel({ ); }; - return ( - + const hasContent = + (triageState && Boolean(triage)) || + (sandboxState && Boolean(sandbox)) || + Boolean(technicalMarkdown); + const Body = hasContent ? TabScreenScrollView : CenteredState; + + const content = ( + ); + + return {content}; } diff --git a/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx index 05437176f0..abb2f21119 100644 --- a/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/finding-detail-screen.mounted.test.tsx @@ -4,31 +4,31 @@ // mounted while the dismiss sheet is open, so it re-reads the draft on focus. import { createElement } from 'react'; +import { type SecurityDismissDraft } from '@/lib/hooks/use-security-dismiss-draft'; +import { SecurityCommandRetryCard } from './security-command-retry-card'; import TestRenderer, { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CenteredState } from '@/components/centered-state'; import { QueryError } from '@/components/query-error'; +import { TabScreenScrollView } from '@/components/tab-screen'; +import { SettingsRecoveryStatus } from './settings-recovery-status'; +import { FindingDetailsPanel } from './finding-details-panel'; +import { FindingAnalysisPanel } from './finding-analysis-panel'; +import { FindingRemediationPanel } from './finding-remediation-panel'; import { Skeleton } from '@/components/ui/skeleton'; import { type SecurityFinding } from '@/lib/security-agent'; import { FindingDetailScreen } from './finding-detail-screen'; -type Draft = { - reason: string; - comment: string; - lastError: string | null; - retryable: boolean | null; -}; - const dismissDraft = vi.hoisted(() => ({ - draft: null as Draft | null, - hydrated: true, - persist: vi.fn(), + draft: null as SecurityDismissDraft | null, clear: vi.fn(), refresh: vi.fn(), })); const finding = vi.hoisted(() => ({ isLoading: false, + isFetching: false, isError: false, error: null as unknown, data: undefined as Pick | undefined, @@ -42,30 +42,12 @@ const analysis = vi.hoisted(() => ({ refetch: vi.fn(), })); -const capability = vi.hoisted(() => ({ - canManage: true, - isLoading: false, - isError: false, - refetch: vi.fn(), -})); +const capability = vi.hoisted(() => ({ canManage: true })); const trackInteraction = vi.hoisted(() => ({ mutate: vi.fn() })); const navigation = vi.hoisted(() => ({ history: [] as string[] })); -// Captures the useFocusEffect callback so a test can simulate a focus event. -const focusEffect = vi.hoisted(() => ({ - effect: undefined as (() => void) | undefined, -})); - -// Captures the retry-card props the screen renders. -const retryCards = vi.hoisted(() => ({ - cards: [] as { - lastError: string; - retryable: boolean; - onRetry?: () => void; - onDiscard?: () => void; - }[], -})); +const focusEffect = vi.hoisted(() => vi.fn()); vi.mock('react-native', () => ({ View: 'View', @@ -97,9 +79,7 @@ vi.mock('expo-router', () => ({ canGoBack: () => true, }), useNavigation: () => ({ getState: () => ({ index: navigation.history.length - 1 }) }), - useFocusEffect: (effect: () => void) => { - focusEffect.effect = effect; - }, + useFocusEffect: focusEffect, })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ foreground: '#000', mutedForeground: '#666' }), @@ -115,25 +95,15 @@ vi.mock('@/lib/hooks/use-security-findings', () => ({ vi.mock('@/lib/hooks/use-security-dismiss-draft', () => ({ useSecurityDismissDraft: () => dismissDraft, })); -vi.mock('@/components/security-agent/security-command-retry-card', () => ({ - SecurityCommandRetryCard: (props: { - lastError: string; - retryable: boolean; - onRetry?: () => void; - onDiscard?: () => void; - }) => { - retryCards.cards.push(props); - return null; - }, -})); +vi.mock('./security-command-retry-card', () => ({ SecurityCommandRetryCard: 'RetryCard' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/ui/eyebrow', () => ({ Eyebrow: 'Text' })); vi.mock('@/components/ui/button', () => ({ Button: 'Pressable' })); vi.mock('@/lib/a11y/status-announcement', () => ({ useStatusAnnouncement: vi.fn() })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); vi.mock('@/components/tab-screen', () => ({ - TabScreenScrollView: (props: { children?: unknown }) => props.children, - useTabBarBottomPadding: () => 0, + TabScreenScrollView: 'TabScreenScrollView', })); vi.mock('@/components/security-agent/finding-analysis-panel', () => ({ FindingAnalysisPanel: () => null, @@ -154,10 +124,13 @@ const scopeRoot = '/(app)/(tabs)/(3_profile)/security-agent/personal'; const detailPath = `${scopeRoot}/findings/finding-1`; function renderScreen(): R { + const screen = createElement(FindingDetailScreen, { scope: 'personal', findingId: 'finding-1' }); act(() => { - renderer = TestRenderer.create( - createElement(FindingDetailScreen, { scope: 'personal', findingId: 'finding-1' }) - ); + if (renderer) { + renderer.update(screen); + } else { + renderer = TestRenderer.create(screen); + } }); if (!renderer) { throw new Error('renderer was not created'); @@ -166,18 +139,15 @@ function renderScreen(): R { } function press(tree: R, accessibilityLabel: string) { - const { onPress } = tree.root.findByProps({ accessibilityLabel }).props as { - onPress: () => void; - }; - act(onPress); + act(tree.root.findByProps({ accessibilityLabel }).props.onPress as () => void); } beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); dismissDraft.draft = null; - dismissDraft.hydrated = true; finding.isLoading = false; + finding.isFetching = false; finding.isError = false; finding.error = null; finding.data = { status: 'open', repo_full_name: 'org/repo' }; @@ -186,10 +156,6 @@ beforeEach(() => { analysis.isError = false; analysis.data = undefined; capability.canManage = true; - capability.isLoading = false; - capability.isError = false; - retryCards.cards = []; - focusEffect.effect = undefined; navigation.history = [detailPath]; }); afterEach(() => { @@ -198,46 +164,80 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('FindingDetailScreen dismiss retry card states', () => { - it('renders no retry card when there is no draft (empty)', () => { - renderScreen(); - - expect(retryCards.cards).toHaveLength(0); +describe('FindingDetailScreen pane containers', () => { + it.each([ + ['analysis', FindingAnalysisPanel], + ['remediation', FindingRemediationPanel], + ] as const)('keeps finding Retry outside the selected %s pane scroller', (tab, Panel) => { + finding.isError = true; + finding.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; + const tree = renderScreen(); + const tabs = tree.root.findAllByProps({ accessibilityRole: 'tab' }); + const onPress = tabs[tab === 'analysis' ? 1 : 2]?.props.onPress as () => void; + act(onPress); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + expect(tree.root.findAllByType(Panel)).toHaveLength(1); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + press(tree, 'common.retry'); + expect(finding.refetch).toHaveBeenCalledOnce(); + expect(analysis.refetch).not.toHaveBeenCalled(); }); - it('renders no retry card after accept (no failure recorded)', () => { - dismissDraft.draft = { reason: 'not_used', comment: '', lastError: null, retryable: null }; + it('keeps cached details mounted through a failed refetch and Retry', () => { + const tree = renderScreen(); + const details = tree.root.findByType(FindingDetailsPanel); + finding.isError = true; + finding.error = { data: { code: 'INTERNAL_SERVER_ERROR' } }; renderScreen(); - - expect(retryCards.cards).toHaveLength(0); - }); - - it('renders a retry card with the error and Retry for a retryable failure', () => { - dismissDraft.draft = { - reason: 'not_used', - comment: '', - lastError: 'Network error', - retryable: true, - }; + expect(tree.root.findByType(FindingDetailsPanel)).toBe(details); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(1); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + expect(tree.root.findAllByType(QueryError)).toHaveLength(0); + const retry = tree.root.findByType(SettingsRecoveryStatus); + expect(retry.props.message).toBe('securityAgent.findingDetail.couldNotLoad'); + press(tree, 'common.retry'); + expect(finding.refetch).toHaveBeenCalledOnce(); + expect(analysis.refetch).not.toHaveBeenCalled(); + finding.isFetching = true; renderScreen(); - - expect(retryCards.cards).toHaveLength(1); - expect(retryCards.cards[0]?.lastError).toBe('Network error'); - expect(retryCards.cards[0]?.retryable).toBe(true); + expect(retry.props.isRetrying).toBe(true); + expect(tree.root.findByType(FindingDetailsPanel)).toBe(details); + finding.isFetching = false; + finding.isError = false; + finding.error = null; + renderScreen(); + expect(tree.root.findByType(FindingDetailsPanel)).toBe(details); + expect(tree.root.findAllByType(SettingsRecoveryStatus)).toHaveLength(0); }); - it('renders a retry card with the error and no Retry for a non-retryable failure', () => { - dismissDraft.draft = { - reason: 'not_used', - comment: '', - lastError: 'Security service is not configured', - retryable: false, - }; - renderScreen(); + it.each(['NOT_FOUND', 'FORBIDDEN'])('does not retain cached details after %s', code => { + finding.isError = true; + finding.error = { data: { code } }; + const tree = renderScreen(); + expect(tree.root.findAllByType(FindingDetailsPanel)).toHaveLength(0); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(1); + expect(tree.root.findAllByType(SettingsRecoveryStatus)).toHaveLength(0); + expect(tree.root.findAllByProps({ accessibilityLabel: 'common.retry' })).toHaveLength(0); + }); +}); - expect(retryCards.cards).toHaveLength(1); - expect(retryCards.cards[0]?.lastError).toBe('Security service is not configured'); - expect(retryCards.cards[0]?.retryable).toBe(false); +describe('FindingDetailScreen dismiss retry card states', () => { + it.each([null, { reason: 'not_used', comment: '', lastError: null, retryable: null }])( + 'renders no retry card without a recorded failure: %j', + draft => { + dismissDraft.draft = draft; + expect(renderScreen().root.findAllByType(SecurityCommandRetryCard)).toHaveLength(0); + } + ); + + it.each([ + ['Network error', true], + ['Security service is not configured', false], + ] as const)('renders the %s failure with retryable=%s', (lastError, retryable) => { + dismissDraft.draft = { reason: 'not_used', comment: '', lastError, retryable }; + const card = renderScreen().root.findByType(SecurityCommandRetryCard); + expect(card.props).toMatchObject({ lastError, retryable }); }); it('drops the card on discard by clearing the draft', () => { @@ -247,12 +247,8 @@ describe('FindingDetailScreen dismiss retry card states', () => { lastError: 'boom', retryable: true, }; - renderScreen(); - expect(retryCards.cards).toHaveLength(1); - - act(() => { - retryCards.cards[0]?.onDiscard?.(); - }); + const card = renderScreen().root.findByType(SecurityCommandRetryCard); + act(card.props.onDiscard as () => void); expect(dismissDraft.clear).toHaveBeenCalledTimes(1); }); @@ -260,9 +256,7 @@ describe('FindingDetailScreen dismiss retry card states', () => { it('re-reads the draft on focus', () => { renderScreen(); - act(() => { - focusEffect.effect?.(); - }); + act(focusEffect.mock.lastCall?.[0] as () => void); expect(dismissDraft.refresh).toHaveBeenCalledTimes(1); }); @@ -316,11 +310,7 @@ describe.each([true, false])('finding load states with local history=%s', hasLoc ).toHaveLength(1); press(tree, 'common.retry'); - act(() => { - tree.update( - createElement(FindingDetailScreen, { scope: 'personal', findingId: 'finding-1' }) - ); - }); + renderScreen(); expect(tree.root.findAllByProps({ children: 'org/recovered' })).toHaveLength(1); expect(tree.root.findAllByType(QueryError)).toHaveLength(0); diff --git a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx index f74db7e84d..98274f095b 100644 --- a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx @@ -11,9 +11,10 @@ import { FindingAnalysisPanel } from '@/components/security-agent/finding-analys import { FindingDetailsPanel } from '@/components/security-agent/finding-details-panel'; import { FindingRemediationPanel } from '@/components/security-agent/finding-remediation-panel'; import { SecurityCommandRetryCard } from '@/components/security-agent/security-command-retry-card'; +import { SettingsRecoveryStatus } from '@/components/security-agent/settings-recovery-status'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; -import { TabScreenScrollView, useTabBarBottomPadding } from '@/components/tab-screen'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { useSecurityAgentCapability, useTrackSecurityAgentInteraction, @@ -53,7 +54,6 @@ export function FindingDetailScreen({ scope, findingId }: Readonly('details'); const findingQuery = useSecurityFinding(scope, findingId); @@ -122,19 +122,16 @@ export function FindingDetailScreen({ scope, findingId }: Readonly - - - + ); } - if (findingQuery.isError) { + if (findingQuery.isError && !findingQuery.data) { return ( - - void findingQuery.refetch()} - /> - + void findingQuery.refetch()} + /> ); } @@ -199,6 +194,15 @@ export function FindingDetailScreen({ scope, findingId }: Readonly + {findingQuery.isError ? ( + + void findingQuery.refetch()} + /> + + ) : null} {dismissFailure !== null && dismissDraft.draft ? ( - - {tab === 'details' && } - {tab === 'analysis' && ( - void analysisQuery.refetch()} - /> - )} - {tab === 'remediation' && ( - void analysisQuery.refetch()} - /> - )} - + {tab === 'details' && ( + + + + )} + {tab === 'analysis' && ( + void analysisQuery.refetch()} + /> + )} + {tab === 'remediation' && ( + void analysisQuery.refetch()} + /> + )} ); } diff --git a/apps/mobile/src/components/security-agent/finding-list-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/finding-list-screen.mounted.test.tsx new file mode 100644 index 0000000000..a99999c5de --- /dev/null +++ b/apps/mobile/src/components/security-agent/finding-list-screen.mounted.test.tsx @@ -0,0 +1,192 @@ +import { DEFAULT_SECURITY_FINDING_FILTERS } from '@kilocode/app-shared/security-agent'; +import { act, type ComponentProps, createElement, type ReactElement, type ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import SecurityAgentFilterFindingsRoute from '@/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter'; +import { FlatList } from 'react-native'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PickerSheet } from '@/components/picker-sheet'; +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { ScreenHeader } from '@/components/screen-header'; +import { renderWithProviders } from '@/test/render-with-providers'; +import { FindingListScreen } from './finding-list-screen'; + +type FindingPages = { pages: { findings: { id: string }[] }[] }; +const findings = vi.hoisted(() => ({ + data: undefined as FindingPages | undefined, + isLoading: false, + isError: false, + isFetchingNextPage: false, + isFetchNextPageError: false, + hasNextPage: false, + refetch: vi.fn(), + fetchNextPage: vi.fn(), +})); +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + push: vi.fn(), + bridge: vi.fn(), + toastError: vi.fn(), +})); +vi.mock('react-native', () => ({ + View: 'View', + Pressable: 'Pressable', + RefreshControl: 'RefreshControl', + FlatList: 'FlatList', +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: mocks.push, back: vi.fn() }), + useFocusEffect: vi.fn(), +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('sonner-native', () => ({ toast: { error: mocks.toastError } })); +vi.mock('@/components/ui/icons', () => ({ + ShieldCheck: 'ShieldCheck', + SlidersHorizontal: 'SlidersHorizontal', + Info: 'Info', +})); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); +vi.mock('@/components/picker-sheet', () => ({ PickerSheet: 'PickerSheet' })); +vi.mock('@/components/security-agent/finding-filter-modal', () => ({ + FindingFilterModal: 'FindingFilterModal', +})); +vi.mock('@/components/security-agent/finding-row', () => ({ FindingRow: 'FindingRow' })); +vi.mock('@/components/tab-screen', () => ({ useTabBarBottomPadding: () => 0 })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-security-agent', () => ({ + useSecurityAgentConfig: () => ({ data: { repositorySelectionMode: 'all' } }), + useSecurityAgentRepositories: () => ({ data: [], isLoading: false, isError: false }), + useSecurityAnalysisCapacity: () => ({ runningCount: 0, concurrencyLimit: 3 }), +})); +vi.mock('@/lib/hooks/use-security-findings', () => ({ + useSecurityFindings: (...args: unknown[]) => { + mocks.query(...args); + return findings; + }, +})); +vi.mock('@/lib/hooks/use-route-foreground-refresh', () => ({ useRouteForegroundRefresh: vi.fn() })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' ') })); +vi.mock('@/lib/security-finding-filter-bridge', () => ({ + setSecurityFindingFilterBridge: mocks.bridge, +})); +vi.mock('@/lib/route-registry', () => ({ + SECURITY_FILTER_ROUTE_KEY: 'security-filter', + securityFilterSlot: { get: () => undefined }, + useRouteRegistry: vi.fn(), +})); + +let mounted: Awaited> | undefined = undefined; +async function mount(routeParams: ComponentProps['routeParams'] = {}) { + mounted = await renderWithProviders( + + ); + return mounted.renderer.root; +} + +async function refresh(control: ReactElement<{ onRefresh: () => void }>) { + await act(async () => { + control.props.onRefresh(); + await Promise.resolve(); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + findings.data = { pages: [{ findings: [] }] }; + findings.isLoading = false; + findings.isError = false; + findings.isFetchingNextPage = false; + findings.isFetchNextPageError = false; + findings.hasNextPage = false; + findings.refetch.mockResolvedValue({ isError: false }); +}); +afterEach(() => { + mounted?.unmount(); + mounted = undefined; + vi.unstubAllGlobals(); +}); + +describe('Security Agent list surfaces', () => { + it('centers an empty list outside the list with refresh and the header', async () => { + const root = await mount(); + const empty = root.findByType(EmptyState); + expect(empty.props.title).toBe('securityAgent.findingList.emptyTitle'); + expect(empty.props.placement).not.toBe('top'); + expect(root.findAllByType(FlatList)).toHaveLength(0); + expect(root.findAllByType(ScreenHeader)).toHaveLength(1); + await refresh(empty.props.refreshControl as ReactElement<{ onRefresh: () => void }>); + expect(findings.refetch).toHaveBeenCalledOnce(); + }); + + it('preserves Clear filters in the centered filtered state', async () => { + const root = await mount({ severity: 'high' }); + const empty = root.findByType(EmptyState); + expect(empty.props.title).toBe('securityAgent.findingList.noMatchesTitle'); + const action = empty.props.action as ReactElement<{ onPress: () => void }>; + act(action.props.onPress); + expect(root.findByType(EmptyState).props.title).toBe('securityAgent.findingList.emptyTitle'); + const filter = root.findByType(ScreenHeader).props.headerRight as ReactElement<{ + onPress: () => void; + }>; + act(filter.props.onPress); + expect(mocks.bridge).toHaveBeenLastCalledWith( + expect.objectContaining({ filters: DEFAULT_SECURITY_FINDING_FILTERS }) + ); + }); + + it('centers a load failure without cached rows and retains Retry and refresh', async () => { + findings.isError = true; + const root = await mount(); + const error = root.findByType(QueryError); + expect(error.props.placement).not.toBe('top'); + expect(root.findAllByType(EmptyState)).toHaveLength(0); + act(error.props.onRetry as () => void); + await refresh(error.props.refreshControl as ReactElement<{ onRefresh: () => void }>); + expect(findings.refetch).toHaveBeenCalledTimes(2); + }); + + it('keeps loading ahead of errors and empty content', async () => { + findings.isLoading = true; + findings.isError = true; + const root = await mount(); + expect(root.findAllByType(Skeleton)).toHaveLength(3); + expect(root.findAllByType(EmptyState)).toHaveLength(0); + expect(root.findAllByType(QueryError)).toHaveLength(0); + }); + + it.each([{ items: [] }, { items: [{ id: 'finding-1' }] }])( + 'keeps pagination errors inline with $items', + async ({ items }) => { + findings.data = { pages: [{ findings: items }] }; + findings.isError = true; + findings.isFetchNextPageError = true; + findings.hasNextPage = true; + const root = await mount(); + const list = root.findByType(FlatList); + expect(root.findAllByType(EmptyState)).toHaveLength(0); + expect(root.findAllByType(QueryError)).toHaveLength(0); + act(list.props.onEndReached as () => void); + expect(findings.fetchNextPage).toHaveBeenCalledOnce(); + const footer = list.props.ListFooterComponent as ReactNode; + mounted?.unmount(); + mounted = await renderWithProviders(createElement('Footer', null, footer)); + const error = mounted.renderer.root.findByType(QueryError); + expect(error.props.placement).toBe('top'); + act(error.props.onRetry as () => void); + expect(findings.fetchNextPage).toHaveBeenCalledTimes(2); + } + ); + + it('keeps expired filter guidance outside a scroller', async () => { + mounted = await renderWithProviders(); + expect(mounted.renderer.root.findByType(EmptyState).props.placement).not.toBe('top'); + expect(mounted.renderer.root.findAllByType(PickerSheet)).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/components/security-agent/finding-list-screen.tsx b/apps/mobile/src/components/security-agent/finding-list-screen.tsx index ffbfd2bdf1..22e0ea9a65 100644 --- a/apps/mobile/src/components/security-agent/finding-list-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-list-screen.tsx @@ -50,7 +50,11 @@ function FindingsListFooter({ } if (error) { return ( - + ); } return null; @@ -83,6 +87,7 @@ export function FindingListScreen({ scope, routeParams }: Readonly page.findings) ?? []; + const hasListContent = items.length > 0 || findings.isFetchNextPageError; const scopedRepositories = getSecurityRepositoriesInScope(repositories.data ?? [], config.data); // Repos aren't known yet (still loading or the fetch failed) — the filter // stays disabled instead of silently offering a shrunken repository list. @@ -145,16 +150,44 @@ export function FindingListScreen({ scope, routeParams }: Readonly )} - {!findings.isLoading && findings.isError && !findings.data && ( - - void findings.refetch()} - /> - + {!findings.isLoading && findings.isError && !hasListContent && ( + void findings.refetch()} + refreshControl={} + /> + )} + + {!findings.isLoading && !findings.isError && !hasListContent && ( + } + title={ + filtersActive + ? t('securityAgent.findingList.noMatchesTitle') + : t('securityAgent.findingList.emptyTitle') + } + description={ + filtersActive + ? t('securityAgent.findingList.noMatchesDescription') + : t('securityAgent.findingList.emptyDescription') + } + action={ + filtersActive ? ( + + ) : undefined + } + /> )} - {!findings.isLoading && (!findings.isError || findings.data) && ( + {!findings.isLoading && hasListContent && ( item.id} @@ -184,33 +217,6 @@ export function FindingListScreen({ scope, routeParams }: Readonly } - ListEmptyComponent={ - { - setFilters(DEFAULT_SECURITY_FINDING_FILTERS); - }} - > - {t('securityAgent.findingList.clearFilters')} - - ) : undefined - } - /> - } /> )} diff --git a/apps/mobile/src/components/security-agent/finding-remediation-panel.mounted.test.tsx b/apps/mobile/src/components/security-agent/finding-remediation-panel.mounted.test.tsx index 131ab67e6b..58f90851c9 100644 --- a/apps/mobile/src/components/security-agent/finding-remediation-panel.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/finding-remediation-panel.mounted.test.tsx @@ -6,7 +6,13 @@ // that omits `remediationTimeline`, so the panel treats a missing field as an // empty list. -import { createElement } from 'react'; +import { type ComponentProps, createElement } from 'react'; +import { CenteredState } from '@/components/centered-state'; +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { TabScreenScrollView } from '@/components/tab-screen'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -18,6 +24,9 @@ const mocks = vi.hoisted(() => ({ routerPush: vi.fn(), prReviewEnabled: true, openExternalUrl: vi.fn(), + start: vi.fn(), + retry: vi.fn(), + cancel: vi.fn(), })); vi.mock('react-native', () => ({ @@ -44,8 +53,10 @@ vi.mock('@/components/security-agent/collapsible-section', () => ({ vi.mock('@/components/security-agent/finding-status-badge', () => ({ FindingStatusBadge: () => null, })); -vi.mock('@/components/empty-state', () => ({ EmptyState: () => null })); -vi.mock('@/components/query-error', () => ({ QueryError: () => null })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' })); +vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); vi.mock('@/components/ui/kv-row', () => ({ KvRow: () => null })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: () => null })); @@ -58,9 +69,9 @@ vi.mock('@/components/ui/text', () => ({ }, })); vi.mock('@/lib/hooks/use-security-remediation', () => ({ - useStartSecurityRemediation: () => ({ mutate: vi.fn(), isPending: false }), - useRetrySecurityRemediation: () => ({ mutate: vi.fn(), isPending: false }), - useCancelSecurityRemediation: () => ({ mutate: vi.fn(), isPending: false }), + useStartSecurityRemediation: () => ({ mutate: mocks.start, isPending: false }), + useRetrySecurityRemediation: () => ({ mutate: mocks.retry, isPending: false }), + useCancelSecurityRemediation: () => ({ mutate: mocks.cancel, isPending: false }), })); vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({ @@ -70,8 +81,6 @@ vi.mock('@/lib/hooks/use-theme-colors', () => ({ }), })); vi.mock('@kilocode/app-shared/security-agent', () => ({ - formatRemediationOrigin: (origin: string) => origin, - formatValidationEvidenceEntry: () => '', getRemediationStatusPresentation: () => ({ label: 'Not started', tone: 'neutral', @@ -85,14 +94,6 @@ type R = TestRenderer.ReactTestRenderer; function analysisFixture(overrides: Record = {}): SecurityAnalysis { return { - findingState: { status: 'open' }, - status: 'completed', - startedAt: null, - completedAt: null, - error: null, - analysis: null, - sessionId: null, - cliSessionId: null, remediationSummary: null, remediationCapability: { canStart: false, @@ -108,7 +109,10 @@ function analysisFixture(overrides: Record = {}): SecurityAnaly } as unknown as SecurityAnalysis; } -function renderPanel(analysis: SecurityAnalysis): R { +function renderPanel( + analysis: SecurityAnalysis | undefined, + props: Partial> = {} +): R { const ref: { current: R | undefined } = { current: undefined }; act(() => { ref.current = TestRenderer.create( @@ -119,6 +123,7 @@ function renderPanel(analysis: SecurityAnalysis): R { isLoading: false, isError: false, onRetry: () => undefined, + ...props, }) ); }); @@ -145,6 +150,74 @@ describe('FindingRemediationPanel remediation timeline', () => { texts.items = []; }); + it('centers an empty remediation with its blocker reason', () => { + const tree = renderPanel(analysisFixture()); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(1); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + expect(texts.items).toContain('Finding is no longer open.'); + expect(tree.root.findAllByType(Button)).toHaveLength(0); + }); + + it.each([ + { remediationSummary: { status: 'queued' } }, + { + remediationAttempts: [ + { + id: 'attempt-1', + status: 'running', + origin: 'manual', + updatedAt: '2026-04-29T02:00:00.000Z', + }, + ], + }, + { + remediationTimeline: [ + { action: 'security.remediation.queued', occurredAt: '2026-04-29T01:16:12.945Z' }, + ], + }, + ])('keeps substantive remediation content in the report scroller: %j', content => { + const tree = renderPanel(analysisFixture(content), { isError: true }); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(1); + expect(tree.root.findAllByType(QueryError)).toHaveLength(0); + }); + + it('centers the absent response without another container', () => { + const tree = renderPanel(undefined); + expect(tree.root.findByType(EmptyState).props.placement).not.toBe('top'); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + }); + + it('keeps loading ahead of an absent response failure', () => { + const tree = renderPanel(undefined, { isLoading: true, isError: true }); + expect(tree.root.findAllByType(Skeleton)).toHaveLength(2); + expect(tree.root.findAllByType(QueryError)).toHaveLength(0); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(0); + }); + + it('keeps Retry in a full-body absent response failure', () => { + const onRetry = vi.fn<() => void>(); + const tree = renderPanel(undefined, { isError: true, onRetry }); + const error = tree.root.findByType(QueryError); + expect(error.props.placement).not.toBe('top'); + expect(tree.root.findAllByType(TabScreenScrollView)).toHaveLength(0); + act(error.props.onRetry as () => void); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('preserves server-approved actions in the centered state', () => { + const tree = renderPanel( + analysisFixture({ + remediationCapability: { canStart: true, canRetry: true, canCancel: false }, + }) + ); + expect(tree.root.findAllByType(CenteredState)).toHaveLength(1); + pressButtons(tree); + expect(mocks.start).toHaveBeenCalledWith({ findingId: 'finding-1' }); + expect(mocks.retry).toHaveBeenCalledWith({ findingId: 'finding-1' }); + }); + it('renders remediation timeline labels in order', () => { renderPanel( analysisFixture({ @@ -182,15 +255,8 @@ describe('FindingRemediationPanel remediation timeline', () => { expect(texts.items).toContain('Cancelled'); }); - it('renders nothing extra when the timeline is empty', () => { - renderPanel(analysisFixture({ remediationTimeline: [] })); - - expect(texts.items).not.toContain('Progress'); - expect(texts.items).not.toContain('Remediation requested'); - }); - - it('renders without throwing when the response omits remediationTimeline', () => { - const r = renderPanel(analysisFixture({ remediationTimeline: undefined })); + it.each([[], undefined])('renders an empty or omitted timeline: %j', remediationTimeline => { + const r = renderPanel(analysisFixture({ remediationTimeline })); expect(r.toJSON()).not.toBeNull(); expect(texts.items).not.toContain('Progress'); @@ -206,67 +272,22 @@ describe('FindingRemediationPanel pull request navigation', () => { mocks.prReviewEnabled = true; }); - it('navigates in-app for a github.com PR URL when the flag is on', () => { - const r = renderPanel( - analysisFixture({ - remediationSummary: { - status: 'pr_opened', - prUrl: 'https://github.com/kilo/kilo/pull/123', - prNumber: 123, - prDraft: false, - outcomeSummary: null, - }, - }) - ); - - pressButtons(r); - - expect(mocks.routerPush).toHaveBeenCalledWith('/(app)/pr-review/kilo/kilo/123'); - expect(mocks.openExternalUrl).not.toHaveBeenCalled(); - }); - - it('falls back to the browser when the flag is off', () => { - mocks.prReviewEnabled = false; - const r = renderPanel( - analysisFixture({ - remediationSummary: { - status: 'pr_opened', - prUrl: 'https://github.com/kilo/kilo/pull/123', - prNumber: 123, - prDraft: false, - outcomeSummary: null, - }, - }) - ); - - pressButtons(r); - - expect(mocks.routerPush).not.toHaveBeenCalled(); - expect(mocks.openExternalUrl).toHaveBeenCalledWith('https://github.com/kilo/kilo/pull/123', { - label: 'pull request', - }); - }); - - it('falls back to the browser for a non-GitHub URL', () => { - const r = renderPanel( - analysisFixture({ - remediationSummary: { - status: 'pr_opened', - prUrl: 'https://gitlab.com/kilo/kilo/-/merge_requests/123', - prNumber: 123, - prDraft: false, - outcomeSummary: null, - }, - }) - ); - - pressButtons(r); - - expect(mocks.routerPush).not.toHaveBeenCalled(); - expect(mocks.openExternalUrl).toHaveBeenCalledWith( - 'https://gitlab.com/kilo/kilo/-/merge_requests/123', - { label: 'pull request' } + it.each([ + ['https://github.com/kilo/kilo/pull/123', true, true], + ['https://github.com/kilo/kilo/pull/123', false, false], + ['https://gitlab.com/kilo/kilo/-/merge_requests/123', true, false], + ] as const)('opens %s with PR review=%s in-app=%s', (prUrl, enabled, inApp) => { + mocks.prReviewEnabled = enabled; + pressButtons( + renderPanel(analysisFixture({ remediationSummary: { status: 'pr_opened', prUrl } })) ); + if (inApp) { + expect(mocks.routerPush).toHaveBeenCalledWith('/(app)/pr-review/kilo/kilo/123'); + expect(mocks.openExternalUrl).not.toHaveBeenCalled(); + } else { + expect(mocks.routerPush).not.toHaveBeenCalled(); + expect(mocks.openExternalUrl).toHaveBeenCalledWith(prUrl, { label: 'pull request' }); + } }); it('routes both the summary and attempt buttons in-app', () => { diff --git a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx index 8e82bfd1e3..465e247652 100644 --- a/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx +++ b/apps/mobile/src/components/security-agent/finding-remediation-panel.tsx @@ -6,6 +6,8 @@ import { type TFunction } from 'i18next'; import { ActivityIndicator, Alert, View } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { CenteredState } from '@/components/centered-state'; +import { TabScreenScrollView } from '@/components/tab-screen'; import { CollapsibleSection } from '@/components/security-agent/collapsible-section'; import { FindingStatusBadge } from '@/components/security-agent/finding-status-badge'; import { EmptyState } from '@/components/empty-state'; @@ -226,7 +228,7 @@ export function FindingRemediationPanel({ if (isLoading && !analysis) { return ( - + @@ -234,18 +236,13 @@ export function FindingRemediationPanel({ } if (isError && !analysis) { - return ( - - - - ); + return ; } if (!analysis) { return ( @@ -281,8 +278,12 @@ export function FindingRemediationPanel({ ? getRemediationUnavailableKey(remediationCapability.retryReason) : null; - return ( - + const hasContent = + Boolean(remediationSummary) || remediationAttempts.length > 0 || remediationTimeline.length > 0; + const Body = hasContent ? TabScreenScrollView : CenteredState; + + const content = ( + ); + + return {content}; } diff --git a/apps/mobile/src/components/security-agent/scope-entry-screen.test.ts b/apps/mobile/src/components/security-agent/scope-entry-screen.test.ts index 9eec70b3a3..72c208f816 100644 --- a/apps/mobile/src/components/security-agent/scope-entry-screen.test.ts +++ b/apps/mobile/src/components/security-agent/scope-entry-screen.test.ts @@ -1,5 +1,6 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts RN trees without a DOM. */ -import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { type MobileRouter } from '@kilocode/trpc/mobile'; +import { securityConfigFixture } from './security-config.test-fixture'; import { onlineManager, QueryClient } from '@tanstack/react-query'; import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'; import { createElement } from 'react'; @@ -18,46 +19,13 @@ let permissionData = { hasPermissions: true, reauthorizeUrl: null as string | null, }; -type ConfigData = inferRouterOutputs['organizations']['securityAgent']['getConfig']; -const defaultConfigData: ConfigData = { - hasConfig: false, - configRevision: 1, - isEnabled: false, - slaCriticalDays: 15, - slaHighDays: 30, - slaMediumDays: 45, - slaLowDays: 90, - slaEnabled: false, - autoSyncEnabled: true, - repositorySelectionMode: 'all', - selectedRepositoryIds: [], - modelSlug: 'test/model', - triageModelSlug: 'test/model', - analysisModelSlug: 'test/model', - analysisMode: 'auto', - autoDismissEnabled: false, - autoDismissConfidenceThreshold: 'high', - autoAnalysisEnabled: false, - autoAnalysisMinSeverity: 'high', - autoAnalysisIncludeExisting: false, - autoRemediationEnabled: false, - autoRemediationMinSeverity: 'high', - autoRemediationIncludeExisting: false, - autoRemediationRequireApproval: true, - autoRemediationEnabledAt: null, - remediationModelSlug: 'test/model', - slaNotificationsEnabled: false, - slaNotificationMinSeverity: 'high', - slaNotificationWarningDays: 3, - newFindingNotificationsEnabled: false, - newFindingNotificationMinSeverity: 'high', -}; -let configData: ConfigData = structuredClone(defaultConfigData); +let configData = structuredClone(securityConfigFixture); let repositoriesData: { id: number }[] = []; let roles: { organizationId: string; role: string }[] = []; let queryClient = new QueryClient(); let renderer: TestRenderer.ReactTestRenderer | undefined = undefined; let previousOnline = true; +let mintFailed = false; vi.mock('@/lib/trpc', async () => { const { createTRPCContext } = await import('@trpc/tanstack-react-query'); @@ -108,6 +76,8 @@ vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' } vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); function host(root: TestRenderer.ReactTestInstance, type: string) { return root.findAll(node => node.type === type); @@ -149,11 +119,15 @@ beforeEach(() => { defaultOptions: { queries: { retry: false, gcTime: Infinity } }, }); permissionData = { hasIntegration: true, hasPermissions: true, reauthorizeUrl: null }; - configData = structuredClone(defaultConfigData); + configData = structuredClone(securityConfigFixture); + mintFailed = false; repositoriesData = [{ id: 1 }]; roles = [{ organizationId: 'org_123', role: 'owner' }]; transport.mockReset().mockImplementation(async input => { await Promise.resolve(); + if (mintFailed && procedureFor(input) === 'mintInstallState') { + throw new Error('Setup failed'); + } const data: Record = { getPermissionStatus: permissionData, getConfig: configData, @@ -191,6 +165,20 @@ describe.each(['personal', 'org_123'])('ScopeEntryScreen %s routing', scope => { expect(host(root, 'DashboardScreen')).toHaveLength(0); }); + it.each([false, true])( + 'keeps one header when setup mint fails with integration=%s', + async hasIntegration => { + permissionData = { hasIntegration, hasPermissions: false, reauthorizeUrl: null }; + mintFailed = true; + const root = await mount(scope); + expect(host(root, 'ScreenHeader')).toHaveLength(1); + expect(host(root, 'PlatformErrorScreen')).toHaveLength(0); + expect(host(root, 'QueryError')).toHaveLength(1); + expect(host(root, 'QueryError')[0]?.props.placement).not.toBe('top'); + expect(host(root, 'SecurityAgentSetup')).toHaveLength(0); + } + ); + it('uses the server reauthorization URL without minting another token', async () => { permissionData = { hasIntegration: true, @@ -239,6 +227,8 @@ describe.each([ onlineManager.setOnline(false); const root = await mount('personal', Screen); await retry(root); + expect(host(root, 'CenteredState')).toHaveLength(1); + expect(host(root, 'TabScreenScrollView')).toHaveLength(0); expect(host(root, 'Switch')[0]?.props.value).toBe(false); expect(host(root, 'Switch')[0]?.props.disabled).toBe(expected.disabled); expect( diff --git a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx index adb157bc6b..ce61cf833c 100644 --- a/apps/mobile/src/components/security-agent/scope-entry-screen.tsx +++ b/apps/mobile/src/components/security-agent/scope-entry-screen.tsx @@ -7,6 +7,7 @@ import { View } from 'react-native'; import { AuditReportButton } from '@/components/security-agent/audit-report-button'; import { selectScopeEntryView } from '@/components/security-agent/scope-entry-render'; import { PlatformErrorScreen } from '@/components/platform-error-screen'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { DashboardScreen } from '@/components/security-agent/dashboard-screen'; import { SecurityAgentSetup } from '@/components/security-agent/security-agent-setup'; @@ -159,8 +160,7 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { return ( - void performMint()} @@ -197,8 +197,7 @@ export function ScopeEntryScreen({ scope }: Readonly<{ scope: string }>) { return ( - void performMint()} diff --git a/apps/mobile/src/components/security-agent/security-agent-setup.mounted.test.tsx b/apps/mobile/src/components/security-agent/security-agent-setup.mounted.test.tsx new file mode 100644 index 0000000000..434bd1b9fc --- /dev/null +++ b/apps/mobile/src/components/security-agent/security-agent-setup.mounted.test.tsx @@ -0,0 +1,66 @@ +import { act, createElement } from 'react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { CenteredState } from '@/components/centered-state'; +import { Button } from '@/components/ui/button'; +import { renderWithProviders } from '@/test/render-with-providers'; +import { SecurityAgentSetup } from './security-agent-setup'; + +const authorization = vi.hoisted(() => vi.fn()); +vi.mock('react-native', () => ({ + View: 'View', + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios' }, +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } })); +vi.mock('@/components/ui/icons', () => ({ ShieldCheck: 'ShieldCheck' })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); +vi.mock('@/lib/external-auth/use-external-auth-return', () => ({ + useExternalAuthReturn: () => ({ markLaunched: vi.fn(), clearLaunch: vi.fn() }), +})); +vi.mock('@/lib/pr-review/connect-gate-platform', () => ({ + openAuthorizationAndWaitForReturn: authorization, +})); + +let mounted: Awaited> | undefined = undefined; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + authorization.mockReset().mockResolvedValue('sheet-close'); +}); +afterEach(() => { + mounted?.unmount(); + mounted = undefined; + vi.unstubAllGlobals(); +}); + +it('centers setup, disables Connect while authorizing, and refreshes on return', async () => { + const result = Promise.withResolvers<'sheet-close'>(); + authorization.mockReturnValueOnce(result.promise); + const onConnected = vi.fn().mockResolvedValue(undefined); + mounted = await renderWithProviders( + createElement(SecurityAgentSetup, { + title: 'Connect GitHub', + description: 'Authorize the GitHub App.', + buttonLabel: 'Connect', + url: 'https://github.com/apps/kilo', + onConnected, + }) + ); + const body = mounted.renderer.root.findByType(CenteredState); + const button = body.findByType(Button); + expect(button.props.disabled).toBe(false); + act(button.props.onPress as () => void); + expect(button.props.disabled).toBe(true); + expect(authorization).toHaveBeenCalledWith('ios', 'https://github.com/apps/kilo'); + expect(onConnected).not.toHaveBeenCalled(); + await act(async () => { + result.resolve('sheet-close'); + await result.promise; + }); + expect(onConnected).toHaveBeenCalledOnce(); + expect(button.props.disabled).toBe(false); +}); diff --git a/apps/mobile/src/components/security-agent/security-agent-setup.tsx b/apps/mobile/src/components/security-agent/security-agent-setup.tsx index 42a921fb8d..4187f8c02b 100644 --- a/apps/mobile/src/components/security-agent/security-agent-setup.tsx +++ b/apps/mobile/src/components/security-agent/security-agent-setup.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { useTabBarBottomPadding } from '@/components/tab-screen'; +import { CenteredState } from '@/components/centered-state'; import { useExternalAuthReturn } from '@/lib/external-auth/use-external-auth-return'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { openAuthorizationAndWaitForReturn } from '@/lib/pr-review/connect-gate-platform'; @@ -28,7 +28,6 @@ export function SecurityAgentSetup({ onConnected, }: Readonly>) { const colors = useThemeColors(); - const tabBarPadding = useTabBarBottomPadding(); const [connecting, setConnecting] = useState(false); const { t } = useTranslation(); @@ -58,23 +57,22 @@ export function SecurityAgentSetup({ }; return ( - - - {title} - {description} - - + + + + {title} + {description} + + + ); } diff --git a/apps/mobile/src/components/security-agent/security-config.test-fixture.ts b/apps/mobile/src/components/security-agent/security-config.test-fixture.ts new file mode 100644 index 0000000000..7d469ffe78 --- /dev/null +++ b/apps/mobile/src/components/security-agent/security-config.test-fixture.ts @@ -0,0 +1,37 @@ +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; + +type ConfigData = inferRouterOutputs['organizations']['securityAgent']['getConfig']; + +export const securityConfigFixture: ConfigData = { + hasConfig: false, + configRevision: 1, + isEnabled: false, + slaCriticalDays: 15, + slaHighDays: 30, + slaMediumDays: 45, + slaLowDays: 90, + slaEnabled: false, + autoSyncEnabled: true, + repositorySelectionMode: 'all', + selectedRepositoryIds: [], + modelSlug: 'test/model', + triageModelSlug: 'test/model', + analysisModelSlug: 'test/model', + analysisMode: 'auto', + autoDismissEnabled: false, + autoDismissConfidenceThreshold: 'high', + autoAnalysisEnabled: false, + autoAnalysisMinSeverity: 'high', + autoAnalysisIncludeExisting: false, + autoRemediationEnabled: false, + autoRemediationMinSeverity: 'high', + autoRemediationIncludeExisting: false, + autoRemediationRequireApproval: true, + autoRemediationEnabledAt: null, + remediationModelSlug: 'test/model', + slaNotificationsEnabled: false, + slaNotificationMinSeverity: 'high', + slaNotificationWarningDays: 3, + newFindingNotificationsEnabled: false, + newFindingNotificationMinSeverity: 'high', +}; diff --git a/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx b/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx index 80a1653436..8ac25fe33b 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.mounted.test.tsx @@ -9,6 +9,7 @@ import { trpcClient, TRPCProvider } from '@/lib/trpc'; import { renderWithProviders } from '@/test/render-with-providers'; import { ScopeEntryScreen } from './scope-entry-screen'; import { SettingsOverviewScreen } from './settings-overview-screen'; +import { securityConfigFixture } from './security-config.test-fixture'; const committedConnectivity = vi.hoisted(() => ({ status: 'online' as 'online' | 'offline' | 'unknown', @@ -81,6 +82,8 @@ vi.mock('@/components/screen-header', () => ({ ScreenHeader: 'ScreenHeader' })); vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' })); vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'TabScreenScrollView' })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); function host(root: ReactTestInstance, type: string) { return root.findAll(node => node.type === type); @@ -124,12 +127,7 @@ beforeEach(() => { onlineManager.setOnline(true); queryClient = new QueryClient({ defaultOptions: { queries: { retry: 3, gcTime: Infinity } } }); committedConnectivity.status = 'online'; - configData = { - isEnabled: false, - repositorySelectionMode: 'all', - selectedRepositoryIds: [], - analysisMode: 'auto', - }; + configData = structuredClone(securityConfigFixture); repositoriesData = [{ id: 1 }]; roles = [{ organizationId: 'org_123', role: 'owner' }]; failures.clear(); @@ -232,6 +230,8 @@ describe.each([ failures.clear(); await retry(root); expect(host(root, 'Switch')[0]?.props.disabled).toBe(false); + expect(host(root, 'CenteredState')).toHaveLength(isEnabled ? 0 : 1); + expect(host(root, 'TabScreenScrollView')).toHaveLength(isEnabled ? 1 : 0); expect(active.every(query => query.state.status === 'success')).toBe(true); expect(onlineManager.isOnline()).toBe(false); } diff --git a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx index b41b755bb5..a8b6a11f9e 100644 --- a/apps/mobile/src/components/security-agent/settings-overview-screen.tsx +++ b/apps/mobile/src/components/security-agent/settings-overview-screen.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Switch, View } from 'react-native'; +import { CenteredState } from '@/components/centered-state'; import { AuditReportButton } from '@/components/security-agent/audit-report-button'; import { PlatformErrorScreen } from '@/components/platform-error-screen'; import { ScreenHeader } from '@/components/screen-header'; @@ -30,12 +31,7 @@ const ANALYSIS_MODE_KEYS = { auto: 'securityAgent.analysisMode.auto', shallow: 'securityAgent.analysisMode.shallow', deep: 'securityAgent.analysisMode.deep', -} satisfies Record; - -/** Looks up a possibly-unknown key in a literal dictionary without widening its type. */ -function lookup(dictionary: Readonly>, key: string): V | undefined { - return (dictionary as Readonly>)[key]; -} +}; function SettingsOverviewSkeleton() { const { t } = useTranslation(); @@ -166,8 +162,9 @@ export function SettingsOverviewScreen({ data.newFindingNotificationsEnabled, data.slaNotificationsEnabled, ].filter(Boolean).length; - const analysisModeKey = lookup(ANALYSIS_MODE_KEYS, data.analysisMode); - const analysisModeLabel = analysisModeKey ? t(analysisModeKey) : data.analysisMode; + const analysisModeLabel = Object.hasOwn(ANALYSIS_MODE_KEYS, data.analysisMode) + ? t(ANALYSIS_MODE_KEYS[data.analysisMode]) + : data.analysisMode; const handleToggle = (value: boolean) => { void Haptics.selectionAsync(); @@ -228,117 +225,121 @@ export function SettingsOverviewScreen({ ); }; + const Body = data.isEnabled ? TabScreenScrollView : CenteredState; + return ( - - - - - {t('securityAgent.settingsOverview.securityAgent')} - - - {data.isEnabled ? repoCountLabel : t('securityAgent.settingsOverview.disabled')} - + + + + + + {t('securityAgent.settingsOverview.securityAgent')} + + + {data.isEnabled ? repoCountLabel : t('securityAgent.settingsOverview.disabled')} + + + {capability.canManage ? ( + + ) : ( + + {data.isEnabled + ? t('securityAgent.settingsOverview.enabled') + : t('securityAgent.settingsOverview.disabled')} + + )} - {capability.canManage ? ( - - ) : ( - - {data.isEnabled - ? t('securityAgent.settingsOverview.enabled') - : t('securityAgent.settingsOverview.disabled')} - + + {(!data.isEnabled || repositoriesLoading || recoveryError) && ( + + {renderRepositoryStatus()} + {showRepoCta ? ( + { + router.push(getSecurityAgentPath(scope, 'settings/repositories')); + }} + /> + ) : null} + )} - - {(!data.isEnabled || repositoriesLoading || recoveryError) && ( - - {renderRepositoryStatus()} - {showRepoCta ? ( + {data.isEnabled && ( + { router.push(getSecurityAgentPath(scope, 'settings/repositories')); }} /> - ) : null} - - )} - - {data.isEnabled && ( - - { - router.push(getSecurityAgentPath(scope, 'settings/repositories')); - }} - /> - { - router.push(getSecurityAgentPath(scope, 'settings/analysis')); - }} - /> - { - router.push(getSecurityAgentPath(scope, 'settings/automation')); - }} - /> - { - router.push(getSecurityAgentPath(scope, 'settings/notifications')); - }} - /> - { - router.push(getSecurityAgentPath(scope, 'settings/sla')); - }} - /> - - )} - + { + router.push(getSecurityAgentPath(scope, 'settings/analysis')); + }} + /> + { + router.push(getSecurityAgentPath(scope, 'settings/automation')); + }} + /> + { + router.push(getSecurityAgentPath(scope, 'settings/notifications')); + }} + /> + { + router.push(getSecurityAgentPath(scope, 'settings/sla')); + }} + /> + + )} + + ); } diff --git a/apps/mobile/src/components/share/share-destination-list.mounted.test.tsx b/apps/mobile/src/components/share/share-destination-list.mounted.test.tsx new file mode 100644 index 0000000000..4e66587fbe --- /dev/null +++ b/apps/mobile/src/components/share/share-destination-list.mounted.test.tsx @@ -0,0 +1,201 @@ +import { act, createElement, type ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@/test/render-with-providers'; +import { type ShareCliSpawnRow } from './share-cli-spawn'; +import { ShareDestinationList } from './share-destination-list'; +import { type ShareDestinationRow } from './share-destinations'; +import { type ShareGateState } from './share-gate-state'; + +vi.mock('react-native', () => ({ + View: 'View', + TextInput: 'TextInput', + FlatList: (props: { + data: ShareDestinationRow[]; + ListHeaderComponent?: ReactNode; + ListEmptyComponent?: ReactNode; + renderItem: (info: { item: ShareDestinationRow }) => ReactNode; + }) => + createElement( + 'FlatList', + null, + props.ListHeaderComponent, + props.data.length > 0 + ? props.data.map((item): ReactNode => props.renderItem({ item })) + : props.ListEmptyComponent + ), +})); +vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ bottom: 12 }) })); +vi.mock('@/components/centered-state', () => ({ CenteredState: 'CenteredState' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/ui/icons', () => ({ Search: 'Search', Terminal: 'Terminal' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/agents/session-list-section-header', () => ({ + SessionListSectionHeader: 'SectionHeader', +})); +vi.mock('@/components/agents/session-row', () => ({ StoredSessionRow: 'StoredSessionRow' })); +vi.mock('@/components/destination-option-row', () => ({ + DestinationOptionRow: 'DestinationOptionRow', +})); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ useThemeColors: () => ({}) })); + +const instance: ShareCliSpawnRow = { + connectionId: 'cli-1', + name: 'Laptop', + projectName: 'project', + kind: 'cli', + startedAt: null, + gitBranch: null, +}; +const empty: ShareGateState = { + kind: 'empty', + message: 'No sessions', + showNewSession: true, + showRetry: false, + showList: false, +}; +const retryable: ShareGateState = { + kind: 'retryable', + message: 'Failed to load', + showNewSession: true, + showRetry: true, + showList: false, +}; +const happy: ShareGateState = { + kind: 'happy', + showNewSession: true, + showRetry: false, + showList: true, + listMode: 'rows', +}; +const destinations = Array.from({ length: 9 }, (_, index) => ({ + session_id: `session-${index}`, + title: `Task ${index}`, + git_branch: 'main', + live: false, +})) as ShareDestinationRow[]; + +async function mount( + state: ShareGateState, + instances: ShareCliSpawnRow[] = [], + rows: ShareDestinationRow[] = [] +) { + const onRetry = vi.fn<() => void>(); + const onSelect = vi.fn<(row: ShareDestinationRow) => void>(); + const onSpawnInstance = vi.fn<(row: ShareCliSpawnRow) => void>(); + const mounted = await renderWithProviders( + createElement(ShareDestinationList, { + headerContent: createElement('Header'), + state, + destinations: rows, + instances, + spawningConnectionId: null, + instanceRowsDisabled: false, + destinationsDisabled: false, + onRetry, + onSelect, + onSpawnInstance, + }) + ); + return mounted; +} + +describe('ShareDestinationList surface states', () => { + it.each([empty, retryable])( + 'lifts $kind outside the list without connected CLI choices', + async state => { + const { renderer, unmount } = await mount(state); + expect(renderer.root.findAll(node => String(node.type) === 'FlatList')).toHaveLength(0); + const bodyType = state.kind === 'retryable' ? 'QueryError' : 'CenteredState'; + const body = renderer.root.find(node => String(node.type) === bodyType); + expect((body.props as { placement?: string }).placement).not.toBe('top'); + expect(renderer.toJSON()).toHaveLength(2); + unmount(); + } + ); + + it.each([empty, retryable])('keeps $kind inline beside connected CLI choices', async state => { + const { renderer, unmount } = await mount(state, [instance]); + const list = renderer.root.find(node => String(node.type) === 'FlatList'); + expect(list.findAll(node => String(node.type) === 'DestinationOptionRow')).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(0); + if (state.kind === 'retryable') { + expect(list.find(node => String(node.type) === 'QueryError').props).toMatchObject({ + placement: 'top', + }); + } + unmount(); + }); + + it.each(['stale-share', 'non-retryable-classification'] as const)( + 'centers %s instead of an empty list', + async kind => { + const { renderer, unmount } = await mount( + { + kind, + message: 'Unavailable share', + showNewSession: false, + showRetry: false, + showList: false, + }, + [instance] + ); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'FlatList')).toHaveLength(0); + expect( + renderer.root.findAll(node => String(node.type) === 'DestinationOptionRow') + ).toHaveLength(0); + unmount(); + } + ); + + it('keeps the search input mounted across empty and populated results', async () => { + const { renderer, unmount } = await mount(happy, [], destinations); + const input = renderer.root.find(node => String(node.type) === 'TextInput'); + const onChangeText = (input.props as { onChangeText: (text: string) => void }).onChangeText; + const header = renderer.root.find( + node => (node.props as { collapsable?: boolean }).collapsable === false + ); + expect(header.findAll(node => String(node.type) === 'Header')).toHaveLength(1); + act(() => { + onChangeText('no match'); + }); + expect(renderer.root.findAll(node => String(node.type) === 'FlatList')).toHaveLength(0); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(1); + expect(renderer.root.find(node => String(node.type) === 'TextInput')).toBe(input); + act(() => { + onChangeText('main'); + }); + expect(renderer.root.findAll(node => String(node.type) === 'StoredSessionRow')).toHaveLength(9); + expect(renderer.root.find(node => String(node.type) === 'TextInput')).toBe(input); + unmount(); + }); + + it('keeps connected CLI choices when search removes every session', async () => { + const { renderer, unmount } = await mount(happy, [instance], destinations); + const input = renderer.root.find(node => String(node.type) === 'TextInput'); + act(() => { + (input.props as { onChangeText: (text: string) => void }).onChangeText('no match'); + }); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(0); + expect( + renderer.root.findAll(node => String(node.type) === 'DestinationOptionRow') + ).toHaveLength(1); + expect(renderer.root.findAll(node => String(node.type) === 'StoredSessionRow')).toHaveLength(0); + unmount(); + }); + + it('keeps the loading skeleton ahead of empty states', async () => { + const { renderer, unmount } = await mount({ + kind: 'loading', + showNewSession: true, + showRetry: false, + showList: true, + listMode: 'skeleton', + }); + expect(renderer.root.findAll(node => String(node.type) === 'Skeleton')).toHaveLength(5); + expect(renderer.root.findAll(node => String(node.type) === 'CenteredState')).toHaveLength(0); + unmount(); + }); +}); diff --git a/apps/mobile/src/components/share/share-destination-list.tsx b/apps/mobile/src/components/share/share-destination-list.tsx index 3ae22169b9..4b58165752 100644 --- a/apps/mobile/src/components/share/share-destination-list.tsx +++ b/apps/mobile/src/components/share/share-destination-list.tsx @@ -1,11 +1,12 @@ import { Search, Terminal } from '@/components/ui/icons'; -import { useMemo, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FlatList, TextInput, View, type ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SessionListSectionHeader } from '@/components/agents/session-list-section-header'; import { StoredSessionRow } from '@/components/agents/session-row'; +import { CenteredState } from '@/components/centered-state'; import { DestinationOptionRow } from '@/components/destination-option-row'; import { QueryError } from '@/components/query-error'; import { Skeleton } from '@/components/ui/skeleton'; @@ -20,6 +21,7 @@ const SEARCH_THRESHOLD = 8; const SKELETON_COUNT = 5; type ShareDestinationListProps = { + headerContent?: ReactNode; state: ShareGateState; destinations: readonly ShareDestinationRow[]; onSelect: (row: ShareDestinationRow) => void; @@ -106,13 +108,8 @@ function CliInstanceRows({ ); } -/** - * Destination FlatList for the share gate. Must be a direct child of the - * formSheet screen content (paired with the collapsable header View). - * Search is ListHeaderComponent — scrolls with the list, shown only when - * loaded destination count > 8. - */ export function ShareDestinationList({ + headerContent, state, destinations, onSelect, @@ -126,8 +123,7 @@ export function ShareDestinationList({ const { bottom } = useSafeAreaInsets(); const { t } = useTranslation(); const [search, setSearch] = useState(''); - - const showSearch = destinations.length > SEARCH_THRESHOLD; + const showSearch = state.kind === 'happy' && destinations.length > SEARCH_THRESHOLD; const filtered = useMemo(() => { const q = search.trim().toLowerCase(); @@ -142,126 +138,90 @@ export function ShareDestinationList({ }, [destinations, search]); const contentPad = useMemo(() => ({ paddingBottom: bottom + 16 }) satisfies ViewStyle, [bottom]); - const growContentPad = useMemo( - () => ({ paddingBottom: bottom + 16, flexGrow: 1 }) satisfies ViewStyle, - [bottom] - ); - - const cliSection = - instances.length > 0 ? ( - - ) : null; + const noChoices = instances.length === 0; + let body: ReactNode = null; - if (state.kind === 'loading') { - return ( - `skeleton-${index}`} - ListHeaderComponent={ - <> - {cliSection} - - - } - renderItem={() => null} - contentContainerStyle={contentPad} - keyboardShouldPersistTaps="handled" - /> + if (state.kind === 'stale-share' || state.kind === 'non-retryable-classification') { + body = ( + + {state.message} + ); - } - - if (state.kind === 'retryable') { - return ( - 'error'} - ListHeaderComponent={cliSection} - ListEmptyComponent={ - - } - renderItem={() => null} - contentContainerStyle={growContentPad} - keyboardShouldPersistTaps="handled" - /> + } else if (noChoices && state.kind === 'retryable') { + body = ; + } else if ( + noChoices && + (state.kind === 'empty' || (state.kind === 'happy' && filtered.length === 0)) + ) { + body = ( + + + {state.kind === 'empty' ? state.message : t('share.noMatchingSessions')} + + ); - } + } else { + let emptyContent: ReactNode = null; + if (state.kind === 'loading') { + emptyContent = ; + } else if (state.kind === 'retryable') { + emptyContent = ; + } else if (state.kind === 'empty') { + emptyContent = ; + } else if (search.trim()) { + emptyContent = ; + } - if (state.kind === 'empty') { - return ( + body = ( 'empty'} - ListHeaderComponent={cliSection} - ListEmptyComponent={} - renderItem={() => null} - contentContainerStyle={growContentPad} + data={state.kind === 'happy' ? filtered : []} + keyExtractor={item => item.session_id} + ListHeaderComponent={ + instances.length > 0 ? ( + + ) : null + } keyboardShouldPersistTaps="handled" - /> - ); - } - - // Terminal non-retryable states: header already shows the message; keep an - // empty FlatList so the formSheet still has [header, list] as direct children. - // No CLI section (criterion 20). - if (state.kind === 'stale-share' || state.kind === 'non-retryable-classification') { - return ( - 'terminal'} - renderItem={() => null} + keyboardDismissMode="on-drag" contentContainerStyle={contentPad} - keyboardShouldPersistTaps="handled" + renderItem={({ item }) => ( + + { + if (destinationsDisabled) { + return; + } + onSelect(item); + }} + /> + + )} + ListEmptyComponent={emptyContent} /> ); } - // happy return ( - item.session_id} - ListHeaderComponent={ - <> - {cliSection} - {showSearch ? : null} - - } - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - contentContainerStyle={contentPad} - renderItem={({ item }) => ( - - { - if (destinationsDisabled) { - return; - } - onSelect(item); - }} - /> - - )} - ListEmptyComponent={ - search.trim() ? : null - } - /> + <> + + {headerContent} + {showSearch ? : null} + + {body} + ); } diff --git a/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx b/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx index 0ce05d78ff..4b414f1177 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.mounted.test.tsx @@ -7,7 +7,7 @@ // real `ShareGateSheet` with every RN-touching dependency stubbed and drives // the spawn via the list's captured `onSpawnInstance` prop. -import { createElement } from 'react'; +import { createElement, type ReactNode } from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -187,9 +187,10 @@ vi.mock('./share-destination-list', () => ({ ShareDestinationList: (props: { onSpawnInstance: (row: ShareCliSpawnRow) => void; instanceRowsDisabled: boolean; - }) => { + headerContent: ReactNode; + }): ReactNode => { shareDestinationListProps.current = props; - return null; + return props.headerContent; }, })); diff --git a/apps/mobile/src/components/share/share-gate-sheet.tsx b/apps/mobile/src/components/share/share-gate-sheet.tsx index ce00d0f7c9..68f27088c8 100644 --- a/apps/mobile/src/components/share/share-gate-sheet.tsx +++ b/apps/mobile/src/components/share/share-gate-sheet.tsx @@ -56,10 +56,6 @@ type ShareGateSheetProps = { shareId: string | undefined; }; -/** - * Share gate formSheet body. Exactly two direct children of the screen - * content: a collapsable={false} header block and the FlatList. - */ export function ShareGateSheet({ shareId }: Readonly) { const router = useRouter(); const colors = useThemeColors(); @@ -383,14 +379,10 @@ export function ShareGateSheet({ shareId }: Readonly) { }, [sessions]); const showNewSession = state.showNewSession; - const showTerminalMessage = - state.kind === 'stale-share' || state.kind === 'non-retryable-classification'; const previewPayload = payload !== null && state.kind !== 'stale-share' ? payload : null; - // Header block: title+close, preview, New session. collapsable={false} is - // required so react-native-screens finds it as the formSheet header. const header = ( - + ) { ) : null} - {showTerminalMessage ? ( - - {state.message} - - ) : null} - {reviewPr ? ( ) { ); - // Always pair the collapsable header with a FlatList (formSheet constraint). return ( - <> - {header} - - + ); } diff --git a/apps/mobile/src/components/sheet-header.mounted.test.tsx b/apps/mobile/src/components/sheet-header.mounted.test.tsx index 622b3468f1..c93b14b809 100644 --- a/apps/mobile/src/components/sheet-header.mounted.test.tsx +++ b/apps/mobile/src/components/sheet-header.mounted.test.tsx @@ -332,7 +332,7 @@ describe('SheetHeader', () => { expect(tree).toHaveLength(2); expect(tree[0]?.type).toBe('View'); expect(tree[0]?.props.collapsable).toBe(false); - expect(tree[1]?.type).toBe('ScrollView'); + expect(tree[1]?.type).toBe(expired ? 'EmptyState' : 'ScrollView'); expect(pressablesByLabel(renderer.root, 'Done')).toHaveLength(1); renderer.unmount(); diff --git a/apps/mobile/src/components/trusted-hosts-screen.tsx b/apps/mobile/src/components/trusted-hosts-screen.tsx index 2bd2f73eb5..4453f3b7fe 100644 --- a/apps/mobile/src/components/trusted-hosts-screen.tsx +++ b/apps/mobile/src/components/trusted-hosts-screen.tsx @@ -21,67 +21,67 @@ export function TrustedHostsScreen() { return ( - - {!hasLoaded && ( - - {[0, 1].map(index => ( - - - - - ))} - - )} - - {hasLoaded && trustedHosts.length === 0 && ( - { - router.back(); - }} - > - {t('trustedHosts.backToPreferences')} - - } - /> - )} - - {hasLoaded && trustedHosts.length > 0 && ( - - {trustedHosts.map(host => ( - - - {host} - - { - revokeHost(host); - }} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={t('trustedHosts.revoke', { host })} - className="min-h-11 min-w-11 shrink-0 items-center justify-center active:opacity-70" + {hasLoaded && trustedHosts.length === 0 ? ( + { + router.back(); + }} + > + {t('trustedHosts.backToPreferences')} + + } + /> + ) : ( + + {!hasLoaded ? ( + + {[0, 1].map(index => ( + + + + + ))} + + ) : ( + + {trustedHosts.map(host => ( + - - - - ))} - - )} - + + {host} + + { + revokeHost(host); + }} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('trustedHosts.revoke', { host })} + className="min-h-11 min-w-11 shrink-0 items-center justify-center active:opacity-70" + > + + + + ))} + + )} + + )} ); } From d0970ea2e1dc9421953e81675f9c9b51316895e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 2 Sep 2026 03:17:18 +0200 Subject: [PATCH 07/10] test(mobile): isolate the image failure fixture --- .../src/components/agents/part-detail-images.mounted.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx index f2534465e6..3e4ca4939b 100644 --- a/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx +++ b/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx @@ -57,6 +57,7 @@ vi.mock('./mono-scroll-block', () => ({ vi.mock('./chat-markdown-text', () => ({ ChatMarkdownText: 'ChatMarkdownText' })); vi.mock('./code-block', () => ({ CodeBlock: 'CodeBlock' })); vi.mock('./fixed-part-row', () => ({ FixedPartRow: 'FixedPartRow' })); +vi.mock('./suggest-tool-card', () => ({ SuggestToolCardBody: 'SuggestToolCardBody' })); vi.mock('./tool-cards', async () => { const { ReadToolCardBody } = await import('./tool-cards/read-tool-card'); const { GenericToolCardBody } = await import('./tool-cards/generic-tool-card'); From 68d308dca5769c1413d3f286d3c8c2f13f80f262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 2 Sep 2026 13:22:45 +0200 Subject: [PATCH 08/10] fix(mobile): add bounded clearance for centered states --- .../src/lib/centered-state-layout.test.ts | 51 ++++++++++++++++--- apps/mobile/src/lib/centered-state-layout.ts | 4 +- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/lib/centered-state-layout.test.ts b/apps/mobile/src/lib/centered-state-layout.test.ts index c6cce43372..a1f4fc3312 100644 --- a/apps/mobile/src/lib/centered-state-layout.test.ts +++ b/apps/mobile/src/lib/centered-state-layout.test.ts @@ -32,14 +32,15 @@ describe('getCenteredStateLayout', () => { expect(380 + layout.paddingTop + 50).toBe(550); }); - it('does not shift a short-sheet state when it fits at the exact center', () => { + it('balances a short sheet when exact centering would crowd the header', () => { const layout = getCenteredStateLayout({ surface: { top: 0, bottom: 320 }, viewport: { top: 60, bottom: 320 }, contentHeight: 180, }); - expect(layout.paddingTop).toBe(10); - expect(60 + layout.paddingTop + 90).toBe(160); + expect(layout.paddingTop).toBe(40); + expect(60 + layout.paddingTop + 90).toBe(190); + expect(layout.paddingBottom).toBe(40); }); it('keeps content below a tall header when the target is obstructed', () => { @@ -48,7 +49,7 @@ describe('getCenteredStateLayout', () => { viewport: { top: 450, bottom: 800 }, contentHeight: 200, }); - expect(layout.paddingTop).toBe(0); + expect(layout.paddingTop).toBe(48); }); it('keeps content above an overlay without counting its inset twice', () => { @@ -58,8 +59,46 @@ describe('getCenteredStateLayout', () => { contentHeight: 600, bottomInset: 80, }); - expect(layout.paddingTop).toBe(0); - expect(layout.paddingBottom).toBe(20); + expect(layout.paddingTop).toBe(10); + expect(layout.paddingBottom).toBe(10); + }); + + it.each([ + { + name: 'Android half-sheet', + surface: { top: 320, bottom: 640 }, + viewport: { top: 384, bottom: 640 }, + contentHeight: 177.5, + paddingTop: 39.25, + }, + { + name: 'iOS language picker above the keyboard', + surface: { top: 30, bottom: 435 }, + viewport: { top: 155, bottom: 667 }, + contentHeight: 109, + paddingTop: 48, + }, + { + name: 'Android language picker above the keyboard', + surface: { top: 48, bottom: 340 }, + viewport: { top: 173, bottom: 640 }, + contentHeight: 108.5, + paddingTop: 29.25, + }, + ])('adds bounded clearance for the $name', ({ surface, viewport, contentHeight, paddingTop }) => { + const layout = getCenteredStateLayout({ surface, viewport, contentHeight }); + expect(layout.paddingTop).toBe(paddingTop); + expect(viewport.top + layout.paddingTop + contentHeight).toBeLessThanOrEqual(surface.bottom); + }); + + it('does not force overflow when content exactly fills the available body', () => { + expect( + getCenteredStateLayout({ + surface: { top: 0, bottom: 400 }, + viewport: { top: 100, bottom: 400 }, + contentHeight: 300, + }) + ).toEqual({ minHeight: 300, paddingTop: 0, paddingBottom: 0 }); }); it('gives tall content normal scrollable padding rather than a negative offset', () => { diff --git a/apps/mobile/src/lib/centered-state-layout.ts b/apps/mobile/src/lib/centered-state-layout.ts index 64194ff9f7..537b344ea2 100644 --- a/apps/mobile/src/lib/centered-state-layout.ts +++ b/apps/mobile/src/lib/centered-state-layout.ts @@ -11,6 +11,7 @@ type CenteredStateLayoutInput = { }; const STATE_GAP = 16; +const PREFERRED_CLEARANCE = 48; export function getStateSurfaceInsets({ surface, @@ -49,8 +50,9 @@ export function getCenteredStateLayout({ const bottom = Math.min(visible.bottom, surface.bottom - bottomInset); const idealTop = (surface.top + surface.bottom - contentHeight) / 2; const fits = contentHeight <= bottom - top; + const clearance = Math.min(PREFERRED_CLEARANCE, Math.max(0, (bottom - top - contentHeight) / 2)); const contentTop = fits - ? Math.max(top, Math.min(idealTop, bottom - contentHeight)) + ? Math.max(top + clearance, Math.min(idealTop, bottom - contentHeight - clearance)) : top + STATE_GAP; return { From 1c40a9bcdb9f7e80d31eb8b04e9607f45f679158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 2 Sep 2026 14:33:50 +0200 Subject: [PATCH 09/10] fix(mobile): stabilize centered states on the pixel grid --- .../centered-state.mounted.test.tsx | 1 + .../centered-state.stability.mounted.test.tsx | 116 ++++++++++++++++++ apps/mobile/src/components/centered-state.tsx | 19 ++- 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/components/centered-state.stability.mounted.test.tsx diff --git a/apps/mobile/src/components/centered-state.mounted.test.tsx b/apps/mobile/src/components/centered-state.mounted.test.tsx index 417a6f9c0c..bf4c6d8440 100644 --- a/apps/mobile/src/components/centered-state.mounted.test.tsx +++ b/apps/mobile/src/components/centered-state.mounted.test.tsx @@ -48,6 +48,7 @@ const native = vi.hoisted(() => { vi.mock('@/components/centered-state-surface', () => ({ useStateSurface: () => native.surface })); vi.mock('@/lib/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' ') })); vi.mock('react-native', () => ({ + PixelRatio: { roundToNearestPixel: (value: number) => Math.round(value * 2) / 2 }, View: 'View', ScrollView: (props: ComponentPropsWithRef) => { const { ref, ...rest } = props; diff --git a/apps/mobile/src/components/centered-state.stability.mounted.test.tsx b/apps/mobile/src/components/centered-state.stability.mounted.test.tsx new file mode 100644 index 0000000000..61d8b855f4 --- /dev/null +++ b/apps/mobile/src/components/centered-state.stability.mounted.test.tsx @@ -0,0 +1,116 @@ +import { act, type ComponentPropsWithRef, createElement, useImperativeHandle } from 'react'; +import { + type LayoutChangeEvent, + ScrollView, + type ScrollViewProps, + type ViewProps, +} from 'react-native'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '@/test/render-with-providers'; +import { CenteredState } from './centered-state'; +import { type useStateSurface } from './centered-state-surface'; + +type ScrollNode = NonNullable>; +const native = vi.hoisted(() => { + const surface: NonNullable> = { + frame: { top: 0, bottom: 800 }, + bounds: { top: 0, bottom: 800 }, + safeAreaTop: 0, + safeAreaBottom: 0, + source: 'layout', + topInset: 0, + bottomInset: 0, + topReservation: 0, + bottomReservation: 0, + nativeViewportFillsSurface: false, + register: vi.fn(() => vi.fn()), + }; + return { scale: 2, viewportTop: 0.5, surface }; +}); + +vi.mock('@/components/centered-state-surface', () => ({ useStateSurface: () => native.surface })); +vi.mock('@/lib/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' ') })); +vi.mock('react-native', () => ({ + PixelRatio: { + roundToNearestPixel: (value: number) => Math.round(value * native.scale) / native.scale, + }, + View: 'View', + ScrollView: (props: ComponentPropsWithRef) => { + const { ref, ...rest } = props; + useImperativeHandle(ref, () => { + const node: Partial = { + measureInWindow: onMeasure => { + onMeasure(0, native.viewportTop, 400, 800 - native.viewportTop); + }, + }; + const scroll: Partial = { getNativeScrollRef: () => node as ScrollNode }; + return scroll as ScrollView; + }, []); + return createElement('ScrollView', rest); + }, +})); + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + native.scale = 2; + native.viewportTop = 0.5; +}); +afterEach(() => vi.unstubAllGlobals()); + +async function mount() { + const mounted = await renderWithProviders({null}); + const measure = (height: number) => { + const props = mounted.renderer.root.findByProps({ testID: 'state-content' }).props as ViewProps; + const event: Partial = { + nativeEvent: { layout: { x: 0, y: 0, width: 400, height } }, + }; + act(() => props.onLayout?.(event as LayoutChangeEvent)); + }; + const layout = () => { + const props = mounted.renderer.root.findByType(ScrollView).props as ScrollViewProps; + return props.contentContainerStyle as { paddingTop: number; paddingBottom: number }; + }; + return { ...mounted, measure, layout }; +} + +it.each([ + { scale: 2, origin: 0.25, intrinsicHeight: 100.75 }, + { scale: 3, origin: 0, intrinsicHeight: 100.25 }, +])( + 'settles the root-relative rounding cycle at $scale× density', + async ({ scale, origin, intrinsicHeight }) => { + native.scale = scale; + const round = (value: number) => Math.round(value * native.scale); + native.viewportTop = round(origin) / scale; + const mounted = await mount(); + mounted.measure((round(origin + intrinsicHeight) - round(origin)) / native.scale); + const positions: number[] = []; + for (let frame = 0; frame < 8; frame += 1) { + const { paddingTop } = mounted.layout(); + positions.push(paddingTop); + const height = + (round(origin + paddingTop + intrinsicHeight) - round(origin + paddingTop)) / native.scale; + mounted.measure(height); + } + expect(new Set(positions.slice(-4)).size).toBe(1); + expect(mounted.layout().paddingTop * native.scale).toBeCloseTo( + Math.round(mounted.layout().paddingTop * native.scale), + 6 + ); + mounted.unmount(); + } +); + +it('normalizes fractional-pixel measurement noise but still responds to real height changes', async () => { + native.scale = 3; + native.viewportTop = 1 / 3; + const mounted = await mount(); + mounted.measure(200.333_328_247_070_3); + const first = mounted.layout(); + mounted.measure(200.333_343_505_859_38); + expect(mounted.layout()).toEqual(first); + mounted.measure(200.333_343_505_859_38 + 1 / native.scale); + expect(mounted.layout().paddingTop).toBeLessThan(first.paddingTop); + mounted.unmount(); +}); diff --git a/apps/mobile/src/components/centered-state.tsx b/apps/mobile/src/components/centered-state.tsx index 1477a18485..67fe9ba024 100644 --- a/apps/mobile/src/components/centered-state.tsx +++ b/apps/mobile/src/components/centered-state.tsx @@ -1,5 +1,11 @@ import { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { type LayoutChangeEvent, ScrollView, type ScrollViewProps, View } from 'react-native'; +import { + type LayoutChangeEvent, + PixelRatio, + ScrollView, + type ScrollViewProps, + View, +} from 'react-native'; import { useStateSurface } from '@/components/centered-state-surface'; import { getCenteredStateLayout, type StateFrame } from '@/lib/centered-state-layout'; @@ -74,7 +80,7 @@ export function CenteredState({ }, [measure]); const measureContent = useCallback((event: LayoutChangeEvent) => { - setContentHeight(event.nativeEvent.layout.height); + setContentHeight(PixelRatio.roundToNearestPixel(event.nativeEvent.layout.height)); }, []); const layout = useMemo( () => @@ -91,7 +97,14 @@ export function CenteredState({ : undefined, [surface, viewport, contentHeight] ); - const contentStyle = useMemo(() => ({ flexGrow: 1, ...layout }), [layout]); + const contentStyle = useMemo( + () => ({ + flexGrow: 1, + ...layout, + paddingTop: layout ? PixelRatio.roundToNearestPixel(layout.paddingTop) : undefined, + }), + [layout] + ); const ready = layout !== undefined; if (!surface) { From 9d819d1a67ee6e36f478aa56ccfcb9408d1a643d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 2 Sep 2026 15:05:00 +0200 Subject: [PATCH 10/10] fix(mobile): keep centered layout on a consistent pixel grid --- .../centered-state.stability.mounted.test.tsx | 115 +++++++++++++++++- apps/mobile/src/components/centered-state.tsx | 10 +- apps/mobile/src/lib/centered-state-layout.ts | 18 ++- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/components/centered-state.stability.mounted.test.tsx b/apps/mobile/src/components/centered-state.stability.mounted.test.tsx index 61d8b855f4..c22bc4955d 100644 --- a/apps/mobile/src/components/centered-state.stability.mounted.test.tsx +++ b/apps/mobile/src/components/centered-state.stability.mounted.test.tsx @@ -26,7 +26,7 @@ const native = vi.hoisted(() => { nativeViewportFillsSurface: false, register: vi.fn(() => vi.fn()), }; - return { scale: 2, viewportTop: 0.5, surface }; + return { scale: 2, viewportTop: 0.5, viewportBottom: 800, surface }; }); vi.mock('@/components/centered-state-surface', () => ({ useStateSurface: () => native.surface })); @@ -41,7 +41,7 @@ vi.mock('react-native', () => ({ useImperativeHandle(ref, () => { const node: Partial = { measureInWindow: onMeasure => { - onMeasure(0, native.viewportTop, 400, 800 - native.viewportTop); + onMeasure(0, native.viewportTop, 400, native.viewportBottom - native.viewportTop); }, }; const scroll: Partial = { getNativeScrollRef: () => node as ScrollNode }; @@ -55,6 +55,12 @@ beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); native.scale = 2; native.viewportTop = 0.5; + native.viewportBottom = 800; + native.surface.frame = { top: 0, bottom: 800 }; + native.surface.bounds = { top: 0, bottom: 800 }; + native.surface.topInset = 0; + native.surface.bottomInset = 0; + native.surface.nativeViewportFillsSurface = false; }); afterEach(() => vi.unstubAllGlobals()); @@ -69,7 +75,11 @@ async function mount() { }; const layout = () => { const props = mounted.renderer.root.findByType(ScrollView).props as ScrollViewProps; - return props.contentContainerStyle as { paddingTop: number; paddingBottom: number }; + return props.contentContainerStyle as { + minHeight: number; + paddingTop: number; + paddingBottom: number; + }; }; return { ...mounted, measure, layout }; } @@ -102,6 +112,105 @@ it.each([ } ); +it.each([ + { clip: 'viewport', top: 0 }, + { clip: 'viewport', top: 100 / 3 }, + { clip: 'surface', top: 0 }, + { clip: 'inset', top: 0 }, +])('keeps fractional exact-fit content inside the $clip at $top', async ({ clip, top }) => { + native.scale = 3; + native.viewportTop = Math.fround(top); + const height = Math.fround(532 / native.scale); + const bottom = native.viewportTop + height; + if (clip === 'viewport') { + native.viewportBottom = bottom; + } else if (clip === 'surface') { + native.surface.frame = { top: 0, bottom }; + } else { + native.surface.bottomInset = 800 - bottom; + } + const mounted = await mount(); + mounted.measure(height); + const fitted = mounted.layout(); + expect(fitted.paddingTop).toBe(0); + expect( + Math.round((fitted.paddingTop + 532 / native.scale + fitted.paddingBottom) * native.scale) + ).toBe(Math.round((native.viewportBottom - native.viewportTop) * native.scale)); + mounted.measure(Math.fround(533 / native.scale)); + expect(mounted.layout().paddingTop).toBe(16); + mounted.measure(height); + expect(mounted.layout()).toEqual(fitted); + mounted.unmount(); +}); + +it.each([ + { scale: 2, top: 320, viewportTop: 384, bottom: 640, height: 177.5, paddingTop: 39.5 }, + { scale: 3, top: 0, viewportTop: 100, bottom: 800, height: 533 / 3, paddingTop: 634 / 3 }, +])('does not add scrolling after rounding padding at $scale× density', async geometry => { + native.scale = geometry.scale; + native.viewportTop = geometry.viewportTop; + native.viewportBottom = geometry.bottom; + native.surface.frame = { top: geometry.top, bottom: geometry.bottom }; + const mounted = await mount(); + mounted.measure(geometry.height); + const layout = mounted.layout(); + expect(layout.paddingTop).toBe(geometry.paddingTop); + expect(layout.minHeight).toBe(geometry.bottom - geometry.viewportTop); + expect( + Math.round((layout.paddingTop + geometry.height + layout.paddingBottom) * native.scale) + ).toBe(Math.round(layout.minHeight * native.scale)); + mounted.unmount(); +}); + +it.each([ + { + name: 'original surface center with unequal header and footer', + bottom: 800, + nativeBottom: 800, + viewportBottom: 760, + height: 200, + nativeFill: false, + expected: { minHeight: 680, paddingTop: 220, paddingBottom: 260 }, + }, + { + name: 'native sheet clipped above the keyboard', + bottom: 400, + nativeBottom: 700, + viewportBottom: 620, + height: 120, + nativeFill: true, + expected: { minHeight: 620, paddingTop: 60, paddingBottom: 440 }, + }, + { + name: 'overflow above the keyboard', + bottom: 400, + nativeBottom: 700, + viewportBottom: 620, + height: 800, + nativeFill: true, + expected: { minHeight: 620, paddingTop: 16, paddingBottom: 316 }, + }, + { + name: 'native sheet flow footer', + bottom: 500, + nativeBottom: 500, + viewportBottom: 420, + height: 700, + nativeFill: true, + expected: { minHeight: 420, paddingTop: 16, paddingBottom: 96 }, + }, +])('preserves the $name', async geometry => { + native.viewportTop = 80; + native.viewportBottom = geometry.viewportBottom; + native.surface.frame = { top: 0, bottom: geometry.bottom }; + native.surface.bounds = { top: 0, bottom: geometry.nativeBottom }; + native.surface.nativeViewportFillsSurface = geometry.nativeFill; + const mounted = await mount(); + mounted.measure(geometry.height); + expect(mounted.layout()).toMatchObject(geometry.expected); + mounted.unmount(); +}); + it('normalizes fractional-pixel measurement noise but still responds to real height changes', async () => { native.scale = 3; native.viewportTop = 1 / 3; diff --git a/apps/mobile/src/components/centered-state.tsx b/apps/mobile/src/components/centered-state.tsx index 67fe9ba024..cdc5a52a99 100644 --- a/apps/mobile/src/components/centered-state.tsx +++ b/apps/mobile/src/components/centered-state.tsx @@ -93,18 +93,12 @@ export function CenteredState({ bottomInset: surface.bottomInset, nativeViewportFillsSurface: surface.nativeViewportFillsSurface, nativeViewportBottom: surface.bounds?.bottom, + roundToPixel: value => PixelRatio.roundToNearestPixel(value), }) : undefined, [surface, viewport, contentHeight] ); - const contentStyle = useMemo( - () => ({ - flexGrow: 1, - ...layout, - paddingTop: layout ? PixelRatio.roundToNearestPixel(layout.paddingTop) : undefined, - }), - [layout] - ); + const contentStyle = useMemo(() => ({ flexGrow: 1, ...layout }), [layout]); const ready = layout !== undefined; if (!surface) { diff --git a/apps/mobile/src/lib/centered-state-layout.ts b/apps/mobile/src/lib/centered-state-layout.ts index 537b344ea2..e19ed74a3f 100644 --- a/apps/mobile/src/lib/centered-state-layout.ts +++ b/apps/mobile/src/lib/centered-state-layout.ts @@ -8,6 +8,7 @@ type CenteredStateLayoutInput = { bottomInset?: number; nativeViewportFillsSurface?: boolean; nativeViewportBottom?: number; + roundToPixel?: (value: number) => number; }; const STATE_GAP = 16; @@ -43,23 +44,28 @@ export function getCenteredStateLayout({ bottomInset = 0, nativeViewportFillsSurface = false, nativeViewportBottom = surface.bottom, + roundToPixel = (value: number) => value, }: CenteredStateLayoutInput) { const viewportBottom = nativeViewportFillsSurface ? nativeViewportBottom : viewport.bottom; const visible = intersectStateFrames(viewport, surface); const top = Math.max(visible.top, surface.top + topInset); const bottom = Math.min(visible.bottom, surface.bottom - bottomInset); const idealTop = (surface.top + surface.bottom - contentHeight) / 2; - const fits = contentHeight <= bottom - top; + const fits = contentHeight <= roundToPixel(bottom - top); const clearance = Math.min(PREFERRED_CLEARANCE, Math.max(0, (bottom - top - contentHeight) / 2)); const contentTop = fits ? Math.max(top + clearance, Math.min(idealTop, bottom - contentHeight - clearance)) : top + STATE_GAP; + const paddingTop = Math.max(0, contentTop - viewport.top); + const paddingBottom = fits + ? Math.max(0, viewportBottom - contentTop - contentHeight) + : Math.max(STATE_GAP, viewportBottom - bottom + STATE_GAP); + const roundedPaddingTop = roundToPixel(paddingTop); + return { - minHeight: Math.max(0, viewportBottom - viewport.top), - paddingTop: Math.max(0, contentTop - viewport.top), - paddingBottom: fits - ? Math.max(0, viewportBottom - contentTop - contentHeight) - : Math.max(STATE_GAP, viewportBottom - bottom + STATE_GAP), + minHeight: roundToPixel(Math.max(0, viewportBottom - viewport.top)), + paddingTop: roundedPaddingTop, + paddingBottom: roundToPixel(paddingTop + paddingBottom) - roundedPaddingTop, }; }