* feat(android): composer @ mentions with session chips and file hits Mirror the web composer @-mention autocomplete: an active `@word` at the composer tail opens a dropdown that merges two sources like the web's `[...sessionHits, ...fileHits]`: - Session rows from the cached session list via `matchSessionsForMention` (web `sessionReference.ts` port): sidebar dedupe by `flavor:agentSessionId`, `hasConversationContent == true` gate so empty stubs are never reference targets, current session excluded, score tiers (title exact/prefix/contains, id prefix/contains, other-field) with active +50 / archived -25, Long-scored with updatedAt as tiebreak, limit 20. Insert text is Copy-reference prose (`See session "<title>" (/sessions/<id>) for context.` + steer suffix) with JSON-quote escaping, whitespace normalization, and 120-grapheme truncation via BreakIterator. - File rows via `GET /api/sessions/:id/files` (limit 50) for codex / copilot flavors only: copilot inserts `@<fullPath>`, codex inserts `@"<fullPath>"` with quote/backslash escaping; RPC failure degrades silently to session-only rows (web parity), CancellationException rethrown. Trigger detection ports `findActiveWord.ts` byte-for-byte (hard-stop scan, `@` boundary rule, `/`+`.` path continuation forward-only, bare `@` popup, no code-fence exemption), evaluated at the end of the text (the Android composer is a cursor-less String field - full cursor-aware mirror kept as `Mentions.findActiveWord`). Selection swaps the whole word and appends a trailing space (`applySuggestion.ts`). The `hasConversationContent` gate field already exists on this lineage as a non-nullable Boolean (absent / JSON null decodes to `false` via HapiJson coercion, asserted in a new SessionDecodingTest case); the `searchSessionFiles` seam moves onto `ChatSessionApi` with a defaulted `limit` parameter. MentionDropdown reuses the SlashCommandDropdown chrome; new string `chat_mention_suggestions` lands at identical key positions in en/zh-rCN/ru, and three zh keys a prior merge had parked at the file tail move back to their en-mirrored positions so all three locales key-sync exactly. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Align mention prefix scan and title whitespace with the web - findActiveWord default prefixes now mirror the web trigger set ['@', '/', '$'] (HappyComposer.tsx): a boundary-legal '/' or '$' tail word stops the backward scan, so "@ref /tmp" yields the "/tmp" word (routed to the slash/skill domain) instead of joining a multi-word mention query whose whole-word swap would swallow the typed "/tmp" on selection. - sanitizeSessionReferenceTitle now collapses the JS /\s+/ Unicode whitespace set (NBSP, U+3000, U+2028/2029, U+FEFF, ...) so CJK titles normalize byte-identically to the web. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Mentions: caret-aware detection + stale-row invalidation (review fixes) Consolidates the two review fixes from #2155 into this implementation: caret-position — detection and insertion now evaluate at the composer caret instead of the text tail. Mentions.mentionQueryOf gains a selection-aware overload; the VM tracks text + caret as one atomic ComposerDraft (mirroring the field's TextFieldValue), ChatComposer reports text/selection in a single callback and adopts external text at the reported caret, and applySuggestion rests the cursor after the insertion. A selected range suppresses suggestions (web rule). stale-suggestion — the file leg emits an empty batch before awaiting each RPC, so the previous query's rows never linger while a new query's request is in flight (delayed-response regression test included). Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Fix androidTest lambda arity after composer callback consolidation CodexPlanActionsTest renders ChatComposer with onTextChange = {}, which no longer compiles now that the callback reports (text, selectionStart, selectionEnd) atomically. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * feat(android): composer scheduled send with preset and specific picks The composer clock entry (web ScheduleTimePicker twin): the exact web preset set (+5m/+30m/+1h/+4h, resolved at the send instant) plus a specific-datetime tab with the web's 30 s past-grace and 7-day ceiling (ScheduleSend pure-logic object). A pending pick arms ChatViewModel.pendingSchedule, expires absolute picks on the clock, downgrades steer long-presses to queue (the wire forbids scheduledAt + steer), rides the send RPC as epoch-millis scheduledAt, and the queued bar's Edit re-arms a future fire time as an absolute pick (computeEditPendingSchedule). Queue rows now label with the web formatScheduledTime ("Oct 25, 2:30 PM", the year only when it differs from the current one). Declared divergences from web/src (all also in KDoc): - Paste gate: the web drops file pastes while a schedule is pending; the Android composer has no clipboard-file paste path, so the illegal combination is made unreachable instead — the attach button disables while a pick is pending and the clock button disables with attachments on the tray (the same mutual exclusion the web enforces). - Pending-pick lifetime: the web clears the pick only after the send mutation is accepted and restores it on failure; Android clears at dispatch because the failed row already carries wire scheduledAt and retryFailedMessage re-fires with it — the retry is the restore. - Panel chrome: the web anchors a fixed panel above the clock button (bottom sheet on touch viewports) and closes on outside-pointer-down; Android renders the panel above the input card like the slash/mention dropdown precedent and closes on the clock toggle or a selection. The specific tab splits the web's single datetime-local box into date + time fields for touch keyboards; the web's min/max input attributes are browser affordances, not mirrored — the pure validator is the gate, as on the web. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Guard scheduled-send edge paths found in adversarial review - editQueuedMessage no longer re-arms the schedule restore while the attachment tray is non-empty: the wire forbids scheduledAt + attachments and the clock button is disabled with attachments, so an armed pick would be invisible, un-clearable, and crash the send on the invariant check. The drop surfaces a notice instead. - The restore is now guarded by a pick snapshot taken at tap time: a schedule re-picked during the cancel round-trip wins over the restore (web snapshots text + pendingSchedule together in onEdit). - The /clear|/new concurrent shortcut now requires scheduledAt == null like the web — a scheduled clear queues as a scheduled message. - The pending-schedule expiry job is an AtomicReference: set/clear raced across the main thread and the edit-restore coroutine could leak a stale job that clears a newer pick at its deadline. - formatScheduledTime follows the locale's hour cycle and date order like web Intl ("25 окт., 14:30" / "10月25日 14:30" / "Oct 25, 2:30 PM"); the zh queued-scheduled label drops its full-width colon. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Fix preview lambda arity after composer callback consolidation Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Reject scheduled sends over attachments and serialize schedule transitions The wire contract excludes attachments from scheduled sends, but only the clock button's enabled state enforced it — the picker panel stays open across an attachment add and the share-import path can drop a file into the tray while a pick is armed. Three layers now hold: - ViewModel transition gate: arming a pick over a non-empty tray is rejected in applyPendingScheduleLocked (clears always allowed); the open picker panel also closes the moment a chip appears (ChatComposer). - Send guard: sendMessage resolves the schedule BEFORE the draft clears or the tray consumes and bails with a ScheduleAttachmentConflict notice, so both sides stay recoverable in the UI. - performSend's invariant tripwire stays as the last net. Same commit, the two flagged Minors from review: - Replacement/expiry serialization: the pick value swap and the expiry registration become one critical section (scheduleMutex), replacing the AtomicReference job handle whose getAndSet still left a window where a restore published A, a callback picked B, and A's timer cleared B at A's deadline. Uncontended transitions stay synchronous via tryLock; the timer body re-checks the revision inside the lock so an obsolete timer can never clear a newer pick. - Queued-edit restore now snapshots the schedule revision (web pendingScheduleRevisionAtEdit) instead of value equality, so a pick - or a pick plus explicit clear - re-made while the cancel round-trip is in flight wins over the restore even when the slot's value lands back where it started. Interaction tests: rejection while chips sit in the tray, pre-consume send guard, consecutive absolute picks keeping only the newest expiry, a preset surviving a restored pick's deadline, and edit-restore losing to both a mid-flight pick+clear and a clear+same-preset reselection. Co-Authored-By: Claude Code <noreply@anthropic.com> * Mask specific date/time entry for digit keyboards The Specific-tab fields kept KeyboardType.Number, but a numeric soft keyboard has no `-` or `:` key, so the required YYYY-MM-DD / HH:mm shapes were literally untypeable. The fields now auto-insert the separators as digits arrive (maskDateInput / maskTimeInput): digits only, capped at the wire shape, idempotent on already-formatted text (a pasted "2026-11-05" stays put; deleting just re-masks the rest). A half-typed pair (non-blank, so Submit is tappable) used to fail the tap silently: parseSpecificInput returns null and onSubmit did nothing. It now surfaces an inline invalid-shape error (SpecificError.Invalid, Android-only — the web's datetime-local box cannot hold a malformed shape), joining Past/TooFar and clearing on the next edit like them. Pure-logic tests cover keystroke-by-keystroke separator growth for both fields, mask idempotence, non-digit/overflow capping, and that mask output feeds the strict parser unchanged (partial masks parse to null, never a silently wrong instant). Test tags (schedule-date-input / schedule-time-input / schedule-submit) added for the instrumentation tests. Co-Authored-By: Claude Code <noreply@anthropic.com> * Add schedule composer instrumentation tests Device-level state transitions for the two review-flagged surfaces, one setContent per test against the 320dp CI viewport: - Mutual exclusion: an open picker panel dismisses the moment an attachment chip lands, and the clock button reports disabled with the non-empty tray. - Preset flow: tapping +30m delivers Preset("+30m") and closes the panel. - Digit-IME entry: performTextInput injects through the IME channel (the soft keyboard's path), so raw digits typed into the Specific fields must come back masked ("20261105"→"2026-11-05", "1430"→"14:30") and the submitted pick resolves to the typed wall time in the device zone, asserted against the exact epoch ms. - Partial input ("20261"/"143") submits to the inline invalid error string, not a silent no-op. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: HAPI <noreply@hapi.run>
HAPI Android
Native Android client (Kotlin + Jetpack Compose) for the HAPI hub. Fully
independent from the web app; shares the
client contract and the web-generated
golden fixtures (shared/fixtures/).
End users: install HAPI from Google Play. This README covers development builds.
- applicationId:
run.hapi.companion· minSdk 26 · target/compileSdk 36 - Toolchain: Gradle 8.14.2 (wrapper) · AGP 8.11.1 · Kotlin 2.1.21 · Compose BOM 2025.05.00 · JDK 17+ (CI uses 21)
Current capabilities
The app provides sessions/chat, approvals and questions, new sessions, attachments, files/Git, Scratchlist, standard dictation, usage/storage, settings and encrypted-relay or direct-FCM notifications. Model and permission controls follow agent/session capabilities; usage/storage require the owner namespace. See the native app guide for platform differences, pairing and features currently available through the web.
- Session actions: pin/archive plus Rename, Delete and explicit Reopen from the list/chat actions. Delete requires an inactive session. Resume or Reopen can return another session ID: seed its window, move the draft and replace navigation with the returned ID.
- Composer: optimistic send, queue/steer where supported, drafts and
attachments uploaded on selection. Slash suggestions combine session
metadata and the slash-command RPC; the skills
$picker has no UI. - Incoming shares: Android Share → HAPI accepts single/multiple files, captions, text and links. Choose a session on the current or another paired hub; stopped sessions require explicit restore before upload. Content is added to the existing composer for manual sending. Private, no-backup staging survives pairing and process recreation until handoff, with a 24-hour pending-content lifetime. Once imported, attachments follow the ordinary composer lifecycle (they are not persisted as attachment drafts).
- Dictation: provider discovery on chat entry picks the first
standard-capable provider. The microphone stays hidden until one is available. First use requestsRECORD_AUDIO;MediaRecorderrecords m4a/AAC, multipart transcription inserts text into the draft without sending.DictationControlleris tested with recorder/API fakes. - Background actions: FCM permission Allow/Deny and inline Reply enqueue
WorkManager jobs. Workers resolve paired hubs using stored credentials,
without requiring a foreground
HubGraph. Firebase-free builds skip push.
Building
Native chat scrolling architecture and acceptance checklist: Native transcript scrolling.
Requires an Android SDK for :app/:core:data (set ANDROID_HOME or
android/local.properties with sdk.dir=...). :core:protocol alone needs
only a JDK.
cd android
./gradlew :core:protocol:test :core:data:testDebugUnitTest :app:testDebugUnitTest
./gradlew :app:assembleDebug :app:lintDebug
./gradlew :app:installDebug # connected device
./gradlew :app:connectedDebugAndroidTest # emulator/device instrumentation
Without an Android SDK you can still run the protocol suite by configuring only the needed projects:
./gradlew --no-configuration-cache --configure-on-demand :core:protocol:test
CI (.github/workflows/android.yml) runs protocol/data/app unit tests,
:app:assembleDebug, :app:lintDebug, and Compose instrumentation on API 29
and API 36 for PRs touching android/** or shared/fixtures/**.
Protocol conformance fixtures
:core:protocol is the porting target for web/src/chat/ and is verified
against golden fixtures generated from the web implementation.
The test task already passes the fixtures location as a system property:
// core/protocol/build.gradle.kts
tasks.test {
systemProperty("hapi.fixtures.dir", rootDir.parentFile.resolve("shared/fixtures").absolutePath)
}
Fixture-driven tests read System.getProperty("hapi.fixtures.dir") from the
checked-in golden fixture set. CI re-runs this suite whenever android/**
or shared/fixtures/** change.
Fixtures pin protocol/state semantics, not identical web/native presentation.
When changing fixture inputs or generation, run bun run gen:fixtures at the
repo root and include generated changes; never hand-edit fixtures. See the
fixture guidance.
Pairing
HAPI is self-hosted: the app talks to a hub you run. Pairing = giving the
app a hub URL plus that hub's access token; the app verifies the hub
(GET /health, requiring the supported protocol version exactly), exchanges
the token for a JWT
(POST /api/auth), stores the credentials in EncryptedSharedPreferences
(keyed per hub — multiple hubs can be paired, one active at a time), and
lands on the session UI. Three entry points:
- QR scan — the hub prints two QR codes when started with
--relay(also under web Settings → Companion pairing). The in-app scanner accepts both: the companion deeplink (hapicompanion://bind?hub=…&code=…) and the web direct-access URL (…?hub=…&token=…). - Deep link — scanning the companion QR with the system camera opens the
app directly with a confirm screen (
hapicompanion://bindintent filter). - Manual entry — hub URL + access token, for hubs started without
--relay.
Pairing against a development hub
# Start a hub with the built-in HTTPS relay (prints the access token + QR codes).
hapi hub --relay
# Use the printed https:// URL. For a source-tree `bun run dev` hub, put an
# HTTPS reverse proxy or tunnel in front of localhost:3006 first.
adb shell am start -a android.intent.action.VIEW \
-d "hapicompanion://bind?hub=https%3A%2F%2Fhub.example.com&code=<accessToken>" # optional: exercises the deep link
The app rejects plain-http hub URLs in manual entry, deep links, QR codes,
and restored hub state. The manifest also sets
android:usesCleartextTraffic="false"; there is no debug or LAN exemption.
Sign-out (home → Hubs and settings → Sign out) deletes the stored credentials
for that hub and drops it from the roster.
Temporary authentication refresh failures (network/5xx) keep paired credentials. A rejected access token or a second 401 after a successful refresh requires re-pairing. See the auth contract.
Firebase / push
Official Firebase builds: pair an updated hub and allow
notifications. The official Firebase client configuration is bundled in the
app. Hubs without a private FCM_SERVICE_ACCOUNT_PATH use the official push
relay automatically; users do not create Firebase projects or configure
service-account keys. Google Play services and FCM connectivity are needed.
The push relay also works with hubs accessed through Tailscale or other HTTPS
setups; it is independent of hapi hub --relay.
Private builds: create a Firebase project for your application ID,
download google-services.json into android/app/, and configure the hub's
FCM_SERVICE_ACCOUNT_PATH for that same project. Existing configured hubs
keep direct delivery under the default HAPI_ANDROID_PUSH=auto. Use
relay, fcm, or off to select explicitly. HAPI_PUSH_RELAY_URL overrides
the shared Android/iOS relay URL. One hub cannot mix private-project builds
and official-project builds.
Builds without Firebase: the Google services plugin stays conditional.
Without app/google-services.json, Firebase does not initialize and push
paths no-op; ordinary PR builds require no credentials. Official builds use
a separate mandatory configuration check (below).
Encrypted relay: the app registers its FCM token, install ID and random
32-byte pushKey with every paired hub. The ID/key are persisted together in
Keystore-encrypted preferences excluded from cloud backup and device
transfer. First upgrade replaces the old DataStore identity through the
hub's token deduplication; later token rotations reuse the ID/key. On start,
pairing, token rotation and worker retries, registrations refresh
automatically. Sign-out unregisters before wiping that hub's credentials.
Relay messages carry only hapi_v/hapi_e. AES-256-GCM decryption uses the
same golden vector as iOS; notification content is unavailable to the relay
and Google. Failed decrypts are dropped. Direct private FCM retains its
existing unwrapped data payload. Rendering, foreground-chat suppression,
Allow/Deny and Reply workers use the same decoded PushPayload in both paths.
input-request notifications instead preview the first question, remaining
question count and session name, using the HIGH-importance input_requests
channel. They offer no Allow/Deny or message Reply: tap to open the session and
answer in the question form.
The notification contract still does not name the sending hub: action
workers try the active hub first, then other paired hubs on session miss;
tapping opens the session against the active hub.
See native companion contract for wire details and relay deployment for maintainer setup.
Official APK/AAB builds
The Android Official Build workflow
is manually dispatched on main.
It tests and uploads signed APK/AAB workflow artifacts. It does not publish to
Google Play itself; the maintainer uploads the AAB to the Play Console.
Provide a new positive version_code and the desired version_name.
Repository configuration, set once by the maintainer:
| Setting | Value |
|---|---|
Variable ANDROID_FIREBASE_PROJECT_ID |
Project used by the deployed relay's FCM service account |
Secret ANDROID_GOOGLE_SERVICES_JSON |
Firebase client config for run.hapi.companion in that project |
Secret HAPI_UPLOAD_KEYSTORE_BASE64 |
Base64-encoded release keystore |
Secret HAPI_UPLOAD_KEYSTORE_PASSWORD |
Keystore password |
Secret HAPI_UPLOAD_KEY_ALIAS |
Optional, defaults to upload |
Secret HAPI_UPLOAD_KEY_PASSWORD |
Optional, defaults to store password |
-PhapiOfficialBuild=true -PhapiFirebaseProjectId=<project> requires a
matching Firebase project/package and release signing configuration.
Missing or mismatched settings fail the build instead of shipping a package
without working push. hapiVersionCode and hapiVersionName override the
normal version defaults. The Firebase service-account private key belongs
only on the relay and must never enter Android build artifacts.
Roll out relay support/credentials first, then the hub, then the app. Verify real background, lock-screen and cold-process notifications using a hub with no Firebase settings; test Allow/Deny/Reply and an iOS push before publishing.
Release signing
Builds do not require signing secrets. :app:bundleRelease produces an unsigned AAB unless an
upload key is configured via gradle properties (user-global
~/.gradle/gradle.properties), environment variables (CI secrets), or
android/local.properties (gitignored; same property names — the
conventional machine-local home, loaded explicitly since it is not part
of gradle's own property chain):
| gradle property | env | meaning |
|---|---|---|
hapiUploadKeystore |
HAPI_UPLOAD_KEYSTORE |
keystore path (~ ok) |
hapiUploadKeystorePassword |
HAPI_UPLOAD_KEYSTORE_PASSWORD |
store password |
hapiUploadKeyAlias |
HAPI_UPLOAD_KEY_ALIAS |
default upload |
hapiUploadKeyPassword |
HAPI_UPLOAD_KEY_PASSWORD |
default: store password |
This is an upload key for Play App Signing (Google holds the actual distribution key, so a lost upload key is resettable in Play Console). Generate one with:
keytool -genkeypair -v -keystore ~/.hapi/upload.keystore -alias upload \
-keyalg RSA -keysize 2048 -validity 10950
Keystores never live in the repo (*.keystore / *.jks are gitignored).
Modules
| Module | Type | Responsibility |
|---|---|---|
:core:protocol |
pure Kotlin/JVM (no Android) | Hub wire types, chat pipeline, message pagination, versioned patches, agent/mode catalogs, git parsers, pairing links, and golden-fixture conformance tests. |
:core:data |
Android library | OkHttp API/SSE transport, per-hub authentication, secure credentials, StateFlow stores and disk snapshots, encrypted push registration/decoding, and background notification actions. |
:app |
Android application | Compose screens for pairing, sessions/chat, approvals, new sessions, files, Scratchlist, dictation, usage/storage, and settings; navigation, localization, FCM service, and WorkManager wiring. |
Dependency direction: :app → :core:data → :core:protocol.
Internationalization
The app ships English (default) and Simplified Chinese
(app/src/main/res/values-zh-rCN/strings.xml). App UI strings
live in resources; both files carry the same key set (lint
MissingTranslation is the gate).
Adding a string
- Add it to
app/src/main/res/values/strings.xmlwith a feature-prefixed key matching the existing convention (chat_,sessions_,files_,scratchlist_,pairing_,settings_,new_session_,notif_,tool_for tool-card titles). Dynamic values use positional format args (%1$s,%2$d); count-dependent copy uses explicit_one/_manykeys (the deliberate house style — no<plurals>). - Add the zh-CN twin to
values-zh-rCN/strings.xml. Terminology source of truth is the web corpusweb/src/lib/locales/zh-CN.ts— reuse its product terms (会话 session, 机器 machine, 权限模式 permission mode, 工作树 worktree, 草稿夹 scratchlist, 语音输入 dictation, 用量 usage, 智能体/代理 agent). Technical identifiers (model ids, flavor names like Claude/Codex, permission-mode catalog labels, CLI flags) stay untranslated, matching the web's choices. - Reference it: composables via
stringResource(R.string...). ViewModels stay string-free — transient notices are semantic sealed types (ChatNotice,ScratchlistNotice,PairingError,DictationErrorKind) resolved at the UI layer; where a ViewModel genuinely composes display text it takes a small Strings seam (FilesStrings,FileViewerStrings,NewSessionStrings) whose defaults are the English values (JVM tests construct without arguments) and whose production instance is resource-resolved in the Navigation holders. Server-provided error text passes through verbatim.
Language switching
Settings → App language offers Follow system (default) / English /
简体中文. The choice persists in LanguagePrefs (DataStore) and applies
immediately via AppCompatDelegate.setApplicationLocales:
MainActivityextendsAppCompatActivity(theme parentTheme.AppCompat.DayNight.NoActionBar) so per-app locales work back to API 26; on API 33+ the frameworkLocaleManagertakes over (the app also declaresandroid:localeConfigfor the system App-languages screen).- The manifest opts into appcompat's
autoStoreLocales(AppLocalesMetadataHolderServicemeta-data), which re-applies the stored choice synchronously on cold start. - Surfaces that resolve strings from the application context — FCM
notifications, WorkManager result updates, notification-action receivers —
wrap their context with
localizedForAppLanguage(AppGraph.appLanguage)(di/LocaleContexts.kt), since per-app locales only retarget activity contexts below API 33.
Out of scope on purpose: :core:protocol presentation strings
(getEventPresentation, tool-group activity titles) stay English — the web
does not translate them either, and terminology parity with the web wins.
Tool previews
Ordinary chat tools stay compact summaries. Tap a tool or tool group to open a native Navigation Compose page; Back returns one level, Close returns to the chat. Groups start at their latest tool, retain their position when returning from details, and never follow streaming updates automatically. The Latest tool toolbar action scrolls explicitly. Agent processes have their own page with lazy child rows. Approvals remain in the conversation or a live process page; ordinary tool detail pages are read-only.
Plan proposals (ExitPlanMode / exit_plan_mode) start fully expanded in the
conversation, rendering the complete input.plan Markdown before approval
controls. Tapping the header folds the card. Plan documents are prewarmed in the
chat Markdown cache and do not use the ordinary tool-output paging budget; raw
input/result remains under Source.
Shared Codex proposals also show Implement plan and Continue planning
when the active session's agentState.codexPlanProposalId matches the tool-call
id. Implementation uses the dedicated plan endpoint, not permission approval;
continue hides that proposal’s action menu locally and focuses the composer,
preserving its draft and plan mode without sending a message. The plan document
remains readable, and a new proposal gets a fresh menu. The menu stays visible
when the document is folded; pending/error state survives row recycling.
Withdrawn, historical and child proposals remain read-only (an outstanding
operation/error can still be shown).
Details recognize namespaced command/script/patch calls, unwrap common nested result envelopes, and keep command exit/status metadata visible. File reads use source-language highlighting; web/agent prose uses Markdown. Source reveals the original input/result, including fields omitted from the preview. Mixed text/media results stay JSON instead of dropping non-text blocks.
Question details show recorded selections, custom answers and notes with
Markdown questions/options. Codex choice questions with isOther: true add
None of the above and focus optional notes when selected; empty notes are
valid. Translations never change the submitted None of the above wire value.
Pi/MCP forms without isOther keep their existing choices. Recorded other
answers and notes are also shown in question details.
request_user_input also restores answers from
historical results; live permission answers take precedence. Answered cards
avoid duplicate results, but retain errors and the full input/result/answers
under Source. Pending questions are answered in the conversation.
Tool inputs/outputs display one part at a time, up to 20,000 Unicode graphemes or 400 source lines. Previous/Next replace the mounted part; visited parts do not accumulate. Large diffs/Markdown use paged source. JSON formatting and output preparation run off the UI thread. File mutation details also offer View current file, distinct from the recorded tool input/result.
Inspectors share the conversation's pipeline and SSE subscription. Opening one freezes tail following, cancels hidden history loading and recording, and hides the keyboard. Returning retains the transcript anchor. Trimmed selections remain readable as labeled snapshots; an epoch reset closes obsolete inspectors.
Long messages and reading layout
User prompts remain inline through 8,000 graphemes and 120 source lines. Larger prompts show a 2,000-grapheme / 24-line preview and View full message opens a reader with one 4,000-grapheme / 80-line part mounted. Pagination preserves whitespace, CRLF, emoji and combining sequences exactly; character counts refer to Unicode graphemes, not UTF-16 offsets.
Copy full content uses the clipboard up to 64 Ki UTF-16 code units. Larger content, or a failed clipboard operation, offers UTF-8 file export through FileProvider and Android's sharesheet; Binder receives a URI, not the text. Exports older than 24 hours are cleaned on the next export.
Body/composer/user text uses 16sp/24sp, code/diff/terminal 14sp/20sp, captions 12sp/16sp. Content and composer share a centered 720dp reading column with 16dp minimum side margins. Bubble widths use actual container constraints, including split-screen; Android font scaling remains enabled.
Connection status and home filters
The chat subtitle reserves its height. Reconnecting · Tap to retry appears after four continuous foreground seconds of outage; retry/backoff transitions do not restart that grace period. Transport state is separate from message events. Default-network/interface/route changes wake reconnect immediately, preserving replay cursors; background retries defer until foreground. A local hub route does not need Android's internet-validation capability.
Home centers the Sessions title between a Hubs and settings icon and a Filters icon, keeping the new-session FAB. The hub menu is the only entry for switching/adding hubs, app settings and sign-out; the active hub is checked. The filter icon marks an applied filter and opens a Material 3 single-selection sheet. Choices come from all sessions, including historical machines; names/IDs determine ordering, never counts. Duplicate names include IDs; unnamed and unknown machines are labeled. The applied filter has a Clear action, is transient per home/hub, and is cleared when no longer valid. The home holder follows the active connection instance, releasing the previous store and filter even when switching back to a previously used hub URL.
Pairing and Settings both link to the privacy policy. These controls and notices ship in English and Simplified Chinese.
Validation status (2026-09-12)
Protocol/data/app JVM suites: 722 tests passed. Debug APK, instrumentation APK and lint passed. Pixel 6 (Android 17/API 37): installed and visually checked the home toolbar, hub/settings menu, applying/clearing filters, English/Chinese switching and code-copy feedback. Code headers keep an 18dp action icon inside a 48dp touch target, at the trailing edge even with short language labels.
API 36 ARM64 emulator (macOS Hypervisor.Framework): 8 targeted instrumentation
tests passed, including 5 new toolbar/code-layout regressions (320dp width,
English/Chinese, 2× font scaling), exact clipboard/file export and question
details. The full 30-test run was not all green: 3 transcript group-anchor
checks and the tool-browser initial-position check also fail with the pre-fix
APK. One question-details timeout passed on targeted rerun; the opt-in frame
probe was skipped. API 37 instrumentation is blocked by the current Espresso
dependency calling the removed InputManager.getInstance() method.
Earlier API 29 checks passed 19 chat/reader regressions. No 60/120 Hz device frame-time, memory, or predictive-back measurements are claimed by these checks.
Unread state uses the hub’s revision read-state contract. List selection and metadata updates never acknowledge reading. The resumed chat acknowledges only its rendered tail, with a durable outbox scoped by hub and namespace.