mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-06 01:11:37 +00:00
local/deploy-main
38
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0de50e6abc |
feat(android): composer scheduled send with preset and specific picks (#2156)
* feat(android): composer @ mentions with session chips and file hits Mirror the web composer @-mention autocomplete: an active `@word` at the composer tail opens a dropdown that merges two sources like the web's `[...sessionHits, ...fileHits]`: - Session rows from the cached session list via `matchSessionsForMention` (web `sessionReference.ts` port): sidebar dedupe by `flavor:agentSessionId`, `hasConversationContent == true` gate so empty stubs are never reference targets, current session excluded, score tiers (title exact/prefix/contains, id prefix/contains, other-field) with active +50 / archived -25, Long-scored with updatedAt as tiebreak, limit 20. Insert text is Copy-reference prose (`See session "<title>" (/sessions/<id>) for context.` + steer suffix) with JSON-quote escaping, whitespace normalization, and 120-grapheme truncation via BreakIterator. - File rows via `GET /api/sessions/:id/files` (limit 50) for codex / copilot flavors only: copilot inserts `@<fullPath>`, codex inserts `@"<fullPath>"` with quote/backslash escaping; RPC failure degrades silently to session-only rows (web parity), CancellationException rethrown. Trigger detection ports `findActiveWord.ts` byte-for-byte (hard-stop scan, `@` boundary rule, `/`+`.` path continuation forward-only, bare `@` popup, no code-fence exemption), evaluated at the end of the text (the Android composer is a cursor-less String field - full cursor-aware mirror kept as `Mentions.findActiveWord`). Selection swaps the whole word and appends a trailing space (`applySuggestion.ts`). The `hasConversationContent` gate field already exists on this lineage as a non-nullable Boolean (absent / JSON null decodes to `false` via HapiJson coercion, asserted in a new SessionDecodingTest case); the `searchSessionFiles` seam moves onto `ChatSessionApi` with a defaulted `limit` parameter. MentionDropdown reuses the SlashCommandDropdown chrome; new string `chat_mention_suggestions` lands at identical key positions in en/zh-rCN/ru, and three zh keys a prior merge had parked at the file tail move back to their en-mirrored positions so all three locales key-sync exactly. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Align mention prefix scan and title whitespace with the web - findActiveWord default prefixes now mirror the web trigger set ['@', '/', '$'] (HappyComposer.tsx): a boundary-legal '/' or '$' tail word stops the backward scan, so "@ref /tmp" yields the "/tmp" word (routed to the slash/skill domain) instead of joining a multi-word mention query whose whole-word swap would swallow the typed "/tmp" on selection. - sanitizeSessionReferenceTitle now collapses the JS /\s+/ Unicode whitespace set (NBSP, U+3000, U+2028/2029, U+FEFF, ...) so CJK titles normalize byte-identically to the web. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Mentions: caret-aware detection + stale-row invalidation (review fixes) Consolidates the two review fixes from #2155 into this implementation: caret-position — detection and insertion now evaluate at the composer caret instead of the text tail. Mentions.mentionQueryOf gains a selection-aware overload; the VM tracks text + caret as one atomic ComposerDraft (mirroring the field's TextFieldValue), ChatComposer reports text/selection in a single callback and adopts external text at the reported caret, and applySuggestion rests the cursor after the insertion. A selected range suppresses suggestions (web rule). stale-suggestion — the file leg emits an empty batch before awaiting each RPC, so the previous query's rows never linger while a new query's request is in flight (delayed-response regression test included). Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Fix androidTest lambda arity after composer callback consolidation CodexPlanActionsTest renders ChatComposer with onTextChange = {}, which no longer compiles now that the callback reports (text, selectionStart, selectionEnd) atomically. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * feat(android): composer scheduled send with preset and specific picks The composer clock entry (web ScheduleTimePicker twin): the exact web preset set (+5m/+30m/+1h/+4h, resolved at the send instant) plus a specific-datetime tab with the web's 30 s past-grace and 7-day ceiling (ScheduleSend pure-logic object). A pending pick arms ChatViewModel.pendingSchedule, expires absolute picks on the clock, downgrades steer long-presses to queue (the wire forbids scheduledAt + steer), rides the send RPC as epoch-millis scheduledAt, and the queued bar's Edit re-arms a future fire time as an absolute pick (computeEditPendingSchedule). Queue rows now label with the web formatScheduledTime ("Oct 25, 2:30 PM", the year only when it differs from the current one). Declared divergences from web/src (all also in KDoc): - Paste gate: the web drops file pastes while a schedule is pending; the Android composer has no clipboard-file paste path, so the illegal combination is made unreachable instead — the attach button disables while a pick is pending and the clock button disables with attachments on the tray (the same mutual exclusion the web enforces). - Pending-pick lifetime: the web clears the pick only after the send mutation is accepted and restores it on failure; Android clears at dispatch because the failed row already carries wire scheduledAt and retryFailedMessage re-fires with it — the retry is the restore. - Panel chrome: the web anchors a fixed panel above the clock button (bottom sheet on touch viewports) and closes on outside-pointer-down; Android renders the panel above the input card like the slash/mention dropdown precedent and closes on the clock toggle or a selection. The specific tab splits the web's single datetime-local box into date + time fields for touch keyboards; the web's min/max input attributes are browser affordances, not mirrored — the pure validator is the gate, as on the web. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Guard scheduled-send edge paths found in adversarial review - editQueuedMessage no longer re-arms the schedule restore while the attachment tray is non-empty: the wire forbids scheduledAt + attachments and the clock button is disabled with attachments, so an armed pick would be invisible, un-clearable, and crash the send on the invariant check. The drop surfaces a notice instead. - The restore is now guarded by a pick snapshot taken at tap time: a schedule re-picked during the cancel round-trip wins over the restore (web snapshots text + pendingSchedule together in onEdit). - The /clear|/new concurrent shortcut now requires scheduledAt == null like the web — a scheduled clear queues as a scheduled message. - The pending-schedule expiry job is an AtomicReference: set/clear raced across the main thread and the edit-restore coroutine could leak a stale job that clears a newer pick at its deadline. - formatScheduledTime follows the locale's hour cycle and date order like web Intl ("25 окт., 14:30" / "10月25日 14:30" / "Oct 25, 2:30 PM"); the zh queued-scheduled label drops its full-width colon. Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Fix preview lambda arity after composer callback consolidation Co-Authored-By: Claude Code <noreply@anthropic.com> via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Reject scheduled sends over attachments and serialize schedule transitions The wire contract excludes attachments from scheduled sends, but only the clock button's enabled state enforced it — the picker panel stays open across an attachment add and the share-import path can drop a file into the tray while a pick is armed. Three layers now hold: - ViewModel transition gate: arming a pick over a non-empty tray is rejected in applyPendingScheduleLocked (clears always allowed); the open picker panel also closes the moment a chip appears (ChatComposer). - Send guard: sendMessage resolves the schedule BEFORE the draft clears or the tray consumes and bails with a ScheduleAttachmentConflict notice, so both sides stay recoverable in the UI. - performSend's invariant tripwire stays as the last net. Same commit, the two flagged Minors from review: - Replacement/expiry serialization: the pick value swap and the expiry registration become one critical section (scheduleMutex), replacing the AtomicReference job handle whose getAndSet still left a window where a restore published A, a callback picked B, and A's timer cleared B at A's deadline. Uncontended transitions stay synchronous via tryLock; the timer body re-checks the revision inside the lock so an obsolete timer can never clear a newer pick. - Queued-edit restore now snapshots the schedule revision (web pendingScheduleRevisionAtEdit) instead of value equality, so a pick - or a pick plus explicit clear - re-made while the cancel round-trip is in flight wins over the restore even when the slot's value lands back where it started. Interaction tests: rejection while chips sit in the tray, pre-consume send guard, consecutive absolute picks keeping only the newest expiry, a preset surviving a restored pick's deadline, and edit-restore losing to both a mid-flight pick+clear and a clear+same-preset reselection. Co-Authored-By: Claude Code <noreply@anthropic.com> * Mask specific date/time entry for digit keyboards The Specific-tab fields kept KeyboardType.Number, but a numeric soft keyboard has no `-` or `:` key, so the required YYYY-MM-DD / HH:mm shapes were literally untypeable. The fields now auto-insert the separators as digits arrive (maskDateInput / maskTimeInput): digits only, capped at the wire shape, idempotent on already-formatted text (a pasted "2026-11-05" stays put; deleting just re-masks the rest). A half-typed pair (non-blank, so Submit is tappable) used to fail the tap silently: parseSpecificInput returns null and onSubmit did nothing. It now surfaces an inline invalid-shape error (SpecificError.Invalid, Android-only — the web's datetime-local box cannot hold a malformed shape), joining Past/TooFar and clearing on the next edit like them. Pure-logic tests cover keystroke-by-keystroke separator growth for both fields, mask idempotence, non-digit/overflow capping, and that mask output feeds the strict parser unchanged (partial masks parse to null, never a silently wrong instant). Test tags (schedule-date-input / schedule-time-input / schedule-submit) added for the instrumentation tests. Co-Authored-By: Claude Code <noreply@anthropic.com> * Add schedule composer instrumentation tests Device-level state transitions for the two review-flagged surfaces, one setContent per test against the 320dp CI viewport: - Mutual exclusion: an open picker panel dismisses the moment an attachment chip lands, and the clock button reports disabled with the non-empty tray. - Preset flow: tapping +30m delivers Preset("+30m") and closes the panel. - Digit-IME entry: performTextInput injects through the IME channel (the soft keyboard's path), so raw digits typed into the Specific fields must come back masked ("20261105"→"2026-11-05", "1430"→"14:30") and the submitted pick resolves to the typed wall time in the device zone, asserted against the exact epoch ms. - Partial input ("20261"/"143") submits to the inline invalid error string, not a silent no-op. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
bd3fb24811 |
feat(android): global connection and runner version-skew banners (extracted onto main) (#2205)
* feat(android): global connection and runner version-skew banners
Two Home-screen chrome banners mirroring the web reference banner stack:
- Global connection banner: folds the SSE engine's attributed failure
notice (reconnectNoticeFailure, 4s grace) with the resync lifecycle
into one notice - Syncing (300ms debounce, 10s auto-clear cap,
depth-counted episodes) suppresses Reconnecting, which labels the
outage reason (heartbeat timeout / visibility recovery / connect
timeout / transport error with HTTP code / closed). The engine now
attributes every attempt end into ConnectionState.lastFailure,
cleared on the next successful handshake.
- Runner version-skew banner: capability-set comparison
(RunnerSkew.REQUIRED_MACHINE_CAPABILITIES, not semver) flags runners
missing agent-availability/cursor-chat-store-status; expanded card +
minimized chip, snooze 1h, manual restart via new
POST /api/machines/:id/restart-runner (restart gated on
newer-binary-on-disk mtime drift AND supervised restart).
StoreSyncTargets grows an onResyncEvent lifecycle hook (default no-op)
feeding the syncing half; HomeScreen grows optional chrome params
(null hides each banner).
Declared divergences from web/src:
- Banners mount on Home only, not above every route: an open chat
already surfaces its own pipe state ("Reconnecting... Tap to retry"),
so the two never double up on one screen (web mounts App-wide).
- The web OfflineBanner is not ported: a native app has no browser
online/offline event pair; hub reachability is expressed through the
reconnecting banner.
- Minimize/snooze state is process-lifetime memory (web: sessionStorage
- same session-scoped lifetime per platform); the snooze timer does
not survive process death.
- Runner rows filter only on capability skew; the active filter lives
upstream in MachineStore (web filters machine.active on its side).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* Fix codeless transport attribution and banner polish from review
- SseEngine: a codeless Failure (DNS/TLS/socket errors — the dominant
mobile outage shape) now attributes TransportError like the web's
unconditional onerror → transport-error mapping, instead of degrading
to a "connection closed" label.
- ConnectionChrome: a resync restart while the syncing banner is
already shown keeps it shown (web doStartSync reshuffles timers with
isSyncing staying true — no blink); the web's first-connect/30s-from-
background display gating is now a declared divergence.
- RunnerSkewBanner: the expanded card scrolls past a 320dp cap (web
40vh) so many skewed hosts cannot push the session list off-screen;
restart failures surface the hub body's parsed error field instead
of raw embedded JSON; handoff-hint/tooltip omissions declared.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* Show the reconnecting banner for auth-side outages with no failure reason
Review finding (ConnectionState.kt): a paired hub starting with a stale
JWT whose token refresh keeps failing cycles SseEngine Backoff without
any transport attempt running, so lastFailure stays null — after the 4s
grace the notice flow emitted null and ConnectionChrome read "no
banner". Home could sit disconnected indefinitely with no notice.
Outage visibility now lives on its own marker, independent of the
optional reason: reconnectOutageNotice (was reconnectNoticeFailure)
emits OutageNotice(lastFailure) once the grace elapses — non-null even
when no attempt attributed a reason — and ConnectionChrome maps any
OutageNotice to Reconnecting(failure), so a reason-less outage renders
the plain "Reconnecting…" strip (web shows the banner on sseDisconnected
regardless of reason; the parenthetical label simply drops).
Coverage added per review:
- repeated null-token retries keep the notice visible (engine level);
- null-reason outage still emits a notice after the grace (state level);
- outage without a reason still shows the banner (chrome level);
- the notice hides while backgrounded and returns with the foreground.
Resync debounce/cap and runner-restart error paths were already covered
(a sync shorter than the debounce…, a stuck sync auto-clears…, restart
failure surfaces the message…).
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Code <noreply@anthropic.com>
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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. |
||
|
|
e0db9c6815 | fix(notifications): distinguish user questions from permission requests | ||
|
|
6f4aacce6b | feat(android): enable default push through official relay | ||
|
|
32ca6f543a | feat(android): improve native chat reading and navigation | ||
|
|
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. |
||
|
|
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. |
||
|
|
4948d23669 | fix(android): enforce Google Play compliance | ||
|
|
e5a8212f4a | feat(session): validate agents and browse workspace directories | ||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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).
|
||
|
|
8e5ec6a3cb | merge: B-M3f Android composer attachments | ||
|
|
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). |
||
|
|
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). |
||
|
|
75f830002b |
merge: B-M4e Android usage/storage dashboards + settings
# Conflicts: # android/app/src/main/kotlin/app/hapi/companion/Navigation.kt |
||
|
|
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 |
||
|
|
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).
|
||
|
|
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). |
||
|
|
ca138c912c | merge: B-M3ab Android composer + permission actions + session config | ||
|
|
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).
|
||
|
|
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)
|
||
|
|
b23afe24fe | feat(android): read-only chat screen + store wiring (B-M2d2) | ||
|
|
dfcf9849b9 | merge: B-M2c Android message window store, pagination fixtures green | ||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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).
|
||
|
|
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
|
||
|
|
83bd25aa3b | feat(android): scaffold Compose app + core modules + CI (B-M0) |