Replace the single-column settings screen with a category directory
whose rows come from a declarative catalog (SettingsHubCatalog: one
entry = route + title + summary + icon + visibility lambda), so adding
a category later (Display, Chat, Voice, Machines) is a list element,
not layout surgery. Mirrors the web settings IA at the category level.
- General subpage (settings/general) inherits the uncategorized
sections: Appearance (theme + Material You), Language, About
(versions + hub health). Zero behavior change per item.
- The owner-only Insights section dissolves into hub-level rows:
Usage/Storage render only for the owner (same ns=default gate) and
route to their existing dashboards, matching the web's owner-only
nav categories (SettingsNav OWNER_ONLY_CATEGORIES).
- Flat settings/... route pattern with per-hub ViewModelHolder, same
as the existing usage/storage precedents; back from a subpage lands
on the hub. The hub's holder passes probeHealth=false so opening the
hub itself fires no /health request; the General holder keeps it.
- Previews: hub owner/non-owner gating + General body. JVM tests pin
catalog order, the owner gate, route uniqueness, and row summaries.
- Strings: settings_category_general(+_summary) in en/ru/zh; relocate
three zh keys (chat_tool_resolved, chat_plan_implement,
chat_plan_continue) that had drifted to the file tail and broke the
three-language key order (KEYSYNC) — pure moves, values untouched.
via [HAPI](https://hapi.run)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
The ComposerAttachments harness left the constructor default
encodeDispatcher (Dispatchers.Default, a real thread pool) in place.
With two uploads in flight the second-launched one can finish encoding
first, reach FakeUploadApi.uploadFile first, and consume the scripted
results deque out of launch order — the queued failure gets stolen by
the upload meant to succeed, so the robbed chip never settles to its
scripted state and items.first {} hangs until runTest's dispatch
timeout (UncompletedCoroutinesError, intermittent under suite load).
Pin the encode hop to the scope's own dispatcher (the runTest scheduler
in every test), making upload arrival order equal launch order
deterministically; upload still hops dispatchers as in production. Add
a regression-lock test scripting two results over two concurrent adds.
Product code is untouched: upload() keys results by attachment id and
is arrival-order independent — the ordering dependency lives purely in
the test fake's deque.
via [HAPI](https://hapi.run)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
* fix(codex): drop safe-yolo from Codex permission modes
Every Codex session runs on the native shared runtime (runCodex is
runSharedCodex), which rejects safe-yolo. The mode stayed in
CODEX_PERMISSION_MODES and six filters hid it again in the web picker,
composer, hub permission-mode route, hapi set-mode and both native
apps. API callers that validate against the flavor catalog (e.g. a
spawn with permissionMode: 'safe-yolo') still passed it through and
only failed inside the runner.
Remove it from the Codex catalog (shared, iOS, Android, fixture) and
delete the filters. autoReview is the native replacement. Safe Yolo
stays for Gemini, Kimi and Copilot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ios): Codex option list now has four modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: list Claude background tasks from the snapshot instead of guessing
The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".
The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): only attribute a Bash command from its background launch result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(native): accept backgroundTasks in strict session patch decoders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The native iOS and Android apps are now published. Replace the
build-from-source install path with store links, mark the native apps as
paid and the Web/PWA as the free client, and add store buttons to the
website hero. Source builds remain documented for developers.
* feat(android): add session date-range filter with activity-aware picker
- Pure SessionDateRangeFilter port of the web SessionList.tsx semantics:
strict YYYY-MM-DD parsing with calendar round-trip validation,
[start local midnight, end local midnight + 1 day) windows anchored to
the injected zone, session.updatedAt as the activity field
- M3 DateRangePicker dialog (opens on the selected start's month, Clear
resets without closing) plus a lightweight recent-activity legend —
declared divergence: M3 exposes no per-day decoration API, so the web's
in-cell "has session activity" emphasis surfaces beside the picker
instead; explicit Apply replaces the web's close-after-second-click
- Wired as a transient, unpersisted lens (web useState parity) applied
before the machine scope; the activity-day set and machine chips both
derive from the unfiltered list so filtering never hides alternatives
- Top-bar calendar action with active dot badge, active-range summary
row with clear, date-specific empty state; strings in en/zh-rCN/ru
(en-ru key order KEYSYNC clean)
- JVM tests: predicate window edges, DST spring-forward, zone-anchored
midnights, strict parsing, VM lens combination and pre-filter
activity-day derivation
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): refresh session date filters after timezone changes
* Deduplicate Android sessions before applying date filters
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(android): add scratchlist row move, copy, and delete-confirm actions
Align the scratchlist entry card with the web client's per-row actions
(ScratchlistPanel inventory):
- Move up/down: adjacent swap, disabled at the list edges, local-only
like web v2 — no protocol request; the next refetch restores hub
`createdAt DESC` order (pure `ScratchlistEntryOps.moveEntry`).
- Copy: copies only entry.text via ClipboardManager (FullTextAction
precedent), swaps the icon to a check for 1500 ms on success, single
per-list copied state, silent no-op on failure.
- Delete confirm: texts strictly longer than 100 characters (web
SCRATCHLIST_CONFIRM_DELETE_THRESHOLD, untrimmed length) confirm via
AlertDialog before delete, both from the row and the editor sheet;
shorter notes delete directly.
New en/ru/zh-rCN strings ship in the same commit.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): guard saved scratchlist notes and restart copy feedback
* test(android): uniquely select scratchlist confirmation actions
* Fix Android scratchlist editor test targeting
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(android): remember new-session launch defaults per machine and agent
Port of the web launch-preference persistence
(`hapi:newSession:launchSettings:v1:<machine>:<agent>`): model, effort,
reasoning effort and — for shared-permission-mode flavors only — the
permission mode of a successfully spawned session are remembered per
(machineId, agentFlavor) dimension and prefill the next create form for
that dimension (fresh screen, agent switch, machine switch).
- Storage: NewSessionPrefsData gains a nested `launchSettings` map inside
the existing `hapi_prefs` DataStore blob; legacy blobs without the
field decode to empty defaults, corrupt shapes keep the pre-existing
whole-blob default degrade (DirectoryExpandPrefs contract).
- Capture happens at spawn success only (web saves on every
create-success path); grok renders the native select but its mode is
deliberately not remembered (web `usesSharedPermissionModeState`).
- Restore: resolvePreferredLaunchSettings validates claude's static model
and effort lists, drops `xhigh` from opencode's reasoning efforts and
coerces a removed permission mode to `default` — never a stale toggle;
codex's dynamic model passes through for the existing settled-catalog
reconciliation.
- A restored in-progress draft outranks the remembered defaults until the
user switches agent or machine (web `preserveRestoredDraftRef`).
- Machine switches now also reset effort/reasoning effort/shared mode to
the new dimension's remembered values (or sentinels), matching the web
restore effect that re-runs on machineId change.
Divergences from web: no `cursorSelectedBase` (no Android picker yet);
agy's static model presets are not re-validated on Android (no v1 picker,
the stored value is always `auto`).
Tests: prefs round-trip/cold-start, legacy-blob tolerance, corrupt blob
and corrupt-entry fallback (real temp-file DataStore, pure JVM); capture
gating and resolve coercion logic; VM prefill per dimension, machine and
agent switching, spawn-success capture, and draft precedence.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): preserve launch selections across machine heartbeats
---------
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(android): settled permission verdict details
Port the web PermissionFooter verdict classification so a settled
permission row explains HOW a request was decided, not just that it was.
Behavior vs web (web/src/components/ToolCard/PermissionFooter.tsx:11-63):
- codex family sessions (flavor family + Codex/Gemini/OpenCode/Copilot/
Cursor tool prefixes): approved + decision approved_for_session shows
"Approved for session"; denied + decision abort shows "Aborted" with
the wire reason appended when present.
- claude sessions: mode acceptEdits shows "Approved: allow all edits"
(previously the raw mode id was appended); decision approved_for_session
or an allowedTools hit — plain tool name or the `Bash(command)`
qualifier built from the tool input command/cmd — shows
"Approved for session" (previously indistinguishable from a plain
approval).
- canceled now appends the wire reason (previously dropped).
- resolved/pending/denied unchanged in shape; denied keeps the error tone
and aborted shares it.
Deliberate divergence: web's own settled footer renders only a minimal
red reason box (PermissionFooter.tsx:207-220) — its formatPermissionSummary
classification is unreachable there. Android renders the classified
verdict line in the existing subdued row style instead, as intended.
Implementation: new pure JVM classifier
feature/chat/permissions/PermissionVerdict.kt (exhaustive status ->
PermissionVerdictLabel mapping, unit-tested in PermissionVerdictTest);
ToolCallBlockView.PermissionStateRow becomes a thin renderer over it.
Strings added in en/zh-rCN/ru (ru mirrors en key order; zh values from
web locales).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): persistent permission-mode chip + inactive gating
Item 2 — persistent permission-mode chip above the composer, mirroring
the web StatusBar mode span (StatusBar.tsx:253-265, 389-393):
- New PermissionModeChip composable + PermissionModeChipRule pure object:
resolve the raw wire mode, keep it only when the flavor offers it, and
hide `default` everywhere except copilot (model=auto sessions still
surface the chip). Unknown wire ids hide (no raw fallback).
- Tone mapping follows the web PERMISSION_TONE_CLASSES (StatusBar.tsx:
43-48): neutral=onSurfaceVariant, info=primary, warning=tertiary,
danger=error. Labels stay en catalog strings — web mode labels are
hardcoded English, not i18n.
- The chip consults the unfiltered flavor catalog: the concurrent-clients
Safe-Yolo carve-out only narrows the switcher, never the chip.
- Deliberate divergence: the web span is not clickable (its switcher is
the composer settings sheet / Shift+Tab). Android has no Shift+Tab, so
tapping the chip opens the session config sheet that hosts the
switcher — same surface as the top-bar gear, no new top-bar icon
(ChatScreen keeps its two-icons-max rule).
Item 4 — permission-mode gating aligned with web (SessionChat.tsx:
2079, 2138-2163; HappyComposer.tsx:536-537, 1544-1549, 1960-1995):
- Inactive sessions stay switchable, matching web: SessionChat passes
allowSendWhenInactive unconditionally, so configurationControlsDisabled
never trips for inactive threads and the offline notice stays
informational only (the send path auto-resumes).
- The permission section now disappears for copilot sessions under local
terminal control — web withholds onPermissionModeChange there and the
whole section then hides (HappyComposer.tsx:1595). Other flavors keep
switching while terminal-controlled.
- ChatViewModel.setPermissionMode gains the matching belt-and-suspenders
guard; the controlledByUser derivation is extracted into
terminalControlled() (controlledByUser && !concurrentClients, same as
SessionChat.tsx:996) and shared with buildConfigUi.
Tests: PermissionModeChipTest (6 cases — default/codex/copilot/pi/unknown/
safe-yolo), ChatViewModelInteractionTest +3 (withheld for terminal
copilot; survives terminal claude + shared-clients copilot; inactive
stays switchable). No new user-visible strings (chip label is catalog
en, contentDescription reuses chat_config_permission_mode).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(android): derive the dsh chip expectation from the flavor catalog
The hardcoded "dsh offers no modes" assumption breaks on trees where dsh
gained the codex-style mode list — derive the expectation from the
catalog so the test pins the actual contract (the chip mirrors the
flavor's offered list).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): permission input semantics parity (editor inputType, URL confirm, grok auto gate)
Three behavior alignments with the web app's permission surfaces:
1. Editor inputType (web requestUserInput.ts:152-153, 191-195):
a required text question with `inputType === 'editor'` is always
answered and its note is pushed untrimmed (even the literal
"user_note: "); non-editor notes stay trimmed and optional.
Android: PermissionInputs.requestUserInputAnswerValues /
isRequestUserInputAnswered now take the question, gate on the
literal "editor" inputType; the footer's note field grows to
minLines = 2 to invite multi-line answers.
2. request_user_input URL confirmation (web requestUserInput.ts:46-70,
129-141; RequestUserInputFooter.tsx:142-152): the sign-in url only
survives when it parses as http/https; submitting requires the
CLI-synthesized __mcp_url_confirmation option (description === url)
to be selected, and submission opens the url before approving.
Android: parseRequestUserInput + normalizeRequestUserInputUrl +
isRequestUserInputUrlConfirmed; the footer enforces the confirm
guard, then opens the url via ACTION_VIEW. Divergences: the raw
wire string is compared (the CLI synthesizes the description from
the same value web would re-normalize, so they are equivalent for
every CLI-produced request), and the open-failure message is
platform-adapted (no popup blocker on Android).
3. Grok Auto permission gate (web NewSessionForm index.tsx + grok
models probe): when the machine reports
`autoPermissionModeSupported === false` for the chosen cwd, the Auto
option is disabled with an " (unavailable)" suffix, a hint line is
shown, and an existing Auto selection resets to Default. The gate is
grok-only (claude and other agents never gate) and tri-state — null
(unknown, probe pending or failed) never disables.
Android: GrokModelsResponse wire type (nullable field),
HapiApi.getMachineGrokModelsForCwd, NewSessionViewModel probes on
agent/cwd/machine changes (deduped, cancellable, existence-gated),
OptionItem gains `enabled`, the permission dropdown honors it and
renders the hint. In-session switching stays ungated, matching web.
New strings in en / zh-rCN / ru (KEYSYNC: en and ru key order
identical).
Tests: RequestUserInputTest covers editor/untrimmed-note/url
parsing/confirmation semantics; NewSessionViewModelTest covers gate
disable+reset+hint, tri-state never-gate cases, and agent/cwd gating;
HapiApiTest covers the cwd query param and nullable decoding.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): accept browser-valid sign-in URLs
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* android/protocol: carry hasConversationContent through the session projection
The hub sends `hasConversationContent` on both `SessionSummary` and the
full `Session`; the sidebar's empty-stub hiding (web `SessionList.tsx`)
depends on it. Decode it on both wire models and project it through
`toSessionSummary` exactly like shared/src/sessionSummary.ts does
(`session.hasConversationContent ?? false`), so SSE upserts land with the
same signal REST lists already carry. Optional-with-default keeps older
hub payloads decodable.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* android: session-list sidebar parity — agent-session dedup, stub hiding, relevance search
Port the web sidebar's list body to the Android session list:
- Collapse duplicate views of one agent session (resume/reopen chains)
into a single row — winner by active → selected → pin → recency —
keyed on `flavor:agentSessionId` (web `getSessionDedupKey`).
- Hide fresh auto-created "new session" stubs: inactive, conversation-
less, no title signal and no agent id (web `isSidebarEmptySessionStub`);
selected/active/pinned rows are never hidden.
- Live search box with the web's relevance ranking (field weights,
boundary + title-phrase bonuses, wildcard `*`/`?`, AND across terms)
and a distinct no-results state. Pinned rows stay contiguous while
searching.
Pure logic lives in `SessionListFilters` / `SessionListSearch` (Kotlin
objects, JVM-tested against the web reference cases); the ViewModel
pipeline is visible → dedup+stub → canonical sort → search filter+rank.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* android: ru translations for session search parity
Upstream landed values-ru after this stack's merge base; new strings
need ru entries or :app:lintDebug fails with MissingTranslation.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): preserve global pins in session search
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(codex): expose Approve for me as a permission mode (#2016)
Add autoReview to the Codex permission catalog. It keeps the
workspace-write sandbox with on-request approvals and routes escalations
to Codex's native reviewer (approvals_reviewer = "auto_review"), the same
config as `hapi codex --approve-for-me`. It works at launch and mid-session
via thread/settings/update; explicit modes now reset the reviewer to user,
and sessions report autoReview from the native reviewer setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(native): expect Auto-review in the Codex permission lists (#2016)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(modes): label autoReview as Auto, matching Claude's auto mode (#2016)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(android): settled permission verdict details
Port the web PermissionFooter verdict classification so a settled
permission row explains HOW a request was decided, not just that it was.
Behavior vs web (web/src/components/ToolCard/PermissionFooter.tsx:11-63):
- codex family sessions (flavor family + Codex/Gemini/OpenCode/Copilot/
Cursor tool prefixes): approved + decision approved_for_session shows
"Approved for session"; denied + decision abort shows "Aborted" with
the wire reason appended when present.
- claude sessions: mode acceptEdits shows "Approved: allow all edits"
(previously the raw mode id was appended); decision approved_for_session
or an allowedTools hit — plain tool name or the `Bash(command)`
qualifier built from the tool input command/cmd — shows
"Approved for session" (previously indistinguishable from a plain
approval).
- canceled now appends the wire reason (previously dropped).
- resolved/pending/denied unchanged in shape; denied keeps the error tone
and aborted shares it.
Deliberate divergence: web's own settled footer renders only a minimal
red reason box (PermissionFooter.tsx:207-220) — its formatPermissionSummary
classification is unreachable there. Android renders the classified
verdict line in the existing subdued row style instead, as intended.
Implementation: new pure JVM classifier
feature/chat/permissions/PermissionVerdict.kt (exhaustive status ->
PermissionVerdictLabel mapping, unit-tested in PermissionVerdictTest);
ToolCallBlockView.PermissionStateRow becomes a thin renderer over it.
Strings added in en/zh-rCN/ru (ru mirrors en key order; zh values from
web locales).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): persistent permission-mode chip + inactive gating
Item 2 — persistent permission-mode chip above the composer, mirroring
the web StatusBar mode span (StatusBar.tsx:253-265, 389-393):
- New PermissionModeChip composable + PermissionModeChipRule pure object:
resolve the raw wire mode, keep it only when the flavor offers it, and
hide `default` everywhere except copilot (model=auto sessions still
surface the chip). Unknown wire ids hide (no raw fallback).
- Tone mapping follows the web PERMISSION_TONE_CLASSES (StatusBar.tsx:
43-48): neutral=onSurfaceVariant, info=primary, warning=tertiary,
danger=error. Labels stay en catalog strings — web mode labels are
hardcoded English, not i18n.
- The chip consults the unfiltered flavor catalog: the concurrent-clients
Safe-Yolo carve-out only narrows the switcher, never the chip.
- Deliberate divergence: the web span is not clickable (its switcher is
the composer settings sheet / Shift+Tab). Android has no Shift+Tab, so
tapping the chip opens the session config sheet that hosts the
switcher — same surface as the top-bar gear, no new top-bar icon
(ChatScreen keeps its two-icons-max rule).
Item 4 — permission-mode gating aligned with web (SessionChat.tsx:
2079, 2138-2163; HappyComposer.tsx:536-537, 1544-1549, 1960-1995):
- Inactive sessions stay switchable, matching web: SessionChat passes
allowSendWhenInactive unconditionally, so configurationControlsDisabled
never trips for inactive threads and the offline notice stays
informational only (the send path auto-resumes).
- The permission section now disappears for copilot sessions under local
terminal control — web withholds onPermissionModeChange there and the
whole section then hides (HappyComposer.tsx:1595). Other flavors keep
switching while terminal-controlled.
- ChatViewModel.setPermissionMode gains the matching belt-and-suspenders
guard; the controlledByUser derivation is extracted into
terminalControlled() (controlledByUser && !concurrentClients, same as
SessionChat.tsx:996) and shared with buildConfigUi.
Tests: PermissionModeChipTest (6 cases — default/codex/copilot/pi/unknown/
safe-yolo), ChatViewModelInteractionTest +3 (withheld for terminal
copilot; survives terminal claude + shared-clients copilot; inactive
stays switchable). No new user-visible strings (chip label is catalog
en, contentDescription reuses chat_config_permission_mode).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(android): derive the dsh chip expectation from the flavor catalog
The hardcoded "dsh offers no modes" assumption breaks on trees where dsh
gained the codex-style mode list — derive the expectation from the
catalog so the test pins the actual contract (the chip mirrors the
flavor's offered list).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Port the web PermissionFooter verdict classification so a settled
permission row explains HOW a request was decided, not just that it was.
Behavior vs web (web/src/components/ToolCard/PermissionFooter.tsx:11-63):
- codex family sessions (flavor family + Codex/Gemini/OpenCode/Copilot/
Cursor tool prefixes): approved + decision approved_for_session shows
"Approved for session"; denied + decision abort shows "Aborted" with
the wire reason appended when present.
- claude sessions: mode acceptEdits shows "Approved: allow all edits"
(previously the raw mode id was appended); decision approved_for_session
or an allowedTools hit — plain tool name or the `Bash(command)`
qualifier built from the tool input command/cmd — shows
"Approved for session" (previously indistinguishable from a plain
approval).
- canceled now appends the wire reason (previously dropped).
- resolved/pending/denied unchanged in shape; denied keeps the error tone
and aborted shares it.
Deliberate divergence: web's own settled footer renders only a minimal
red reason box (PermissionFooter.tsx:207-220) — its formatPermissionSummary
classification is unreachable there. Android renders the classified
verdict line in the existing subdued row style instead, as intended.
Implementation: new pure JVM classifier
feature/chat/permissions/PermissionVerdict.kt (exhaustive status ->
PermissionVerdictLabel mapping, unit-tested in PermissionVerdictTest);
ToolCallBlockView.PermissionStateRow becomes a thin renderer over it.
Strings added in en/zh-rCN/ru (ru mirrors en key order; zh values from
web locales).
via [HAPI](https://hapi.run)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
* feat(android): add Russian locale
Translate all 518 app strings into values-ru/strings.xml, register ru in
locales_config.xml, and add AppLanguage.RUSSIAN to the in-app language
picker.
* fix(android): use count-agnostic Russian forms for dynamic counts
The *_many strings used a genitive-plural noun after the number, which is
wrong for 2-4 (e.g. "2 инструментов"). The house style avoids <plurals>,
so phrase these labels with the noun first ("Инструменты: %1") instead:
tool groups, review findings, queued/new messages, agent steps, questions,
lines, chars, edits, usage requests, scratchlist count and attachment cap.
* fix(android): make the process-steps label count-safe
* fix(android): use a count-safe token label in the usage tooltip
---------
Co-authored-by: mrudy <mrudy@local>
Add current-proposal decoding, dedicated implementation requests and composer-only continuation on iOS and Android. Keep pending/errors across row recycling, reject stale actions and prevent duplicate submissions.
Include native regression tests and localized plan controls.
Group ordinary Codex commands with default tools across web, iOS and Android while preserving exploration and user-shell boundaries.
Add regression tests, generated protocol fixtures and shared-command coverage in the iOS transcript UI suite.
Render ExitPlanMode and exit_plan_mode Markdown from input.plan in iOS and Android conversations. Preserve approvals, raw source and diagnostics while hiding empty output placeholders and prewarming plan documents.
Add generated protocol fixtures and native regression coverage for long plans, live updates, recycling, themes and typography.
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.
Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.
Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
Bridge main-session PermissionRequest hooks without suppressing the native
terminal dialog. Reconcile replies against native results and clean up on
timeout, cancellation, mode switches, and session changes.
Keep reply IDs distinct from native tool IDs across web and native clients;
add protocol fixtures and regression tests.
Refs #1796
Add viewport-driven paging with layout acknowledgements, bounded retries, cancellation gates, and epoch-safe history retention.
Preserve transcript anchors and expansion state, fix tool-group identity collisions, and serialize Android history coordination on Main.
Reduce per-scroll composition and layout work; add native regression tests, CI coverage, and profiling guidance.
* refactor(web): route create-form permission control through one native-select predicate
Extract usesNativePermissionSelect(flavor) (grok || codex-family, matching
the existing iOS/Android predicate of the same name) and route
PermissionField's select-vs-toggle gate through it instead of an inline
condition. Rename the codex-family-only state codexFamilyPermissionMode to
nativePermissionMode since it now backs a shared predicate, not just the
codex family. Behavior is unchanged: any stale sessionStorage draft written
under the old codexFamilyPermissionMode key has no value under the new key
and falls back to 'default', which only matters within a single browser
tab's lifetime.
* feat(web): let Claude pick a permission mode when creating a session
Claude was the only create-form flavor still on the global HAPI YOLO toggle
while grok and the codex family got the native permission select, so there was
no way to start a session in Plan Mode without creating it first and switching
the mode from the composer. usesNativePermissionSelect now gates the control
for claude as well, and the spawn body carries permissionMode (including
'default') instead of yolo, which is the shape the other native-select flavors
already send.
The stored hapi:newSession:yolo preference is bridged into the select rather
than dropped, but only for the flavors that have actually moved onto it
(LEGACY_YOLO_BRIDGE_AGENTS = codex, claude). copilot, gemini, kimi and opencode
moved earlier and settled on 'default'; re-enabling Yolo for them now would
widen permissions rather than migrate a preference. This narrows the
sessionStorage draft bridge too, which until now fired for the whole codex
family with no allow-list, so their draft restores yield 'default' instead of
'yolo' — same-tab-lifetime state only.
Claude and the codex family share one nativePermissionMode state and their mode
sets do not overlap, so the existing agent-change reset plus the flavor filters
in the draft loader and the stored launch settings are what keep a codex mode
out of a Claude spawn. Adds the regression test that pins it: pick a mode under
codex, switch to Claude, create, assert the payload carries 'default'.
* feat(ios): let Claude pick a permission mode when creating a session
Extend usesNativePermissionSelect to include claude alongside grok and the
codex family, matching the web change. buildSpawnRequest now derives both
yolo and permissionMode from that single predicate instead of two separate
local flags, so claude sends permissionMode (including 'default') and no
longer sends yolo. Unlike web, iOS carries no persistent YOLO preference
across sessions to migrate — the toggle only lives in the in-memory form or
a draft deleted on success — so there is no bridging logic to add here.
* feat(android): let Claude pick a permission mode when creating a session
Extend usesNativePermissionSelect to include claude alongside grok and the
codex family, matching the web and iOS changes. buildSpawnRequest derives
both yolo and permissionMode from that single predicate, so claude sends
permissionMode (including 'default') and no longer sends yolo. Unlike web,
Android has no persistent YOLO preference to migrate: the toggle only lives
in the form draft, which is deleted once a session is created.
The agent-switch test asserted claude renders the YOLO toggle; it now checks
the native select for claude and keeps the toggle assertion on cursor, which
still carries it.
* fix(web): keep streamed reasoning/text block ids stable across snapshot rows
Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.
Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.
Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.
* fix(ios,android): mirror stream-stable block ids in native chat ports
The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.
Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.
* fix(web,ios,android): reject blank stream ids as block identity
Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.
Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.
* fix(ios): use normalized stream id for block construction identity
The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.
Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.
* fix(web): pin blank stream-id identity contract in golden fixtures
Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.
* feat(hub): make title provider max_tokens and timeout env-tunable
Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.
Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.
* docs(hub): document title provider max_tokens/timeout env knobs
Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
Android versionName and iOS MARKETING_VERSION move from their scaffold
values (0.1.0 / 1.0) to the CLI's current release number, so store
listings and About screens read the same version as the hub they pair
with. versionCode / CURRENT_PROJECT_VERSION stay at 1 for the first
store uploads.
local.properties is the conventional gitignored home for machine-local
secrets, but it is not part of gradle's own property chain — the seam
now loads it explicitly as the third source (gradle property > env >
local.properties, same names). Also imports java.util.Properties at the
top of the script: the bare FQN resolves against the java extension
accessor in Android Kotlin DSL and fails to compile.
Same conditional philosophy as the google-services.json plugin: with no
configuration, :app:bundleRelease builds an unsigned AAB and the repo
needs no secrets; an upload keystore supplied via user-global gradle
properties or env (HAPI_UPLOAD_KEYSTORE + passwords, ~ expanded) signs
release builds for Play App Signing. Verified both paths: unsigned
bundleRelease (first R8 run — builds clean, 8.0MB vs 18.8MB debug) and
a throwaway-keystore signed bundle (jarsigner: jar verified). Also
fixes the stale FCM_PROJECT_ID reference in the README.
* fix(acp): carry the live reasoning marker on the wire payload
ACP agents stream thoughts a token at a time, so the handler coalesces
them into a buffer and re-sends the whole buffer under a stable stream
id every 250ms. The converter dropped the marker that says a payload is
one of those throttled snapshots, leaving the hub unable to tell a
replaceable snapshot from the settled message that closes the stream.
Mirrors how the text variant already forwards streamSnapshot.
* fix(hub): keep one stored message per reasoning stream
OpenCode reasoning arrives as a series of growing snapshots sharing one
stream id, and every snapshot was persisted as its own message. A 26h
session reached 48,844 rows and 63MB, and because the web budgets a
fixed number of messages, its 400-message window covered barely three
minutes of conversation — scrolling up walked through duplicate
snapshots instead of history.
Retire a stream's earlier live snapshots once their replacement is
stored. Sweeping only after the insert matters: the two statements are
separate transactions, so clearing first would leave a window where a
crash takes the whole stream. Only rows marked live are eligible and the
replacement is spared, so a stream always keeps at least one row and the
settled message that closes it is never removed.
Live rendering is unchanged: the web still receives every snapshot and
already folds them by stream id.
* fix(web): spend the message window on conversation, not repeated snapshots
The window budgets raw messages, but a reasoning stream renders as a
single folded block no matter how many snapshots it arrived in. On
sessions recorded before the hub started retiring them, those snapshots
fill the window on their own: in one 26h session the newest 400 messages
covered 202 seconds, so scrolling up paged through duplicates instead of
history.
Collapse each stream to its newest snapshot before trimming. Rendering
is unchanged — the timeline already folds them by stream id — and rows
without a stream id are never touched.
* fix(ios,android): port reasoning-snapshot compaction to the native windows
The window logic in HapiProtocol and :core:protocol is a one-to-one port
of the web store, so collapsing superseded reasoning snapshots only on
the web left the native windows budgeting raw snapshot rows. The hub
stores one row per stream now, but a client that already holds the older
snapshots still spends its window on them.
Add the same stream-id reader and compaction to both ports, in the shape
each already uses for agent-run rows, and pin the behaviour with a
pagination fixture. Both fixture suites enumerate shared/fixtures/pagination
from disk, so the ports cannot drift from the web again without CI saying
so.
Expedited WorkManager requests needed an FGS fallback on API 26-30,
which dragged in FOREGROUND_SERVICE + FOREGROUND_SERVICE_DATA_SYNC and
the Play Console foreground-service declaration that comes with them.
Not worth it: gate setExpedited on API 31+ (JobScheduler path, no FGS),
let 26-30 enqueue as plain work, delete the getForegroundInfo overrides
and the worker notification, and strip WorkManager's library-merged
FOREGROUND_SERVICE with tools:node=remove. Merged manifest verified
FGS-free.