fix(desktop): restored background tabs resolve their session title without a click

A restored session tile has no runtimeId and never mounts its pane until first
activation, so the by-id resolution effect inside SessionTilePane never runs.
When the row is also outside the recents page and project tree, tileTitle()
falls back to "New session" until the user clicks the tab (#94167).

Add a one-shot backfill, wired next to watchSessionTiles(): once the gateway
is open, look each unrestored, untitled, unlisted tile up via
resolveStoredSession(id, tile.ownerRoute). That call already upserts the row
into $sessions, which the tab strip watches, so the tab renames itself —
nothing new is persisted and workspaceTabTitle stays the Bot Chat marker.

Live repro (Electron e2e, target session pushed off the 50-row recents page
by 60 newer sessions, restored as a stacked background tab): main showed
"New session" after boot with no click; with this fix the tab reads the real
title while the pane is still unmounted.

Closes #94167
Supersedes #94212

Co-authored-by: 686f6c61 <github@00b.tech>
This commit is contained in:
Teknium
2026-09-02 04:45:40 -07:00
parent ad800ea8cd
commit 8d6a286fe8
3 changed files with 74 additions and 2 deletions

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { sessionTileResumeFailure } from './session-tile'
import { $gatewayState, $sessions, setSessions } from '@/store/session'
import { $sessionTiles } from '@/store/session-states'
import { sessionTileResumeFailure, startUnrestoredTileTitleBackfill } from './session-tile'
describe('sessionTileResumeFailure', () => {
it('keeps a confirmed durable session retryable instead of repeating a stale 404', () => {
@@ -17,3 +20,43 @@ describe('sessionTileResumeFailure', () => {
expect(sessionTileResumeFailure('session not found', true, false)).toBeUndefined()
})
})
describe('startUnrestoredTileTitleBackfill (#94167)', () => {
afterEach(() => {
$gatewayState.set('idle')
$sessionTiles.set([])
setSessions([])
})
it('backfills unlisted unrestored tiles by id via their ownerRoute once the gateway opens', async () => {
const ownerRoute = { connectionId: 'conn-a', profile: 'writer' }
setSessions([{ id: 'listed', title: 'Already listed' } as never])
$sessionTiles.set([
{ ownerRoute, storedSessionId: 'old-chat' },
{ storedSessionId: 'listed' },
{ runtimeId: 'rt-live', storedSessionId: 'live' },
{ storedSessionId: 'bot', workspaceTabTitle: 'Bot Chat' }
])
const lookup = vi.fn(async (id: string) => {
const row = { id, title: 'Quarterly review' } as never
setSessions(prev => [row, ...prev])
return row
})
const stop = startUnrestoredTileTitleBackfill(lookup as never)
expect(lookup).not.toHaveBeenCalled()
$gatewayState.set('open')
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1))
expect(lookup).toHaveBeenCalledWith('old-chat', ownerRoute)
expect($sessions.get().find(row => row.id === 'old-chat')?.title).toBe('Quarterly review')
// One-shot: a later reconnect does not re-probe.
$gatewayState.set('idle')
$gatewayState.set('open')
expect(lookup).toHaveBeenCalledTimes(1)
stop()
})
})

View File

@@ -478,6 +478,33 @@ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined
)
}
/** One-shot by-id title fill for restored tiles that never mount (#94167).
* A restored background tab has no runtimeId and does not mount its pane, so
* the resolution effect above never runs; when its row is outside the recents
* page and project tree, `tileTitle()` reads "New session" until first click.
* `resolveStoredSession` upserts the row into `$sessions`, which the tab strip
* already watches — nothing is persisted. Runs once the gateway can answer. */
export function startUnrestoredTileTitleBackfill(lookup = resolveStoredSession): () => void {
const run = () => {
if ($gatewayState.get() !== 'open') {
return
}
off()
for (const tile of $sessionTiles.get()) {
if (!tile.runtimeId && !tile.workspaceTabTitle && !tileStoredRow(tile.storedSessionId)) {
void lookup(tile.storedSessionId, tile.ownerRoute).catch(() => undefined)
}
}
}
const off = $gatewayState.listen(run)
run()
return off
}
/** The tab's REGISTERED name. Deliberately the bare placeholder for a draft
* rather than its live composer title (`tabTitle` renders that): re-registering
* per keystroke would re-render the strip, and holding the draft's text here

View File

@@ -84,6 +84,7 @@ import { startSessionDrag } from '../chat/session-drag'
import {
SessionTileCloseConfirm,
stackSessionTilesIntoMain,
startUnrestoredTileTitleBackfill,
watchSessionTiles,
WorkspaceTabMenu
} from '../chat/session-tile'
@@ -459,6 +460,7 @@ watchContributedPanes()
// into the transparent overlay).
if (!isBrowserWindow() && !isHudWindow()) {
watchSessionTiles()
startUnrestoredTileTitleBackfill()
watchRouteTiles()
watchPreviewTiles()
}