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/build.gradle b/apps/mobile/modules/kilo-surface-geometry/android/build.gradle
new file mode 100644
index 0000000000..05204646b1
--- /dev/null
+++ b/apps/mobile/modules/kilo-surface-geometry/android/build.gradle
@@ -0,0 +1,16 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.kilosurfacegeometry'
+version = '1.0.0'
+
+android {
+ namespace 'expo.modules.kilosurfacegeometry'
+
+ defaultConfig {
+ versionCode 1
+ versionName '1.0.0'
+ }
+}
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..c7fd26fe96
--- /dev/null
+++ b/apps/mobile/modules/kilo-surface-geometry/android/src/main/java/expo/modules/kilosurfacegeometry/KiloSurfaceGeometryModule.kt
@@ -0,0 +1,267 @@
+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")
+ 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, 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, 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, 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
+ }
+ 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)
+ val top = visible.top.coerceIn(0f, height)
+ val bottom = visible.bottom.coerceIn(top, height)
+ return geometry(top, bottom, height, safeTop, safeBottom, 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, 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
+ )
+
+ 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..857c84d360
--- /dev/null
+++ b/apps/mobile/modules/kilo-surface-geometry/ios/KiloSurfaceGeometryModule.swift
@@ -0,0 +1,321 @@
+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")
+ 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, @unchecked Sendable {
+ 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 observedTag: 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.observedTag = 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": observedTag, "visibleTop": 0.0, "visibleBottom": 0.0, "boundsHeight": 0.0,
+ "safeAreaTop": 0.0, "safeAreaBottom": 0.0]
+ }
+ if observingWindow {
+ observeAncestors()
+ if frame != root.bounds {
+ frame = root.bounds
+ layoutIfNeeded()
+ }
+ }
+ let geometry = measure(root)
+ var event: [String: Any] = geometry
+ event["tag"] = observedTag
+ 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)]
+ 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)
+ ]
+ }
+
+ 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..38675750a9
--- /dev/null
+++ b/apps/mobile/modules/kilo-surface-geometry/native-surface-geometry.test.ts
@@ -0,0 +1,82 @@
+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,
+};
+
+beforeEach(() => {
+ vi.resetModules();
+ vi.resetAllMocks();
+ native.requireOptionalNativeModule.mockReturnValue(native);
+ 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('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);
+ 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('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/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..3e4ca4939b
--- /dev/null
+++ b/apps/mobile/src/components/agents/part-detail-images.mounted.test.tsx
@@ -0,0 +1,298 @@
+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('./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');
+ 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-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..4f9eac0d29
--- /dev/null
+++ b/apps/mobile/src/components/centered-state-surface.tsx
@@ -0,0 +1,246 @@
+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,
+ failure: measurement.failure,
+ ...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..bf4c6d8440
--- /dev/null
+++ b/apps/mobile/src/components/centered-state.mounted.test.tsx
@@ -0,0 +1,277 @@
+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 { 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];
+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', () => ({
+ PixelRatio: { roundToNearestPixel: (value: number) => Math.round(value * 2) / 2 },
+ View: 'View',
+ ScrollView: (props: ComponentPropsWithRef) => {
+ const { ref, ...rest } = props;
+ useImperativeHandle(ref, () => native.scroll as ScrollView, []);
+ return createElement('ScrollView', rest);
+ },
+}));
+
+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 } },
+};
+
+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('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',
+ 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.stability.mounted.test.tsx b/apps/mobile/src/components/centered-state.stability.mounted.test.tsx
new file mode 100644
index 0000000000..c22bc4955d
--- /dev/null
+++ b/apps/mobile/src/components/centered-state.stability.mounted.test.tsx
@@ -0,0 +1,225 @@
+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, viewportBottom: 800, 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, native.viewportBottom - 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;
+ 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());
+
+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 {
+ minHeight: number;
+ 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.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;
+ 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
new file mode 100644
index 0000000000..cdc5a52a99
--- /dev/null
+++ b/apps/mobile/src/components/centered-state.tsx
@@ -0,0 +1,131 @@
+import { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
+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';
+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 = 'centered-state',
+ 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(PixelRatio.roundToNearestPixel(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,
+ roundToPixel: value => PixelRatio.roundToNearestPixel(value),
+ })
+ : 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/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('common.retry')}
-
-
+
+
+
+ {t('kiloPass.unavailable')}
+
+
+ {t('kiloPass.couldNotLoad')}
+
+
+ {t('common.retry')}
+
+
+
);
}
@@ -121,38 +128,31 @@ function KiloPassUnavailableScreen({
return (
-
-
-
+
+
+
{t('kiloPass.subscriptionHeaderDescription')}
-
- {KILO_PASS_TITLE}
- {description}
- {isWebManagement && presentation.webUrl ? (
- {
- if (!presentation.webUrl) {
- return;
- }
- void openExternalUrl(presentation.webUrl, {
- label: t('kiloPass.kiloPassManagement'),
- });
- }}
- variant="outline"
- >
- {t('kiloPass.manage')}
-
- ) : null}
-
-
-
+ {KILO_PASS_TITLE}
+ {description}
+ {isWebManagement && presentation.webUrl ? (
+ {
+ if (!presentation.webUrl) {
+ return;
+ }
+ void openExternalUrl(presentation.webUrl, {
+ label: t('kiloPass.kiloPassManagement'),
+ });
+ }}
+ variant="outline"
+ >
+ {t('kiloPass.manage')}
+
+ ) : 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)}
+
+ {t(content.ctaLabelKey)}
+
+
- );
- }
-
- return (
-
-
-
-
-
- {t(content.titleKey)}
-
- {t(content.bodyKey)}
-
-
-
- {t(content.ctaLabelKey)}
-
-
-
+
);
}
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')}
-
-
- {
- void openExternalUrl(WEB_BASE_URL, { label: 'kilo.ai' });
- }}
- accessibilityRole="link"
- >
- {t('kiloclaw.onboarding.flow.openKiloAi')}
-
-
-
+
+
+
+
+
+
+
+ {t('kiloclaw.onboarding.flow.review')}
+
+
+ {t('kiloclaw.onboarding.flow.manualReviewTitle')}
+
+
+ {t('kiloclaw.onboarding.flow.manualReviewBody')}
+
+
+ {
+ void openExternalUrl(WEB_BASE_URL, { label: 'kilo.ai' });
+ }}
+ accessibilityRole="link"
+ >
+ {t('kiloclaw.onboarding.flow.openKiloAi')}
+
+
+
+
);
}
if (errorCategory === 'generic') {
const danger = toneColor('danger');
return (
-
-
-
-
-
-
- {t('kiloclaw.onboarding.flow.provisioning')}
-
-
- {t('kiloclaw.onboarding.flow.somethingWentWrong')}
-
-
- {t('kiloclaw.onboarding.flow.genericErrorBody')}
-
-
-
- {t('common.tryAgain')}
-
-
+
+
+
+
+
+
+
+ {t('kiloclaw.onboarding.flow.provisioning')}
+
+
+ {t('kiloclaw.onboarding.flow.somethingWentWrong')}
+
+
+ {t('kiloclaw.onboarding.flow.genericErrorBody')}
+
+
+
+ {t('common.tryAgain')}
+
+
+
);
}
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('common.tryAgain')}
-
-
-
- {t('kiloclaw.onboarding.provisioning.continueInBackground')}
+
+
+
+
+
+
+
+ {t('kiloclaw.onboarding.flow.provisioning')}
-
-
-
+ {t(content.titleKey)}
+
+ {t(content.bodyKey, {
+ name: botName,
+ displayMinutes: formatNumber(OVERALL_TIMEOUT_MS / 60_000, i18n.language),
+ })}
+
+
+
+
+ {t('common.tryAgain')}
+
+
+
+ {t('kiloclaw.onboarding.provisioning.continueInBackground')}
+
+
+
+
+
);
}
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}
- {
- if (token) {
- void persistToken(token, refreshToken, expiresIn);
- }
- }}
- accessibilityLabel={t('login.retrySignIn')}
- >
- {t('common.retry')}
-
-
+
+
+ {persistError}
+ {
+ if (token) {
+ void persistToken(token, refreshToken, expiresIn);
+ }
+ }}
+ accessibilityLabel={t('login.retrySignIn')}
+ >
+ {t('common.retry')}
+
+
+
);
}
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 ? (
+ {
+ setFilters(DEFAULT_SECURITY_FINDING_FILTERS);
+ }}
+ >
+ {t('securityAgent.findingList.clearFilters')}
+
+ ) : 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}
- {
- void connect();
- }}
- >
- {connecting ? : null}
- {buttonLabel}
-
-
+
+
+
+ {title}
+ {description}
+ {
+ void connect();
+ }}
+ >
+ {connecting ? : null}
+ {buttonLabel}
+
+
+
);
}
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"
+ >
+
+
+
+ ))}
+
+ )}
+
+ )}
);
}
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..a1f4fc3312
--- /dev/null
+++ b/apps/mobile/src/lib/centered-state-layout.test.ts
@@ -0,0 +1,261 @@
+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('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(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', () => {
+ const layout = getCenteredStateLayout({
+ surface: { top: 0, bottom: 800 },
+ viewport: { top: 450, bottom: 800 },
+ contentHeight: 200,
+ });
+ expect(layout.paddingTop).toBe(48);
+ });
+
+ 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(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', () => {
+ 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..e19ed74a3f
--- /dev/null
+++ b/apps/mobile/src/lib/centered-state-layout.ts
@@ -0,0 +1,71 @@
+export type StateFrame = Readonly<{ top: number; bottom: number }>;
+
+type CenteredStateLayoutInput = {
+ surface: StateFrame;
+ viewport: StateFrame;
+ contentHeight: number;
+ topInset?: number;
+ bottomInset?: number;
+ nativeViewportFillsSurface?: boolean;
+ nativeViewportBottom?: number;
+ roundToPixel?: (value: number) => number;
+};
+
+const STATE_GAP = 16;
+const PREFERRED_CLEARANCE = 48;
+
+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,
+ 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 <= 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: roundToPixel(Math.max(0, viewportBottom - viewport.top)),
+ paddingTop: roundedPaddingTop,
+ paddingBottom: roundToPixel(paddingTop + paddingBottom) - roundedPaddingTop,
+ };
+}
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..d66c6be572
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.mounted.test.tsx
@@ -0,0 +1,233 @@
+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,
+});
+
+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.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => {
+ onFrame(0);
+ return 1;
+ });
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+ 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('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();
+ 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');
+ 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();
+ mounted.emit(snapshot(42));
+ expect(mounted.read()).toBe(previous);
+ mounted.emit(snapshot());
+ expect(mounted.read()).toBe(previous);
+ 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);
+ 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 });
+ expect(mounted.read()?.geometry?.visibleBottom).toBe(500);
+ 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 () => {
+ 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..96d78670ac
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-native-state-geometry.ts
@@ -0,0 +1,130 @@
+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;
+ failure?: string;
+};
+
+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.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,
+ failure: 'Native view tag unavailable',
+ });
+ 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) &&
+ 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,
+ failure: 'Invalid native surface geometry',
+ });
+ 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 {
+ const geometry = await observeSurface(tag);
+ if (!receivedGeometry) {
+ publish(geometry);
+ }
+ } catch (error) {
+ if (active) {
+ setObservation({
+ node,
+ status: 'failed',
+ geometry: null,
+ failure: error instanceof Error ? error.message : 'Native observation failed',
+ });
+ }
+ }
+ };
+ const stop = async () => {
+ try {
+ await unobserveSurface(tag);
+ } catch (error) {
+ captureException(error, {
+ tags: { 'error.subsystem': 'surface_geometry', 'error.operation': 'unobserve' },
+ });
+ }
+ };
+ const startFrame = requestAnimationFrame(() => {
+ if (active) {
+ void start();
+ }
+ });
+ return () => {
+ active = false;
+ cancelAnimationFrame(startFrame);
+ 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..a60565fb17
--- /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';
+ failure?: string;
+};
+
+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 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 (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(() => {
+ measure();
+ return () => {
+ requestRef.current += 1;
+ };
+ }, [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' };
+ }
+ 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',
+ failure,
+ };
+ }, [frame, native.geometry, native.status, androidSheet, height, failure]);
+
+ 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..ded61c6a10
--- /dev/null
+++ b/apps/mobile/src/lib/native-surface-geometry.ts
@@ -0,0 +1,46 @@
+import { type NativeModule, requireOptionalNativeModule } from 'expo';
+
+export type NativeSurfaceGeometry = Readonly<{
+ tag: number;
+ visibleTop: number;
+ visibleBottom: number;
+ boundsHeight: number;
+ safeAreaTop: number;
+ safeAreaBottom: number;
+}>;
+
+type SurfaceGeometryEvents = {
+ onSurfaceGeometryChange: (geometry: NativeSurfaceGeometry) => void;
+};
+
+type SurfaceGeometryModule = InstanceType> & {
+ observeSurface: (nativeViewTag: number) => Promise;
+ unobserveSurface: (nativeViewTag: number) => Promise;
+};
+
+const nativeModule = requireOptionalNativeModule('KiloSurfaceGeometry');
+
+export const isNativeSurfaceGeometryAvailable = nativeModule !== null;
+
+export function addSurfaceGeometryListener(
+ listener: (geometry: NativeSurfaceGeometry) => void
+): { remove: () => void } | null {
+ return nativeModule?.addListener('onSurfaceGeometryChange', listener) ?? null;
+}
+
+export async function observeSurface(nativeViewTag: number): Promise {
+ if (!nativeModule) {
+ 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',
],
},
});