Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
105 changes: 95 additions & 10 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
Platform,
ActivityIndicator,
Alert,
type CellRendererProps,
type NativeSyntheticEvent,
type NativeScrollEvent,
} from "react-native"
import { useLocalSearchParams, Stack, useRouter, useFocusEffect } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
Expand Down Expand Up @@ -39,6 +42,7 @@ import { useConnections } from "../../src/stores/connections"
import { useAuth } from "../../src/stores/auth"
import { useCatalog } from "../../src/stores/catalog"
import { useSpeech } from "../../src/lib/speech"
import { MessageNavigation } from "../../src/lib/message-navigation"

// --- Builtin slash commands ---
const BUILTIN_COMMANDS: SlashCommand[] = [
Expand Down Expand Up @@ -80,6 +84,9 @@ export default function SessionScreen() {
const { t } = useTranslation()

const flatListRef = useRef<FlatList>(null)
const navigation = useRef(new MessageNavigation()).current
const retry = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const attempts = useRef(0)
const modelSheetRef = useRef<BottomSheet>(null)
const variantSheetRef = useRef<BottomSheet>(null)
const [input, setInput] = useState("")
Expand Down Expand Up @@ -181,6 +188,7 @@ export default function SessionScreen() {
const messageData = useMemo(
() =>
(messages || [])
.filter((msg) => msg.role === "user" || msg.role === "assistant")
.filter((msg) => !revertMessageID || msg.id.startsWith("temp-") || msg.id < revertMessageID)
.map((msg) => ({
message: msg,
Expand All @@ -190,6 +198,42 @@ export default function SessionScreen() {
[messages, parts, revertMessageID],
)

navigation.sync(messageData.map((item) => item.message.id))

const seekMessage = useCallback(() => {
if (retry.current) clearTimeout(retry.current)
if (flatListRef.current && navigation.seek(flatListRef.current, attempts.current++)) {
retry.current = setTimeout(seekMessage, 100)
}
}, [navigation])

const MessageCell = useCallback((props: CellRendererProps<(typeof messageData)[number]>) => {
const key = props.item.message.id
const { onLayout, ...cell } = props
useEffect(() => () => { navigation.frames.delete(key) }, [key, navigation])
return (
<View {...cell} onLayout={(event) => {
navigation.frames.set(key, event.nativeEvent.layout)
onLayout?.(event)
if (navigation.target === key) seekMessage()
}}>
{props.children}
</View>
)
}, [navigation, seekMessage])

useEffect(() => {
navigation.manual()
navigation.frames.clear()
return () => { if (retry.current) clearTimeout(retry.current) }
}, [id, navigation])

const moveMessage = (direction: 1 | -1) => {
if (navigation.move(direction) === undefined) return
attempts.current = 0
seekMessage()
}

// Tracks the latest composer text without pulling `input` into
// handleMessageLongPress's deps — kept as a plain ref assignment (not
// state) so the callback below stays referentially stable across
Expand Down Expand Up @@ -252,8 +296,10 @@ export default function SessionScreen() {
}, [applyRevertResult, t])

const scrollToBottom = useCallback((animated = true) => {
navigation.manual(0)
if (retry.current) clearTimeout(retry.current)
flatListRef.current?.scrollToOffset({ offset: 0, animated })
}, [])
}, [navigation])

// Re-select on every focus, not just mount. currentSession/messages/
// permissions are a single global store, and the native stack keeps screens
Expand Down Expand Up @@ -451,10 +497,11 @@ export default function SessionScreen() {
}

// In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom.
const handleScroll = useCallback((event: any) => {
const { contentOffset } = event.nativeEvent
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, layoutMeasurement } = event.nativeEvent
navigation.observe(contentOffset.y, layoutMeasurement.height)
setShowScrollButton(contentOffset.y > 200)
}, [])
}, [navigation])

// Debounce: onEndReached can fire multiple times during a single scroll gesture
const loadingTriggered = useRef(false)
Expand Down Expand Up @@ -621,6 +668,8 @@ export default function SessionScreen() {
if (hasMore && !loadingMore) loadOlderMessages()
}}
onScrollToTop={() => {
navigation.manual()
if (retry.current) clearTimeout(retry.current)
flatListRef.current?.scrollToEnd({ animated: true })
}}
onClose={() => setShowInfo(false)}
Expand Down Expand Up @@ -669,6 +718,17 @@ export default function SessionScreen() {
ref={flatListRef}
data={messageData}
inverted
CellRendererComponent={MessageCell}
onLayout={(event) => {
navigation.observe(navigation.offset, event.nativeEvent.layout.height)
}}
onScrollBeginDrag={() => {
navigation.manual()
if (retry.current) clearTimeout(retry.current)
}}
onScrollToIndexFailed={({ index, averageItemLength }) => {
flatListRef.current?.scrollToOffset({ offset: averageItemLength * index, animated: false })
}}
keyExtractor={(item) => item.message.id}
renderItem={({ item }) => (
<MessageBubble
Expand Down Expand Up @@ -703,10 +763,25 @@ export default function SessionScreen() {
<Text style={[s.emptyHint, isDark && s.metaDark]}>{t("session.empty.hint")}</Text>
</View>
)}
{showScrollButton && (
<TouchableOpacity style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => scrollToBottom(true)}>
<Ionicons name="chevron-down" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
{messageData.length > 0 && (
<View style={s.scrollControls}>
<TouchableOpacity accessibilityRole="button" accessibilityLabel="Previous message"
style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => moveMessage(1)}>
<Ionicons name="chevron-up" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
<TouchableOpacity accessibilityRole="button" accessibilityLabel="Next message"
style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => moveMessage(-1)}>
<Ionicons name="chevron-down" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
{showScrollButton && (
<TouchableOpacity accessibilityRole="button" accessibilityLabel="Latest"
style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => scrollToBottom(true)}>
<Ionicons name="chevron-down" size={20} style={{ marginBottom: -10 }} color={isDark ? "#ffffff" : "#0a0a0a"} />
<Ionicons name="chevron-down" size={20} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
)}
{!showScrollButton && <View style={{ height: 44 }} />}
</View>
)}
</View>
)}
Expand Down Expand Up @@ -868,13 +943,23 @@ const s = StyleSheet.create({
listWrap: { flex: 1, position: "relative" },

// Messages
messageList: { padding: 16, paddingBottom: 8 },
// Reserve 44pt controls + 16pt inset + 16pt gap on the physical right.
// Android's inverted list flips both axes, so its content padding is mirrored.
messageList: {
padding: 16,
paddingBottom: 8,
paddingLeft: Platform.OS === "android" ? 76 : 16,
paddingRight: Platform.OS === "android" ? 16 : 76,
},

// Scroll button
scrollBtn: {
scrollControls: {
position: "absolute",
bottom: 16,
right: 16,
gap: 4,
},
scrollBtn: {
width: 44,
height: 44,
borderRadius: 22,
Expand Down
95 changes: 95 additions & 0 deletions docs/qa/ISSUE-214-MESSAGE-NAVIGATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Issue 214: Message Navigation Validation

## Scope

Previous and Next use single carets immediately above double-caret Latest.
Targets are the starts of rendered user/assistant messages in the inverted
FlatList. Layout-only/system rows are not navigation targets.

The change does not modify native keyboard handling, the composer, safe-area
configuration, server code, release metadata, or unrelated IME PR #208.

## Automated Gates

- `npm ci --legacy-peer-deps`: completed using the existing lockfile.
- `npm test`: 348 passed, zero failures (15 navigation regressions).
- `npm run typecheck`: passed.
- `npm run check:versions`: passed, existing 0.4.15 / versionCode 42 unchanged.
- `git diff --check`: passed.
- `npm run lint`: unavailable; package.json has no lint script despite the
contributing guide naming one. No lint pass is claimed.
- Android `./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a
--max-workers=2 --console=plain`: passed under a capacity lease, with SDK/JDK
environment configured and `SENTRY_DISABLE_AUTO_UPLOAD=true`.

The first build reached signing and failed because the checkout lacked
`android/app/debug.keystore`. Generating the ignored development key using the
same setup as CI allowed the build to pass; no signing material is committed.

## Physical Android Runtime

Device: Pixel 8 Pro. The installed production package had a different signing
certificate, so it was preserved. A temporary external Gradle init script used
the supported `androidComponents.finalizeDsl` API to build a separate
`cc.agentlabs.opencode.navigation214` test package. No tracked Android source or
configuration changed. The JavaScript bundle contains the actual feature diff.

The existing `tests/fixtures/mock-opencode-server.ts` supplied a separate test
connection via ADB reverse. The fixture was seeded through its REST API with
25 numbered user messages and 25 assistant responses, each containing 70 lines.
This is mock protocol coverage, not live AI/backend validation.

Snapshot-driven ADB assertions passed these flows:

1. From the newest response's partially visible end, Previous reaches user
Message 25 at the viewport top, then the older assistant's line 1, then user
Message 24. The repeated tap advances at exact message-start boundaries.
2. Next reaches the adjacent assistant's line 1, then user Message 25.
3. Manually scrolling into the middle of the long response discards the tap
anchor; Previous reaches Message 24 from the new viewport position.
4. With an unsent draft and keyboard open, tapping navigation preserves the
draft and composer `focused=true`; Android input-method state remains shown.
5. Latest reaches line 70 of the newest response and preserves the draft.
6. Seventeen consecutive Previous taps reach numbered Message 17 through every
intermediate message start, beyond the initial render window. Latest then
returns to the newest response end.

The harness first needed two test-only corrections: keyboard capitalization
must be accounted for, and keyboard dismissal must not send a blind Back key
when the keyboard is already closed. Neither required a product change.

Screenshots from the actual physical-device build:

- [Message start and controls](screenshots/issue-214-message-navigation.png)
- [Keyboard, draft and controls](screenshots/issue-214-keyboard-navigation.png)

The keyboard screenshot is not evidence that the composer is fully visible
above this device's IME. Existing keyboard layout work remains outside scope.

## Independent Review

An independent actual-diff code/behavior/UX review initially found three
issues: virtualized estimates could stall, completed targets could repin a
streaming response away from Latest, and resizing could leave a stale anchor.
All three were remediated and regression-tested.

The independent final review ran the 15 focused tests, typecheck and diff check,
inspected installed React Native 0.81.5 list internals, the runtime harness and
both screenshots. Verdict: **PASS code/static compact-control UX; BLOCKED merge
readiness**. No remaining actionable correctness defect was established.

## Blocked Gates

- Mandatory vision CUA was actually attempted with Python 3.12 and the existing
runner against the isolated installed package. It exited 1 with:
`Set AZURE_OPENAI_API_KEY, AZURE_DEV_AI_API_KEY, OPENAI_API_KEY, XAI_API_KEY, or GEMINI_API_KEY`.
The documented `~/.env.d/azure-openai.env` file is absent. Deterministic ADB
assertions do not substitute for this gate.
- The documented live server's `/global/health` request timed out after 15
seconds. No server/daemon changes or permission workarounds were attempted.
- Native rapid-tap callback ordering, live streaming/appends, and constrained
keyboard/composer layout remain incompletely validated. Pure regressions
cover rapid pending taps, growth/appends, resizing and manual cancellation.

Do not merge or release until the mandatory CUA gate passes and remaining
runtime gaps have been checked. The focused PR remains draft while blocked.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions src/components/chat/message-navigation-layout.regression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { runInNewContext } from "node:vm"

// Follow the existing source-level RN regression pattern: node:test cannot
// render native layouts. This guard fails on the original overlapping layout;
// changed-head device/keyboard acceptance is still required.
const source = readFileSync(new URL("../../../app/session/[id].tsx", import.meta.url), "utf8")

test("message content reserves a gutter for the floating navigation controls", () => {
assert.match(source, /contentContainerStyle=\{s\.messageList\}/)
assert.match(source, /<View style=\{s\.scrollControls\}>/)
const content = source.match(/messageList:\s*\{([^}]+)\}/)?.[1] ?? ""
const controls = source.match(/scrollControls:\s*\{([^}]+)\}/)?.[1] ?? ""
const button = source.match(/scrollBtn:\s*\{([^}]+)\}/)?.[1] ?? ""
const inset = Number(controls.match(/right:\s*(\d+)/)?.[1])
const width = Number(button.match(/width:\s*(\d+)/)?.[1])
const height = Number(button.match(/height:\s*(\d+)/)?.[1])

assert.ok(width >= 44 && height >= 44, "keep practical navigation hit targets")
for (const os of ["android", "ios"]) {
const style = runInNewContext(`({${content}})`, { Platform: { OS: os } })
// RN 0.81's Android inverted list uses scale:-1 (both axes), while iOS
// uses scaleY:-1. Content padding is transformed; the overlay is not.
const padding = os === "android" ? style.paddingLeft ?? style.padding : style.paddingRight ?? style.padding
assert.ok(padding >= inset + width + 16, `${os}: physical-right text must clear the control rail and gap`)
}
})
Loading