From 272d3be6c8c4371ba57501befdbebb8d3872f341 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 18:16:21 -0500 Subject: [PATCH] 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. --- apps/desktop/src/store/session-states.test.ts | 43 ++++++++++++++++++- apps/desktop/src/store/session-states.ts | 36 ++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index 18d4142f104..f0b4d4c4faf 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 273b0315873..a44329f10d3 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -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 +): 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.