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.
The hub's fork, rewind and export routes had no iOS UI. The chat's
menu now offers:
- Fork Conversation: when the agent advertises forkCurrent, the
session is live and no Codex turn is running (the web gate). The new
session opens in place of this chat.
- Rewind or Fork…: a sheet listing loaded user messages that have a
native point in metadata.conversationHistoryPoints. Each row offers a
confirmed Fork from Here or Rewind to Here, gated like web: forks
need an idle turn except on Codex, and rewinds always need one. The
actions live in a sheet, not on the transcript bubbles, because those
keep text selection and cached row heights. After a rewind the tail
resyncs with a history reset. An ambiguous native boundary (409
ambiguous_native_boundary_fork_safe) offers a fork from the same
point instead.
- Export Conversation…: saves the hub's lossless JSON payload through
the Files save panel, using the web download's filename. A large
export asks before retrying with force; a 413 explains the limit.
Markdown export stays web-only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iOS session list could only filter by machine. Web also has text
search, an unread-only filter and Mark All as Read.
- Search: a .searchable field ranks matches with the web relevance
rules, ported from sessionListSearch.ts via Android as
SessionListSearch. It searches title, worktree, summary, agent,
machine, path and id; every word must match, with */? wildcards.
Results keep the global/project pin sections, so the Pinned header
still applies.
- Filters menu:
- It now holds an Unread Only toggle that keeps the iPad's selected
chat listed.
- The machine section appears only with two or more machines, as
before; the menu itself shows whenever the hub has sessions.
- Mark All as Read advances the local last-seen watermarks after a
confirmation, as on web.
- Empty list: an empty search or filter result gets its own empty state
instead of "No sessions yet".
getSessionTitle moves into HapiKit so search and the rows share it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API wrappers existed but iOS had no UI for these actions, while
web and Android did. The session row's context menu and the chat's …
menu now offer Rename for every session. Active sessions keep Archive.
Inactive sessions get Reopen and a confirmed Delete instead of
Archive, which the hub rejects with 409 for them.
The store applies renames and deletes optimistically. A failed rename
refetches server state; a failed delete restores the row and the
cached detail. Reopen marks the returned session active right away.
When reopen returns a superseding id, the chat seeds the new window,
moves the draft and navigates to it, as send-resume already does. A
reopen from the list also moves the draft and opens the session. A
422 shows the hub's message with the missing fields.
On iPhone, an authoritative removal (delete, or archive elsewhere) now
pops that chat, matching the iPad split, which already clears the
selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.
Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
FNV-1a filename fallback, FoundationNetworking imports for URLSession
types, corelibs URLCache diskPath: initializer, get-only
waitsForConnectivity, delegate-based SSE transport where
URLSession.bytes(for:) does not exist
Per-session parked notes mirroring the Android B-M4d feature:
- HapiProtocol Models/ScratchlistApi.swift: wire types for entries CRUD
(idempotent create via client entryId+createdAt), attachment metadata,
limits (defaults from shared/src/scratchlistAttachments.ts), upload
envelope, and the typed error codes (scratchlist_at_cap,
scratchlist_attachment_too_large, scratchlist_attachment_in_use, ...).
- Endpoints/ScratchlistEndpoints.swift: GET/POST/PUT/DELETE entries,
limits, base64 upload, raw-bytes attachment fetch, attachment delete.
- Stores/ScratchlistStore.swift: @MainActor @Observable per-session cache
behind the SessionScratchlistStoring seam - open/release observation,
16 ms-coalesced refetch on the scratchlistUpdatedAt SSE signal,
optimistic create/update/delete with surgical entryId reconcile +
rollback (refresh preserves in-flight optimistic creates), 200-entry cap
pre-check + hub 409 verdict, uploadsInFlight, cached limits with
offline defaults, UTF-16 text clamp at 10000.
- Stores/ScratchlistAttachmentGuard.swift: pure Fits/Downscale/Reject
budget verdicts ported verbatim.
- SessionListStore: onScratchlistInvalidation callback fired when a
session patch carries scratchlistUpdatedAt (the seam the M4b comment
reserved); HubSession wires it into the store and injects the store
into ChatInteractor.
- ChatInteractor (additive tail section): scratchlist store property,
scratchlistCount badge seam, insertComposerText, parkComposerDraft
(composer clears only after the hub accepts; at-cap/failed keep the
draft).
- Features/Scratchlist/: sheet off the chat toolbar's note icon with
count badge - entry cards (4-line preview, relative age, authed
thumbnails via NSCache loader, filename chips), edit sheet
(PhotosPicker -> guard -> JPEG downscale -> upload spinner tile,
remove, delete/save), full-screen viewer (GeneratedImage pattern),
per-entry To composer, and Park current draft in the screen header (a
deliberate placement divergence from Android's composer button - the
composer UI is owned by the concurrent attachments package). iOS-only
import step transcodes disallowed-but-decodable rasters (HEIC) to JPEG
before the guard.
- Tests (36, all transcribed from or mirroring the Android suites): store
CRUD optimistic/rollback/at-cap/invalidation/upload/limits with
canonical wire-body asserts + the SessionListStore seam test, 9 guard
verdicts, 9 interactor park/insert/badge seam tests over a fake store.
Verified on Linux via a dockerized Swift 6.0 scratch copy (FIFO HTTP
performer): full HapiClient build under strict concurrency, 36/36 new
tests green, SessionListStore 17/17 and ChatInteractor 29/29 suites
green; app-side screen model + loader typechecked against stub
frameworks; SwiftUI views and the CG-based import parse-checked (macOS
CI compiles them).