* feat(mobile): draw the last known tab strip while a session reconnects
Reopening a workspace the phone has already visited threw away everything
it knew. The route clears its tabs on mount, so until the reconnect lands
and the first snapshot is applied the session screen has an empty header
and a bare spinner, even though the strip it is about to be handed is the
one it drew a minute ago.
Persist the four fields the strip actually draws -- id, type, title, agent
-- per host and workspace, and add a reconnecting-with-cache shape to the
route state so those rows render immediately, disabled, under the ids the
live snapshot will reuse. Live tabs always outrank the cache, so a
mid-session drop keeps its mounted terminals; an exhausted retry loop or a
rejected pairing outranks it the other way, because a strip the user cannot
reach is worse than the existing offline affordance. With nothing cached
the screen behaves exactly as before.
The body stays a placeholder. Replaying stored scrollback into the terminal
WebView would double-render the same rows once the live stream replays them,
so the strip is the cached content and the body waits for the stream.
* fix(mobile): keep shell titles and unpaired hosts out of the cached tab strip
Review of the reconnect strip cache found two ways it leaked.
A terminal's title is whatever the shell last set, which is routinely the
command line: a psql URL with an inline password, a curl with a bearer
token. Both fit well inside the 64-character cap and both were written to
plaintext AsyncStorage verbatim. Browser tabs carried their page title the
same way. Terminals and browsers now collapse to a fixed label, with a
resolved agent naming itself because that lookup is a closed enum. The rule
lives in the storage module rather than its caller, so it holds for entries
an older build already wrote, and a tab type this build cannot draw is
dropped instead of having its title trusted.
The cache also survived forgetting a host. Nothing expired an entry, and
the module-global memory map meant a later save from any surviving host
serialized the forgotten host's rows straight back to disk. Both cleanup
paths now evict by host, dropping the in-memory rows and rewriting storage,
with a pending debounced write cancelled so it cannot restore them.
Also: the storage key digests the workspace id, which ended in a filesystem
path, and cached rows carry the same de-emphasis as the disabled tab-bar
buttons beside them, so an inert row does not pass for a live one.
* fix(mobile): make a forgotten host's cached tab strip actually leave disk
Review finding on this PR, fixed here so it rides along with the rest.
writeFile swallowed its own rejection, so deleteCachedSessionTabStripForHost
resolved successfully while the unpaired host's plaintext tab titles stayed on
disk, and removeHostAndCloseClient discarded the promise with void so nothing
could have observed the failure anyway.
The write now throws. The debounced save keeps a best-effort catch, since a
dropped cache refresh costs one repaint and the next save rewrites the whole
map, so only the deletion path needs the failure. Host removal awaits the
deletion and logs a failure but never rethrows: the metadata removal has
committed and the client is closed by that point, so reporting a finished
removal as failed would be wrong. The unpaired-host credential sweep already
awaited the deletion and now sees the rejection, consistent with its sibling
credential deletions.
Two ways the rows could come back are closed as well. The cache refuses saves
for a host it has been told to forget, so a snapshot racing the deletion cannot
re-insert it, and the deletion awaits any debounced write already on the wire,
since that write built its blob from the map as it was and would otherwise race
the purge for the last word on disk. The refusal lasts for the process, so
re-pairing the same host caches again from the next app launch, which is the
cheap direction for a deletion the user asked for.
* fix(mobile): order the tab-strip cache writes so a purge is the last word
Two debounced writes could sit on the AsyncStorage bridge at once, and the
second replaced the in-flight handle. A host purge then awaited only the newer
write, so the older blob -- snapshotted while the forgotten host was still in
the map -- could commit after it and restore the host's titles to disk. Writes
now queue behind one chain and the purge queues last.
The unpaired-credential sweep also aborted on a cache-purge failure, stranding
the write revision and onDeleted after every credential was already deleted. It
now warns and finishes, as removeHostAndCloseClient already did.