feat: add restore/unarchive for archived sessions

Archived sessions had no way back to the active list: the only available
action was "Delete permanently". Add restore per session (sidebar context
menu, Archive page row) and in bulk (sidebar selection bar).

The OpenCode server cannot clear time.archived over HTTP — session.update
only applies the field for a finite number, so an omitted key is a no-op
and null is silently ignored (verified against opencode 1.18.12). Restore
therefore writes time.archived = 0: every client-side reader classifies
archive state by truthiness, so 0 reads as active in the UI, the event
reducer, and the OpenCode app/TUI.

The server's time_archived IS NULL list filter still excludes such rows,
so the global session cache no longer issues an archived:false request
for its active list. Full and per-directory loads now fetch once with
the inclusive flag and split client-side via splitGlobalSessionsByArchived,
which also halves per-directory refresh requests. Directory bootstrap
keeps the server filter because live child stores must not hold archived
sessions; a restored session re-enters its live store through the
authoritative session.updated event.

unarchiveSession/unarchiveSessions follow the archiveSession contract:
wait for server confirmation before reconciling stores, runtime-guard
every reconciliation, preserve partial batch results, and fail loudly
when the server keeps the session archived instead of toasting a
successful no-op.

Closes #2346
This commit is contained in:
Serhii Dziupin
2026-08-04 13:21:04 +03:00
parent f710b49854
commit 577aca0437
27 changed files with 548 additions and 69 deletions
+26 -4
View File
@@ -240,16 +240,38 @@ Examples of global-store updates performed in `session-actions.ts`:
- `updateSessionTitle()` -> `upsertSession(result.data)`
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
- `archiveSession()` / `archiveSessions()` -> wait for server confirmation, then upsert each archived session
- `unarchiveSession()` / `unarchiveSessions()` -> wait for server confirmation, then upsert each restored session
- `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
### Restore (unarchive) contract
The OpenCode server cannot clear `time.archived` over HTTP: `session.update`
only applies the field when the payload carries a finite number, so an omitted
key is a no-op and `null` is silently ignored. Restore therefore writes
`time.archived = 0` (`UNARCHIVED_TIMESTAMP` in `session-actions.ts`). Every
client-side reader classifies archive state by truthiness of `time.archived`,
so `0` reads as active in the UI, the event reducer, and the OpenCode app/TUI.
The server's `time_archived IS NULL` list filter still excludes such rows, so
any query that wants a truthful active list must fetch inclusively
(`archived: true`) and split client-side (`splitGlobalSessionsByArchived`).
The global sessions store does this for its full and per-directory loads;
directory bootstrap keeps using the server filter because live child stores
must not hold archived sessions. A restored session re-enters its live
directory store through the authoritative `session.updated` event the server
publishes for the update; until then it remains fully visible through the
global store (sidebar, switcher) and addressable by ID (message loading).
Archive and delete actions capture the active runtime key when they start and
recheck it before every store reconciliation, so a response
produced by the previous runtime is rejected instead of mutating the current
runtime's live or global session state. A guarded batch stops at the first
observed runtime change: sessions the server already confirmed remain archived
or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed
on the captured runtime is returned in `failedIds` so existing partial-failure
runtime's live or global session state. Restore follows the same guard: a
stale completion returns `false` without touching any store. A guarded batch
stops at the first observed runtime change: sessions the server already
confirmed remain archived, restored, or deleted and stay in
`archivedIds`/`restoredIds`/`deletedIds`, while every ID not confirmed on the
captured runtime is returned in `failedIds` so existing partial-failure
feedback stays truthful.
Callers whose confirmation can span a runtime switch may pass an
`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default.