mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-06 01:11:37 +00:00
0de50e6abcc9ddcf57cadd9bcbc7d72ff6b2ba0e
50
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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> |
||
|
|
4b3f92ea84 | fix(android): keep session ordering timestamps consistently Long | ||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
b41b9293e9 |
fix(web): render cold sessions from a small initial page (#1809)
* fix(web): speed up cold session first paint * test(web): update pagination fixtures for cold page * test(web): wait for cold page before loading history * feat(native): match cold session pagination * test(native): cover cached re-entry page size |
||
|
|
a6880fe592 |
fix(native): add Codex plan proposal actions
Add current-proposal decoding, dedicated implementation requests and composer-only continuation on iOS and Android. Keep pending/errors across row recycling, reject stale actions and prevent duplicate submissions. Include native regression tests and localized plan controls. |
||
|
|
26fddff038 |
fix(chat): restore grouping for shared Codex commands
Group ordinary Codex commands with default tools across web, iOS and Android while preserving exploration and user-shell boundaries. Add regression tests, generated protocol fixtures and shared-command coverage in the iOS transcript UI suite. |
||
|
|
6f4aacce6b | feat(android): enable default push through official relay | ||
|
|
0c4abcb3d1 |
feat(codex): share sessions across terminal and web
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle. Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes. Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation. |
||
|
|
a729456682 |
fix(claude): answer local permission prompts from web
Bridge main-session PermissionRequest hooks without suppressing the native terminal dialog. Reconcile replies against native results and clean up on timeout, cancellation, mode switches, and session changes. Keep reply IDs distinct from native tool IDs across web and native clients; add protocol fixtures and regression tests. Refs #1796 |
||
|
|
c2e3d16b7e |
feat(native): improve anchored chat scrolling and history loading
Add viewport-driven paging with layout acknowledgements, bounded retries, cancellation gates, and epoch-safe history retention. Preserve transcript anchors and expansion state, fix tool-group identity collisions, and serialize Android history coordination on Main. Reduce per-scroll composition and layout work; add native regression tests, CI coverage, and profiling guidance. |
||
|
|
3873e58496 |
fix(web): keep streamed reasoning/text block ids stable across snapshot rows (#1741)
* fix(web): keep streamed reasoning/text block ids stable across snapshot rows
Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.
Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.
Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.
* fix(ios,android): mirror stream-stable block ids in native chat ports
The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.
Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.
* fix(web,ios,android): reject blank stream ids as block identity
Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.
Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.
* fix(ios): use normalized stream id for block construction identity
The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.
Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.
* fix(web): pin blank stream-id identity contract in golden fixtures
Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.
* feat(hub): make title provider max_tokens and timeout env-tunable
Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.
Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.
* docs(hub): document title provider max_tokens/timeout env knobs
Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
|
||
|
|
4948d23669 | fix(android): enforce Google Play compliance | ||
|
|
e5a8212f4a | feat(session): validate agents and browse workspace directories | ||
|
|
be1ef2a2e4 |
feat(dsh): integrate DeepSeek Harness through ACP (#1632)
* feat(dsh): add DeepSeek Harness ACP flavor * fix(dsh): update mobile flavor catalogs * fix(dsh): keep mobile spawn policy managed * fix(dsh): keep managed policy and prompt retry * fix(dsh): suppress unsupported runner policy flags * fix(dsh): align native managed-policy UX |
||
|
|
0aebf39c78 |
fix(opencode): keep one stored message per reasoning stream (#1643)
* fix(acp): carry the live reasoning marker on the wire payload ACP agents stream thoughts a token at a time, so the handler coalesces them into a buffer and re-sends the whole buffer under a stable stream id every 250ms. The converter dropped the marker that says a payload is one of those throttled snapshots, leaving the hub unable to tell a replaceable snapshot from the settled message that closes the stream. Mirrors how the text variant already forwards streamSnapshot. * fix(hub): keep one stored message per reasoning stream OpenCode reasoning arrives as a series of growing snapshots sharing one stream id, and every snapshot was persisted as its own message. A 26h session reached 48,844 rows and 63MB, and because the web budgets a fixed number of messages, its 400-message window covered barely three minutes of conversation — scrolling up walked through duplicate snapshots instead of history. Retire a stream's earlier live snapshots once their replacement is stored. Sweeping only after the insert matters: the two statements are separate transactions, so clearing first would leave a window where a crash takes the whole stream. Only rows marked live are eligible and the replacement is spared, so a stream always keeps at least one row and the settled message that closes it is never removed. Live rendering is unchanged: the web still receives every snapshot and already folds them by stream id. * fix(web): spend the message window on conversation, not repeated snapshots The window budgets raw messages, but a reasoning stream renders as a single folded block no matter how many snapshots it arrived in. On sessions recorded before the hub started retiring them, those snapshots fill the window on their own: in one 26h session the newest 400 messages covered 202 seconds, so scrolling up paged through duplicates instead of history. Collapse each stream to its newest snapshot before trimming. Rendering is unchanged — the timeline already folds them by stream id — and rows without a stream id are never touched. * fix(ios,android): port reasoning-snapshot compaction to the native windows The window logic in HapiProtocol and :core:protocol is a one-to-one port of the web store, so collapsing superseded reasoning snapshots only on the web left the native windows budgeting raw snapshot rows. The hub stores one row per stream now, but a client that already holds the older snapshots still spends its window on them. Add the same stream-id reader and compaction to both ports, in the shape each already uses for agent-run rows, and pin the behaviour with a pagination fixture. Both fixture suites enumerate shared/fixtures/pagination from disk, so the ports cannot drift from the web again without CI saying so. |
||
|
|
f0e5ba9c0f |
feat(codex): mid-turn Steer via app-server turn/steer (#888) (#1606)
* feat(shared): steer capability gates and live steered signal schemas - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which agents can deliver queued messages into the active turn (pi, codex, cursor ACP; legacy stream-json cursor excluded) - AgentState.steeringActive, DecryptedMessage.steered and messages-consumed live signal (never persisted by the hub) * feat(cli): queue reservations and steered messages-consumed option - MessageQueue2 gains takeByLocalId/restoreReservation/ beginReservationDispatch/commitReservation so an async steer can reserve a queued row without racing the main loop's turn/start drain - emitMessagesConsumed accepts steered: true to mark mid-turn delivery * feat(codex): mid-turn steer via app-server turn/steer (#888) - CodexAppServerClient.steerTurn + TurnSteerParams/Response types - CodexRemoteLauncher registers the steer-queued-message RPC handler: reserves the queued row, validates it against the active turn (no control commands, matching mode hash), injects via turn/steer with an epoch guard that invalidates in-flight steers on abort/cleanup - steeringActive agent state tracks the active-turn window - hub syncEngine gate opens to codex; messages-consumed relays steered * feat(web): Steered badge and steer gating for codex sessions - HappyUserMessage shows a ↳ Steered badge fed by the live messages-consumed steered signal, preserved across server echoes and refetches (mergeMessages carries the optimistic marker) - SessionChat gates canSteer via isSteeringSupportedForSession instead of the pi-only check - clearStaleQueuedStatus normalizes a queued status on an invoked message - fix(web): drop duplicate showSessionSummaryInChat in markdown test (upstream typecheck breakage) * fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise codex and pi only; cursor joins when its soft-steer handler lands (#1609) - turn/steer now splits dispatch (stdin accepted) from completion (turn finished): the hub RPC acks once dispatch succeeds — never on the concurrent turn's completion, which can exceed the 30s RPC window - queue row commits only after the turn settles; a rejected/aborted steer restores the row so the message still delivers via turn/start, and a dispatched steer is never restored (no duplicate delivery) - steer carries clientUserMessageId (echoed as userMessage.clientId) so ambiguous transport failures can reconcile the thread later - client tests cover dispatch/complete split and stdin-write failure * fix(codex): reconcile dispatched steers before restoring; align error copy - A dispatched turn/steer whose completion fails (disconnect / protocol error) is now reconciled via thread/read by clientUserMessageId before the queued row is restored — the instruction is only re-delivered by turn/start when the thread never received it - Reconcile targets the pinned steer thread, not whichever turn is current when completion fails - syncEngine unsupported-flavor error now matches the capability gate (Pi and Codex only until the cursor handler lands) - launcher tests cover steer success (ack on dispatch), reconcile-accepted and reconcile-rejected outcomes * fix(codex): consume the row at dispatch; drop background reconcile - The hub RPC acks and the queue row is consumed as soon as stdin accepts turn/steer; completion is background-only logging. A dispatched steer is never restored, so the same localId cannot be re-delivered via turn/start after the caller was told the steer succeeded - Dispatch failure (stdin write error) still restores the row and reports failure - steer.completed rejection is always handled (no unhandled rejection on the dispatch-failure path) - tests updated: completion failure after dispatch keeps the row consumed; dispatch failure restores it * fix(codex): distinguish definite rejection from indeterminate completion - Transport-level failures (timeout, abort, disconnect, spawn, protocol) carry an indeterminate marker; explicit JSON-RPC error responses do not - After a dispatched steer, turn completion resolves → commit + consumed; a definite app-server rejection restores the row (instruction was never accepted, so turn/start cannot duplicate it); an indeterminate outcome leaves the row reserved so it can never be delivered twice - Completion handling registers before awaiting dispatch so the dispatch-failure path cannot leak an unhandled rejection - client/launcher tests cover explicit rejection (restore), indeterminate outcome (row stays reserved) and dispatch failure * fix(codex): reconcile indeterminate steers instead of a permanent reservation - After an indeterminate completion (disconnect/protocol), reconcile the thread by clientUserMessageId immediately: accepted → commit + consumed, provably rejected → restore, still unreadable → keep the reservation and retry from the main-loop top on later passes (post-reconnect) - A row never sits in dispatching forever: the hub cannot stamp it invoked while the instruction may never have been accepted - tests: indeterminate keeps reserved while thread unreadable; accepted reconciliation consumes; rejected path restores * fix(codex): accept all thread item shapes; retry reconcile; ack through abort - Reconcile matcher accepts userMessage/user_message with clientId/ client_id, matching the shapes the thread parser supports — an accepted steer can no longer be misclassified as rejected - A pending reconciliation schedules a wakeLoop retry, so a temporary app-server outage cannot strand the reservation behind waitForTurnOrRecovery - The success-path ACK no longer checks the steer epoch: the hub already reported steered on dispatch, so commit + messages-consumed must reach it even when an abort resets the queue in between * fix(codex): reinit reconnected app-server; keep reconcile retries alive - thread/read after a disconnect auto-connects a fresh app-server, which must be initialized before any request — reconcile now ensures connect + initialize (isConnected getter added to the client) - every still-unknown loop-top reconciliation schedules the next retry, so recovery without external traffic is eventually observed - launcher mock gains isConnected * fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK - Reconciliation runs on a self-rescheduling 1s timer independent of the main loop (wakes it too), so idle loops and waitForTurnOrRecovery still observe app-server recovery; abort clears nothing implicitly — the ACK path commits and consumes even when the reservation was cancelled - Absence of a durable client id is ambiguous: unmatched reads stay 'unknown' and keep retrying instead of restoring the row - CodexAppServerClient tracks initialized state (reset on disconnect/exit) so ensureAppServerInitialized re-initializes a fresh process before thread/read; initialize failures leave the flag false for the next retry - tests: accepted reconciliation via scheduled timer, indeterminate keeps reserved, explicit rejection restores * fix(codex): bind reconciliation to the launcher lifecycle - runSteerReconciliation clears any armed retry timer on entry and never installs a second one, so loop-top and timer-driven passes cannot multiply - shuttingDown is set when the main loop ends: timers are cleared and the pending map is dropped, so an unresolved steer can never respawn an app-server after cleanup (remote-to-local switch included) * fix(codex): report steered only after app-server acceptance - The handler now awaits steer.completed (the inject-acceptance response): an explicit JSON-RPC rejection surfaces as failed and restores the row for the normal turn/start path instead of a false steered - Transport failure after dispatch reports 'Steer outcome is being reconciled' and keeps the row reserved while the timer-driven thread reconciliation runs - dispatch-failure path also swallows the paired completion rejection * fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait - MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching steer reservation: the hub neither deletes the row nor stamps invoked_at (new CancelMessageResponse 'busy' status; web restores the optimistic row); pushIsolateAndClear and reset/close share cancelReservations so /clear-style commands cannot have a rejected steer resurrect a discarded prompt - turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a lost response is indeterminate and funnels into thread reconciliation instead of stranding the reservation - tests updated for the tri-state cancel contract * fix(codex,web): busy-aware edit flow; bound reconciliation reads - QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it never prefills the composer when the row is inside an async steer, so a second client cannot send a duplicate - reconcileSteerByClientId bounds thread/read with a 5s timeout so a connected-but-silent app-server cannot hold the reservation in-flight indefinitely * fix(steer): inFlight-dominated cancel acks; bounded reconciliation - hub cancel-queued-message acks check inFlight before removed: a stale duplicate socket reporting removed can no longer delete the durable row while another socket is dispatching the steer - reconciliation entries expire after 60s and mark delivered: after the rejection window, a dispatched steer that the app-server never proved (client ids dropped on restart) is committed instead of polling thread/read forever - pre-dispatch failures (abort before write included) never enter reconciliation — they restore the row and report failure * fix(steer): persist indeterminate outcomes without replay * fix(steer): make ambiguous delivery restart-safe * fix(steer): recover crash-held rows and preserve retry dedup * fix(steer): ack retries and bound stdin dispatch * fix(steer): reconcile indeterminate dispatches and serialize retries * fix(codex): classify stdin callback failures as indeterminate * fix(steer): recheck indeterminate cancels after ACK * fix(steer): close retry and abort races * fix(steer): serialize live retries and abort admission * fix(steer): distinguish live dispatching from unknown * fix(steer): keep ACK failures held and reconcile busy cancel * fix(steer): distinguish held cancel from removal * fix(store): combine schema v24 migrations * fix(store): reserve schema v25 for steer delivery state * fix(steer): keep held cancel state and notify requeue * fix(steer): release explicitly cancelled unknown reservations * fix(codex): reject cancelled reservations before native steer * fix(codex): make reservation restore atomic with state * fix(codex): terminate abandoned transport writes * fix(steer): own abandoned app-server lifecycle and consume races * fix(codex): confirm dispatch and recover abandoned turns * test(codex): mock abandoned transport callback * fix(codex): clear visible turn state on transport loss * fix(steer): claim retries and cover native delivery state * fix(native): preserve indeterminate state on Android hydration * fix(steer): make retry claims single-winner * fix(steer): serialize concurrent retry claims * fix(socket): tolerate missing steer-state ACK callbacks * fix(native): serialize retry operations * docs(web): document unknown steer delivery and retry controls * fix(steer): handle retry failures and abort-before-connect * fix(steer): reinitialize after transport loss and finish iOS retry errors * fix(steer): preserve indeterminate rows across reconnect gaps * test(web): mock indeterminate queued recovery state * fix(steer): recover consumed ACK tombstones * fix(steer): expose consumed cancel tombstones |
||
|
|
b9d5f803b2 |
fix(android): device-feedback round 1
- decode fs.stat-derived epoch fields leniently (fractional mtimeMs from real hubs broke machines/files decode and pinned the offline banner) - offline banner: only a failed sessions fetch counts; machines/baseline failures are advisory; live SSE emission clears it and seeds the unread baseline (all-rows-unread gray dot cascade) - session row: single weighted title (short names no longer truncated at half width), unread dot moved beside the timestamp - composer restyle: borderless input pill with inline mic, hand-drawn vector glyphs replacing emoji icons, 42dp round actions, park-draft relocated to the session overflow menu |
||
|
|
0f3bf5ca1b |
merge: B-M4d Android scratchlist
# Conflicts: # android/app/src/main/kotlin/app/hapi/companion/Navigation.kt # android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt # android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt # android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt |
||
|
|
33d186f444 |
feat(android): scratchlist (B-M4d)
Per-session notes/drafts workbench, the native twin of the web ScratchlistPanel + use-hub-scratchlist (tiann/hapi#893): - wire/ScratchlistApi.kt: entry/attachment/limits DTOs mirroring shared/src schemas, caps (200 entries / 10k chars), typed error codes (scratchlist_at_cap, _attachment_too_large, _attachment_in_use, ...). - HapiApi: entries CRUD (POST idempotent-on-entryId), limits GET, base64-JSON attachment upload, raw-bytes fetch over the cached image client, attachment delete. - SessionStore now surfaces scratchlistUpdatedAt patches as a scratchlistInvalidations SharedFlow (bare refetch trigger, sse.md). - ScratchlistStore: per-session entries StateFlow, refresh on open + on SSE signal (observed sessions only, 16 ms coalesced), optimistic create/update/delete with surgical rollback, friendly atCap state (local pre-check + hub 409), uploads-in-flight progress, cached limits with offline defaults. ScratchlistAttachmentGuard holds the pure pre-upload budget verdicts (fits / downscale-to-target / reject). - feature/scratchlist: chat/{id}/scratchlist route, entry cards (text preview, relative age, authed Coil thumbnails), edit sheet (text + attachment strip with photo picker, downscale-to-JPEG import, remove, delete), FAB new note, full-screen viewer (generated-image pattern). - Chat seams: top-bar notepad badge (entry count), composer overflow "Park draft to scratchlist" (clears only after the hub accepts), and per-entry "To composer" that inserts into the live ChatViewModel of the chat entry below the route. Tests: store CRUD optimistic/rollback + cap 409 + invalidation-signal refetch + upload progress/413 + attachment delete 409 (MockWebServer), guard verdicts, ChatViewModel park/insert/badge seams (fake store). |
||
|
|
75f830002b |
merge: B-M4e Android usage/storage dashboards + settings
# Conflicts: # android/app/src/main/kotlin/app/hapi/companion/Navigation.kt |
||
|
|
769e0ff390 |
feat(android): usage/storage dashboards + settings scaffold (B-M4e)
- wire: UsageApi.kt (UsageSummaryResponse/buckets/totals, SqliteStorageUsageResponse) - api: GET /api/usage/summary?range&timeZone + GET /api/storage/sqlite on HapiApi - feature/settings: settings home (theme system/light/dark/OLED + Material You toggle, language persist-only until M5, About with app/protocol/hub health), usage dashboard (range control, 8 stat tiles, Canvas daily bars with tap tooltip, byAgent/byModel bar lists), storage dashboard (Canvas donut + rows) - owner gating: JwtPeek ns == 'default' hides Usage/Storage entries (web SettingsNav parity); screens map 403 to an owner-only explanation - theme plumbing: ThemePrefs/LanguagePrefs on hapi_prefs DataStore, exposed via AppGraph, applied at MainActivity setContent through HapiTheme(oled/dynamic) - tests: usage/storage wire decode, HapiApi query+403 shapes, formatting/ hit-rate/calendar-fill/slice math, theme+language round-trips, ns gating, usage/storage/settings viewmodels |
||
|
|
210f9b3ff7 |
feat(android): files/git browser + file viewer (B-M4c)
:core:protocol git/: GitStatusParser (porcelain v2 --branch: branch headers
incl. detached/initial, 1/2/u records, untracked/ignored) + NumstatParser
(counts, binary, brace + plain rename normalization) + buildGitStatusFiles
merge — behavior cross-checked against web/src/lib/gitParsers.ts by running
the same inputs through the TS implementation, quirks preserved.
Endpoints: HapiApi gains git-status, git-diff-numstat(?staged),
git-diff-file(path, staged?), file read (base64), files search (?query&limit),
directory (?path); wire types in new wire/FilesApi.kt.
feature/files: FilesScreen (chat/{id}/files) with Changes (branch header,
staged/unstaged sections, status letters + ±counts), Browse (lazy directory
tree, dirs-first sort, hidden-file toggle), Search (300ms debounced) tabs;
FileViewerScreen (chat/{id}/file?path&staged&mode&line) with diff mode
(UnifiedDiffParser → DiffView, staged/unstaged toggle) ⇄ full mode (CodeBlock
with 400-line highlight cap, markdown Source/Preview via the shared Markdown
renderer, image bitmaps), copy path, middle-ellipsis path. Chat wiring: file
citations open the viewer in full mode (cited line shown as a hint chip — no
per-line highlight inside the single-Text CodeBlock), top-bar folder icon
opens the files browser.
Tests: parser fixtures (renames, binary, detached, conflicts, untracked
dirs) + VM tests with fakes (numstat merge, degraded numstat banner, lazy
directory loading/cache, hidden filter, search debounce, diff auto-fallback,
staged reload, base64/image/binary decode).
|
||
|
|
c164af0738 |
feat(android): dictation, slash commands, session ops (B-M3ce)
- Voice dictation: DictationController (seam over recorder + api, JVM-tested), MediaRecorder m4a/AAC recorder, provider discovery via GET /api/voice/transcription/providers (new HapiApi method + wire types), mic button + recording chip + RECORD_AUDIO flow in the composer, transcript appended with space separator (web appendTranscript twin). - Slash commands: '/' at start-of-input opens a dropdown merging metadata.slashCommands names with GET /slash-commands (RPC wins dedupe, exact>prefix>contains filter); tap inserts '/name '. Skills '$' deferred. - Session ops: SessionStore gains rename (optimistic + roll-forward), delete (optimistic + 409 restore), reopen (marks returned id active); list long-press sheet + chat top-bar overflow wire Rename/Reopen/Delete; chat reopen reuses the supersede path (window seed + draft move + ChatEvent.SessionSuperseded); 422 formatted via formatReopenError; inactive-session affordance bar above the composer. - Additive wire/API: TranscriptionProvidersResponse/TranscriptionProviderInfo, HapiApi.getTranscriptionProviders, ChatSessionApi.getSlashCommands, SessionListStore rename/delete/reopen. - Manifest: RECORD_AUDIO + microphone uses-feature required=false. README blurb. - Tests: DictationController (happy/no-provider/errors/cancel), slash merge/filter/trigger, store rename/delete/reopen (MockWebServer), VM reopen-supersede/delete/rename/slash suggestions; full gate green (protocol 227, data 148, app 96 tests; assembleDebug OK). |
||
|
|
3ce85936c7 | fix(android): dedupe CodexModels wire types from parallel B-M3ab/B-M3d merges | ||
|
|
ca138c912c | merge: B-M3ab Android composer + permission actions + session config | ||
|
|
78a5ff9961 |
feat(android): composer, permission actions, session config (B-M3ab)
Interaction layer turning the read-only chat into a working remote control:
- Composer: multiline input bar with optimistic sends (appendOptimistic ->
POST -> status settle), queue-by-default delivery with a long-press
Send&Steer intent while a turn is active, abort button during thinking,
tap-to-retry on failed rows, per-session drafts (DataStore, hub-scoped
keys), attachment chip seam for M4.
- session_inactive (409) recovery: one POST /resume then retry; a
superseding session id seeds the new window, migrates the draft and
emits SessionSuperseded for renavigation (web resolveSessionId parity).
- Queued bar: uninvoked sends with Cancel (DELETE; invoked-race ingests the
authoritative row as sent), Edit (cancel + composer prefill, draft-kept
guard) and Steer (POST steer; invoked answers reconcile a missed consume).
reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
claude {} / allowTools / mode:acceptEdits, codex-family decision:
approved / approved_for_session / abort -- plus AskUserQuestion flat
answers and request_user_input nested answers forms; optimistic
Resolving/AlreadyHandled overrides settled by the agentState patch.
- Session config sheet: catalog-driven permission-mode picker, claude
static model/effort catalogs (ported to :core:protocol catalog), codex
models via GET /codex-models (new HapiApi endpoint + wire types) with
per-model reasoning efforts; optimistic detail updates rolled back to
server truth on error.
- Lifecycle: ProcessLifecycleOwner -> SseEngine.setLifecycleForeground +
POST /api/visibility per subscription (VisibilityReporter fed by the new
SyncTargets.onHandshake hook); the global SSE pipe moved from the session
list VM to HubGraph lifetime (GlobalSsePipe) so queued/consumed
bookkeeping and list badges stay fresh while a chat is open.
- Tests: VM-level interaction suite (optimistic send/failure/retry,
inactive-resume both id paths, cancel invoked-race, steer, exact
approve/deny body JSON incl. both answers formats, config optimistic +
rollback, drafts) + GlobalSsePipe tests; full gate green (554 tests,
assembleDebug).
|
||
|
|
e640aa42fd |
feat(android): new session flow (B-M3d)
Machine -> directory -> agent/options -> spawn, web NewSession parity:
- feature/newsession: NewSessionScreen + NewSessionViewModel (plain class
over fakeable seams), NewSessionForm/Logic (exact SpawnSessionRequest
mapping), NewSessionGateway, DataStore-backed prefs (last machine,
per-machine recent paths LRU cap 8, form draft so backing out keeps input)
- directory: server-side autocomplete (list-directory on the parent path,
debounced 250ms with per-parent cache), recent-path chips, paths/exists
probe with the web's missing-dir affordances (worktree = error, simple =
two-tap create-and-make-directory)
- options per flavor: claude static models+effort; codex machine catalog
(hidden on rpc_target_missing) + reasoning effort + collaboration mode +
fast tier gate; grok/codex-family native permission modes; claude/agy/
cursor YOLO toggle with native-mode hint; pi managed note; copilot agent
mode; worktree name validation; startingMode omitted (remote default)
- wire/api: CodexModelsResponse + GET /machines/:id/codex-models (flagged
addition), MockWebServer coverage
- nav: newSession route (optional machineId), FAB on the session list,
spawn success navigate-replaces to chat/{id}
- tests: spawn-body exactness (4 configs), debounce/parent derivation/
listing cache, recent LRU, worktree validation, two-tap missing dir,
draft restore + codex catalog reconcile (21 new app tests green)
|
||
|
|
80948e93c9 | merge: B-M2a Android chat pipeline port (48 fixtures green) | ||
|
|
03651984e0 |
feat(android): chat pipeline port — 48 chat fixtures green (B-M2a)
Port web/src/chat/ (normalize → reduce → group) to :core:protocol app.hapi.protocol.chat, file-for-file: - ChatTypes (blocks/tool-call/permission/usage; AgentEvent sealed over a verbatim raw JsonObject so wire events project byte-exact) - Normalize/NormalizeUser/NormalizeAgent (codex/output/event families, agy mapping, skip rules, stringify-never-drop fallback) - Tracer (parentToolUseId grouping, prompt fallback, orphans) - ReducerEvents/ReducerTools/ReducerCliOutput/ReducerTimeline/Reducer (usage-limit pipe parsing, dedupe + title echo, api-error folding, tool pairing, stream coalescing, agent-run cards, turn-duration, title-changed synthesis, sentinel suppression, pending-permission synthesis with oldest-visible gate, latestUsage, goal filtering) - ToolGroups (families, id stability, buildVisibleChatBlocks) - ToolPresentation (event labels; java.time in place of toLocaleString) - FixtureProjection + CanonicalJson + ChatFixtureTest: every shared/fixtures/chat/*.json replayed and compared under canonical JSON equality, with a loud fixtureVersion gate TS undefined maps to Kotlin null, TS null to JsonNull; permission objects carry a presence set so JS spread-merge semantics survive. :core:protocol:test 223/223 green (48 fixtures), :app:assembleDebug ok. |
||
|
|
dfcf9849b9 | merge: B-M2c Android message window store, pagination fixtures green | ||
|
|
99df45e99a |
feat(android): message window store port, pagination fixtures green (B-M2c)
:core:protocol window/ — pure state machine ported function-for-function from web/src/lib/message-window-store.ts + messages.ts: merge-by-(id|localId) with optimistic echo reconciliation, position ordering (invokedAt ?? createdAt, seq, ASCII id tie-break), trim-preserving-queued with the codex agent-run budget, epoch reset handling, latest-replace with request-baseline identity preservation, consumed/cancelled/queued-reconcile transitions, tail/history modes, hydrate/persist shapes. MessageRetention ports the null-decision tree of normalizeDecryptedMessage (dedup with the B-M2a pipeline port flagged). :core:data store/ — per-session MessageWindowStore (StateFlow + Mutex, single-flight tail sync with trailing drain, fetchOlder with epoch-reset resync, SSE ingest hooks, optimistic sends, queued-state reconciliation, seedFrom for resume id changes) + WindowSnapshots (atomic JSON files, LRU 10; JsonSnapshotStore dedup TODO) + MessageWindowStores registry. Minimal MessagesApi interface (sealed MessagesQuery) extracted over the two message endpoints, implemented by HapiApi. Gate: PaginationFixtureTest replays all 11 shared/fixtures/pagination scripts against the real store — requests, older-load outcomes, reconcile candidates and the final window projection all exact — 11/11 green; plus targeted concurrency/snapshot/seed unit tests. :app:assembleDebug green. |
||
|
|
96d81a3f12 |
merge: B-M2b Android session/machine stores + session list
# Conflicts: # android/app/build.gradle.kts |
||
|
|
f702cbf844 |
feat(android): session/machine stores + session list (B-M2b)
:core:protocol — summary-side patch path ported from the web reference: - wire/SummaryPatching.kt: patchSessionSummary port with the summary path's >= versioned gates (replicated web divergence vs the detail path's strict >, documented), derived-field recompute (pendingRequestsCount/Kinds/Requests capped 5, todoProgress), render-irrelevance filter (activeAt-only keep-alive suppression), toSessionSummary/toSessionSummaryMetadata projection, and the deprecated canApplyVersionedSummaryPatch legacy gate. - wire/SessionSorting.kt: exact sortSessionSummaries comparator (globalPinned > pinned > active > pendingRequestsCount among active > updatedAt desc; Long-safe, stable). - SessionMetadata grows the per-flavor agent session id fields the agentSessionId projection needs. :core:data store/ — StateFlow stores with per-hub JSON snapshots: - JsonSnapshotStore: debounced (500 ms) atomic tmp+fsync+rename snapshots, synchronous cold-start load, corrupt files degrade to null. - SessionStore: sorted summary list + per-id detail cache (strict-> detail patching via SessionPatching), full-session upsert preserving hub-computed scheduled fields, REST fallback for unparseable payloads, coalesced refresh (16 ms batch like the web), optimistic pin/archive. - MachineStore: full/patch/null machine-updated decision tree per sse.md. - LastSeenStore: per-session last-seen watermarks + once-per-scope baseline seeding + unread derivation (sessionLastSeen.ts/sessionAttention.ts port). - StoreSyncTargets: SyncEventRouter fan-in — global-scope message-stream events refresh the list, gap handshakes full-resync. :app feature/sessions/ — standalone screen + plain-constructor ViewModel: - SessionListScreen: pinned section, status dot with thinking pulse, flavor/ machine/worktree meta line, pending badge, todo chip, unread dot, machine filter chips, PullToRefreshBox, offline banner, empty states, long-press pin/archive sheet; taps emit onOpenSession only (no navigation). - SessionListViewModel: store combine -> UiState, owns the global SSE subscription while started (subscribe after collector registration), entry refresh, error SharedFlow for snackbars. Tests: SummaryPatching/SessionSorting JVM tests (cases mined from useSSE.test.ts), store tests (stale/equal/newer patches, full replace, keep-alive identity, snapshot round-trips, optimistic rollback), ViewModel combine + handshake tests with fake stores. All of :core:protocol:test, :core:data:test, :app:testDebugUnitTest, :app:assembleDebug green. |
||
|
|
3cf2a669c3 | feat(android): pairing flow, deep link, DI graphs, navigation skeleton (B-M1d) | ||
|
|
3570017743 |
merge: B-M2d1 Android markdown/code/diff rendering foundation
# Conflicts: # android/gradle/libs.versions.toml |
||
|
|
d39197b97d |
feat(android): markdown/code/diff rendering foundation (B-M2d1)
:core:protocol (pure JVM, all web-transform ports fixture-style tested): - markdown/MarkdownTransforms.kt: repairTables (remark-repair-tables port), disableIndentedCode (enabledBlockTypes minus IndentedCodeBlock == micromark disable codeIndented) + canonical parser factory (GFM tables, strikethrough requireTwoTildes, autolink), stripCjkAutolinkArtifacts, detectFilePathLinks/ matchWholeFilePath/rewriteExplicitLinkTarget (remark-file-path-links port) - markdown/HrefPolicy.kt: Allowed|ConfirmFirst|Blocked scheme policy with double-percent-decode + control-char-strip bypass hardening (web parity); scheme-less/SPA/protocol-relative fail closed on native - git/UnifiedDiffParser.kt: full unified diff -> DiffFile/DiffHunk/DiffLine (rename/copy/new/deleted/binary/mode/similarity, quoted paths, bare codex diffs, tolerant of truncated hunks + wrong LLM counts) - 56 new JVM tests (repair matrix, parser config, CJK, path detection incl. negatives, href matrix, 11 diff scenarios cross-checked vs web semantics) :app Compose UI: - ui/markdown/Markdown.kt: transforms -> commonmark AST walk; paragraphs/ headings/lists (ordered/bullet/task via stripped text markers)/blockquote/ tables (intrinsic-width grid in horizontal scroll)/thematic break/html monospace fallback; unknown fences (mermaid/math) degrade to CodeBlock; LinkAnnotation links via LocalMarkdownLinkHandler (onFilePath/onUrl+policy) - ui/markdown/CodeBlock.kt: header chip + copy, horizontal scroll, highlights 1.0.0 syntax coloring off-main via produceState with 200-entry LRU keyed by (hash, len, lang, dark), 400-line cap then plain - ui/components/DiffView.kt: hunk headers, +/- tinted rows, dual line-number gutters, compact/expand - ui/theme: HapiExtendedColors (code/diff/table/quote tokens mirroring web index.css light/dark/OLED) + pure-black OLED scheme, provided by HapiTheme - MarkdownPreviewParameterProvider + kitchen-sink @Previews (compile-checked) deps (Maven Central verified): org.commonmark:commonmark 0.24.0 + gfm-tables/ gfm-strikethrough/autolink exts, dev.snipme:highlights 1.0.0 (jvm variant) Verified: :core:protocol:test 141 green, :app:assembleDebug green |
||
|
|
6f1dc23d77 |
feat(android): auth + HapiApi transport (B-M1b)
:core:data auth + API transport for track B M1:
- auth/: JwtPeek (unverified {uid,ns,exp} peek for proactive refresh),
CredentialStore interface + EncryptedPrefsCredentialStore
(security-crypto) + in-memory impl, HubUrls origin normalization,
HubRegistry (ordered multi-hub roster + active hub behind a storage
seam), AuthInterceptor + single-flight TokenAuthenticator (401 ->
re-exchange -> retry once, Mutex single-flight, terminal AuthEvents,
ensureFreshToken() for SSE pre-connect)
- api/: HapiApi (plain OkHttp + kotlinx.serialization, one suspend fun
per v1 endpoint incl. generated-image bytes + multipart transcription
helper), ApiError with (status, code) per errors.md
- HubSession: per-hub factory wiring clients (timeouts, auth decoration,
256 MB image cache), DI-free
- :core:protocol wire/: request DTOs (ApiRequests.kt) + response DTOs
(auth, health, resume/reopen, cancel/steer/queued-state, spawn,
slash-commands/skills, machine list-directory/paths-exists, uploads,
transcription)
- tests: 45 unit tests (MockWebServer) — refresh/retry-once, concurrent
single-flight, terminal 401 paths, ApiError code mapping, request
shapes (cursors+epoch, deliveryMode, nested answers, explicit nulls),
JwtPeek/HubUrls/HubRegistry
|
||
|
|
b09dd67268 |
feat(android): protocol wire models, catalogs, session patching (B-M1a)
:core:protocol, package app.hapi.protocol — pure-JVM foundations for the
Android client, mirrored from shared/src/schemas.ts + sessionSummary.ts and
docs/api/client-contract/{sse,pagination,messages}.md:
- wire/: shared HapiJson config; DecryptedMessage with tri-state invokedAt
(custom serializer — explicit null = queued vs absent = legacy-invoked);
AttachmentMetadata; AgentState + (completed) requests; Session verified
field-by-field against SessionSchema; typed-subset SessionMetadata;
SessionSummary/PendingRequest; SessionPatch with VersionedValue wrappers
and a strict SessionPatches.parse (unknown key / empty / mistyped -> null,
replicating SessionPatchSchema.strict()); Machine family; the 13-type
SyncEvent union behind total SyncEvents.parse (unknown/malformed ->
Unknown, never throws; machine-updated data kept tri-state for the
removed-vs-refetch distinction); MessagesPage + REST envelopes.
- catalog/: AgentFlavor (known + Other(raw)) with capabilities/labels from
flavors.ts; PermissionMode/tone table, per-flavor mode lists, codex
collaboration + copilot agent modes incl. legacy 'fleet' normalization.
- patch/SessionPatching.kt: exact port of web/src/lib/sessionPatch.ts —
strict > version gates, max-monotonic updatedAt, activeAt <60s
render-irrelevance, and deliberately NOT applying activeTurnStartedAt /
scratchlistUpdatedAt (the TS reference never assigns them).
Tests (85 total, all green): golden-fixture decoding over shared/fixtures/
chat/* (fails loudly on zero files or unsupported VERSION), version-gate /
keep-alive cases ported from useSSE.test.ts, full-session + list + messages
page decode, SyncEvent union coverage, catalog pins (TODO(K6): switch to
fixtures/catalogs/modes.json once generated). :app:assembleDebug verified.
|
||
|
|
83bd25aa3b | feat(android): scaffold Compose app + core modules + CI (B-M0) |