Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .kilo_workflow/dispatch-role.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ STRIP='$(env | grep -oE "^(KILO|OPENCODE)[A-Za-z0-9_]*" | sed "s/^/-u /" | tr "\
# Redirection below means an attached pane shows nothing at all. Say so in the
# pane itself — a blank window reads as a dead agent otherwise. This prints to
# the terminal only, never into the log, so the EXITCODE contract is untouched.
CMD="echo $(printf '%q' "$NAME: output goes to $LOG — this pane stays blank by design; watch with: tail -f $LOG") && cd $(printf '%q' "$WT") && env $STRIP kilo run $(printf '%q' "$MSG") --agent $(printf '%q' "$ROLE") --title $(printf '%q' "$NAME")"
# `--auto`: a headless `kilo run` auto-REJECTS every permission ask it cannot
# pre-empt (e.g. protected ~/.config/kilo reads), and the reject kills the
# round silently with EXITCODE=0 and no sentinel (see
# learnings/system/headless-role-dispatch-dies-on-permission-ask.md in the main
# checkout). Role definitions already grant what these agents need; --auto
# stops the CLI answering "no" on the agent's behalf.
CMD="echo $(printf '%q' "$NAME: output goes to $LOG — this pane stays blank by design; watch with: tail -f $LOG") && cd $(printf '%q' "$WT") && env $STRIP kilo run $(printf '%q' "$MSG") --agent $(printf '%q' "$ROLE") --auto --title $(printf '%q' "$NAME")"
for arg in "$@"; do CMD+=" $(printf '%q' "$arg")"; done
CMD+=" > $(printf '%q' "$LOG") 2>&1; echo EXITCODE=\$? >> $(printf '%q' "$LOG")"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# mobile: Android a11y bounds are clamped to the visible viewport — clip probes need pixels or control offsets

Symptom: on Android, `maestro hierarchy` bounds for a partially off-screen React Native
card report only the VISIBLE band (clamped at the screen's content edge, e.g. y=444 on a
2560px-tall emulator), unlike iOS which reports window/content coordinates past the edge.
Consequences for E2E geometry probes:

- A card's true top cannot be read once it crosses the viewport edge; repeated dumps show
the card "stuck" at the edge value. Compute the true top from a child control's reported
offset (PR-review thread cards: control row sits ~35px below the card's true top).
- Taps on a control whose band is clamped to a few px (e.g. reported `[72,444][775,453]`)
are unreliable and usually miss — Maestro and ADB/uiautomator taps alike.
- Zero-motion assertions remain valid with clamped bands: if neither the card's true rect
nor the viewport moved, the clamped band is byte-identical; any true motion changes it.

For header-visibility questions (does a title render behind the app chrome), don't trust
a11y at all: take a screenshot and scan pixels — locate the card background color's first
visible row and count dark text pixels in the header band (PR-review card bg is
(240,238,230), page bg (251,250,245); the chrome is opaque, not blurred).
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# mobile: D3 drag-cancel race (7b) — Maestro tap→swipe turnaround loses to the ~300-600ms settle window on Android

Symptom: verifying "drag the list mid-settle cancels the deferred expand" (D3 guard): tap
a top-clipped thread's expander, then swipe. On iOS the drag lands in time and the expand
is cancelled. On Android the thread expands anyway, repeatedly — looks like a platform
defect and is not.

Cause: the deferred expand fires ~300-600ms after the tap (scrollToIndex animation ~300ms
plus promise resolution). Maestro's per-step turnaround (tap command completes, next swipe
command starts) is ~500-800ms on Android, so the drag always begins after the expand.

Fix / instruments, fastest first:
1. One `adb shell` call with both events — `input tap x y && input swipe ...` (~30ms gap) —
but only with UNclamped control bounds (see
mobile-android-a11y-bounds-clamped-to-viewport.md); a tap on a clamped sliver misses.
2. `maestro test <tap-only-flow> && adb shell input swipe ...` — still ~500ms, too slow.
3. The same-thread retap (flow 7a) proves generation-cancellation lands mid-settle on
Android: if a second tap supersedes cleanly (exactly one expand, no collapse), the
cancel mechanism works on the platform; combine with iOS 7b for the drag path before
classifying an Android 7b expansion as a product failure.

UPDATE 2026-07-29 (pr-review-d957 r2): instrument 1 CONFIRMED working — first honest
attempt with `adb shell "input tap 423 479 && input swipe 540 1400 540 1100 250"` on an
UNclamped control (control top 458 > edge 444) cancelled the expand; the thread stayed
collapsed (verified by re-exposing the card and reading `Expand thread`, not just its
bounds band, which is ambiguous while clamped). r1's three failures used clamped-sliver
targets, which the learning already flags. To unclamp, ADB-nudge down in ~50px steps until
the control's reported top clears the clamp edge.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# mobile: never run two maestro commands concurrently on one UDID; screenshot loops finish before maestro's slow startup

Symptom: `IOSDriverTimeoutException: iOS driver not ready in time` mid-run on iOS when a
background `maestro test` (tap) overlapped a `maestro hierarchy` started 1.5s later on the
same simulator. Two XCUITest channels on one UDID wedge the driver.

Recovery: kill only the stale `xcodebuild test-without-building` PID bound to the UDID
(see `mobile-maestro-ios-driver-timeout-kill-stale-only.md`); app state, scroll position,
and login all survive. Retry with `MAESTRO_DRIVER_STARTUP_TIMEOUT=300000`.

Second, related timing fact (iOS, machine under parallel-workflow load): maestro's JVM +
driver startup takes 15-25+s before the tap lands. A `xcrun simctl io screenshot` loop
(10-30 shots, ~0.5s each) started concurrently with the tap command ALWAYS finishes before
the tap lands — every frame is pre-tap. Timed screenshot/hierarchy capture of sub-second
windows (e.g. an 800ms probe delay) is not achievable this way, and concurrent maestro
for the window is what causes the timeout above.

What works instead: instrument the app with temporary `console.log` probes (captured from
`pnpm dev:capture mobile`), take hierarchy dumps at leisure before and after the
interaction, and DERIVE mid-window screen geometry from the logged scroll values:
screen_y = (layoutY + firstItemOffset) - absoluteLastScrollOffset + K, where K (the
screen-y of the list viewport's content-top edge) is calibrated from a moment where both a
hierarchy bound and the logged offsets are known. On pr-review-d957 iOS K=367, confirmed
within ±1.3pt at five independent moments across three runs and scroll regions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# mobile: positioning a thread at the top clip — FlashList max-offset clamp and sub-threshold swipes

Symptom: E2E flows that need a collapsed resolved thread clipped ~5-20pt above the visible
content top (PR-review flows 4 / 7a / 7b) cannot nudge the card high enough: swipes stop
moving the list, always at the same card position.

Cause: two independent constraints.
1. The list is at FlashList's computed max scroll offset. The clamp releases only when the
content BELOW the target card exceeds one viewport height AND has been measured. With
freshly mounted data (collapsed cards + unmeasured conversation comments), a mid-list
card simply cannot reach the clip. In the pr-review-d957 fixture (iOS), gamma clamped at
y228 until page 2 was loaded via "Load more" — r1's own flow-4 setup dump shows page-2
threads (zeta/eta) already loaded for the same reason. On Android (2424px), zeta could
not be clipped until theta (below it) was expanded, adding ~850px below.
2. Short Maestro swipes (<~10% of screen height, e.g. 1-6%) are silently ignored on both
platforms (velocity/distance below the scroll-recognition threshold); hierarchy bounds
stay byte-identical. Do not retry them in a loop — they never land.

Working sequence per platform:
- Load page 2 first (`scrollUntilVisible` 'Load more comments' + tap), or expand a thread
below the target to grow content.
- Approach with 30% Maestro swipes, one command at a time with a hierarchy probe between
(back-to-back commands after big swipes get ignored intermittently).
- Final 40-100px positioning: iOS — a 6-8% swipe usually lands once the clamp is released;
Android — `adb shell input swipe x y1 x y2 300` works reliably but ONLY once the offset
can grow (it is also clamped, not broken).
- Android reminder: the a11y clamp edge (y=444 on pixel9 API35, 1080x2424) is NOT the
opaque-chrome bottom (~y536 pixels); verify clip geometry with a screenshot pixel scan,
not bounds alone.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# mobile: devSeedUserGithubToken 500s when a sibling e2e user already owns github_user_id 999001

Symptom: `githubApps.devSeedUserGithubToken` with `githubUserId: "999001"` returns a 500
("Failed query: insert into user_github_app_tokens ... on conflict ..."), even though the
runbook says a sibling seed should surface as a benign `upserted: false`.

Cause: the local postgres on this machine is SHARED across worktrees (this worktree's stack
used the base `postgres` DB on localhost:5432 — the `postgres-N` DBs belong to other flows;
find yours via `select count(*) from user_auth_provider where provider_account_id='<e2e email>'`
across DBs). A previous section seeded github_user_id 999001 for a DIFFERENT kilo_user_id
(`e2e-mobile-mobile-audit-w1-pr-safety@example.com`). The upsert targets
(kilo_user_id, github_app_type); with no row for YOUR user it attempts a plain insert, which
violates unique index `UQ_user_github_app_tokens_github_user_app` on (github_user_id, app_type).

Fix: list existing ids first —
`psql -h localhost -p 5432 -U postgres -d postgres -c "select github_user_id, github_login from user_github_app_tokens"`
— and seed with a FREE id (999003 worked). The github_user_id value only feeds the token
envelope AAD, which round-trips through the same row, so any unique value works against the stub.

Also: the seed mutation needs an authenticated call. The web fake-login cookie path did NOT
satisfy tRPC (`UNAUTHORIZED`). Working path with curl, same as the app's own native login:
`POST /api/auth/native/otp {email}` → read the 6-digit code from the newest `dev/logs/emails/*.html`
→ `POST /api/auth/native/token {provider:'email', email, code}` → `{token}` → call tRPC with
`Authorization: Bearer <token>`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# mobile: Maestro childOf never matches RN a11y cards on iOS — tap by bounds-derived point

Symptom: `tapOn: {text: 'Expand thread', childOf: {text: 'Discussion thread src/gamma.ts L33 (RIGHT)'}}`
fails with "Element not found: Text matching regex: Expand thread", while `maestro hierarchy`
clearly shows both labels on screen.

Cause: in Maestro's flattened iOS a11y tree, a RN View with `accessibilityLabel` (the thread card)
and the Pressables INSIDE it (`Expand thread`, `Unresolve thread`) are SIBLINGS at the same depth,
not parent/descendant — verified by walking the hierarchy JSON. `childOf` finds zero parents, and
the error misleadingly names the child selector.

Fix: parse `maestro --device <udid> hierarchy` output — every element carries `bounds` as
`[x1,y1][x2,y2]` — and `tapOn: {point: <center-x>,<center-y>}`. Verify positions with a fresh
hierarchy dump immediately before the point tap; positions were stable across repeated dumps.

Second trap: taps whose y is under the app's fixed header chrome are silently swallowed — Maestro
logs COMPLETED but nothing happens (same signature as the keyboard swallow). On the PR-review
screen the tab selector row ends at y≈166 (iPhone 17 Pro) and the effective dead zone extends to
about y≈178-180; a tap at y=174 was eaten, y=180 worked. When positioning a row for a header tap,
leave its control at y≳185; a collapsed thread card's expand control sits only 13pt below the card
top, so a "partially clipped at the list top" card's control is always inside the dead zone.

Also: Maestro text matching treats the pattern as regex — literal parens in labels like
`L33 (RIGHT)` still MATCH because the matching is not strict full-string in every code path
(`scrollUntilVisible` found the element with the unescaped pattern).
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# mobile: IOSDriverTimeoutException — killing ONLY the stale xcodebuild sufficed (no sim reboot)

Update to `mobile-maestro-ios-driver-timeout-stale-xcodebuild.md` (which prescribes
kill xcodebuild + `simctl shutdown && boot`).

Observed 2026-07-29 (pr-review-d957 repro run, sibling section thrashing Maestro driver restarts
on their own UDID every ~8s): `IOSDriverTimeoutException: iOS driver not ready in time` mid-run.
`ps aux | grep "xcodebuild test-without-building" | grep <UDID>` showed exactly one stale driver
bound to my UDID. Killing just that PID (no simulator shutdown, no reboot) and retrying with
`MAESTRO_DRIVER_STARTUP_TIMEOUT=300000` recovered immediately — app state, scroll position, and
login all survived, so the in-flight E2E probe sequence continued without re-navigation.

Try the kill-only path first; keep the learning's shutdown+boot as the fallback when the driver
still won't come up.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# mobile: Maestro `direction: DOWN` swipe is eaten by a sticky FlashList header overlay

Symptom: on a list with `stickyHeaderIndices` (PR-review Files tab), swipes that scroll
content DOWN (offset decreases, e.g. `direction: DOWN` or start≈30% end≈70%) silently do
nothing — Maestro logs COMPLETED, hierarchy is byte-identical. Up-swipes work fine.

Cause: FlashList's StickyHeaders overlay is an absolute-positioned sibling ON TOP of the
list, not part of the scroll content. A gesture starting inside the overlay's frame (on the
PR Files screen it sits at the list top, y≈233-267 ≈ 27-30% of an 874pt screen) does not
scroll the list. Maestro's directional DOWN swipe starts near y≈30%, inside the overlay.

Fix: use explicit-coordinate swipes whose start is below the overlay
(`start: 50%, 40%` → `end: 50%, 75%`). Verify movement with a before/after hierarchy grep
(bounds of a stable row). Note: real users dragging from the stuck header row hit the same
non-scrollable strip — standard FlashList StickyHeaders behavior, not a defect to report
unless the product wants drag-from-header scrolling.
17 changes: 17 additions & 0 deletions .kilo_workflow/learnings/mobile-tablet-app-startup-broken.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# mobile: app startup/consent unusable on tablet simulators (iPad + tablet AVD) — blocks tablet E2E login

Symptom (observed 2026-07-29, pr-review-d957, both surfaces on the same day):
- iPad Pro 11-inch (iOS 26.5): app boots to "Welcome to Kilo Code" but the consent body
and `Accept and continue` button never enter the a11y tree (logo + title only, "1 page"
scroll bars). `login.sh` and its cold-relaunch retry both fail — never reaches the email
field.
- kilo_pixel_tablet_api35 (Android): after the dev-client deep link, the resumed
MainActivity exposes NO app content (a11y tree = status bar only; screenshot pixels are
mostly black + page-bg). Metro serves the Android bundle fine. force-stop + deep-link
relaunch (the one supported recovery) does not recover.

The phone surfaces (iPhone 17 Pro iOS 26.5, kilo_pixel9_api35) work the same day, so this
is tablet-form-factor specific. The welcome/consent screen and startup are outside any
PR-review change area. Impact: tablet side-by-side regression checks (PR-review flow 9)
are environment-limited until app startup on tablets is fixed; do not classify PR-area
failures from tablet evidence until login works there.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# pr-review-d957: re-applying the GitHub-stub temp fixture edits from prior verifier logs

The sanctioned temp edits to `apps/mobile/e2e/github-api-stub/server.mjs` (9 threads
[a,b,c open + r1-r6 resolved], 8 long-patch files, `/files` route, page cap 5,
per-fixture counts) exist only inside `e2e-verifier-r0.log` / `-r1.log` as Edit-tool
unified diffs — no patch file was ever saved.

Extraction traps (cost ~30min in r3):
1. Each Edit block appears TWICE consecutively in the log (terminal echo) — dedupe
consecutive identical blocks (12 blocks → 6 unique).
2. The diffs' context lines lost leading whitespace (a log artifact; dedent varies per
hunk: 2-4 spaces) — they do NOT match the pristine file, so `git apply` of the
concatenated hunks fails.
3. The `+` lines and the `@@` line numbers ARE intact; `@@` numbers are in
evolving-file coordinates (each Edit diffed the already-edited file).

Working recipe: `git apply` hunks 1-2 (their context survived), then for hunks 3-6
extract only the `+` lines, re-indent to match sibling code, and insert by anchored line
surgery at the real file locations (thread-array close, `conversationComment(2006,...)`,
`changed_files: 2,` triple, before the check-runs route). r1's page-cap edit (not in r0)
goes in the `PrReviewThreads` handler verbatim from the r1 log. Verify with
`node --check` plus curl probes: `/pulls/1/files` → 8 files with 4-13KB patches;
GraphQL `PrReviewThreads` page 1 → 5 threads hasNextPage=true, page 2 → 4 threads
(r4-r6,c). r3's ready-made hunk files and insert content are in
`$SCRATCH/e2e-r3/hunk*.patch` / `hunk*-insert.txt`.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
HunkSideBySideHeader,
SideBySideRow,
} from '@/components/pr-review/diff/pr-diff-side-by-side-row';
import { collapseOnMarkViewed } from '@/lib/pr-review/diff/collapse-on-mark-viewed';
import { type ExpandSeparatorItem, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items';
import { type ParsedHunk } from '@/lib/pr-review/diff/parse-patch';
import { sideForDiffLineType } from '@/lib/pr-review/diff-selection';
Expand Down Expand Up @@ -89,6 +90,7 @@ export function useDiffRenderItem({
}}
onToggleViewed={() => {
void viewed.toggle(item.file.path);
setExpanded(prev => collapseOnMarkViewed(prev, item.file.path, item.viewed));
}}
/>
);
Expand All @@ -101,6 +103,7 @@ export function useDiffRenderItem({
githubUrl={item.githubUrl}
onToggleViewed={() => {
void viewed.toggle(item.file.path);
setExpanded(prev => collapseOnMarkViewed(prev, item.file.path, item.viewed));
}}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import { dedupeFilesByPath } from '@/lib/pr-review/diff/dedupe-file-pages';
import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder';
import { itemTypeFor, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items';
import { stickyFileHeaderIndices } from '@/lib/pr-review/diff/sticky-file-headers';
import { usePrDiffContextLoader } from '@/lib/pr-review/diff/use-pr-diff-context-loader';
import {
useFetchToCompletion,
Expand Down Expand Up @@ -179,6 +180,8 @@ export function PrReviewFileList({
]
);

const stickyHeaderIndices = useMemo(() => stickyFileHeaderIndices(items), [items]);

const { handleContentSizeChange } = usePrDiffListScroll({
owner,
repo,
Expand Down Expand Up @@ -271,6 +274,11 @@ export function PrReviewFileList({
renderItem={renderItem}
keyExtractor={item => item.key}
getItemType={item => itemTypeFor(item)}
stickyHeaderIndices={stickyHeaderIndices}
// Height changes above the viewport (expand / collapse-on-mark)
// misfire mVCP and jump the list; gap-context insert after
// scroll-away is rare and acceptable without anchor hold.
maintainVisibleContentPosition={{ disabled: true }}
// Re-measure rows when the bounded font scale changes.
extraData={diffFontMetrics.scale}
onContentSizeChange={handleContentSizeChange}
Expand Down
Loading
Loading