Commit Graph
36 Commits
Author SHA1 Message Date
weishu 6ed83eda3c fix: show unread for running sessions
Running sessions previously suppressed the ordinary unread dot, hiding
answers the user had not seen. Unread is now
manualUnread || contentRevision > readRevision on web, iOS and Android.

Web rows draw the running/background spinner beside the unread dot, and
unread outranks background status. Because session patches arrive before
the message they count, a chat following its visible tail hides its own
ordinary dot until the rendered acknowledgement lands (display-only; web
and iPad split view).
2026-11-01 15:39:46 +08:00
weishu 5ef4304a4a fix: synchronize read state across web and native clients
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices.

Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
2026-11-01 09:52:51 +08:00
weishu 01dabad204 fix(sessions): stabilize ordering by latest user input
Persist and backfill the last user-message timestamp, and use it for deterministic session ordering across web and native clients while preserving manual pins.

Remove the fixed-order setting and guard ordering against transcript echoes, replayed messages, and stale REST/SSE updates. Add regression coverage and regenerate the SSE fixture.
2026-10-29 12:47:52 +08:00
b8675110b1 feat(android): add scratchlist row move, copy, and delete-confirm actions (#2132)
* 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>
2026-10-26 15:14:17 +08:00
5d4dc058a5 feat(android): permission input semantics parity (editor inputType, URL confirm, grok auto gate) (#2127)
* 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>
2026-10-26 14:34:16 +08:00
AnanovoandGitHub b41b9293e9 fix(web): render cold sessions from a small initial page (#1809)
* fix(web): speed up cold session first paint

* test(web): update pagination fixtures for cold page

* test(web): wait for cold page before loading history

* feat(native): match cold session pagination

* test(native): cover cached re-entry page size
2026-09-14 09:07:14 +08:00
weishu a6880fe592 fix(native): add Codex plan proposal actions
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.
2026-09-13 19:48:14 +08:00
weishu e0db9c6815 fix(notifications): distinguish user questions from permission requests 2026-09-13 12:41:38 +08:00
weishu 6f4aacce6b feat(android): enable default push through official relay 2026-09-12 17:52:53 +08:00
weishu 32ca6f543a feat(android): improve native chat reading and navigation 2026-09-12 16:56:41 +08:00
weishu 0c4abcb3d1 feat(codex): share sessions across terminal and web
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.
2026-09-12 10:59:17 +08:00
weishu c2e3d16b7e feat(native): improve anchored chat scrolling and history loading
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.
2026-09-10 16:22:36 +08:00
weishu 4948d23669 fix(android): enforce Google Play compliance 2026-08-25 16:44:03 +08:00
weishu e5a8212f4a feat(session): validate agents and browse workspace directories 2026-08-25 16:12:29 +08:00
SSU-WEI HUANGandGitHub f0e5ba9c0f feat(codex): mid-turn Steer via app-server turn/steer (#888) (#1606)
* feat(shared): steer capability gates and live steered signal schemas

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which
  agents can deliver queued messages into the active turn (pi, codex,
  cursor ACP; legacy stream-json cursor excluded)
- AgentState.steeringActive, DecryptedMessage.steered and
  messages-consumed  live signal (never persisted by the hub)

* feat(cli): queue reservations and steered messages-consumed option

- MessageQueue2 gains takeByLocalId/restoreReservation/
  beginReservationDispatch/commitReservation so an async steer can reserve
  a queued row without racing the main loop's turn/start drain
- emitMessagesConsumed accepts steered: true to mark mid-turn delivery

* feat(codex): mid-turn steer via app-server turn/steer (#888)

- CodexAppServerClient.steerTurn + TurnSteerParams/Response types
- CodexRemoteLauncher registers the steer-queued-message RPC handler:
  reserves the queued row, validates it against the active turn (no
  control commands, matching mode hash), injects via turn/steer with an
  epoch guard that invalidates in-flight steers on abort/cleanup
- steeringActive agent state tracks the active-turn window
- hub syncEngine gate opens to codex; messages-consumed relays steered

* feat(web): Steered badge and steer gating for codex sessions

- HappyUserMessage shows a ↳ Steered badge fed by the live
  messages-consumed steered signal, preserved across server echoes and
  refetches (mergeMessages carries the optimistic marker)
- SessionChat gates canSteer via isSteeringSupportedForSession instead of
  the pi-only check
- clearStaleQueuedStatus normalizes a queued status on an invoked message
- fix(web): drop duplicate showSessionSummaryInChat in markdown test
  (upstream typecheck breakage)

* fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise
  codex and pi only; cursor joins when its soft-steer handler lands (#1609)
- turn/steer now splits dispatch (stdin accepted) from completion (turn
  finished): the hub RPC acks once dispatch succeeds — never on the
  concurrent turn's completion, which can exceed the 30s RPC window
- queue row commits only after the turn settles; a rejected/aborted steer
  restores the row so the message still delivers via turn/start, and a
  dispatched steer is never restored (no duplicate delivery)
- steer carries clientUserMessageId (echoed as userMessage.clientId) so
  ambiguous transport failures can reconcile the thread later
- client tests cover dispatch/complete split and stdin-write failure

* fix(codex): reconcile dispatched steers before restoring; align error copy

- A dispatched turn/steer whose completion fails (disconnect / protocol
  error) is now reconciled via thread/read by clientUserMessageId before
  the queued row is restored — the instruction is only re-delivered by
  turn/start when the thread never received it
- Reconcile targets the pinned steer thread, not whichever turn is
  current when completion fails
- syncEngine unsupported-flavor error now matches the capability gate
  (Pi and Codex only until the cursor handler lands)
- launcher tests cover steer success (ack on dispatch), reconcile-accepted
  and reconcile-rejected outcomes

* fix(codex): consume the row at dispatch; drop background reconcile

- The hub RPC acks and the queue row is consumed as soon as stdin accepts
  turn/steer; completion is background-only logging. A dispatched steer is
  never restored, so the same localId cannot be re-delivered via turn/start
  after the caller was told the steer succeeded
- Dispatch failure (stdin write error) still restores the row and reports
  failure
- steer.completed rejection is always handled (no unhandled rejection on
  the dispatch-failure path)
- tests updated: completion failure after dispatch keeps the row consumed;
  dispatch failure restores it

* fix(codex): distinguish definite rejection from indeterminate completion

- Transport-level failures (timeout, abort, disconnect, spawn, protocol)
  carry an indeterminate marker; explicit JSON-RPC error responses do not
- After a dispatched steer, turn completion resolves → commit + consumed;
  a definite app-server rejection restores the row (instruction was never
  accepted, so turn/start cannot duplicate it); an indeterminate outcome
  leaves the row reserved so it can never be delivered twice
- Completion handling registers before awaiting dispatch so the
  dispatch-failure path cannot leak an unhandled rejection
- client/launcher tests cover explicit rejection (restore), indeterminate
  outcome (row stays reserved) and dispatch failure

* fix(codex): reconcile indeterminate steers instead of a permanent reservation

- After an indeterminate completion (disconnect/protocol), reconcile the
  thread by clientUserMessageId immediately: accepted → commit + consumed,
  provably rejected → restore, still unreadable → keep the reservation and
  retry from the main-loop top on later passes (post-reconnect)
- A row never sits in dispatching forever: the hub cannot stamp it invoked
  while the instruction may never have been accepted
- tests: indeterminate keeps reserved while thread unreadable; accepted
  reconciliation consumes; rejected path restores

* fix(codex): accept all thread item shapes; retry reconcile; ack through abort

- Reconcile matcher accepts userMessage/user_message with clientId/
  client_id, matching the shapes the thread parser supports — an accepted
  steer can no longer be misclassified as rejected
- A pending reconciliation schedules a wakeLoop retry, so a temporary
  app-server outage cannot strand the reservation behind waitForTurnOrRecovery
- The success-path ACK no longer checks the steer epoch: the hub already
  reported steered on dispatch, so commit + messages-consumed must reach
  it even when an abort resets the queue in between

* fix(codex): reinit reconnected app-server; keep reconcile retries alive

- thread/read after a disconnect auto-connects a fresh app-server, which
  must be initialized before any request — reconcile now ensures
  connect + initialize (isConnected getter added to the client)
- every still-unknown loop-top reconciliation schedules the next retry,
  so recovery without external traffic is eventually observed
- launcher mock gains isConnected

* fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK

- Reconciliation runs on a self-rescheduling 1s timer independent of the
  main loop (wakes it too), so idle loops and waitForTurnOrRecovery still
  observe app-server recovery; abort clears nothing implicitly — the ACK
  path commits and consumes even when the reservation was cancelled
- Absence of a durable client id is ambiguous: unmatched reads stay
  'unknown' and keep retrying instead of restoring the row
- CodexAppServerClient tracks initialized state (reset on disconnect/exit)
  so ensureAppServerInitialized re-initializes a fresh process before
  thread/read; initialize failures leave the flag false for the next retry
- tests: accepted reconciliation via scheduled timer, indeterminate
  keeps reserved, explicit rejection restores

* fix(codex): bind reconciliation to the launcher lifecycle

- runSteerReconciliation clears any armed retry timer on entry and never
  installs a second one, so loop-top and timer-driven passes cannot
  multiply
- shuttingDown is set when the main loop ends: timers are cleared and the
  pending map is dropped, so an unresolved steer can never respawn an
  app-server after cleanup (remote-to-local switch included)

* fix(codex): report steered only after app-server acceptance

- The handler now awaits steer.completed (the inject-acceptance response):
  an explicit JSON-RPC rejection surfaces as failed and restores the row
  for the normal turn/start path instead of a false steered
- Transport failure after dispatch reports 'Steer outcome is being
  reconciled' and keeps the row reserved while the timer-driven thread
  reconciliation runs
- dispatch-failure path also swallows the paired completion rejection

* fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait

- MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching
  steer reservation: the hub neither deletes the row nor stamps invoked_at
  (new CancelMessageResponse 'busy' status; web restores the optimistic
  row); pushIsolateAndClear and reset/close share cancelReservations so
  /clear-style commands cannot have a rejected steer resurrect a discarded
  prompt
- turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a
  lost response is indeterminate and funnels into thread reconciliation
  instead of stranding the reservation
- tests updated for the tri-state cancel contract

* fix(codex,web): busy-aware edit flow; bound reconciliation reads

- QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it
  never prefills the composer when the row is inside an async steer, so a
  second client cannot send a duplicate
- reconcileSteerByClientId bounds thread/read with a 5s timeout so a
  connected-but-silent app-server cannot hold the reservation in-flight
  indefinitely

* fix(steer): inFlight-dominated cancel acks; bounded reconciliation

- hub cancel-queued-message acks check inFlight before removed: a stale
  duplicate socket reporting removed can no longer delete the durable row
  while another socket is dispatching the steer
- reconciliation entries expire after 60s and mark delivered: after the
  rejection window, a dispatched steer that the app-server never proved
  (client ids dropped on restart) is committed instead of polling
  thread/read forever
- pre-dispatch failures (abort before write included) never enter
  reconciliation — they restore the row and report failure

* fix(steer): persist indeterminate outcomes without replay

* fix(steer): make ambiguous delivery restart-safe

* fix(steer): recover crash-held rows and preserve retry dedup

* fix(steer): ack retries and bound stdin dispatch

* fix(steer): reconcile indeterminate dispatches and serialize retries

* fix(codex): classify stdin callback failures as indeterminate

* fix(steer): recheck indeterminate cancels after ACK

* fix(steer): close retry and abort races

* fix(steer): serialize live retries and abort admission

* fix(steer): distinguish live dispatching from unknown

* fix(steer): keep ACK failures held and reconcile busy cancel

* fix(steer): distinguish held cancel from removal

* fix(store): combine schema v24 migrations

* fix(store): reserve schema v25 for steer delivery state

* fix(steer): keep held cancel state and notify requeue

* fix(steer): release explicitly cancelled unknown reservations

* fix(codex): reject cancelled reservations before native steer

* fix(codex): make reservation restore atomic with state

* fix(codex): terminate abandoned transport writes

* fix(steer): own abandoned app-server lifecycle and consume races

* fix(codex): confirm dispatch and recover abandoned turns

* test(codex): mock abandoned transport callback

* fix(codex): clear visible turn state on transport loss

* fix(steer): claim retries and cover native delivery state

* fix(native): preserve indeterminate state on Android hydration

* fix(steer): make retry claims single-winner

* fix(steer): serialize concurrent retry claims

* fix(socket): tolerate missing steer-state ACK callbacks

* fix(native): serialize retry operations

* docs(web): document unknown steer delivery and retry controls

* fix(steer): handle retry failures and abort-before-connect

* fix(steer): reinitialize after transport loss and finish iOS retry errors

* fix(steer): preserve indeterminate rows across reconnect gaps

* test(web): mock indeterminate queued recovery state

* fix(steer): recover consumed ACK tombstones

* fix(steer): expose consumed cancel tombstones
2026-08-19 20:07:39 +08:00
weishu 53e33bead1 merge: B-M4a Android FCM push + notification actions
# Conflicts:
#	android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt
#	android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt
#	android/app/src/main/res/values/strings.xml
2026-08-18 10:52:19 +08:00
weishu 0f3bf5ca1b merge: B-M4d Android scratchlist
# Conflicts:
#	android/app/src/main/kotlin/app/hapi/companion/Navigation.kt
#	android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt
#	android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt
#	android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt
2026-08-18 10:50:57 +08:00
weishu bf63d2ad7b feat(android): FCM push + notification actions (B-M4a)
Gradle/Firebase: firebase-messaging + work-runtime-ktx in the catalog and
:app; com.google.gms.google-services applied CONDITIONALLY (only when
app/google-services.json exists) so the repo builds green without any
Firebase config; committed google-services.json.example + README section
(CI-injected official builds / self-build drop-in / v1.x runtime
FirebaseOptions path); real config gitignored. push/PushBinding.kt is the
availability seam: no Firebase -> every push path no-ops.

Registration (:core:data push/DeviceRegistrar): stable DataStore UUID
deviceId; POST /api/devices/register {token, platform:'phone', deviceId}
fanned out to EVERY paired hub on app start, on pairing (roster addition),
and on onNewToken; transient failures retry via a per-hub WorkManager
unique work item; best-effort DELETE on sign-out while the hub's JWT still
works.

Service (fcm/HapiFirebaseMessagingService): data-only contract v1 decoding
in :core:data push/PushPayload — channels permission_requests(HIGH) /
ready / task_notifications (created on app start), type-<sessionId>
coalescing tags, severity accent colors, notifySummary-driven ready
bodies, unknown type/contractVersion degrade to plain title/body (default
channel, no actions). Suppress-when-open: foreground + that session's
chat composed -> skip (SSE already shows it). Tap -> internal MainActivity
intent route (no public URI) -> existing navigation opens the chat.

Actions: permission-request Allow/Deny and ready/task RemoteInput Reply +
Dismiss -> NotificationActionReceiver -> expedited CoroutineWorkers
(approve/deny {} bodies, reply {text, localId}) through on-demand
HubSessions built from stored credentials (HapiWorkerFactory +
Configuration.Provider + on-demand WorkManager init — no HubGraph needed
in background); notification updates pending -> done / "Already handled"
(404 request-gone) / inactive / failed. Multi-hub: payload has no hub URL,
so workers try the ACTIVE hub first, then other paired hubs on 404
session-miss (single-hub users always hit first try).

Hub check: android.priority=HIGH is already set unconditionally for every
FCM message (hub/src/fcm/fcmService.ts) — no hub change needed.

Tests (34 new, :core:data): payload keys/severities/unknown contract
version, channel routing, suppress-when-open; registrar fan-out /
addition-only / retry-on-transient / null-token no-op via fake seams;
action wire bodies + Bearer header + multi-hub resolution through a real
HubSession against two MockWebServers. Full gate green with AND without
google-services.json (protocol 227, data 182, app 96 tests; debug +
release/R8/lintVital assemble).
2026-08-18 10:50:07 +08:00
weishu 8e5ec6a3cb merge: B-M3f Android composer attachments 2026-08-18 10:47:21 +08:00
weishu 33d186f444 feat(android): scratchlist (B-M4d)
Per-session notes/drafts workbench, the native twin of the web
ScratchlistPanel + use-hub-scratchlist (tiann/hapi#893):

- wire/ScratchlistApi.kt: entry/attachment/limits DTOs mirroring
  shared/src schemas, caps (200 entries / 10k chars), typed error codes
  (scratchlist_at_cap, _attachment_too_large, _attachment_in_use, ...).
- HapiApi: entries CRUD (POST idempotent-on-entryId), limits GET,
  base64-JSON attachment upload, raw-bytes fetch over the cached image
  client, attachment delete.
- SessionStore now surfaces scratchlistUpdatedAt patches as a
  scratchlistInvalidations SharedFlow (bare refetch trigger, sse.md).
- ScratchlistStore: per-session entries StateFlow, refresh on open +
  on SSE signal (observed sessions only, 16 ms coalesced), optimistic
  create/update/delete with surgical rollback, friendly atCap state
  (local pre-check + hub 409), uploads-in-flight progress, cached
  limits with offline defaults. ScratchlistAttachmentGuard holds the
  pure pre-upload budget verdicts (fits / downscale-to-target / reject).
- feature/scratchlist: chat/{id}/scratchlist route, entry cards (text
  preview, relative age, authed Coil thumbnails), edit sheet (text +
  attachment strip with photo picker, downscale-to-JPEG import, remove,
  delete), FAB new note, full-screen viewer (generated-image pattern).
- Chat seams: top-bar notepad badge (entry count), composer overflow
  "Park draft to scratchlist" (clears only after the hub accepts), and
  per-entry "To composer" that inserts into the live ChatViewModel of
  the chat entry below the route.

Tests: store CRUD optimistic/rollback + cap 409 + invalidation-signal
refetch + upload progress/413 + attachment delete 409 (MockWebServer),
guard verdicts, ChatViewModel park/insert/badge seams (fake store).
2026-08-18 10:47:02 +08:00
weishu ac5cebf32f feat(android): composer attachments (B-M3f)
Composer attachment tray wired end to end: "+" bottom sheet (photo
library / camera / files), upload-on-pick against POST upload
(JSON+base64), per-chip uploading -> ready/failed states with retry and
best-effort delete-on-remove, AttachmentMetadata riding SendMessageRequest
and the optimistic row so user bubbles thumbnail instantly, and a
UserTextBlockView upgrade decoding wire previewUrl data URLs (web-sent
messages thumbnail too).

Mobile-data compression policy (differs from web, which uploads
originals): recompressible images over 4 MB downscale to 2048 px JPEG
q85 (filename swaps to .jpg); GIF/SVG/non-images keep originals; hard
50 MB reject with a notice, plus a capped read guarding unknown-size
picks. previewUrl embeds a <=512 px JPEG thumb instead of the web's
full-size data URL to keep send bodies small.

Simplifications noted in KDoc: attachments do not persist in drafts v1
(holder onCleared discards un-sent uploads after best-effort deletes);
inactive sessions fail the upload chip until a text send auto-resumes.
Scheduled sends guard the wire constraint (scheduledAt excludes
attachments) with a loud check.

Seam: ChatSessionApi now extends AttachmentUploadApi (HapiApi methods
gain override); camera captures use a FileProvider cache scratch,
rememberSaveable across rotation.

Tests: pure policy decisions (plan/sample-size/filenames/data URLs),
controller state machine incl. mid-upload removal orphan cleanup,
VM send/retry/refusal flows with metadata assertions, MockWebServer
wire shapes for upload + delete. Full gate green (protocol/data/app
tests + assembleDebug).
2026-08-18 10:46:44 +08:00
weishu 75f830002b merge: B-M4e Android usage/storage dashboards + settings
# Conflicts:
#	android/app/src/main/kotlin/app/hapi/companion/Navigation.kt
2026-08-18 10:45:42 +08:00
weishu 769e0ff390 feat(android): usage/storage dashboards + settings scaffold (B-M4e)
- wire: UsageApi.kt (UsageSummaryResponse/buckets/totals, SqliteStorageUsageResponse)
- api: GET /api/usage/summary?range&timeZone + GET /api/storage/sqlite on HapiApi
- feature/settings: settings home (theme system/light/dark/OLED + Material You
  toggle, language persist-only until M5, About with app/protocol/hub health),
  usage dashboard (range control, 8 stat tiles, Canvas daily bars with tap
  tooltip, byAgent/byModel bar lists), storage dashboard (Canvas donut + rows)
- owner gating: JwtPeek ns == 'default' hides Usage/Storage entries (web
  SettingsNav parity); screens map 403 to an owner-only explanation
- theme plumbing: ThemePrefs/LanguagePrefs on hapi_prefs DataStore, exposed via
  AppGraph, applied at MainActivity setContent through HapiTheme(oled/dynamic)
- tests: usage/storage wire decode, HapiApi query+403 shapes, formatting/
  hit-rate/calendar-fill/slice math, theme+language round-trips, ns gating,
  usage/storage/settings viewmodels
2026-08-18 10:43:40 +08:00
weishu 210f9b3ff7 feat(android): files/git browser + file viewer (B-M4c)
:core:protocol git/: GitStatusParser (porcelain v2 --branch: branch headers
incl. detached/initial, 1/2/u records, untracked/ignored) + NumstatParser
(counts, binary, brace + plain rename normalization) + buildGitStatusFiles
merge — behavior cross-checked against web/src/lib/gitParsers.ts by running
the same inputs through the TS implementation, quirks preserved.

Endpoints: HapiApi gains git-status, git-diff-numstat(?staged),
git-diff-file(path, staged?), file read (base64), files search (?query&limit),
directory (?path); wire types in new wire/FilesApi.kt.

feature/files: FilesScreen (chat/{id}/files) with Changes (branch header,
staged/unstaged sections, status letters + ±counts), Browse (lazy directory
tree, dirs-first sort, hidden-file toggle), Search (300ms debounced) tabs;
FileViewerScreen (chat/{id}/file?path&staged&mode&line) with diff mode
(UnifiedDiffParser → DiffView, staged/unstaged toggle) ⇄ full mode (CodeBlock
with 400-line highlight cap, markdown Source/Preview via the shared Markdown
renderer, image bitmaps), copy path, middle-ellipsis path. Chat wiring: file
citations open the viewer in full mode (cited line shown as a hint chip — no
per-line highlight inside the single-Text CodeBlock), top-bar folder icon
opens the files browser.

Tests: parser fixtures (renames, binary, detached, conflicts, untracked
dirs) + VM tests with fakes (numstat merge, degraded numstat banner, lazy
directory loading/cache, hidden filter, search debounce, diff auto-fallback,
staged reload, base64/image/binary decode).
2026-08-18 10:40:34 +08:00
weishu c164af0738 feat(android): dictation, slash commands, session ops (B-M3ce)
- Voice dictation: DictationController (seam over recorder + api, JVM-tested),
  MediaRecorder m4a/AAC recorder, provider discovery via
  GET /api/voice/transcription/providers (new HapiApi method + wire types),
  mic button + recording chip + RECORD_AUDIO flow in the composer,
  transcript appended with space separator (web appendTranscript twin).
- Slash commands: '/' at start-of-input opens a dropdown merging
  metadata.slashCommands names with GET /slash-commands (RPC wins dedupe,
  exact>prefix>contains filter); tap inserts '/name '. Skills '$' deferred.
- Session ops: SessionStore gains rename (optimistic + roll-forward),
  delete (optimistic + 409 restore), reopen (marks returned id active);
  list long-press sheet + chat top-bar overflow wire Rename/Reopen/Delete;
  chat reopen reuses the supersede path (window seed + draft move +
  ChatEvent.SessionSuperseded); 422 formatted via formatReopenError;
  inactive-session affordance bar above the composer.
- Additive wire/API: TranscriptionProvidersResponse/TranscriptionProviderInfo,
  HapiApi.getTranscriptionProviders, ChatSessionApi.getSlashCommands,
  SessionListStore rename/delete/reopen.
- Manifest: RECORD_AUDIO + microphone uses-feature required=false. README blurb.
- Tests: DictationController (happy/no-provider/errors/cancel), slash
  merge/filter/trigger, store rename/delete/reopen (MockWebServer),
  VM reopen-supersede/delete/rename/slash suggestions; full gate green
  (protocol 227, data 148, app 96 tests; assembleDebug OK).
2026-08-18 10:16:05 +08:00
weishu ca138c912c merge: B-M3ab Android composer + permission actions + session config 2026-08-17 21:02:33 +08:00
weishu 78a5ff9961 feat(android): composer, permission actions, session config (B-M3ab)
Interaction layer turning the read-only chat into a working remote control:

- Composer: multiline input bar with optimistic sends (appendOptimistic ->
  POST -> status settle), queue-by-default delivery with a long-press
  Send&Steer intent while a turn is active, abort button during thinking,
  tap-to-retry on failed rows, per-session drafts (DataStore, hub-scoped
  keys), attachment chip seam for M4.
- session_inactive (409) recovery: one POST /resume then retry; a
  superseding session id seeds the new window, migrates the draft and
  emits SessionSuperseded for renavigation (web resolveSessionId parity).
- Queued bar: uninvoked sends with Cancel (DELETE; invoked-race ingests the
  authoritative row as sent), Edit (cancel + composer prefill, draft-kept
  guard) and Steer (POST steer; invoked answers reconcile a missed consume).
  reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
  claude {} / allowTools / mode:acceptEdits, codex-family decision:
  approved / approved_for_session / abort -- plus AskUserQuestion flat
  answers and request_user_input nested answers forms; optimistic
  Resolving/AlreadyHandled overrides settled by the agentState patch.
- Session config sheet: catalog-driven permission-mode picker, claude
  static model/effort catalogs (ported to :core:protocol catalog), codex
  models via GET /codex-models (new HapiApi endpoint + wire types) with
  per-model reasoning efforts; optimistic detail updates rolled back to
  server truth on error.
- Lifecycle: ProcessLifecycleOwner -> SseEngine.setLifecycleForeground +
  POST /api/visibility per subscription (VisibilityReporter fed by the new
  SyncTargets.onHandshake hook); the global SSE pipe moved from the session
  list VM to HubGraph lifetime (GlobalSsePipe) so queued/consumed
  bookkeeping and list badges stay fresh while a chat is open.
- Tests: VM-level interaction suite (optimistic send/failure/retry,
  inactive-resume both id paths, cancel invoked-race, steer, exact
  approve/deny body JSON incl. both answers formats, config optimistic +
  rollback, drafts) + GlobalSsePipe tests; full gate green (554 tests,
  assembleDebug).
2026-08-17 21:01:32 +08:00
weishu e640aa42fd feat(android): new session flow (B-M3d)
Machine -> directory -> agent/options -> spawn, web NewSession parity:

- feature/newsession: NewSessionScreen + NewSessionViewModel (plain class
  over fakeable seams), NewSessionForm/Logic (exact SpawnSessionRequest
  mapping), NewSessionGateway, DataStore-backed prefs (last machine,
  per-machine recent paths LRU cap 8, form draft so backing out keeps input)
- directory: server-side autocomplete (list-directory on the parent path,
  debounced 250ms with per-parent cache), recent-path chips, paths/exists
  probe with the web's missing-dir affordances (worktree = error, simple =
  two-tap create-and-make-directory)
- options per flavor: claude static models+effort; codex machine catalog
  (hidden on rpc_target_missing) + reasoning effort + collaboration mode +
  fast tier gate; grok/codex-family native permission modes; claude/agy/
  cursor YOLO toggle with native-mode hint; pi managed note; copilot agent
  mode; worktree name validation; startingMode omitted (remote default)
- wire/api: CodexModelsResponse + GET /machines/:id/codex-models (flagged
  addition), MockWebServer coverage
- nav: newSession route (optional machineId), FAB on the session list,
  spawn success navigate-replaces to chat/{id}
- tests: spawn-body exactness (4 configs), debounce/parent derivation/
  listing cache, recent LRU, worktree validation, two-tap missing dir,
  draft restore + codex catalog reconcile (21 new app tests green)
2026-08-17 20:40:36 +08:00
weishu b23afe24fe feat(android): read-only chat screen + store wiring (B-M2d2) 2026-08-17 17:02:37 +08:00
weishu dfcf9849b9 merge: B-M2c Android message window store, pagination fixtures green 2026-08-17 15:51:34 +08:00
weishu 99df45e99a feat(android): message window store port, pagination fixtures green (B-M2c)
:core:protocol window/ — pure state machine ported function-for-function from
web/src/lib/message-window-store.ts + messages.ts: merge-by-(id|localId) with
optimistic echo reconciliation, position ordering (invokedAt ?? createdAt, seq,
ASCII id tie-break), trim-preserving-queued with the codex agent-run budget,
epoch reset handling, latest-replace with request-baseline identity
preservation, consumed/cancelled/queued-reconcile transitions, tail/history
modes, hydrate/persist shapes. MessageRetention ports the null-decision tree
of normalizeDecryptedMessage (dedup with the B-M2a pipeline port flagged).

:core:data store/ — per-session MessageWindowStore (StateFlow + Mutex,
single-flight tail sync with trailing drain, fetchOlder with epoch-reset
resync, SSE ingest hooks, optimistic sends, queued-state reconciliation,
seedFrom for resume id changes) + WindowSnapshots (atomic JSON files, LRU 10;
JsonSnapshotStore dedup TODO) + MessageWindowStores registry. Minimal
MessagesApi interface (sealed MessagesQuery) extracted over the two message
endpoints, implemented by HapiApi.

Gate: PaginationFixtureTest replays all 11 shared/fixtures/pagination scripts
against the real store — requests, older-load outcomes, reconcile candidates
and the final window projection all exact — 11/11 green; plus targeted
concurrency/snapshot/seed unit tests. :app:assembleDebug green.
2026-08-17 15:50:47 +08:00
weishu f702cbf844 feat(android): session/machine stores + session list (B-M2b)
:core:protocol — summary-side patch path ported from the web reference:
- wire/SummaryPatching.kt: patchSessionSummary port with the summary path's
  >= versioned gates (replicated web divergence vs the detail path's strict >,
  documented), derived-field recompute (pendingRequestsCount/Kinds/Requests
  capped 5, todoProgress), render-irrelevance filter (activeAt-only keep-alive
  suppression), toSessionSummary/toSessionSummaryMetadata projection, and the
  deprecated canApplyVersionedSummaryPatch legacy gate.
- wire/SessionSorting.kt: exact sortSessionSummaries comparator
  (globalPinned > pinned > active > pendingRequestsCount among active >
  updatedAt desc; Long-safe, stable).
- SessionMetadata grows the per-flavor agent session id fields the
  agentSessionId projection needs.

:core:data store/ — StateFlow stores with per-hub JSON snapshots:
- JsonSnapshotStore: debounced (500 ms) atomic tmp+fsync+rename snapshots,
  synchronous cold-start load, corrupt files degrade to null.
- SessionStore: sorted summary list + per-id detail cache (strict-> detail
  patching via SessionPatching), full-session upsert preserving hub-computed
  scheduled fields, REST fallback for unparseable payloads, coalesced refresh
  (16 ms batch like the web), optimistic pin/archive.
- MachineStore: full/patch/null machine-updated decision tree per sse.md.
- LastSeenStore: per-session last-seen watermarks + once-per-scope baseline
  seeding + unread derivation (sessionLastSeen.ts/sessionAttention.ts port).
- StoreSyncTargets: SyncEventRouter fan-in — global-scope message-stream
  events refresh the list, gap handshakes full-resync.

:app feature/sessions/ — standalone screen + plain-constructor ViewModel:
- SessionListScreen: pinned section, status dot with thinking pulse, flavor/
  machine/worktree meta line, pending badge, todo chip, unread dot, machine
  filter chips, PullToRefreshBox, offline banner, empty states, long-press
  pin/archive sheet; taps emit onOpenSession only (no navigation).
- SessionListViewModel: store combine -> UiState, owns the global SSE
  subscription while started (subscribe after collector registration),
  entry refresh, error SharedFlow for snackbars.

Tests: SummaryPatching/SessionSorting JVM tests (cases mined from
useSSE.test.ts), store tests (stale/equal/newer patches, full replace,
keep-alive identity, snapshot round-trips, optimistic rollback), ViewModel
combine + handshake tests with fake stores. All of :core:protocol:test,
:core:data:test, :app:testDebugUnitTest, :app:assembleDebug green.
2026-08-17 15:47:05 +08:00
weishu f275bae4cb merge: B-M1c Android SSE engine + reconnect state machine
# Conflicts:
#	android/README.md
#	android/core/data/build.gradle.kts
#	android/gradle/libs.versions.toml
2026-08-17 15:16:38 +08:00
weishu 483155ece3 feat(android): SSE engine + reconnect state machine (B-M1c)
:core:data app.hapi.data.sse — the /api/events transport per
docs/api/client-contract/sse.md (reference web/src/hooks/useSSE.ts):

- SseConnection: SseTransport seam (Connected/Event/Failure flow) with the
  okhttp-sse implementation on a dedicated client (readTimeout=0, no cache,
  own dispatcher), buildEventsUrl, Last-Event-ID header on resume, and an
  acceptEncodingIdentity fallback flag for the gzip escape hatch.
- ReconnectPolicy: normative constants + pure backoff schedule (immediate
  first retry, 1s..30s exponential, 300s ceiling after 8 attempts, 0..500ms
  injected jitter).
- SseEngine: per-key (global / session:<id>) connection loops — handshake
  gate on connection-changed{status:connected} with ok/gap resume verdict,
  per-key cursors advanced only after the downstream hand-off
  (at-least-once), 10s connect deadline, 90s staleness watchdog ticking 10s,
  one silent 401 re-auth per cycle costing no backoff attempt, background
  retry deferral + 45s foreground stale check; all timing via injected
  delay/clock for virtual-time tests.
- SyncEventRouter: 13-type union fan-out to the SyncTargets seam (M2 wires
  stores), handshake gap -> requestFullResync, Unknown ignored.

Tests (32): virtual-time engine suite (fake transport + turbine), policy
schedule with seeded jitter, router mapping, and MockWebServer integration
through the real okhttp transport — including proof that gzip SSE frames
surface incrementally (flush-per-event body withheld behind a throttle).
Gzip also verified against a live local hub (Content-Encoding: gzip,
handshake decoded instantly, heartbeat +30.0s mid-stream).
2026-08-17 15:14:55 +08:00
weishu 6f1dc23d77 feat(android): auth + HapiApi transport (B-M1b)
:core:data auth + API transport for track B M1:

- auth/: JwtPeek (unverified {uid,ns,exp} peek for proactive refresh),
  CredentialStore interface + EncryptedPrefsCredentialStore
  (security-crypto) + in-memory impl, HubUrls origin normalization,
  HubRegistry (ordered multi-hub roster + active hub behind a storage
  seam), AuthInterceptor + single-flight TokenAuthenticator (401 ->
  re-exchange -> retry once, Mutex single-flight, terminal AuthEvents,
  ensureFreshToken() for SSE pre-connect)
- api/: HapiApi (plain OkHttp + kotlinx.serialization, one suspend fun
  per v1 endpoint incl. generated-image bytes + multipart transcription
  helper), ApiError with (status, code) per errors.md
- HubSession: per-hub factory wiring clients (timeouts, auth decoration,
  256 MB image cache), DI-free
- :core:protocol wire/: request DTOs (ApiRequests.kt) + response DTOs
  (auth, health, resume/reopen, cancel/steer/queued-state, spawn,
  slash-commands/skills, machine list-directory/paths-exists, uploads,
  transcription)
- tests: 45 unit tests (MockWebServer) — refresh/retry-once, concurrent
  single-flight, terminal 401 paths, ApiError code mapping, request
  shapes (cursors+epoch, deliveryMode, nested answers, explicit nulls),
  JwtPeek/HubUrls/HubRegistry
2026-08-17 13:07:44 +08:00
weishu 83bd25aa3b feat(android): scaffold Compose app + core modules + CI (B-M0) 2026-08-17 11:22:35 +08:00