# Tell the user when a teammate deletes the page holding their unsaved work \(\#215\) · dara/icarus

[View on GitCafe](https://git.cafe/dara/icarus/commit/bdbd8e5b3ffc721fdb952750e6a2ae966e9ae202)

Repository: [dara/icarus](https://git.cafe/dara/icarus)

Visibility: public

Requested revision: bdbd8e5b3ffc721fdb952750e6a2ae966e9ae202

Requested commit: bdbd8e5b3ffc721fdb952750e6a2ae966e9ae202

Commit: bdbd8e5b3ffc721fdb952750e6a2ae966e9ae202

Tree: caf26d13eeab10df66f1f842af6001ed9a71c667

Author: Dara Adedeji

Committer: GitHub

## Message

```
Tell the user when a teammate deletes the page holding their unsaved work (#215)

* Apply teammates' changes item by item instead of reloading the page

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

* Hold duplicates and full page swaps, look up rotation targets by id, keep held items in place

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

* Keep each element's sortIndex so a deletion re-sends no other element

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

* Send a moved element's new stacking, keep held undrawable lineup bases

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

* Stack by hydrated order on ties; held items take the server's place

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

* Ask what to do with unsaved work on a page a teammate deleted

Live sync cannot send work to a page the server no longer has, so the
unsaved mark never cleared and the page swap waited forever: the canvas
stayed on the deleted page with the sync button spinning, and switching
pages dropped the work without a word.

When the page on screen is gone and holds work the server never got, the
session now stops and asks: restore the page (re-created with everything
on screen, then synced from it) or discard the changes and move to a page
that exists. With nothing unsent, the stale unsaved mark is cleared and
the canvas moves on as before.

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

* Restore only what can be restored whole, and wait out work in flight

Review round 1:
- Rejected work discarded before a restore completes the queue's adoption,
  so its replacement ops are not skipped.
- The restore waits for its page add's ack behind other batches, and takes
  the page as its base only when it came back empty: a page a teammate
  restored first is never read as deletions here.
- A page whose images this device cannot upload again is not restored.
- Discard waits for ops already sent for the page, then withdraws what
  they left; while any remain, the choice stays open.

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

* Tell, don't restore: the server cannot take a deleted page back whole

Review round 2 found restoring the page in place still loses or corrupts
work: the purge of its rows runs after the page is gone, so restored ids
can be refused while the page reads as saved; lineup screenshots were
not re-uploaded; page metadata queued before the deletion was dropped.
Each is a guess the client cannot verify, and AGENTS.md says to fail
loudly rather than save something wrong. The dialog now tells the user
their latest changes could not be saved, and the canvas moves on once
they have read it. Keeping the work needs the server's help.

Switching pages checks the page being left first, so the notice shows
even when a switch comes before anything else asked.

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

* Check a deleted page's work wherever the canvas leaves it

Review round 3:
- Leaving a page (a switch, or the replace a teammate's change waits on)
  checks for lost work itself, busy editor or not, and whatever the save
  flags say; only the automatic notice waits for a gesture to end.
- The user's own delete of the page, still on its way, is not a
  teammate's deletion and says nothing.
- A new queue operation, discardDeletedPage, drops every record of the
  page with its successor, so a paused edit with a successor no longer
  keeps the notice open for good.

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

* Recheck a switch after its flush; discard a deleted page's work whole

Review round 4:
- A switch checks the page it leaves again after flushing it, so a
  deletion that lands meanwhile still tells the user; the prepared page
  transition is unwound.
- discardDeletedPage also drops work whose first save could not be
  verified, and recomputes the queue's error from what remains, so a
  dropped op's error no longer holds the canvas on the deleted page.

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

* Leave a deleted page by loading the next one, not by waiting for it

Review round 5: OK used to hand the swap to the reapply that waits for
all pending cloud work, so another page's paused edit left the deleted
page on screen, editable, with its protection gone. OK now loads the
server's page itself, as a page switch would; work queued for other
pages is not on this canvas and keeps waiting. The notice stays until
the new page is on screen.

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

* Keep an unsent map change and read again when leaving a deleted page

Review round 6:
- Loading the page that replaces the deleted one keeps the map and theme
  on screen while a change to them is still on its way, as using the
  cloud version after a conflict already did (shared helper now).
- OK reads the server again first, so a read that failed earlier does
  not fail every retry.

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

* Resume a page's live read when a refresh reads it

Review round 7: when selecting a page failed (say the page a teammate
deleted moved the editor to one that would not load), no live read was
left running, and a later refresh fetched the page without starting one:
teammates' edits stopped arriving. A refresh that reads the selected page
now starts its live read if none is running for it.

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

* Address CodeRabbit review

- A page's live read claims its turn before cancelling the old one, so
  two refreshes at once start one watcher, not two.
- An image still uploading keeps the unsaved mark: it is not stale.
- The sync button shows the deleted-page notice if it mounts while one
  is already waiting.

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

* Keep unchanged items as they are on a merge; rebuild only new drawing paths

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

* Judge unchanged against what the canvas drew, not the last ack

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

* Held items conflict against what the user saw, not an unseen ack

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

* Count acks of ops this session sent as seen, even after more typing

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

* Back out the unchanged-item reuse; keep its regression tests

Reusing unchanged items on a merge needed live sync to track what the
canvas drew separately from what the server acked, and each attempt at
that left another way to rewind a revision. Server-wins for every item
the user isn't holding is the reviewed behaviour; its path rebuild cost
matches the full reload it replaced. The three new tests still hold.

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

* Let a deleted page go of a send the app closed in the middle of

A page op saved as in flight before the app closed is only waiting to be
replayed after a restart; nothing is sending it. discardDeletedPage kept
every in-flight record, so offline, OK could never clear the deleted
page's work and the user stayed on a page that no longer exists. It now
keeps a record only while this process is sending its strategy, the same
rule the queue view uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Never blame a teammate for the user's own page delete

Once the user's delete of the page on screen was accepted, it left the
queue, so an earlier edit on the page still paused or refused made the
page read as deleted by a teammate. The notice then asked them to throw
away work to leave a page they deleted, and blocked the switch the
delete planned. The session now remembers pages whose delete from this
device the server accepted; the paused or refused edit stays in the sync
status.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop uploads only a deleted page's discarded work referenced

Leaving a deleted page drops its queued changes but left their image
uploads, and the bytes they hold, retrying later. An image placed after
the delete never got a change the server could take, so its staged
upload waited for a reference forever. Leaving now marks the strategy's
uploads for a check before their next attempt: one goes, with its
bytes, only if no queued change and no page on the server references
its image. Marks made offline wait for the connection. A check that
cannot prove the image unreferenced lets the upload go on.

Session tests now use a recording media queue: the real one had no
storage there, and nothing in them exercised it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Notice the user's own delete even before the queue listener runs

The save state listens to the queue before the session does, so when the
ack takes the delete out of the queue, the save state's change can run
the deleted-page check before the session's queue listener records the
page as deleted here. With the live read ahead of the ack and an earlier
edit paused, the teammate notice still showed. The check now records
the accepted deletes in the queue it reads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Check restored uploads for a reference before their first attempt

The mark a discarded page's work leaves on its uploads lives in memory,
so an app closed before the connection came back lost it and the upload
went on. Every upload restored at launch with a durable reference now
starts marked; staged ones already had their own check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep an upload's reference check when removing it fails

The check before an upload's attempt ran outside the upload's error
handling, and dropped its mark first. When removing an unreferenced job
failed, the error escaped the upload worker, stopping it, and the next
attempt uploaded the discarded image. The mark now stays until the check
settles, a failed removal waits for the usual retry, and the job never
uploads in between.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Settle upload reference checks before uploading, not inside it

A job marked for a reference check was checked when the worker picked
it. A removal that kept failing then left it first in line on every
attempt, holding up the uploads behind it, and a staged job took the
removal on a path that could throw out of retryNow. A marked job now
never uploads: each retry first settles the checks it can (online, no
upload running), a failed removal leaves the job marked and waiting,
and every other job goes on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep a staged upload's check when the server can't answer; one check at a time

A reference check that could not tell (a failed server read) dropped
the job's mark. A staged job has no other way out, so it then waited
forever. The check now tells deleted, still referenced, and unknown
apart: unknown keeps a staged job's mark and lets a job that can
upload go on, as before.

Two retries could each run the check on the same job; one that could
not tell let it start uploading while the other deleted it. Checks now
run one pass at a time, and every retry waits for the running pass
before starting an upload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say what the one-pass reference check actually guarantees

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
```

## Parents

- [7a8c1cf8286a78730ac8ea356219a0d2de44fec3](https://git.cafe/dara/icarus/commit/7a8c1cf8286a78730ac8ea356219a0d2de44fec3?format=markdown)

[Source at this commit](https://git.cafe/dara/icarus/tree/bdbd8e5b3ffc721fdb952750e6a2ae966e9ae202?format=markdown)
