The session/chat screen's KeyboardAvoidingView used behavior={undefined}
on Android, relying entirely on the native android:windowSoftInputMode
adjustResize (set in AndroidManifest.xml) to shrink the window and push
the agent/model toolbar + composer above the keyboard.
Since the app adopted Expo's mandatory edge-to-edge display, Android no
longer resizes the window when the keyboard opens (the system assumes
insets are handled dynamically), so adjustResize became a no-op —
leaving the toolbar and input completely hidden behind the keyboard.
Switch to behavior="padding" on both platforms so KeyboardAvoidingView
pushes the composer up using its own JS-measured keyboard height,
independent of native window resize.
Co-authored-by: test <test@test.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Seven correctness bugs in the session composer:
- sessions.ts sendMessage: await the prompt submission and rethrow on
failure instead of a fire-and-forget .catch(), so handleSend's existing
restore-draft-and-alert catch actually runs.
- pasteFromClipboard: route pasted images through toJpeg() so they get
the same resize/compress treatment as picked/captured photos.
- pickFromLibrary/pickFromCamera: wrap toJpeg() in try/catch (and switch
to Promise.allSettled for the multi-select batch) so one bad asset
doesn't silently drop the whole batch; surface a new imageFailed alert.
- pickFromLibrary: cap selection at 10 images.
- useSpeech: abort the native recognition session on unmount so the mic
doesn't stay hot after leaving the screen.
- Surface useSpeech's error via Alert, keyed on the error value so it
fires once per distinct error.
- Undo on the revert banner now also clears the composer, since it was
prefilled by the edit flow and could otherwise be sent as a duplicate.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Five correctness bugs from an adversarial review of the notification and
permission/approval paths (each verified against the code):
1. Notifications never worked for most users (HIGH): OS permission was only
requested when a user manually toggled a Settings switch off→on. Since
categories default on, that path never fired, permission stayed
'undetermined', and send() silently no-op'd every notification. Now request
it once on first live connection (in-context). (app/_layout.tsx)
2. Wrong-session data after back-navigation (HIGH): session screen reads a
global store and its resync ran only on mount; the native stack keeps
screens mounted underneath a pushed one, so returning to a session could
show another session's messages and permission prompts — approving the wrong
session's tool call. Re-select on focus via useFocusEffect. (app/session/[id].tsx)
3. 'Task completed' fired on aborted/errored runs (misleading, and a duplicate
push alongside 'Session error'). Gate the notify by !aborted && !errored.
(src/stores/events.ts)
4. Tapping a connection-drop notification (no sessionId) navigated to an empty
'/session/' dead-end. Route to home instead. (app/_layout.tsx)
5. Double-tap on a single-select question sent two replies; the second hit an
already-resolved request and popped a spurious 'Reply failed' alert. One-shot
guard on reply/reject. (src/components/chat/QuestionPrompt.tsx)
Verified but intentionally NOT changed: 'completed' notifications default off
(a defensible anti-spam choice — the app still notifies when the agent needs
input). typecheck clean, 187/187 tests.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root-caused directory-picker's directory-row-frontend failure: all four
in-app @gorhom/bottom-sheet sheets (DirectoryBrowserSheet, DirectorySwitcher,
ModelPicker, VariantPicker) provide static percentage snapPoints but rely on
v5's enableDynamicSizing default (true), which never resolves without content
wrapped in a size-reporting component — so useAnimatedDetents() permanently
early-exits and the sheets can never actually open. Set
enableDynamicSizing={false} on all four (they already have explicit
snapPoints, so dynamic sizing was never needed).
variant-picker's chip failure was a stale test assumption: src/lib/
model-selection.ts's chooseModelSelection() deliberately returns null for a
fresh session (issue #37/#35 — the provider registry default is unreliable),
so a brand-new session has no model selected and the reasoning-effort chip
has nothing to key off of. Added testIDs (model-chip, model-option-*) and
updated the flow to explicitly pick a model first, matching real usage.
diff-scroll's missing markdown text: switched src/components/markdown/
Markdown.tsx from react-native-marked's FlatList-based default export to its
useMarkdown() hook rendered into a plain View. The chat screen already nests
this inside its own *inverted* FlatList (one row per message) — a nested
VirtualizedList inside an inverted outer list is a known RN footgun where the
inner content can render at zero height instead of just warning. We already
forced scrollEnabled:false + a large initialNumToRender, defeating
virtualization anyway, so rendering the parsed blocks directly loses nothing.
Extended the existing react-native-marked .d.ts shim (added for a React
18/19 ReactNode mismatch) to also declare useMarkdown/useMarkdownHookOptions.
Added diagnostic screenshots to directory-picker.yaml and diff-scroll.yaml
at the previously-failing steps for faster triage if these regress again.
Closes#104.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Extends the i18n infra from #97 (Settings screen only) to the rest of the
app: session chat screen, connection add/edit/list screens, sessions list
(incl. directory grouping from #96), navigation titles, notifications
category metadata, error boundary, telemetry consent modal, auth gate, and
every chat UI component (permission/question prompts, status indicator,
model/variant pickers, directory switcher/browser, reasoning block, tool
call card, session info).
- 244 new keys added to en.json/zh-Hans.json with reviewed, natural
Simplified Chinese (not machine-garbage), keeping key sets identical.
- User content, server URLs, code snippets, log/error-detail text, and
diagnostics-classify.ts (pure dependency-free module feeding Sentry/
support reports) are intentionally left untranslated per scope.
- Interpolation used for counts/names (e.g. reconnect attempt, files
count, connection name in delete confirmations); categoryMeta/
CONNECTION_TYPES switched to labelKey indirection since they're
module-level constants evaluated before i18next is guaranteed ready.
- Added src/lib/i18n/catalog-parity.test.ts (node --test) asserting
en.json/zh-Hans.json expose identical key sets and no empty values,
to catch future locale drift.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test: E2E coverage for directory picker, all-sessions, variant picker
Extend the Maestro suite for the features merged into main today:
DirectoryBrowserSheet's server-folder picker, the directory-less
all-sessions-across-projects list (+ the #46/#48 open-across-project
regression), and VariantPicker's reasoning-effort chip.
- tests/fixtures/mock-opencode-server.ts: GET /file (directory-scoped via
the x-opencode-directory header) with a small fake tree, GET /project
for the "Server Projects" section, POST /session honoring the directory
header, GET /session/:id (needed to open a session from the all-sessions
list), GET /provider variants for VariantPicker, and an optional
--seed-sessions mode that pre-populates two sessions across two
directories. --fail-auth mode is untouched.
- .maestro/flows/directory-picker.yaml, all-sessions.yaml,
variant-picker.yaml: three new flows, run in the same emulator session
as the existing activation flows.
- Additive testIDs on DirectoryBrowserSheet, the "Browse Folders" row,
session list rows, the variant chip, and VariantPicker rows.
- .github/workflows/activation-e2e.yml: two more mock server instances
(4098 seeded, 4099 fresh) and three more maestro test steps.
Verified: tsc --noEmit clean, all 108 existing unit tests pass, every new
mock endpoint curled against its real shape read from the app code, YAML
validated. No Android emulator available locally to run the Maestro flows
themselves.
* test(mock): enforce per-directory session scoping so #46/#48 coverage can fail
Review finding (HIGH): GET /session/:id and /session/:id/message ignored
x-opencode-directory, so all-sessions.yaml could not fail if the directory
threading fix regressed. The mock now mirrors the real server's per-directory
workspace scoping:
- GET /session/:id and GET /session/:id/message 404 unless the request's
x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the stored
session's directory.
- GET /session without ?roots=true is scoped to the request's directory;
loadSessions()'s directory-less roots=true call still returns everything.
- Document the port-4099 shared-state coupling between directory-picker and
variant-picker flows, and why all-sessions.yaml now has teeth (flow comment).
Curl-verified: correct header 200, wrong/no header 404, scoped vs roots
listing, create-then-open paths for all three flows, --fail-auth untouched.
tsc clean, 108/108 unit tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
* fix(e2e): widen connect-handshake wait past client's own 30s timeout
Run 29546383612 (7cbd3a6, first real emulator execution of these flows)
failed on activation-positive.yaml: "Assert that id: connection-status-dot
is visible" timed out after the flow's 20s extendedWaitUntil, right after
tapOn connect-submit-button.
The mock server itself is fast (verified locally: health + project/current
+ path respond in ~30ms total), so this isn't a mock fidelity gap. But
Quick Connect's testConnection()/addConnection() path chains up to 3
fetches (health, then project.current + path.get in parallel), and each
individual fetch is capped by src/lib/sdk.ts REQUEST_TIMEOUT_MS = 30_000 —
strictly longer than the 20s the flow was willing to wait. A first-attempt
emulator-to-host (10.0.2.2) connection that's merely slow to establish,
rather than outright failing, would blow past the test's wait before the
app's own client-side timeout even fires.
Bump the connect -> connection-status-dot / "Connection Failed" waits from
20000 to 40000 across all 5 flows that share this pattern
(activation-positive, activation-negative-401, all-sessions,
directory-picker, variant-picker) so the wait is never shorter than the
code path it's gating on. Assertions are unchanged — still requires the
real dot / real error text, just with a timeout that isn't racing the
client.
Verified locally: typecheck clean, all 108 unit tests pass, YAML parses,
mock server confirmed fast under direct curl. Emulator behavior itself
(whether 40s consistently clears it) is unverified until the next CI run.
* fix(e2e): use adb reverse + 127.0.0.1 instead of 10.0.2.2; capture logcat/maestro debug
Root cause of the activation-e2e failure (connect step timed out, ~0 requests
reaching the mock): the 10.0.2.2 host alias is unreliable under the headless
emulator-runner — the app's http://10.0.2.2:4096/global/health never completed,
so connection-status-dot never rendered.
- run-e2e-flows.sh: single script (fixes cd-per-line fragility) that adb-reverses
each mock port (4096-4099) into the emulator's localhost, runs every flow with
--debug-output, and dumps logcat on exit.
- All flows now connect to 127.0.0.1:<port> (the adb reverse target).
- Upload maestro-debug (UI hierarchy on failure) + logcat as artifacts so future
failures are diagnosable instead of blind.
* fix(e2e): connect via 127.0.0.1:PORT in IP field, stop typing into port input
Root cause of every activation-e2e connect failure (proven by the app's own
logcat diagnostic: '[diag] probe start http://127.0.0.1:40966 ... server
unreachable'): the port field defaults to useState("4096"), and the flow's
eraseText + inputText "4096" raced the controlled number-pad input, leaving
"40966" — nothing listens there, so connect always failed. This was never a
10.0.2.2 / adb reverse issue.
Fix: buildUrl already extracts host:port from the IP field, so enter
127.0.0.1:<port> there and remove the flaky port-field steps entirely.
pastedPort overrides the default port state, so each flow's port is
deterministic (4096 positive / 4097 negative / 4098 all-sessions / 4099
directory+variant).
---------
Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: edit/revert sent messages via server revert API
Wires the mobile client up to the opencode server's session.revert /
session.unrevert endpoints (the same primitive the desktop TUI uses to
edit the last message). Long-press a user message bubble -> "Edit
message" reverts it server-side and prefills the composer with its
text; a banner offers Undo while the revert is pending (it's only
cleaned up server-side on the next prompt). Degrades gracefully with
an alert on older servers that 404 the /revert route.
Closes#56
* fix(revert): address code review findings on edit/revert message flow
- Confirm before overwriting an in-progress composer draft when editing
a sent message (F2)
- Restore reverted message's file attachments into the composer, not
just its text (F3)
- Distinguish 401/403 from other revert failures with an accurate
"Authentication failed" message instead of a generic one (F4)
- Exclude optimistic "temp-" message IDs from the revert cutoff
comparison so concurrently-sent messages aren't hidden (F9)
---------
Co-authored-by: engineer <engineer@gray-knight-m1.local>
* feat(#49): improve project picker with recents + server projects
- New Session modal now shows:
- Current project as tappable row (tap to create session immediately)
- Recent Projects section: list of previously used dirs as tappable rows
- Server Projects section: projects known to opencode server (from /project API)
- Manual path input as fallback (unchanged behavior)
- Modal body is now scrollable to handle long lists
- All selection paths call addRecentDirectory to keep recents up to date
- TypeScript clean (pre-existing VariantPicker.tsx error unrelated)
* feat: add reasoning effort (variant) picker to session screen (#47)
- Add VariantPicker bottom sheet component (low/medium/high/auto)
- Add variant state to catalog store, reset on model change
- Pass variant through sendMessage -> sdk.session.prompt()
- Add reasoning chip to toolbar, shown only for models with variants
- Parse model.variants from provider API response in catalog and sdk types
API field: variant in POST /session/:id/prompt_async
Server maps variant -> reasoningEffort via model variant config
* fix(models): preserve reasoning effort across messages
Reset the selected variant only when the provider/model pair actually changes, including catalog reloads and agent-driven model switches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The KeyboardAvoidingView used behavior='height' on Android, which
conflicts with the native android:windowSoftInputMode='adjustResize'
set in AndroidManifest.xml. This causes the keyboard to overlap the
text input instead of pushing it up.
Fix: use behavior={undefined} on Android, letting the native
adjustResize handle keyboard avoidance — the recommended approach.
Closes#53
(cherry picked from commit 5d2380b794a240e7ee9b72a95b1b5161403704d2)
Co-authored-by: cloph <128580843+cloph-dsp@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds deterministic end-to-end coverage for first-open -> telemetry consent
-> server URL entry -> connect -> send first message -> receive reply,
targeting the 0%-7-day-retention investigation (GitHub issue #76).
- tests/fixtures/mock-opencode-server.ts: dependency-free HTTP+SSE stub
matching the REAL client protocol (src/lib/sdk.ts) — REST + a single
long-lived GET /global/event SSE stream, no WebSocket. Supports a
--fail-auth mode that 401s every request to exercise the connect-time
auth-failure class.
- .maestro/flows/activation-positive.yaml: consent -> quick connect ->
new session -> send message -> assert streamed reply renders, with a
screenshot at every step (positive-S1..S8).
- .maestro/flows/activation-negative-401.yaml: same setup against the
--fail-auth server, asserts Quick Connect's existing "Connection Failed"
alert is shown (not silently swallowed) and that the connection is not
saved. Flags in comments that Advanced-mode Save (handleAdvancedSave)
still has no testConnection() check and is a known, uncovered gap.
- testID props added (no restructuring) to the screens/components the
flows drive: TelemetryConsentModal, connection/add.tsx, tabs/index.tsx,
session/[id].tsx, MessageBubble.
- .github/workflows/activation-e2e.yml: new CI job — Android emulator via
reactivecircus/android-emulator-runner, builds the debug-signed APK,
starts both mock server instances, runs both Maestro flows, uploads
screenshots via actions/upload-artifact. Kept separate from the existing
vision-driven cua-smoke.yml, which needs a live server + LLM and isn't
suited to tight deterministic regression assertions.
- .gitignore: Maestro takeScreenshot output is never committed.
Verified locally: mock server exercised standalone via curl (health,
project/current, path, session create, SSE event ordering, message
persistence) in both normal and --fail-auth modes; both Maestro flow
files validated as well-formed YAML; tsc --noEmit clean on all changed
files. No lint script exists in this repo (N/A). Full emulator execution
was not run — no Android SDK/emulator available in this environment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
Shows amber 'Reconnecting… (attempt N)' banner when SSE is down.
Shows brief green 'Connected ✓' flash on reconnect (useRef transition
to avoid atomic state reset bug where lastDisconnectAt resets with
reconnectAttempts in the same set() call).
Banner disappears automatically when SSE is stable.
Updates CUA scenario to check for both ASCII and Unicode ellipsis.
Closes#42
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Found via parallel screen audit; each confirmed in code:
- AuthGate: auto-prompt biometrics on lock (useEffect was imported but unused)
- CodeBlock: horizontal scroll for long code lines (were wrapped/mangled)
- DiffView: horizontal scroll instead of numberOfLines=1 truncation
- chat: biometric-cancel on send shows feedback instead of silently dropping msg
- chat: send failure restores input + attachments and alerts
- chat: removed dead /compact + /clear builtin commands (advertised, no-op)
- sessions: delete + rename failures alert instead of silent; rename guarded
against double-submit
- sessions: onRefresh spinner no longer hangs forever if a refresh rejects
- add/edit connection: validate URL has http(s):// scheme before save/test
typecheck clean, 65/65 unit tests pass. Runtime UI behavior still needs on-device
verification per the pre-posting test gate (HANDOFF §0b).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- sessions.ts: deleteSession looks up session.directory and uses clientFor()
instead of bare client, so cross-project deletes hit the right server path
- index.tsx: submitRename uses clientForDirectory(session.directory) so renames
work for sessions not owned by the active project directory
- index.tsx: handleSwitchDirectory calls catalog.load() after a directory switch
so agents/commands/providers refresh for the new project
- session/[id].tsx: introduce sessionClient (directory-aware) and use it in
handlePermissionReply, handleQuestionReply, handleQuestionReject, slash-command
send, and the refreshPending bootstrap call; also add 'directory' to useEffect deps
- catalog.ts: remove debug console.log