Commit Graph
34 Commits
Author SHA1 Message Date
weishu 26fddff038 fix(chat): restore grouping for shared Codex commands
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.
2026-09-12 19:14:46 +08:00
weishu 6f4aacce6b feat(android): enable default push through official relay 2026-09-12 17:52:53 +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 a729456682 fix(claude): answer local permission prompts from web
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
2026-09-11 18:54:47 +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
3873e58496 fix(web): keep streamed reasoning/text block ids stable across snapshot rows (#1741)
* 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>
2026-09-06 14:20:19 +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 be1ef2a2e4 feat(dsh): integrate DeepSeek Harness through ACP (#1632)
* feat(dsh): add DeepSeek Harness ACP flavor

* fix(dsh): update mobile flavor catalogs

* fix(dsh): keep mobile spawn policy managed

* fix(dsh): keep managed policy and prompt retry

* fix(dsh): suppress unsupported runner policy flags

* fix(dsh): align native managed-policy UX
2026-08-22 12:37:28 +08:00
Junmo KimandGitHub 0aebf39c78 fix(opencode): keep one stored message per reasoning stream (#1643)
* 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.
2026-08-20 08:55:21 +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 b9d5f803b2 fix(android): device-feedback round 1
- decode fs.stat-derived epoch fields leniently (fractional mtimeMs from
  real hubs broke machines/files decode and pinned the offline banner)
- offline banner: only a failed sessions fetch counts; machines/baseline
  failures are advisory; live SSE emission clears it and seeds the unread
  baseline (all-rows-unread gray dot cascade)
- session row: single weighted title (short names no longer truncated at
  half width), unread dot moved beside the timestamp
- composer restyle: borderless input pill with inline mic, hand-drawn
  vector glyphs replacing emoji icons, 42dp round actions, park-draft
  relocated to the session overflow menu
2026-08-18 16:27:32 +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 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 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 3ce85936c7 fix(android): dedupe CodexModels wire types from parallel B-M3ab/B-M3d merges 2026-08-17 21:04:04 +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 80948e93c9 merge: B-M2a Android chat pipeline port (48 fixtures green) 2026-08-17 16:21:25 +08:00
weishu 03651984e0 feat(android): chat pipeline port — 48 chat fixtures green (B-M2a)
Port web/src/chat/ (normalize → reduce → group) to :core:protocol
app.hapi.protocol.chat, file-for-file:

- ChatTypes (blocks/tool-call/permission/usage; AgentEvent sealed over a
  verbatim raw JsonObject so wire events project byte-exact)
- Normalize/NormalizeUser/NormalizeAgent (codex/output/event families,
  agy mapping, skip rules, stringify-never-drop fallback)
- Tracer (parentToolUseId grouping, prompt fallback, orphans)
- ReducerEvents/ReducerTools/ReducerCliOutput/ReducerTimeline/Reducer
  (usage-limit pipe parsing, dedupe + title echo, api-error folding,
  tool pairing, stream coalescing, agent-run cards, turn-duration,
  title-changed synthesis, sentinel suppression, pending-permission
  synthesis with oldest-visible gate, latestUsage, goal filtering)
- ToolGroups (families, id stability, buildVisibleChatBlocks)
- ToolPresentation (event labels; java.time in place of toLocaleString)
- FixtureProjection + CanonicalJson + ChatFixtureTest: every
  shared/fixtures/chat/*.json replayed and compared under canonical
  JSON equality, with a loud fixtureVersion gate

TS undefined maps to Kotlin null, TS null to JsonNull; permission
objects carry a presence set so JS spread-merge semantics survive.

:core:protocol:test 223/223 green (48 fixtures), :app:assembleDebug ok.
2026-08-17 16:20:27 +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 96d81a3f12 merge: B-M2b Android session/machine stores + session list
# Conflicts:
#	android/app/build.gradle.kts
2026-08-17 15:48:09 +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 3cf2a669c3 feat(android): pairing flow, deep link, DI graphs, navigation skeleton (B-M1d) 2026-08-17 15:39:29 +08:00
weishu 3570017743 merge: B-M2d1 Android markdown/code/diff rendering foundation
# Conflicts:
#	android/gradle/libs.versions.toml
2026-08-17 15:15:10 +08:00
weishu d39197b97d feat(android): markdown/code/diff rendering foundation (B-M2d1)
:core:protocol (pure JVM, all web-transform ports fixture-style tested):
- markdown/MarkdownTransforms.kt: repairTables (remark-repair-tables port),
  disableIndentedCode (enabledBlockTypes minus IndentedCodeBlock == micromark
  disable codeIndented) + canonical parser factory (GFM tables, strikethrough
  requireTwoTildes, autolink), stripCjkAutolinkArtifacts, detectFilePathLinks/
  matchWholeFilePath/rewriteExplicitLinkTarget (remark-file-path-links port)
- markdown/HrefPolicy.kt: Allowed|ConfirmFirst|Blocked scheme policy with
  double-percent-decode + control-char-strip bypass hardening (web parity);
  scheme-less/SPA/protocol-relative fail closed on native
- git/UnifiedDiffParser.kt: full unified diff -> DiffFile/DiffHunk/DiffLine
  (rename/copy/new/deleted/binary/mode/similarity, quoted paths, bare codex
  diffs, tolerant of truncated hunks + wrong LLM counts)
- 56 new JVM tests (repair matrix, parser config, CJK, path detection incl.
  negatives, href matrix, 11 diff scenarios cross-checked vs web semantics)

:app Compose UI:
- ui/markdown/Markdown.kt: transforms -> commonmark AST walk; paragraphs/
  headings/lists (ordered/bullet/task via stripped text markers)/blockquote/
  tables (intrinsic-width grid in horizontal scroll)/thematic break/html
  monospace fallback; unknown fences (mermaid/math) degrade to CodeBlock;
  LinkAnnotation links via LocalMarkdownLinkHandler (onFilePath/onUrl+policy)
- ui/markdown/CodeBlock.kt: header chip + copy, horizontal scroll, highlights
  1.0.0 syntax coloring off-main via produceState with 200-entry LRU keyed by
  (hash, len, lang, dark), 400-line cap then plain
- ui/components/DiffView.kt: hunk headers, +/- tinted rows, dual line-number
  gutters, compact/expand
- ui/theme: HapiExtendedColors (code/diff/table/quote tokens mirroring web
  index.css light/dark/OLED) + pure-black OLED scheme, provided by HapiTheme
- MarkdownPreviewParameterProvider + kitchen-sink @Previews (compile-checked)

deps (Maven Central verified): org.commonmark:commonmark 0.24.0 + gfm-tables/
gfm-strikethrough/autolink exts, dev.snipme:highlights 1.0.0 (jvm variant)

Verified: :core:protocol:test 141 green, :app:assembleDebug green
2026-08-17 15:14:01 +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 b09dd67268 feat(android): protocol wire models, catalogs, session patching (B-M1a)
:core:protocol, package app.hapi.protocol — pure-JVM foundations for the
Android client, mirrored from shared/src/schemas.ts + sessionSummary.ts and
docs/api/client-contract/{sse,pagination,messages}.md:

- wire/: shared HapiJson config; DecryptedMessage with tri-state invokedAt
  (custom serializer — explicit null = queued vs absent = legacy-invoked);
  AttachmentMetadata; AgentState + (completed) requests; Session verified
  field-by-field against SessionSchema; typed-subset SessionMetadata;
  SessionSummary/PendingRequest; SessionPatch with VersionedValue wrappers
  and a strict SessionPatches.parse (unknown key / empty / mistyped -> null,
  replicating SessionPatchSchema.strict()); Machine family; the 13-type
  SyncEvent union behind total SyncEvents.parse (unknown/malformed ->
  Unknown, never throws; machine-updated data kept tri-state for the
  removed-vs-refetch distinction); MessagesPage + REST envelopes.
- catalog/: AgentFlavor (known + Other(raw)) with capabilities/labels from
  flavors.ts; PermissionMode/tone table, per-flavor mode lists, codex
  collaboration + copilot agent modes incl. legacy 'fleet' normalization.
- patch/SessionPatching.kt: exact port of web/src/lib/sessionPatch.ts —
  strict > version gates, max-monotonic updatedAt, activeAt <60s
  render-irrelevance, and deliberately NOT applying activeTurnStartedAt /
  scratchlistUpdatedAt (the TS reference never assigns them).

Tests (85 total, all green): golden-fixture decoding over shared/fixtures/
chat/* (fails loudly on zero files or unsupported VERSION), version-gate /
keep-alive cases ported from useSSE.test.ts, full-session + list + messages
page decode, SyncEvent union coverage, catalog pins (TODO(K6): switch to
fixtures/catalogs/modes.json once generated). :app:assembleDebug verified.
2026-08-17 12:06:32 +08:00
weishu 83bd25aa3b feat(android): scaffold Compose app + core modules + CI (B-M0) 2026-08-17 11:22:35 +08:00