Skip to content

fix(app): land on the next session when the open one is deleted - #276

Merged
quickbeard merged 2 commits into
mainfrom
fix/delete-session-jump
Aug 19, 2026
Merged

quickbeard merged 2 commits into
mainfrom
fix/delete-session-jump

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

Deleting the session on screen left the route pointing at it: the tab was spliced out of the store, nothing moved, and the window rendered "This session cannot be found" over a Close Tab button — reached by the most ordinary way there is to delete a session, its own row in the sidebar.

Two bugs, one flow

  1. Nothing moved. The route stayed on the deleted session.
  2. The tab came back. Once the route did move, the deleted session was still in the strip as an Unknown Session tab.

1. Why nothing moved

context/tabs.tsx already has a successor branch for exactly this, and it has been dead for a while. removeSessions() decides whether the deleted session is the one on screen like this:

const currentHref =
  targetServer === server.key && params.dir && params.id
    ? tabHref({ type: "session", server: targetServer, sessionId: params.id })
    : undefined

dir is a param of the legacy /:dir/session/:id route only. Sessions route through /server/:serverKey/session/:id now (app.tsx:662), so the condition is never true, removedCurrent is always false, and the function returns before it can navigate.

I left that branch alone rather than reviving it — it is an upstream file, and two places choosing a destination would race.

What it does now

deleteSession (fork-only pages/home-model.tsx) picks the destination, from the list the user is actually looking at: the sidebar's own rows, Pinned above Recents, snapshotted before the request because the delete refreshes the index they derive from.

  • Lands on the row below the deleted one.
  • Wraps from the bottom of the list back to the top.
  • Opens a new session in the same project when the delete emptied the list.
  • Moves nobody when the deleted session was not the one on screen, or belongs to a server the route is not on.
  • Both Code and Work — the list, the open path and the new-session path are all the ones the sidebar already uses, and none of them are Code-specific.

sessionAfterRemoval is the rule, exported beside descendantIDs and unit-tested for the same reason. Two details worth knowing:

  • It skips every id the delete took. Without that, the wrap comes all the way back around to the deleted session itself — which is the only candidate left when it was the only row. That case is what answers undefined and sends you to a new session.
  • It indexes by the deleted session, not by the session on screen. Those differ when a child session is open and its parent is deleted, and a child is never a row.

2. Why the tab came back

The strip is kept in step with the route by an effect in titlebar.tsx: if the route names a session with no tab, it adds one. Dropping the tabs before navigating put the app in exactly that state for an instant — route still on the deleted session, its tab already gone — so the effect minted a replacement. Titleless, because the removal took the remembered title with it, and permanent, because nothing removes it a second time.

The fix is ordering: navigate first, then drop the tabs, so the route never names a session with no tab. openSession and openProjectNewSession now return their transitions so the delete can await the move. That has a second benefit — the old session stays on screen, tab and all, until the next one is ready to replace it, instead of the tab vanishing under a screen that has not changed yet.

Deliberately out of scope

The in-session header's own delete (message-timeline.tsx) is untouched. It already navigates — parent, else the next session in the directory, else a new draft — so it never produced the not-found screen. Giving it this rule would mean reaching the sidebar's list from a component that has no model; createHomeModel() is a factory each surface calls for itself, not a context. Happy to unify in a follow-up if you want one rule everywhere.

Testing

  • 10 unit cases for sessionAfterRemoval in test-browser/home-session-records.test.ts — row below, crossing Pinned into Recents, wrap, skipping removed rows, wrapping past a removed run, empty list, a deleted session the list does not hold, and order preservation.
  • 3 new e2e cases in e2e/regression/session-delete-navigation.spec.ts — row below, wrap from bottom to top, and the no-op when the deleted session is not on screen. Verified they bite: with the home-model.tsx change stashed, the two navigation cases fail and only the no-op passes.
  • The first of those also asserts the strip holds exactly the tab it landed on, which is what covers the revived tab. It failed before the ordering fix. Note the mock still answers for a deleted session, so the revived tab keeps its title there rather than reading "Unknown Session" — the tab count is what gives it away either way.
  • Full suites on this branch: packages/app typecheck clean, 702 unit + 110 browser passing, Playwright 140 passed / 2 failed (prompt-thinking-level, session-actions-menu:104) — both fail identically on the base commit and are unrelated.
  • No upstream file touched, so no DIVERGENCES.md row: home-model.tsx is fork-only (added by feat(app): make the sidebar persistent and give it a Recents section #173) and the e2e spec is new.

🤖 Generated with Claude Code

@quickbeard
quickbeard force-pushed the fix/delete-session-jump branch from b51358b to 815a414 Compare August 19, 2026 11:24
quickbeard and others added 2 commits August 19, 2026 18:30
Deleting the session on screen left the route pointing at it. The tab was
spliced out of the store, nothing moved, and the window rendered "This
session cannot be found" over a Close Tab button -- a dead end reached by
the most ordinary way there is to delete a session, its own row in the
sidebar.

context/tabs.tsx already had a successor branch for this and it has been
dead for a while: removeSessions() decides whether the deleted session is
the one on screen from `params.dir && params.id`, and `dir` is a param of
the legacy /:dir/session/:id route only. Sessions route through
/server/:serverKey/session/:id now, so the condition is never true, the
branch returns before navigating, and no amount of deleting moves the
route. Left alone rather than revived: it is an upstream file, and two
places choosing a destination would race.

deleteSession (fork-only pages/home-model.tsx) picks the destination
instead, from the list the user is actually looking at -- the sidebar's
own rows, Pinned above Recents, snapshotted before the request because
the delete refreshes the index they derive from. sessionAfterRemoval
scans down from the deleted row and wraps, so the bottom row lands on the
top one, and it skips every id the delete took: without that the wrap
comes back around to the deleted session itself, which is the only
candidate left when it was the only row. That case answers undefined and
opens a new session in the same project instead.

Indexed by the deleted session rather than by the session on screen,
because those differ when a child session is open and its parent is
deleted, and a child is not a row.

Nothing happens unless the route is a session, on the server the sidebar
is focused on, whose id the delete took -- so deleting a session you are
not in still just removes it and its tab. Both sides get this: the list,
the open path and the new-session path are all the ones the sidebar
already uses, and none of them are Code-specific.

The in-session header's own delete (message-timeline.tsx) is untouched.
It already navigates -- parent, else the next session in the directory,
else a new draft -- so it never produced the not-found screen, and giving
it this rule would mean reaching the sidebar's list from a component that
has no model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by deleting an open session in the real app: it lands on the next
one now, but the session just deleted is still in the tab strip, titled
"Unknown Session" and closable only by hand.

The strip is kept in step with the route by an effect in titlebar.tsx: if
the route names a session that has no tab, it adds one. Dropping the tabs
before navigating put the app in exactly that state for an instant -- the
route still on the deleted session, its tab already gone -- so the effect
minted it a replacement. The replacement is titleless because the removal
took the remembered title with it, and it survives the navigation that
follows because nothing removes it a second time.

Navigate first, then drop the tabs. The route never names a session with
no tab, so there is no instant for the effect to act on. openSession and
openProjectNewSession return their transitions so the delete can await
the move, which also means the old session stays on screen, tab and all,
until the next one is ready to replace it -- rather than the tab
disappearing under a screen that has not changed yet.

The e2e case that covers the landing now also asserts the strip holds
exactly the tab it landed on. It fails without this: the mock answers for
a deleted session, so the revived tab keeps its title there and the
count is what gives it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard force-pushed the fix/delete-session-jump branch from 815a414 to 50f24e8 Compare August 19, 2026 11:32
@quickbeard
quickbeard merged commit def1363 into main Aug 19, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the fix/delete-session-jump branch August 19, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant