Skip to content

fix(mobile): keep the outgoing tab out of the tap that switches tabs - #8267

Merged
iscekic merged 6 commits into
mainfrom
drain/tab-switch-input-gate
Oct 22, 2026
Merged

iscekic merged 6 commits into
mainfrom
drain/tab-switch-input-gate

Conversation

@iscekic

@iscekic iscekic commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

What changed

Tapping the Profile Code Reviewer row right after pressing the Profile tab opened
the New session composer instead of the code reviewer. The row itself is correct:
with a settled screen it pushes getCodeReviewerProfilePath(scope). The tap was
delivered to the tab being left.

A bottom-tab press navigates, but the tab being left stays what a touch is hit-tested
against until the destination screen lays out. A tap that starts inside that window runs
whatever the outgoing tab draws at that point. On the emulator the Profile row
([37,908][1043,1052]) and Home's "New coding task" button ([37,938][1043,1053]) share
a tap point, so the second tap opened the composer.

The window is not a frame and not the route commit:

  • a tap 777 ms after the tab press still reached the outgoing screen;
  • a tap at ~450–510 ms was swallowed by a guard that reads a pending flag at touch time.

So the boundary has to be the destination tab's first layout.

Change

  • apps/mobile/src/lib/tab-switch-input-gate.ts (new): the pending window as a
    timestamp, read when a touch arrives (React state could only apply with the commit
    whose absence is the problem). TAB_SWITCH_INPUT_FALLBACK_MS (1200 ms) caps a switch
    that never lays out.
  • apps/mobile/src/components/tab-screen-input-gate.tsx (new): TabSwitchInputGate
    claims touches on the tab surface while the window is open; TabSwitchInputSettle
    closes it from the tab screen's own first layout.
  • apps/mobile/src/app/(app)/(tabs)/_layout.tsx: the surface gate wraps the tab
    navigator, so every tab is covered, including any tab whose own layout file this
    change does not touch.
  • apps/mobile/src/app/(app)/(tabs)/{ (0_home), (2_agents), (3_profile), (4_chat) }/_layout.tsx:
    their Stack is wrapped in TabSwitchInputSettle.
  • apps/mobile/src/components/tab-bar-button.tsx: the entry arms the window on
    press-in, before the navigation starts.
  • apps/mobile/src/components/profile-screen.agent-rows.mounted.test.tsx (new): pins the
    three Profile agent rows — Code Reviewer, Security Agent, Profiles — for an
    organization and for the personal scope. Nothing covered them; only the Home copies
    were pinned. The shared helper now mocks just the scope, so the assertions observe the
    real route builders.

One tab's own layout belongs to an excluded feature and is intentionally untouched;
the surface gate covers that tab, and its window closes through the fallback.

End-to-end verification (Android emulator, German UI, a test organization selected)

Sequence on a settled Home: input tap 945 2260 (Profile tab) then input tap 540 980
(the Profile row's rect, which is Home's new-task button rect).

Before — the same sequence with the gate's flag forced closed (no gate in effect), and
with the pre-fix build earlier in the session:

delay=0.0 row=profile-row: COMPOSER
delay=0.0 row=profile-row: PROFILE
delay=0.2 row=profile-row: PROFILE
delay=0.4 row=profile-row: PROFILE

After — with the gate in place, six runs at 0 / 200 / 400 / 800 ms:

delay=0.0 row=profile-row: PROFILE
delay=0.0 row=profile-row: PROFILE
delay=0.0 row=profile-row: PROFILE
delay=0.2 row=profile-row: PROFILE
delay=0.4 row=profile-row: PROFILE
delay=0.8 row=profile-row: PROFILE

PROFILE is the app staying on the Profile tab: the stray tap is consumed, and the
composer never appears.

Still working after the change, same device and locale, organization selected:

  • a settled tap on the Profile row opens CODE-PRÜFER for that organization;
  • Home's "New coding task" opens the composer;
  • the Agents tab's "New session" button opens the composer;
  • every tab switches, and a tab press 1.5 s after a switch lands.

Tradeoff of gating the whole tab surface: a tab press inside the settling window (for
example, correcting a mis-tap immediately) is consumed; the next press works. This
follows the approved scope for the gate.

Screenshots and the UI dumps they were classified from are posted as a comment on this
PR.

Tests

  • apps/mobile: pnpm test — 1197 files, 17910 tests passed, 2 skipped.
  • pnpm typecheck, oxlint, oxfmt on every changed file: clean.

Proxy answers

  1. Question: End state: The Profile Code Reviewer row opens the code reviewer on current main (German, org and personal scope), and no code path routes it to the New session composer. Which end state do you want?
    Answer: Do not end no_change. Your own evidence shows a real defect: during a tab switch, a tap meant for the Profile row reaches Home's 'Neue Programmieraufgabe' button. Fix that class of issue: make sure the outgoing tab's screen cannot receive touches during a transition, or that the incoming screen owns input as soon as it is visible, on every tab. Also add the mounted regression test that pins the Code Reviewer, Security Agent and Profiles row destinations. Verify on Android, in German, with an org selected. For proof, reproduce the quick tap during a tab switch before and after the fix, and post logs or screenshots on the PR.
    Confidence: 0.55 · Precedent: Fix the root cause and the class of the issue; end without a PR only with proof that the finding is wrong (an earlier session).
  2. Question: Gate scope: During the switch window, how much of the tabs surface should the gate consume?
    Answer: Whole tab area, including the tab bar
    Confidence: 0.65 · Precedent: Do the least amount of work with no drop in quality; one gate leaves no hole and fixes the whole class (earlier sessions).
  3. Question: Window end: When should the gate stop consuming touches?
    Answer: Settle on route commit + 700 ms fallback
    Confidence: 0.75 · Precedent: Design and UX must be best-in-class; a fixed window swallows real taps and can still leak on a slow switch (an earlier session).
  4. Question: Arming: Which tab-switch entry points should arm the gate?
    Answer: Tab press only
    Confidence: 0.70 · Precedent: Programmatic changes have no stray tap, and arming on them would swallow real input after redirects (an earlier session).

Two of these were adjusted after measurement, with the reasons above:

  • Window end: the route commit settles too early (the outgoing views are still the
    touch targets), so each tab screen settles the window from its own first layout, with
    a 1200 ms cap instead of 700 ms — a tap at 777 ms still reached the outgoing screen.
  • Gate scope: kept as answered, the whole tab surface. The corrective-press cost is
    noted above.

Merge from main

main moved after this branch's last push and the PR conflicted in two files. Resolved by
merging main into the branch (no rebase):

  • apps/mobile/src/lib/tab-press-listeners.ts: the profile entry now returns the press plan
    that main introduced (planProfileTabPress). A cross-tab press and a form-sheet re-press
    return to the navigator, so the tab keeps its stack and sheet; a re-press that moves the
    tab to its root dispatches the targeted stack pop (POP_TO_TOP) so the unsaved-changes
    guard can intercept it. The input window still settles on its own timer for a press that
    opens no screen.
  • apps/mobile/src/app/(app)/(tabs)/_layout.tsx: the tab screens keep the listener helpers,
    and the tab navigator stays wrapped in the surface gate. The max-lines exemption main
    added is no longer needed now that the profile entry lives in lib/tab-press-listeners.ts.
  • apps/mobile/src/lib/profile-tab-anchor.test.ts: replaces the source-reading pins main
    added with assertions on the listener itself — cross-tab press, root re-press, and the
    pop from a screen above the root.

Checks on the merged tree: pnpm --filter kilo-app run typecheck clean, pnpm --filter kilo-app run lint clean, and pnpm --filter kilo-app test — 1256 files, 18610 tests passed, 2 skipped.

Re-verified on the Android emulator on the merged tree, German UI, test organization selected
(the surface gate consumes the stray tap, the same sequences that used to open the composer):

  • tab press then an immediate tap at the overlapping point (Profile row / Home new-task
    button), 4 runs: stayed on the Profile tab every time, no composer;
  • the reverse direction, tab press then an immediate tap at the Profile row point: stayed on
    the Start tab, no code reviewer opened;
  • settled tap on the Profile Code Reviewer row: CODE-PRÜFER opens the platform list for the
    selected organization;
  • settled tap on Home's new-task button: the composer opens;
  • a tap two seconds after a tab switch: opens the code reviewer from the Start tab card.

Screenshots of the Profile rows, the opened platform list, and the state after a quick switch
tap are posted as a comment on this PR.

Review follow-ups

The first review round tightened three things, all in the same window:

  • the window now closes only from the tab that holds focus, because the tab being
    left can lay out again mid-switch;
  • a press that opens no screen closes its own window: a cancelled gesture on the
    next tick, and a press on the already-focused tab when its stack transition ends
    (transitionEnd), with a 500 ms cap when there is no transition at all;
  • the press listeners moved to lib/tab-press-listeners.ts, where the profile
    entry's behaviour is asserted directly instead of by reading the layout source.

Re-verified on the emulator after these changes: quick taps at 0 / 200 / 400 / 800 ms
stayed on the Profile tab (7 of 7 runs, no composer); a settled tap opened the platform
list; the new-task button opened the composer; and a pop back to the tab root followed
by a stray tap at the row point stayed on the tab root (2 of 2 runs).

A bottom-tab press navigates, but the tab being left stays what a touch is
hit-tested against until the destination screen lays out: a tap that starts
in that window runs whatever the outgoing tab draws at that point, so the
Profile Code Reviewer row's tap reached the Home new-task button and opened
the new-session composer. The window measured up to about 0.8 s on the
emulator, and the route state changes before it closes, so neither one frame
nor the route commit is a safe boundary.

The tab surface now claims touches that start while a pressed tab is
settling, reading the pending window when the touch arrives. The tab entry
arms it on press-in, and each tab screen closes it from its own first layout.

Adds the gate's unit and mounted tests, and pins the three Profile agent
rows' destinations for an organization and the personal scope.
@iscekic iscekic added drain Work item from the findings drain explorer:android Finding surfaced by the Android explorer labels Oct 20, 2026
@iscekic

iscekic commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

bot: Android emulator evidence for the reported sequence, exercised on the German UI against a local dev backend with a test account.

Sequence: on a settled Home, press the Profile tab and then tap the Profile row's point (the same point is Home's new-task button) about 50 ms later, repeated at 0 / 200 / 400 / 800 ms after the tab press.

  • With the change: 6 of 6 runs stayed on the Profile tab (PROFILE); the new-session composer never appeared.
  • Before the change (same build, gate's pending flag forced closed): the first run opened the new-session composer (COMPOSER).
delay=0.0 row=profile-row: PROFILE
delay=0.0 row=profile-row: PROFILE
delay=0.0 row=profile-row: PROFILE
delay=0.2 row=profile-row: PROFILE
delay=0.4 row=profile-row: PROFILE
delay=0.8 row=profile-row: PROFILE

A settled tap on the same point opens the code reviewer platform list (second image).

Both captures are the personal scope. With an organization selected the same sequence produced the same two results: the stray tap left the app on the Profile tab, and a settled tap opened the platform list for that organization.

Profile tab after the stray tap during the tab switch: no new-session composer

A settled tap on the same row opens the code reviewer platform list

@kilo-code-bot

kilo-code-bot Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The only post-review commit (eb82ec3) narrows to import ordering and explicit vi.fn generics in tab-press-listeners.test.ts; no production code changed and the three previously reported findings remain resolved, so no new defect or leak was introduced.

Assumptions: The branch merged origin/main (60fccaf2), which brings unrelated upstream changes; the incremental scope was confined to the PR's own commits after 7105fbc, and no production file in the PR changed since that review.

Files Reviewed (1 file, incremental 7105fbc..eb82ec3)
  • apps/mobile/src/lib/tab-press-listeners.test.ts - no issues (import order plus typed dispatch/preventDefault mocks; assertions and TAB_SWITCH_ACTIVE_TAB_SETTLE_MS advance unchanged and still match profileTabPressListeners)

Notes: All six prior inline threads have author replies describing fixes in c3c6d1fc, 7105fbc, and this round; targeted checks of tab-press-listeners.ts confirm the focused-tab settle still arms the 500 ms bound and the profile plan still pops only on reset-to-root. No markdown documentation files were changed, so the image-format rule does not apply. No memory leak introduced: the test change adds no listeners, timers, or module-level state.

Previous Review Summaries (4 snapshots, latest commit 7105fbc)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 7105fbc)

Status: No Issues Found | Recommendation: Merge

Executive Summary

The one incremental commit (7105fbcc5) closes the previously reported same-tab gap by settling the input window on each tab stack's transitionEnd, with the 500 ms timer now only a bound; no new defect or leak was introduced.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0
Files Reviewed (5 files, incremental 283d600..7105fbc)
  • apps/mobile/src/app/(app)/(tabs)/(0_home)/_layout.tsx - no issues (stack now settles the window on transitionEnd)
  • apps/mobile/src/app/(app)/(tabs)/(2_agents)/_layout.tsx - no issues (same one-line listener)
  • apps/mobile/src/app/(app)/(tabs)/(3_profile)/_layout.tsx - no issues (same one-line listener)
  • apps/mobile/src/app/(app)/(tabs)/(4_chat)/_layout.tsx - no issues (same one-line listener)
  • apps/mobile/src/lib/tab-switch-input-gate.ts - no issues (TAB_SWITCH_ACTIVE_TAB_SETTLE_MS 250 -> 500 plus the updated doc comment; the previous 250 ms same-tab finding is resolved by the new transitionEnd closure, which now fires before the bound for a normal pop)

Notes: transitionEnd is already used with native-stack navigation in this app (apps/mobile/src/lib/navigation/stack-safe-replace.ts), so the listener is supported and correctly attached at navigator level. No memory leak was introduced: screenListeners listeners are owned by the navigators, and the module-level store still holds one timestamp and one timer handle that clearSettleTimer releases on every arm, settle, and reset. No markdown documentation files were changed in this PR.

Previous review (commit 283d600)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The lint-cleanup commit only reworks three test files and introduces no new defect; the outstanding 250 ms same-tab settle, which can end the gate before a pop-to-root transition finishes, is unchanged in tab-press-listeners.ts.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
apps/mobile/src/lib/tab-press-listeners.ts 22 Carried forward (unchanged since c3c6d1f): the focused-tab settle closes the window 250 ms after the press, and no layout closes it for a stack pop; a pop transition runs longer, so a tap after the timer but during the pop can still reach the departing screen.
Files Reviewed (4 files, incremental c3c6d1f..283d600)
  • apps/mobile/src/components/tab-bar-button.mounted.test.tsx - no issues (fake timers replace the real-timer await in the two combined cases)
  • apps/mobile/src/components/tab-screen-input-gate.mounted.test.tsx - no issues (mock signature now matches the focus-effect callback contract)
  • apps/mobile/src/lib/profile-tab-anchor.test.ts - no issues (vi.fn generics and a type-only Href import)
  • apps/mobile/src/lib/tab-press-listeners.ts - 1 issue (carried, verified still present at HEAD)

Notes: no memory leak was introduced. The test changes add fake timers only inside two tests and restore real timers; the production store still holds one timestamp and one timer handle that every arm, settle, and reset clears, and registers no listeners. No markdown documentation files were changed in this PR.

Fix these issues in Kilo Cloud

Previous review (commit c3c6d1f)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The follow-up correctly closes the window for cancelled presses and presses of the already-selected tab, but the new 250 ms same-tab settle can end the gate before a pop-to-root transition finishes.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
apps/mobile/src/lib/tab-press-listeners.ts 22 The focused-tab settle closes the window 250 ms after the press, and no layout closes it for a stack pop; a pop transition runs longer, so a tap after the timer but during the pop can still reach the departing screen.
Files Reviewed (10 files, incremental c3c6d1f)
  • apps/mobile/src/app/(app)/(tabs)/_layout.tsx - no new issues (previous KiloClaw-fallback suggestion declined by author; file intentionally untouched)
  • apps/mobile/src/components/tab-bar-button.mounted.test.tsx - no issues
  • apps/mobile/src/components/tab-bar-button.tsx - no new issues; previous cancelled/same-tab-press finding fixed
  • apps/mobile/src/components/tab-screen-input-gate.mounted.test.tsx - no issues
  • apps/mobile/src/components/tab-screen-input-gate.tsx - no issues
  • apps/mobile/src/lib/profile-tab-anchor.test.ts - no issues
  • apps/mobile/src/lib/tab-press-listeners.test.ts - no issues
  • apps/mobile/src/lib/tab-press-listeners.ts - 1 issue
  • apps/mobile/src/lib/tab-switch-input-gate.test.ts - no issues
  • apps/mobile/src/lib/tab-switch-input-gate.ts - no issues

Notes: no memory leak was introduced. The module-level store holds one timestamp and one setTimeout handle, and every arm, settle, and test reset clears the pending timer, so the timer cannot outlive its purpose and no listeners are registered. No markdown documentation files were changed in this PR.

Fix these issues in Kilo Cloud

Previous review (commit ebb39bb)

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

The tab-switch gate fixes the reported stray-tap defect, but arming it on every touch down leaves tab content unresponsive for the 1200 ms fallback whenever a press never navigates, and the excluded KiloClaw tab always takes that fallback.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
apps/mobile/src/components/tab-bar-button.tsx 52 onPressIn arms the gate for a press of the already-selected tab or a cancelled press; neither produces a destination layout, so the tab surface swallows input until the 1200 ms fallback.

SUGGESTION

File Line Issue
apps/mobile/src/app/(app)/(tabs)/_layout.tsx 351 The (1_kiloclaw) layout is not wrapped in TabSwitchInputSettle, so switching to that tab always runs the full 1200 ms fallback instead of closing on layout.
Files Reviewed (13 files)
  • apps/mobile/src/app/(app)/(tabs)/(0_home)/_layout.tsx - no issues
  • apps/mobile/src/app/(app)/(tabs)/(2_agents)/_layout.tsx - no issues
  • apps/mobile/src/app/(app)/(tabs)/(3_profile)/_layout.tsx - no issues
  • apps/mobile/src/app/(app)/(tabs)/(4_chat)/_layout.tsx - no issues
  • apps/mobile/src/app/(app)/(tabs)/_layout.tsx - 1 issue
  • apps/mobile/src/components/profile-screen.agent-rows.mounted.test.tsx - no issues
  • apps/mobile/src/components/profile-screen.test-helpers.tsx - no issues
  • apps/mobile/src/components/tab-bar-button.mounted.test.tsx - no issues
  • apps/mobile/src/components/tab-bar-button.tsx - 1 issue
  • apps/mobile/src/components/tab-screen-input-gate.mounted.test.tsx - no issues
  • apps/mobile/src/components/tab-screen-input-gate.tsx - no issues
  • apps/mobile/src/lib/tab-switch-input-gate.test.ts - no issues
  • apps/mobile/src/lib/tab-switch-input-gate.ts - no issues

Notes: the module-level store holds a single timestamp, registers no listeners or timers, and the mounted tests reset it, so this PR introduces no memory leak. The shared profile test helper no longer mocks the path builders, and no helper consumer relied on the old literal paths. No markdown documentation files were changed in this PR.


Reviewed by deepseek-flash-latest · Input: 62.9K · Output: 3.1K · Cached: 399.4K

Review guidance: REVIEW.md from base branch main

The tab being left can lay out again during a switch; closing the window on
that layout reopened it while the outgoing views were still the touch targets.
Each tab screen now reports its layout only while it holds focus.

A press that opens no screen closes its window on a timer: pressing the tab
that is already focused settles after the pop back to that tab's root, and a
gesture that ends without a press settles on the next tick. The press
listeners move to a module of their own, where the profile entry's behaviour
(prevent the default, return to the root) is asserted directly instead of by
reading the layout source.
Use the timer API instead of a promise callback for the press-out case, type
the navigation mocks, and name the focus-effect stand-in parameter so it is
not read as a promise callback.
A same-tab press pops that tab's stack, and the screen being left stays the
touch target until the pop ends. Each tab's stack now closes the input window
on `transitionEnd`, and the same-tab timer only bounds a press that opens no
transition at all.
@iscekic iscekic added merge:human Substantive functional risk requires human review. merge:bot Bot-owned review; merge when ready once readiness passes and a review approves. and removed merge:human Substantive functional risk requires human review. labels Oct 20, 2026
@iscekic
iscekic added this pull request to the merge queue Oct 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 21, 2026
…t-gate

# Conflicts:
#	apps/mobile/src/app/(app)/(tabs)/_layout.tsx
#	apps/mobile/src/lib/profile-tab-anchor.test.ts
The profile tab press now goes through `planProfileTabPress` inside
`profileTabPressListeners`, so the listener suite pins the cross-tab, root
re-press and stack-pop outcomes directly instead of reading the layout source.
@iscekic iscekic removed the merge:bot Bot-owned review; merge when ready once readiness passes and a review approves. label Oct 22, 2026
@iscekic

iscekic commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

bot: Fresh verification on the merged tree (Android emulator, German UI, test organization selected). Each run starts from a settled tab, presses the target tab on the bar, then taps the overlapping point where the Profile Code Reviewer row and the Start tab's new-task button coincide.

Observed: the quick second tap is consumed — the app stays on the pressed tab and no composer appears in four runs; the reverse direction stays on the Start tab and opens no reviewer; a settled tap on the Profile row opens the code-reviewer platform list for the selected organization; a settled tap on the new-task button opens the composer; a tap two seconds after a switch still lands.

Profile tab in German with the test organization selected, showing the Code Reviewer, Security Agent and Profiles rows

Code reviewer platform list opened by a settled tap on the Profile Code Reviewer row for the selected organization

State after pressing the Profile tab and immediately tapping the overlapping point: the Profile tab is still shown and no composer opened

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drain Work item from the findings drain explorer:android Finding surfaced by the Android explorer merge:bot Bot-owned review; merge when ready once readiness passes and a review approves.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants