Commit Graph
120 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>
2026-11-02 18:23:53 +08:00
fb035051c2 feat(android): context-remaining indicator in composer (#2201)
* Add per-message metadata details popup to chat

Messages that carry usage signal (model, token counters, round cost or
duration) now expose an info entry under the message card; tapping it
opens a dialog listing model/tier, token totals, cached-input share,
USD cost, and round duration/turn count.

- protocol: decode result summaries into turn-summary events; native
  turn durations and token counts gate on top-level scope and merge
  per turn id onto the trailing block group
- protocol: response-group aggregation sums per-turn usage across a
  run of assistant blocks (adjacent-turn dedup, first-seen model
  order, synthesized round durations from the activity window)
- protocol: label builders render the popup lines with pinned number
  formatting (grouped totals, compact k/m counts, USD tiers,
  sub-minute durations); tests mirror the full label contract
- app: entry shows only when the message has labels, dialog follows
  the existing alert idiom, strings in en/ru/zh with aligned key order

Co-Authored-By: Claude Code <noreply@anthropic.com>

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Add pure context-usage math for the chat status strip

Token-count abbreviation (plain below 1k, whole k, one-decimal M),
window-share percentages that always complement to 100, an unrounded
warning band (neutral, amber from 70%, red from 90%), a full readout
with cache-read and a clamped remainder, and the window estimate
table: a window reported alongside the usage wins, then per-flavor
defaults minus a 10k headroom (codex 258.4k, pi/claude 200k, claude
1M for [1m] and fable model ids, cursor names carrying an inline
context= segment). Sessions without an estimate keep a size-only
readout. Unit tests cover the thresholds, mid-point rounding, and the
missing-model / missing-usage edges of every flavor branch.

Co-Authored-By: Claude Code <noreply@anthropic.com>

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Show context-remaining indicator above the composer

The composer status strip gains a compact readout of the session
context: the window budget with the free share while the window is
known (ctx 258k, 65% left), otherwise just the current size. The
color escalates neutral, amber, red as the window fills; the text
stays the same in every band. State derives from the session-level
usage projection in ChatUiState and stays hidden until the thread
reports usage. Strings ship in en/ru/zh with aligned key order.

Co-Authored-By: Claude Code <noreply@anthropic.com>

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): salvage single-turn tool-group metadata in aggregation

One assistant message whose groupable tool calls form a single tool group
lost its details entry: aggregation emitted nothing for a one-turn group
without a round summary, and messageMetadataForBlock maps ToolGroupBlock
to null, so once the transcript projection strips the member tools the
usage/model they carried became unreachable.

A group that starts with a ToolGroupBlock now salvages its (deduped)
turn metadata into the aggregate map, keyed by the group block so the
stripped row still resolves it. Groups led by other block kinds keep
their own fields; silent groups salvage nothing. Covers shared-source
members with and without CLI-stamped local ids, the stripped-projection
readback, and the multi-turn aggregation path through a group.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(android): give the elevated context band a semantic amber

ELEVATED used MaterialTheme.colorScheme.tertiary, which the static themes
define as teal and dynamic color derives from the wallpaper, so the amber
caution band the PR promised was anything but. Add a `warning` token to
HapiExtendedColors (amber-700 on light, amber-400 on dark/OLED — outside
the M3 scheme, so wallpaper-derived tertiaries cannot reach it) and map
the band through a pure contextUsageColor() helper.

Unit tests pin the mapping (never the scheme tertiary, stays amber under
teal/wallpaper-like tertiaries) and verify WCAG-AA contrast of the amber
against the light, dark and OLED surfaces.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(android): align the Claude window fallback with the web presets

The copied preset set treated bare sonnet/opus as 200k models and omitted
haiku entirely, so a preset session without a reported window got a 190k
budget (200k of usage read as 0% left) or none at all. Resolve windows
through ClaudeModels.PRESETS mirroring web/src/chat/modelConfig.ts: the
picker aliases (opus/fable/sonnet/haiku) are 1M-window models, claude-
prefixed ids keep the 200k default, and [1m]-suffixed / claude-fable ids
are large.

A device test also pins the chip readout across light/dark/dynamic
themes: known vs unknown windows, all three warning bands, and no
indicator until measurable usage exists.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-02 18:23:23 +08:00
be5f37b9bf feat(android): generalize generated media blocks to video/audio/file (#2162)
* feat(android): render chat markdown math with offline KaTeX

Port the web math pipeline to Android chat markdown. A new
MarkdownMath pre-pass in :core:protocol extracts math with web
semantics (remark-math singleDollarTextMath:false + remark-latex-
bracket-math): $$ display flows, $$ text math only when the closer
run equals the opener and stays inside the paragraph, single $
never triggers math (currency stays prose), \(..\)/\[..\] bracket
pairs, backslash-escaped delimiters stay literal, and code spans,
fences, links and link reference definitions are fully protected.
Matches are lifted behind private-use placeholder tokens and
re-inserted as MathInlineNode/MathBlockNode custom nodes, hoisting
display math out of paragraphs while blockquote/list containers
survive the reparse. 37 JVM tests mirror the web suite's cases.

Rendering is a shared offline WebView (ui/markdown/MathWebView.kt)
fed a self-built HTML document (MathDocument.kt) against bundled
assets/katex/ (KaTeX 0.16.45, same version as web). The view is
network-blocked (blockNetworkLoads + shouldInterceptRequest
refuses http/https/ws/wss) with file/content access disabled;
asset URLs still resolve through the file:///android_asset/katex/
base URL. Theme color and font size are injected per render and
the document reports its height through a JS bridge
(ResizeObserver + onPageFinished re-measure). KaTeX failures
follow the web's three-tier semantics, degrading to monospace
raw TeX.

Inline \(..\) and single-paragraph $$ math degrades to monospace
TeX for v1: AnnotatedString cannot host a WebView and one WebView
per fragment is too costly in chat lists. The parser still emits
MathInlineNode so a future renderer can upgrade inline rendering
without touching the extraction layer.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): convert CSS pixels correctly at the math WebView boundary

The JS bridge reports scrollHeight in CSS pixels, but KaTeXMathBlock
treated the value as Android physical pixels and divided it by the
screen density when sizing the modifier: on a density-3 device a
reported 90 CSS px block collapsed to 30dp instead of ~90dp. The font
conversion had the opposite mismatch — 16sp chat text went through
TextUnit.toPx() (physical pixels) and was injected as `font-size: 48px`
CSS, making formulas render ~3x too large on the same screen.

Define the two boundary conversions explicitly as pure functions next
to the document builder and unit-test them:

- spFontToCssPx: CSS px and sp are both density-independent, so only
  the user's font-scale preference crosses the boundary; density never
  enters (16sp stays 16 CSS px at any density, 21 at fontScale 1.3).
- cssPxToDp: reported CSS px map 1:1 to dp at the default WebView
  scale, multiplied by WebView.scale when the view is zoomed.

The document now also pins the viewport with
`width=device-width, initial-scale=1`, making the 1 CSS px = 1 dp
default scale an explicit property of the document rather than a
WebView default, with a regression test asserting the meta tag.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(android): refresh the math WebView height callback across spec changes

KaTeXMathBlock keyed its height state with remember(spec) but assigned
MathWebView.onHeightChanged inside AndroidView.factory, which runs only
when the Android view is created. The callback therefore captured the
initial spec's state object: when LaTeX, theme color or font size
changed, remember(spec) minted a new state while AndroidView retained
the existing view and its stale callback, so every measurement from the
reloaded document updated the orphaned state and the current block
stayed at its 22dp minimum.

Keep one stable holder (MathViewHeightState) remembered unkeyed for the
mounted view, reset it explicitly in onRender when the spec changes, and
re-point the view's callback plus reset the height inside
AndroidView.update before rendering — the creation-time callback then
always feeds live state. JVM tests drive the holder through spec
changes on one mounted view (the pre-fix wiring cannot compile against
this shape: the factory-captured remember(spec) state is exactly the
orphan the holder removes).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(protocol): bound dollar flow math closing to the enclosing container

findFlowClose kept scanning after the opening line left its container
and accepted an unprefixed closing run, so `> $$\n> x\n\nOrdinary
prose\n\n$$\ny\n$$` paired the quoted opener with the later top-level
`$$`: the prose was swallowed into the formula and the final `$$` lost
its own opener. remark-math (verified against micromark-extension-math,
whose math flow is `concrete` and dies with its container) renders the
quote's math, the prose and a separate top-level equation instead.

Bound matching to the opening container, distinguishing container
shapes: a closer is only accepted from a line that still carries the
container prefix; a blank line ends a blockquote (a following `> $$`
opens a NEW quote, while a `>`-only line is an in-quote blank the
container survives); a list item may resume after blank lines (loose
lists), so blanks are skipped and the next content line decides; an
unrecognized prefix is plain <= 3 space indentation, not a container,
and keeps the unbounded scan (blank lines and fences stay math content).

Regression tests: the quoted example (prose stays prose, the later
equation keeps its own opener), a list-container variant, a
blank-line/new-quote guard, plus two in-container close locks — the
first three fail on the pre-fix scan.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(android): render mermaid fences as offline diagrams

Port the web MermaidDiagram component to Android chat markdown. A fence
whose language is exactly `mermaid` (case-sensitive, like the web's
MARKDOWN_COMPONENTS_BY_LANGUAGE key) now dispatches to MermaidDiagramView
instead of the plain code block; every other fence keeps the CodeBlock
path.

Rendering runs in a hardened offline WebView fed a self-built HTML
document against bundled assets/mermaid/mermaid.min.js (Mermaid 11.14.0,
curl-fetched, sha256-identical to the web app's locked dependency). That
dist file is a self-contained classic script assigning globalThis
mermaid - no imports, no runtime fetches - so no ES-module/CORS
workaround is needed. The view shares the KaTeX skeleton: network stack
blocked, file/content access off, asset URLs through the
file:///android_asset/mermaid/ base URL (OfflineMathWebViewClient
generalized to OfflineMarkdownWebViewClient, no behavior change).

Behavior mirrors the web component line by line: initialize with
startOnLoad:false, securityLevel:'strict', suppressErrorRendering:true
and the web's dark/light themeVariables palettes verbatim (theme flips
re-render); the validate-then-render gate parses with suppressErrors
first and never calls render() after a failed validation, re-parsing
once without suppression only to surface a human-readable reason; svg
element ids are per-instance (`mermaid-<n>`, web useId) so the scoped
styles mermaid emits cannot collide across diagrams in one message;
wide diagrams scroll horizontally inside the document while the svg is
centered at max-width:100% and height:auto.

The code header (language chip + copy) stays above the body like the
web's retained DefaultCodeBlock header; until the diagram renders - and
on failure - the raw source is shown below it (the block's source exit),
with the failure notice appearing only for actual errors so the loading
window never flashes a render-error line. mermaidFallbackUi maps state
to those surfaces as a pure function.

15 JVM tests cover dispatch, offline-ness, palettes, security flags,
the validation gate structure, id uniqueness, generation filtering,
script-breakout safety and the fallback mapping; the driver's runtime
semantics were additionally validated against the real bundle in jsdom
(flowchart/sequence render, invalid source surfaces the parse reason,
hostile labels are rejected/sanitized under strict).

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): convert CSS pixel heights correctly in the mermaid WebView

Both height reporters feeding MermaidDiagramView — the document bridge's
report() (documentElement/body scrollHeight) and
OfflineMarkdownWebViewClient's onPageFinished re-measure — return CSS
pixels, but the rendered branch ran the value through
with(density) { heightPx.toDp() }, which treats it as Android physical
pixels and divides by display density: on a 3x display a diagram
measuring 180 CSS px received a 60dp box and was clipped into an
unnecessarily scrolling WebView.

Fix it exactly like the KaTeX base now does: the document pins
`width=device-width, initial-scale=1` (making 1 CSS px = 1 dp at the
default WebView scale an explicit property of the document rather than a
WebView default), and the reported value crosses the boundary through
the shared density-independent cssPxToDp helper — extracted as the pure
mermaidRenderedHeightDp so the composable no longer holds any density
conversion at all, keeping both markdown renderers on one boundary
contract. (The KaTeXMathBlock repeat of the same bug is fixed by the
rebased base; MathWebView.kt arrives converted already.)

JVM tests: the viewport pin is asserted against the built document
(demonstrated failing on the pre-fix template) and the conversion math
is driven through the pure helper — 180 CSS px stays 180dp because the
boundary carries no density parameter, mirroring the base's
MathDocumentTest contract.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(android): generalize generated media blocks to video/audio/file

The hub's /generated-images/:id endpoint already serves any mime type
(video, audio, arbitrary files arrive as the same generated-image wire
block with a mimeType), but the Android chat rendered every block as an
image. This ports the web GeneratedImageCard generalization so the card
routes by display intent, exactly like web generatedInlineMedia.ts:

- mimeType routing is the web's literal prefix check: video/… gets a
  player, audio/… an audio player, image/… or null keep the existing
  image treatment (unchanged AsyncImage thumbnail + tap-to-zoom dialog),
  everything else is a file card (no separate "unsupported" class —
  unknown mimes ARE the file UI, matching web G3/G8).
- non-image media never downloads on its own: "Load video" / "Load
  audio" / "Prepare download" gate buttons (web's explicit-intent rule
  G4; English labels verbatim from the web card), pulsing skeleton
  while fetching (G7), and the shared "{label} is unavailable. {error}"
  error line (G8). The wire carries no tiny/poster fields, so video
  renders bare — nothing to special-case.
- every kind now sits in the web's G14 card shell with the localized
  "{label}: {fileName}" hint header; images additionally surface the
  error line on Coil failure instead of a silent empty hole.
- downloads go through the same authed OkHttp client Coil uses (JWT +
  silent 401 re-auth + ETag disk cache), staged into a temp file and
  atomically renamed under cache/generated-media/; a completed file
  short-circuits later fetches (immutable, ETag-stable content) and
  non-2xx maps to "HTTP <code>" exactly like the web's ApiError.
- video plays in-app via framework VideoView + MediaController (no new
  media dependencies: Media3 is not on the classpath and pulling it in
  for this card is not worth the MB); audio is a compact
  play/pause + scrub + m:ss row over MediaPlayer (web's native <audio>
  chrome degraded: volume/speed omitted, no waveform — web has none);
  files get Open (ACTION_VIEW over the existing FileProvider, new
  generated-media cache path) and Save (ACTION_CREATE_DOCUMENT picker)
  outlets in place of the browser download link.
- ChatMedia gains a nullable fetchGeneratedMedia field (default null:
  previews/tests degrade to the unavailable line, so stack-only call
  sites merge unchanged).

17 new strings added to en/ru/zh-CN in one commit, mirroring en key
order (KEYSYNC clean; zh count equal — its pre-existing regional order
drift is untouched). 10 JVM tests cover routing (including case
sensitivity and null-mime), mount-gating, label/action mapping, cache
file name sanitizing (path traversal), the MockWebServer round-trip
with status mapping + cached-file short-circuit, and the time
formatter. Gate: :app:testDebugUnitTest :core:data:test
:core:protocol:test :app:lintDebug :app:compileDebugAndroidTestKotlin
BUILD SUCCESSFUL.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): refine generic file MIME types from the filename

The file card passed the wire mimeType straight through to ACTION_VIEW,
substituting application/octet-stream only when the wire type was null
(r4370795783). Passing octet-stream explicitly defeats Android's viewer
matching: a PDF viewer registered for application/pdf is not matched by
an intent whose type says application/octet-stream, so PDFs (and every
other ordinary file whose hub type was octet-stream or missing) opened
with no eligible activity.

resolveFileMimeType now passes a specific wire type through untouched,
but when the wire type is missing, empty, or octet-stream (compared
case-insensitively) it re-derives the type from the original filename's
extension through an injected resolver — android.webkit.MimeTypeMap at
the call site, following the established AttachmentPreparer /
AndroidShareIntake pattern (lowercased extension, slash/wildcard sanity
check on the resolved value). Unknown extensions keep the generic
octet-stream fallback. The resolved type feeds both outlets: the Open
ACTION_VIEW intent and the Save ACTION_CREATE_DOCUMENT contract.

The resolver is a required parameter rather than a default, which keeps
GeneratedMedia.kt free of android.* imports and forces the seam: 3 JVM
tests drive it with a fake table (pdf->application/pdf via extension for
null/octet-stream/empty wire types; unknown extension and no extension
-> octet-stream fallback; specific wire types pass through untouched
even when the extension would map elsewhere). All three fail on the
pre-fix code by construction — resolveFileMimeType did not exist and the
null-wire PDF case asserted the old octet-stream handoff.

Gate: :app:testDebugUnitTest --tests GeneratedMediaTest BUILD SUCCESSFUL
(13 tests, 0 failures).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(android): bound generated media cache names by UTF-8 bytes

sanitizedGeneratedMediaFileName capped the stem at 96 UTF-16 chars and
relied on that alone (r4370795787). Android filename limits are byte
limits (NAME_MAX 255): 80 hanzi + ".pdf" is a 122-char / 281-byte
component once the UUID imageId prefix is attached (verified against the
pre-fix logic: 36-byte id + 1 + 240 + 4 = 281 bytes), so long Chinese
names failed the atomic cache rename outright.

The complete component — id prefix + stem + extension — is now assembled
under a 255-byte UTF-8 budget, with pathological ids and extensions
capped at 64/32 bytes so the stem always keeps room. The stem keeps its
historical 96-char cap (ASCII names keep their exact prior shape), bytes
beyond the budget are cut from the stem only, on code-point boundaries,
and the extension always survives intact. The char-level whitelist
already drops surrogate halves (a lone half is never a letter), so every
surviving char is one complete BMP code point; the truncation helpers
are additionally pair-safe for any future code-point-aware whitelist.

JVM tests: the reviewer's UUID + 80-hanzi ".pdf" case lands at <= 255
bytes with the extension and id prefix preserved (red on the pre-fix
logic, which produced exactly 281); ".tar.gz"-style extensions survive
stem truncation; a battery of multibyte names re-decode byte-identically
under the cap; ASCII names keep their historical shapes ("i-" + 96 a's,
"img-1-media", "id-x.bin", "i-截图01.png").

Gate: :app:testDebugUnitTest --tests GeneratedMediaTest BUILD SUCCESSFUL
(16 tests, 0 failures).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(android): cover audio playback wiring and the file outlets' types

The play/pause/seek decisions and the MediaPlayer lifecycle lived inline
in the audio composable, which made them unreachable from JVM tests (no
emulator here; androidTest only compile-checks). Extract them into
GeneratedAudioSession over a GeneratedAudioPlayer seam — the app side
adapts android.media.MediaPlayer, tests pass a scriptable fake — and the
card now collects the session's flows, with DisposableEffect.onDispose
forwarding to session.release().

JVM coverage through the fake:
 - prepare wires the data source + prepareAsync and reports duration on
   the prepared callback; pre-preparation toggles/seeks stay inert;
 - the toggle maps to start/pause (turbine on the playing flow) and
   natural completion parks the row;
 - seekToFraction lands fraction*duration and clamps to [0, duration],
   reading the position back afterwards;
 - pollPosition only reads while playing (the 250 ms LaunchedEffect);
 - release — the dispose hook — releases the framework player exactly
   once across double-dispose, stops playback, and every later control
   call is inert (a released MediaPlayer throws);
 - player errors and a failing data source both map to the failed line
   and the row goes dead.

The file card's Open/Save construction moves behind generatedFileOutlets
so a JVM test pins that ACTION_VIEW and ACTION_CREATE_DOCUMENT both
carry the *resolved* MIME type (inferred pdf, generic fallback, specific
wire passthrough) plus the suggested save name — android.content.Intent
is not on the unit-test classpath. A MockWebServer fetch with the
reviewer's 80-hanzi + UUID name proves the byte-bounded component really
round-trips the local filesystem: under 255 bytes, extension kept, no
staging litter beside the final file.

These tests fail on the pre-change code by construction: the session,
seam, and outlets did not exist and the logic was not reachable from
the JVM. Explicit loading (loadsOnMount gate), authenticated caching
(hubGraph.session.imageClient: JWT + silent 401 re-auth + ETag cache),
and download cleanup (staged temp deleted on failure, atomic rename on
success) are untouched — only their test surface grew.

Gate: :app:testDebugUnitTest --tests GeneratedMediaTest BUILD SUCCESSFUL
(24 tests, 0 failures).

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:09:49 +08:00
weishu 1fcce105df fix(native): hide empty reasoning blocks 2026-11-02 14:37:17 +08:00
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>
2026-11-02 11:37:47 +08:00
weishu 7ba52b9a5a Release version 0.33.6 2026-11-01 20:30:00 +08:00
weishu 697f4930ee feat(android): question card like iOS
AskUserQuestion / request_user_input get their own card instead of the
generic tool card with an approval banner: one question per page,
outlined option rows with a Recommended badge, a collapsed other/note
field and a single primary action. Settled questions collapse to an
answer summary that opens the inspector. Wire answers and the sign-in
URL confirmation are unchanged.
2026-11-01 16:54:49 +08:00
weishu 5e9694f6fc fix(android): keep the session list entry refresh silent
The list screen is composed as soon as a predictive back gesture starts
previewing it, so the entry refresh flashed the pull-to-refresh spinner
on every back swipe. Only user-initiated refreshes show the indicator.
2026-11-01 16:39:27 +08:00
weishu 3b6f6d151b feat(android): refresh the session list like iOS
- Second section is Recent with compact headers; swipe to pin/unpin and
  archive or delete; Mark as Read in the long-press sheet
- 30dp agent icon with a presence badge (pulsing while running); no dimmed
  rows, row spinners or unread dot (semibold title + accent time)
- Transient connection status under the title after 2s; a failed refresh
  shows "Offline · updated …" with Retry
2026-11-01 16:00:56 +08:00
weishu 6ed83eda3c fix: show unread for running sessions
Running sessions previously suppressed the ordinary unread dot, hiding
answers the user had not seen. Unread is now
manualUnread || contentRevision > readRevision on web, iOS and Android.

Web rows draw the running/background spinner beside the unread dot, and
unread outranks background status. Because session patches arrive before
the message they count, a chat following its visible tail hides its own
ordinary dot until the rendered acknowledgement lands (display-only; web
and iPad split view).
2026-11-01 15:39:46 +08:00
weishu ba2b5c5651 Release version 0.33.5 2026-11-01 13:19:38 +08:00
weishu c94a968b81 fix(android): observe platform IME state in home search tests 2026-11-01 12:56:13 +08:00
weishu b0d01c5307 Release version 0.33.4 2026-11-01 12:26:43 +08:00
AnanovoandGitHub 639f71b6a6 test(android): name the IME phases and upload instrumentation reports (#2256)
* test(android): name the IME phases and upload instrumentation reports

* test(android): report Compose's IME inset height on failure

* test(android): import the WindowInsets.ime extension used by the diagnostic
2026-11-01 09:54:30 +08:00
Junmo KimandGitHub be41e854e0 feat(codex,opencode): show compaction summaries and token counts (#2249)
* fix(chat): render available compaction facts

* feat(opencode): unify native compaction summary cards

* fix(opencode): report actual compaction application lifecycle

* feat(codex): render native compaction checkpoints and metrics

* fix(chat): render compaction summaries and token facts in native clients

* fix(opencode): preserve transcript priming across read failures

* fix(opencode): recover snapshot-only replies after attachment
2026-11-01 09:54:10 +08:00
weishu 5ef4304a4a fix: synchronize read state across web and native clients
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices.

Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
2026-11-01 09:52:51 +08:00
weishu 1c861b2f0e feat(android): redesign home toolbar search and filters 2026-10-30 07:15:39 +08:00
weishu d9acc0c31e Release version 0.33.3 2026-10-29 22:29:49 +08:00
3f8686e623 feat(claude): offer the four current Claude models, drop the 1M presets (#2153)
* fix(claude): drop the obsolete 1M model presets

The opus, sonnet and fable aliases now resolve to models with a native 1M
window (Opus 5.5, Sonnet 5.5, Fable 5.1), so Claude Code documents the
"[1m]" suffix as having no effect on them and its own /model picker no
longer lists 1M rows. Remove Sonnet 1M / Opus 1M / Fable 1M from the
shared, Android and iOS catalogs.

Sessions stored with a "[1m]" value keep working: the composer keeps
the current model selectable, and the context-budget fallback treats any
"[1m]" model and the remaining aliases as 1M, so local-mode sessions on
plain opus/sonnet no longer show a 200k budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): seed a 1M turn-1 context window for bare Claude aliases

With the 1M presets gone, a session on plain fable/opus/sonnet must not
seed 200k before its first result: those aliases now resolve to models
with a native 1M window. Seed 1M when the selected model is a preset
alias; an explicit full id still seeds conservatively until its result
arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): offer the four current Claude models as presets

The Claude presets now mirror the aliases Claude Code's /model lists, in
its order: Opus, Fable, Sonnet, Haiku. Each alias resolves to the newest
model of its family, and all four have a native 1M window, so neither
1M variants nor pinned older versions are offered. Haiku was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): read the effective context window from get_context_usage

The 1M seed for the aliases is wrong for users who disable 1M
(CLAUDE_CODE_DISABLE_1M_CONTEXT) or cap it with autoCompactWindow. In
remote mode, ask Claude via the get_context_usage control request after
each system/init and use its maxTokens, which is what /context shows.
A result's raw contextWindow no longer overwrites that value. Older CLIs
that reject the request keep the existing seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-29 22:28:59 +08:00
weishu 01cdec1179 feat(android): share files and text to sessions 2026-10-29 21:17:19 +08:00
weishu 730dfcb22e Release version 0.33.2 2026-10-29 13:24:52 +08:00
weishu 4b3f92ea84 fix(android): keep session ordering timestamps consistently Long 2026-10-29 13:01:38 +08:00
weishu 01dabad204 fix(sessions): stabilize ordering by latest user input
Persist and backfill the last user-message timestamp, and use it for deterministic session ordering across web and native clients while preserving manual pins.

Remove the fixed-order setting and guard ordering against transcript echoes, replayed messages, and stale REST/SSE updates. Add regression coverage and regenerate the SSE fixture.
2026-10-29 12:47:52 +08:00
6ccde08548 feat(android): render settings pickers as expressive choice dialogs (#2227)
* feat(android): anchor the Material type scale on the shared reading tokens

MaterialTheme was built with only a colorScheme, leaving HapiTypography's
body/code/caption tokens an island that never influenced Material
typography. Derive the app-wide Material type scale from those tokens so
Material widgets and direct token consumers share one type system: the
body token drives bodyLarge (the chat reading size) and the caption token
drives bodySmall (the metadata size), while every other slot keeps its
Material platform default so existing MaterialTheme.typography call sites
stay pixel-identical. Only size and leading come from the tokens; weight,
tracking and family stay Material's. HapiTheme now accepts a typography
argument (defaulting to the shared scale) and passes it through, and the
monospace code token stays a direct-consumer token because Material has
no code slot.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* feat(android): render settings pickers as expressive choice dialogs

The theme and language pickers were the last bare AlertDialog + radio-row
surfaces in the app: plain stacked radio buttons with no visual anchor for
what each option means, and two focus targets per row (the row and the
RadioButton each carried their own click handler). Give small exclusive
pickers a shared expressive form: each option renders as a full-width
choice card whose selected state is a secondaryContainer fill plus a
trailing check, and options can carry a fixed preview swatch.

The theme modes show two-tone palette chips — follow-system spans the
light and dark palettes, OLED is outlined pure black, dark uses the dark
canvas rather than black so the two stay distinguishable at a glance.
Swatch colors are fixed values depicting the mode they name, not derived
from the active scheme, mirroring how the web color-theme cards hardcode
their previews: a "light" chip must stay light even inside a dark dialog.

Rows keep radio semantics through Modifier.selectable with Role.RadioButton
inside a selectableGroup, so TalkBack still announces the standard
"selected, n of m" picker phrasing while each option becomes a single
focus target. Tapping an option still applies it immediately. Language
options skip the swatch slot; the component stays generic over the value.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-29 10:57:55 +08:00
weishu a6cf8fd078 Release version 0.33.1 2026-10-29 10:13:33 +08:00
weishu ee273464f4 Release version 0.33.0 2026-10-29 08:51:11 +08:00
64f464190f feat(android): refactor settings into a hub with category subpages (#2218)
Replace the single-column settings screen with a category directory
whose rows come from a declarative catalog (SettingsHubCatalog: one
entry = route + title + summary + icon + visibility lambda), so adding
a category later (Display, Chat, Voice, Machines) is a list element,
not layout surgery. Mirrors the web settings IA at the category level.

- General subpage (settings/general) inherits the uncategorized
  sections: Appearance (theme + Material You), Language, About
  (versions + hub health). Zero behavior change per item.
- The owner-only Insights section dissolves into hub-level rows:
  Usage/Storage render only for the owner (same ns=default gate) and
  route to their existing dashboards, matching the web's owner-only
  nav categories (SettingsNav OWNER_ONLY_CATEGORIES).
- Flat settings/... route pattern with per-hub ViewModelHolder, same
  as the existing usage/storage precedents; back from a subpage lands
  on the hub. The hub's holder passes probeHealth=false so opening the
  hub itself fires no /health request; the General holder keeps it.
- Previews: hub owner/non-owner gating + General body. JVM tests pin
  catalog order, the owner gate, route uniqueness, and row summaries.
- Strings: settings_category_general(+_summary) in en/ru/zh; relocate
  three zh keys (chat_tool_resolved, chat_plan_implement,
  chat_plan_continue) that had drifted to the file tail and broke the
  three-language key order (KEYSYNC) — pure moves, values untouched.


via [HAPI](https://hapi.run)

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-28 15:20:53 +08:00
261cb3bb6b test(android): pin composer attachment encode hop to the test scheduler (#2189)
The ComposerAttachments harness left the constructor default
encodeDispatcher (Dispatchers.Default, a real thread pool) in place.
With two uploads in flight the second-launched one can finish encoding
first, reach FakeUploadApi.uploadFile first, and consume the scripted
results deque out of launch order — the queued failure gets stolen by
the upload meant to succeed, so the robbed chip never settles to its
scripted state and items.first {} hangs until runTest's dispatch
timeout (UncompletedCoroutinesError, intermittent under suite load).

Pin the encode hop to the scope's own dispatcher (the runTest scheduler
in every test), making upload arrival order equal launch order
deterministically; upload still hops dispatchers as in production. Add
a regression-lock test scripting two results over two concurrent adds.

Product code is untouched: upload() keys results by attachment id and
is arrival-order independent — the ordering dependency lives purely in
the test fake's deque.


via [HAPI](https://hapi.run)

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-28 12:13:08 +08:00
ffd950a220 fix(codex): drop safe-yolo from Codex permission modes (#2183)
* fix(codex): drop safe-yolo from Codex permission modes

Every Codex session runs on the native shared runtime (runCodex is
runSharedCodex), which rejects safe-yolo. The mode stayed in
CODEX_PERMISSION_MODES and six filters hid it again in the web picker,
composer, hub permission-mode route, hapi set-mode and both native
apps. API callers that validate against the flavor catalog (e.g. a
spawn with permissionMode: 'safe-yolo') still passed it through and
only failed inside the runner.

Remove it from the Codex catalog (shared, iOS, Android, fixture) and
delete the filters. autoReview is the native replacement. Safe Yolo
stays for Gemini, Kimi and Copilot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ios): Codex option list now has four modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:55 +08:00
fe2fbb881d fix: list Claude background tasks from the snapshot instead of guessing (#2177)
* fix: list Claude background tasks from the snapshot instead of guessing

The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".

The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): only attribute a Bash command from its background launch result

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): accept backgroundTasks in strict session patch decoders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:41 +08:00
weishu 3a9b5eefd1 docs: point users to the App Store and Google Play apps
The native iOS and Android apps are now published. Replace the
build-from-source install path with store links, mark the native apps as
paid and the Web/PWA as the free client, and add store buttons to the
website hero. Source builds remain documented for developers.
2026-10-28 10:32:21 +08:00
weishu 85ccab1ac6 Release version 0.31.3 2026-10-27 14:23:48 +08:00
weishu 7bf0e5bc35 Release version 0.31.2 2026-10-27 08:32:28 +08:00
65c8dc727a feat(android): session list date-range filter with activity-aware picker (#2141)
* feat(android): add session date-range filter with activity-aware picker

- Pure SessionDateRangeFilter port of the web SessionList.tsx semantics:
  strict YYYY-MM-DD parsing with calendar round-trip validation,
  [start local midnight, end local midnight + 1 day) windows anchored to
  the injected zone, session.updatedAt as the activity field
- M3 DateRangePicker dialog (opens on the selected start's month, Clear
  resets without closing) plus a lightweight recent-activity legend —
  declared divergence: M3 exposes no per-day decoration API, so the web's
  in-cell "has session activity" emphasis surfaces beside the picker
  instead; explicit Apply replaces the web's close-after-second-click
- Wired as a transient, unpersisted lens (web useState parity) applied
  before the machine scope; the activity-day set and machine chips both
  derive from the unfiltered list so filtering never hides alternatives
- Top-bar calendar action with active dot badge, active-range summary
  row with clear, date-specific empty state; strings in en/zh-rCN/ru
  (en-ru key order KEYSYNC clean)
- JVM tests: predicate window edges, DST spring-forward, zone-anchored
  midnights, strict parsing, VM lens combination and pre-filter
  activity-day derivation

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): refresh session date filters after timezone changes

* Deduplicate Android sessions before applying date filters

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-26 15:20:03 +08:00
b8675110b1 feat(android): add scratchlist row move, copy, and delete-confirm actions (#2132)
* feat(android): add scratchlist row move, copy, and delete-confirm actions

Align the scratchlist entry card with the web client's per-row actions
(ScratchlistPanel inventory):

- Move up/down: adjacent swap, disabled at the list edges, local-only
  like web v2 — no protocol request; the next refetch restores hub
  `createdAt DESC` order (pure `ScratchlistEntryOps.moveEntry`).
- Copy: copies only entry.text via ClipboardManager (FullTextAction
  precedent), swaps the icon to a check for 1500 ms on success, single
  per-list copied state, silent no-op on failure.
- Delete confirm: texts strictly longer than 100 characters (web
  SCRATCHLIST_CONFIRM_DELETE_THRESHOLD, untrimmed length) confirm via
  AlertDialog before delete, both from the row and the editor sheet;
  shorter notes delete directly.

New en/ru/zh-rCN strings ship in the same commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): guard saved scratchlist notes and restart copy feedback

* test(android): uniquely select scratchlist confirmation actions

* Fix Android scratchlist editor test targeting

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-26 15:14:17 +08:00
b3795f7aef feat(android): remember new-session launch defaults per machine and agent (#2138)
* feat(android): remember new-session launch defaults per machine and agent

Port of the web launch-preference persistence
(`hapi:newSession:launchSettings:v1:<machine>:<agent>`): model, effort,
reasoning effort and — for shared-permission-mode flavors only — the
permission mode of a successfully spawned session are remembered per
(machineId, agentFlavor) dimension and prefill the next create form for
that dimension (fresh screen, agent switch, machine switch).

- Storage: NewSessionPrefsData gains a nested `launchSettings` map inside
  the existing `hapi_prefs` DataStore blob; legacy blobs without the
  field decode to empty defaults, corrupt shapes keep the pre-existing
  whole-blob default degrade (DirectoryExpandPrefs contract).
- Capture happens at spawn success only (web saves on every
  create-success path); grok renders the native select but its mode is
  deliberately not remembered (web `usesSharedPermissionModeState`).
- Restore: resolvePreferredLaunchSettings validates claude's static model
  and effort lists, drops `xhigh` from opencode's reasoning efforts and
  coerces a removed permission mode to `default` — never a stale toggle;
  codex's dynamic model passes through for the existing settled-catalog
  reconciliation.
- A restored in-progress draft outranks the remembered defaults until the
  user switches agent or machine (web `preserveRestoredDraftRef`).
- Machine switches now also reset effort/reasoning effort/shared mode to
  the new dimension's remembered values (or sentinels), matching the web
  restore effect that re-runs on machineId change.

Divergences from web: no `cursorSelectedBase` (no Android picker yet);
agy's static model presets are not re-validated on Android (no v1 picker,
the stored value is always `auto`).

Tests: prefs round-trip/cold-start, legacy-blob tolerance, corrupt blob
and corrupt-entry fallback (real temp-file DataStore, pure JVM); capture
gating and resolve coercion logic; VM prefill per dimension, machine and
agent switching, spawn-success capture, and draft precedence.

Co-Authored-By: Claude Code <noreply@anthropic.com>

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): preserve launch selections across machine heartbeats

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-26 14:34:42 +08:00
5d4dc058a5 feat(android): permission input semantics parity (editor inputType, URL confirm, grok auto gate) (#2127)
* feat(android): settled permission verdict details

Port the web PermissionFooter verdict classification so a settled
permission row explains HOW a request was decided, not just that it was.

Behavior vs web (web/src/components/ToolCard/PermissionFooter.tsx:11-63):

- codex family sessions (flavor family + Codex/Gemini/OpenCode/Copilot/
  Cursor tool prefixes): approved + decision approved_for_session shows
  "Approved for session"; denied + decision abort shows "Aborted" with
  the wire reason appended when present.
- claude sessions: mode acceptEdits shows "Approved: allow all edits"
  (previously the raw mode id was appended); decision approved_for_session
  or an allowedTools hit — plain tool name or the `Bash(command)`
  qualifier built from the tool input command/cmd — shows
  "Approved for session" (previously indistinguishable from a plain
  approval).
- canceled now appends the wire reason (previously dropped).
- resolved/pending/denied unchanged in shape; denied keeps the error tone
  and aborted shares it.

Deliberate divergence: web's own settled footer renders only a minimal
red reason box (PermissionFooter.tsx:207-220) — its formatPermissionSummary
classification is unreachable there. Android renders the classified
verdict line in the existing subdued row style instead, as intended.

Implementation: new pure JVM classifier
feature/chat/permissions/PermissionVerdict.kt (exhaustive status ->
PermissionVerdictLabel mapping, unit-tested in PermissionVerdictTest);
ToolCallBlockView.PermissionStateRow becomes a thin renderer over it.
Strings added in en/zh-rCN/ru (ru mirrors en key order; zh values from
web locales).

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* feat(android): persistent permission-mode chip + inactive gating

Item 2 — persistent permission-mode chip above the composer, mirroring
the web StatusBar mode span (StatusBar.tsx:253-265, 389-393):
- New PermissionModeChip composable + PermissionModeChipRule pure object:
  resolve the raw wire mode, keep it only when the flavor offers it, and
  hide `default` everywhere except copilot (model=auto sessions still
  surface the chip). Unknown wire ids hide (no raw fallback).
- Tone mapping follows the web PERMISSION_TONE_CLASSES (StatusBar.tsx:
  43-48): neutral=onSurfaceVariant, info=primary, warning=tertiary,
  danger=error. Labels stay en catalog strings — web mode labels are
  hardcoded English, not i18n.
- The chip consults the unfiltered flavor catalog: the concurrent-clients
  Safe-Yolo carve-out only narrows the switcher, never the chip.
- Deliberate divergence: the web span is not clickable (its switcher is
  the composer settings sheet / Shift+Tab). Android has no Shift+Tab, so
  tapping the chip opens the session config sheet that hosts the
  switcher — same surface as the top-bar gear, no new top-bar icon
  (ChatScreen keeps its two-icons-max rule).

Item 4 — permission-mode gating aligned with web (SessionChat.tsx:
2079, 2138-2163; HappyComposer.tsx:536-537, 1544-1549, 1960-1995):
- Inactive sessions stay switchable, matching web: SessionChat passes
  allowSendWhenInactive unconditionally, so configurationControlsDisabled
  never trips for inactive threads and the offline notice stays
  informational only (the send path auto-resumes).
- The permission section now disappears for copilot sessions under local
  terminal control — web withholds onPermissionModeChange there and the
  whole section then hides (HappyComposer.tsx:1595). Other flavors keep
  switching while terminal-controlled.
- ChatViewModel.setPermissionMode gains the matching belt-and-suspenders
  guard; the controlledByUser derivation is extracted into
  terminalControlled() (controlledByUser && !concurrentClients, same as
  SessionChat.tsx:996) and shared with buildConfigUi.

Tests: PermissionModeChipTest (6 cases — default/codex/copilot/pi/unknown/
safe-yolo), ChatViewModelInteractionTest +3 (withheld for terminal
copilot; survives terminal claude + shared-clients copilot; inactive
stays switchable). No new user-visible strings (chip label is catalog
en, contentDescription reuses chat_config_permission_mode).

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* test(android): derive the dsh chip expectation from the flavor catalog

The hardcoded "dsh offers no modes" assumption breaks on trees where dsh
gained the codex-style mode list — derive the expectation from the
catalog so the test pins the actual contract (the chip mirrors the
flavor's offered list).

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* feat(android): permission input semantics parity (editor inputType, URL confirm, grok auto gate)

Three behavior alignments with the web app's permission surfaces:

1. Editor inputType (web requestUserInput.ts:152-153, 191-195):
   a required text question with `inputType === 'editor'` is always
   answered and its note is pushed untrimmed (even the literal
   "user_note: "); non-editor notes stay trimmed and optional.
   Android: PermissionInputs.requestUserInputAnswerValues /
   isRequestUserInputAnswered now take the question, gate on the
   literal "editor" inputType; the footer's note field grows to
   minLines = 2 to invite multi-line answers.

2. request_user_input URL confirmation (web requestUserInput.ts:46-70,
   129-141; RequestUserInputFooter.tsx:142-152): the sign-in url only
   survives when it parses as http/https; submitting requires the
   CLI-synthesized __mcp_url_confirmation option (description === url)
   to be selected, and submission opens the url before approving.
   Android: parseRequestUserInput + normalizeRequestUserInputUrl +
   isRequestUserInputUrlConfirmed; the footer enforces the confirm
   guard, then opens the url via ACTION_VIEW. Divergences: the raw
   wire string is compared (the CLI synthesizes the description from
   the same value web would re-normalize, so they are equivalent for
   every CLI-produced request), and the open-failure message is
   platform-adapted (no popup blocker on Android).

3. Grok Auto permission gate (web NewSessionForm index.tsx + grok
   models probe): when the machine reports
   `autoPermissionModeSupported === false` for the chosen cwd, the Auto
   option is disabled with an " (unavailable)" suffix, a hint line is
   shown, and an existing Auto selection resets to Default. The gate is
   grok-only (claude and other agents never gate) and tri-state — null
   (unknown, probe pending or failed) never disables.
   Android: GrokModelsResponse wire type (nullable field),
   HapiApi.getMachineGrokModelsForCwd, NewSessionViewModel probes on
   agent/cwd/machine changes (deduped, cancellable, existence-gated),
   OptionItem gains `enabled`, the permission dropdown honors it and
   renders the hint. In-session switching stays ungated, matching web.

New strings in en / zh-rCN / ru (KEYSYNC: en and ru key order
identical).

Tests: RequestUserInputTest covers editor/untrimmed-note/url
parsing/confirmation semantics; NewSessionViewModelTest covers gate
disable+reset+hint, tri-state never-gate cases, and agent/cwd gating;
HapiApiTest covers the cwd query param and nullable decoding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): accept browser-valid sign-in URLs

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-26 14:34:16 +08:00
b5b75f7e41 feat(android): session-list sidebar parity — dedup, stub hiding, relevance search (#2104)
* android/protocol: carry hasConversationContent through the session projection

The hub sends `hasConversationContent` on both `SessionSummary` and the
full `Session`; the sidebar's empty-stub hiding (web `SessionList.tsx`)
depends on it. Decode it on both wire models and project it through
`toSessionSummary` exactly like shared/src/sessionSummary.ts does
(`session.hasConversationContent ?? false`), so SSE upserts land with the
same signal REST lists already carry. Optional-with-default keeps older
hub payloads decodable.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* android: session-list sidebar parity — agent-session dedup, stub hiding, relevance search

Port the web sidebar's list body to the Android session list:

- Collapse duplicate views of one agent session (resume/reopen chains)
  into a single row — winner by active → selected → pin → recency —
  keyed on `flavor:agentSessionId` (web `getSessionDedupKey`).
- Hide fresh auto-created "new session" stubs: inactive, conversation-
  less, no title signal and no agent id (web `isSidebarEmptySessionStub`);
  selected/active/pinned rows are never hidden.
- Live search box with the web's relevance ranking (field weights,
  boundary + title-phrase bonuses, wildcard `*`/`?`, AND across terms)
  and a distinct no-results state. Pinned rows stay contiguous while
  searching.

Pure logic lives in `SessionListFilters` / `SessionListSearch` (Kotlin
objects, JVM-tested against the web reference cases); the ViewModel
pipeline is visible → dedup+stub → canonical sort → search filter+rank.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* android: ru translations for session search parity

Upstream landed values-ru after this stack's merge base; new strings
need ru entries or :app:lintDebug fails with MissingTranslation.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): preserve global pins in session search

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
2026-10-26 14:33:59 +08:00
878ca2058c feat(codex): expose Approve for me as a permission mode (#2016) (#2090)
* feat(codex): expose Approve for me as a permission mode (#2016)

Add autoReview to the Codex permission catalog. It keeps the
workspace-write sandbox with on-request approvals and routes escalations
to Codex's native reviewer (approvals_reviewer = "auto_review"), the same
config as `hapi codex --approve-for-me`. It works at launch and mid-session
via thread/settings/update; explicit modes now reset the reviewer to user,
and sessions report autoReview from the native reviewer setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): expect Auto-review in the Codex permission lists (#2016)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(modes): label autoReview as Auto, matching Claude's auto mode (#2016)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:47 +08:00
431c170f4a feat(android): persistent permission-mode chip + inactive gating (#2125)
* 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>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-25 15:34:11 +08:00
4c87a0f1a2 feat(android): settled permission verdict details (#2123)
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).


via [HAPI](https://hapi.run)

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-25 15:34:01 +08:00
99cd3d4172 feat(android): add Russian locale (#1963)
* feat(android): add Russian locale

Translate all 518 app strings into values-ru/strings.xml, register ru in
locales_config.xml, and add AppLanguage.RUSSIAN to the in-app language
picker.

* fix(android): use count-agnostic Russian forms for dynamic counts

The *_many strings used a genitive-plural noun after the number, which is
wrong for 2-4 (e.g. "2 инструментов"). The house style avoids <plurals>,
so phrase these labels with the noun first ("Инструменты: %1") instead:
tool groups, review findings, queued/new messages, agent steps, questions,
lines, chars, edits, usage requests, scratchlist count and attachment cap.

* fix(android): make the process-steps label count-safe

* fix(android): use a count-safe token label in the usage tooltip

---------

Co-authored-by: mrudy <mrudy@local>
2026-10-05 17:14:11 +08:00
weishu facb66ee03 Release version 0.30.7 2026-09-15 09:08:33 +08:00
weishu 6d44996a59 Release version 0.30.6 2026-09-14 15:05:49 +08:00
weishu 5280a3b165 fix(chat): dismiss Codex plan actions when continuing planning 2026-09-14 14:30:08 +08:00
weishu b7e892b99e fix(release): sync iOS and Android versions 2026-09-14 09:48:19 +08:00
AnanovoandGitHub b41b9293e9 fix(web): render cold sessions from a small initial page (#1809)
* fix(web): speed up cold session first paint

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

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

* feat(native): match cold session pagination

* test(native): cover cached re-entry page size
2026-09-14 09:07:14 +08:00
weishu 0a9f15a9a7 fix: align request_user_input other answers across clients 2026-09-13 19:48:14 +08:00
weishu a6880fe592 fix(native): add Codex plan proposal actions
Add current-proposal decoding, dedicated implementation requests and composer-only continuation on iOS and Android. Keep pending/errors across row recycling, reject stale actions and prevent duplicate submissions.

Include native regression tests and localized plan controls.
2026-09-13 19:48:14 +08:00
AnanovoandGitHub 7e7b16767b fix(android): stabilize tool group inspection on API 36 (#1843) 2026-09-13 18:56:56 +08:00