feat(desktop): find and reuse an open blank "New session" tab

An unused draft tab is the one a user would have typed into, so give the
tile store a way to name it (blankDraftTile) and hand it to another
session in place (reuseBlankDraftTile). A blank-but-busy tab has its
first turn in flight and an unbound tile is unknown rather than empty, so
neither is a candidate.
This commit is contained in:
Brooklyn Nicholson 2026-07-29 18:16:21 -05:00
parent fb120f850d
commit 272d3be6c8
2 changed files with 78 additions and 1 deletions

View file

@ -1,8 +1,14 @@
import { describe, expect, it } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { group, split } from '@/components/pane-shell/tree/model'
import type { SessionTile } from '@/store/session-states'
import { focusedSessionNeedsRoute, orderTilesByTree, selectionHomesToWorkspace } from '@/store/session-states'
import {
blankDraftTile,
focusedSessionNeedsRoute,
orderTilesByTree,
selectionHomesToWorkspace
} from '@/store/session-states'
const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
const tilePane = (id: string) => `session-tile:${id}`
@ -64,3 +70,38 @@ describe('focusedSessionNeedsRoute', () => {
expect(focusedSessionNeedsRoute('tile', false)).toBe(false)
})
})
describe('blankDraftTile', () => {
const bound = (storedSessionId: string, runtimeId: string): SessionTile => ({ runtimeId, storedSessionId })
const state = (messages: number, busy = false) =>
({ busy, messages: Array.from({ length: messages }, (_, i) => ({ id: `m${i}` })) }) as ClientSessionState
it('finds the open tab whose session has no messages', () => {
const tiles = [bound('a', 'run-a'), bound('b', 'run-b')]
const states = { 'run-a': state(3), 'run-b': state(0) }
expect(blankDraftTile(tiles, states)).toEqual(tiles[1])
})
it('picks the most recent blank tab when there are several', () => {
const tiles = [bound('a', 'run-a'), bound('b', 'run-b')]
const states = { 'run-a': state(0), 'run-b': state(0) }
expect(blankDraftTile(tiles, states)).toEqual(tiles[1])
})
it('leaves a blank-but-busy tab alone — its first turn is already in flight', () => {
expect(blankDraftTile([bound('a', 'run-a')], { 'run-a': state(0, true) })).toBeNull()
})
it('treats an unbound or unpublished tile as unknown, not empty', () => {
expect(blankDraftTile([tile('a')], {})).toBeNull()
expect(blankDraftTile([bound('a', 'run-a')], {})).toBeNull()
})
it('is null when every open tab holds a conversation', () => {
expect(blankDraftTile([bound('a', 'run-a')], { 'run-a': state(2) })).toBeNull()
expect(blankDraftTile([], {})).toBeNull()
})
})

View file

@ -636,6 +636,42 @@ export function focusedSessionNeedsRoute(focused: 'main' | 'tile' | null, worksp
return !focused || (focused === 'main' && workspaceIsPage)
}
/** The open tab that's still an empty "New session" draft, if there is one.
* That tab is the one the user would have typed into, so an open-from-nowhere
* spends it instead of stacking a second blank tab beside it. Most recent
* wins; a tile whose runtime hasn't bound (or whose state hasn't published) is
* unknown rather than empty, so it's left alone. */
export function blankDraftTile(
tiles: readonly SessionTile[],
states: Record<string, ClientSessionState>
): null | SessionTile {
return (
tiles.findLast(({ runtimeId }) => {
const state = runtimeId ? states[runtimeId] : undefined
return Boolean(state && !state.busy && state.messages.length === 0)
}) ?? null
)
}
/** Hand an open blank draft tab over to `storedSessionId`, keeping its slot.
* False when there's no such tab, so the caller can fall back. The spent draft
* is DISCARDED rather than closed: it never held a conversation, so T
* resurrecting it would just restore an empty tab. */
export function reuseBlankDraftTile(storedSessionId: string): boolean {
const tile = blankDraftTile($sessionTiles.get(), $sessionStates.get())
if (!tile || tile.storedSessionId === storedSessionId) {
return false
}
discardSessionTile(tile.storedSessionId)
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before)
revealTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`)
return true
}
// Closed-tab stack for ⌘⇧T reopen (in-memory) — keyed PER PROFILE like the
// tiles themselves, so ⌘⇧T after a profile switch never resurrects the other
// profile's session. The tile's placement is remembered so it returns in place.