Files
hapi/android/core/data
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
..