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>
2026-11-02 18:23:53 +08:00
2026-11-01 20:30:00 +08:00
2026-08-25 13:31:59 +08:00
2026-01-04 20:45:15 +08:00
2026-03-29 09:47:17 +08:00
2025-12-16 15:03:50 +08:00

HAPI

Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode / Kimi / Copilot / Antigravity / Pi / DeepSeek Harness sessions and control them remotely through native iOS / Android apps, Web / PWA, or Telegram Mini App.

Why HAPI? HAPI is a local-first alternative to Happy. See Why Not Happy? for the key differences.

Features

  • Seamless Handoff - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart.
  • Shared Codex Sessions - Use Codex from your terminal and phone at the same time. Requires Codex 0.154.0+. Usage and limits.
  • Native First - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory.
  • AFK Without Stopping - Step away from your desk? Approve AI requests from your phone with one tap.
  • Your AI, Your Choice - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode, Kimi, Copilot, Antigravity, Pi, DeepSeek Harness—different agents, one unified workflow.
  • Terminal Anywhere - Run commands from your phone's browser or desktop web app, directly connected to the working machine.
  • Voice Control - Use dictation in native apps, or talk to your AI agent hands-free with the web voice assistant.
  • Workspace Browser - Opt-in via one or more hapi runner start --workspace-root <path> flags: browse scoped file trees from the web and start sessions in allowed subdirectories.

Demo

https://github.com/user-attachments/assets/38230353-94c6-4dbe-9c29-b2a2cc457546

Getting Started

npx @twsxtd/hapi hub --relay     # start hub with E2E encrypted relay
npx @twsxtd/hapi                 # choose an agent and start a session

hapi server remains supported as an alias.

Use hapi <agent> [options] to start an agent directly, for example hapi claude or hapi codex. Scripts must specify the agent explicitly. hapi --help shows HAPI's commands and supported agents.

The hub displays a URL and two QR codes. Then connect a client:

  • Web / PWA (free): open the web URL in a browser, or scan the web QR code. Install it as a PWA for a home-screen app and push notifications.
  • Native apps (paid): install HAPI from the App Store or Google Play, then scan the companion QR. See Native apps for pairing.

The relay uses WireGuard + TLS for end-to-end encryption. Your data is encrypted from your device to your machine.

For self-hosted options (Cloudflare Tunnel, Tailscale), see Installation

Docs

Clients

  • Web / PWA (free) - The full-featured web app, including the remote terminal and voice assistant. Works in any browser and installs as a PWA. See Web / PWA.
  • Native iOS / Android apps (paid) - SwiftUI/UIKit and Kotlin Compose clients with chat, approvals, session creation, files, dictation, and native push notifications. See the native app guide for capabilities and pairing.

Download on the App Store Get it on Google Play

Developers can build the apps from source: iOS, Android. Protocol: client contract.

Build from source

Requires Bun 1.4.4.

bun install
bun run build:single-exe

Credits

HAPI means "哈皮" a Chinese transliteration of Happy. Great credit to the original project.

S
Description
GitHub 镜像: wu736139669/hapi(每日自动同步)
Readme AGPL-3.0
138 MiB
Languages
TypeScript 99.5%
CSS 0.2%
JavaScript 0.2%