mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73262 from NousResearch/bb/dedupe-main-tile
fix(desktop): drop a session's tile when it loads into the main tab
This commit is contained in:
commit
30526baab5
2 changed files with 78 additions and 0 deletions
|
|
@ -1253,6 +1253,71 @@ describe('branchStoredSession desktop source tagging', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ── Main/tile dedup (the "same session open in main AND its own tab" bug) ─────
|
||||
// A session is EITHER the main thread OR a tile, never both. openSessionTile
|
||||
// enforces this from the tile side; resumeSession enforces it from the main
|
||||
// side by dropping an existing tile when the session loads into main (cold-start
|
||||
// restore, a pasted/⌘K route, a notification jump), so it can't render twice.
|
||||
describe('resumeSession drops a redundant tile when the session loads into main', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setActiveSessionId(null)
|
||||
setResumeFailedSessionId(null)
|
||||
setMessages([])
|
||||
setSessions([])
|
||||
$sessionTiles.set([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('closes the tile so the session is not open in both main and its own tab', async () => {
|
||||
// The session is already an open tile (e.g. persisted across a restart)...
|
||||
$sessionTiles.set([{ storedSessionId: 'stored-1' }])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
|
||||
|
||||
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
|
||||
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(resume).not.toBeNull())
|
||||
|
||||
// ...and now it loads into main.
|
||||
await resume!('stored-1', true)
|
||||
|
||||
// Its tile is gone — main owns the session, so it renders exactly once.
|
||||
expect($sessionTiles.get().some(t => t.storedSessionId === 'stored-1')).toBe(false)
|
||||
expect($selectedStoredSessionId.get()).toBe('stored-1')
|
||||
})
|
||||
|
||||
it('leaves OTHER sessions tiles untouched', async () => {
|
||||
$sessionTiles.set([{ storedSessionId: 'stored-1' }, { storedSessionId: 'stored-2' }])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
|
||||
|
||||
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
|
||||
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(resume).not.toBeNull())
|
||||
await resume!('stored-1', true)
|
||||
|
||||
// Only the resumed session's tile closes; the sibling tile stays put.
|
||||
expect($sessionTiles.get().map(t => t.storedSessionId)).toEqual(['stored-2'])
|
||||
})
|
||||
})
|
||||
|
||||
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
|
||||
// resumeSession's warm fast-path maps storedSessionId -> runtimeId -> cached
|
||||
// state. A reaped/respawned pooled backend re-mints runtime ids, so a recycled
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import {
|
|||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import {
|
||||
$sessionTiles,
|
||||
closeSessionTile,
|
||||
dropSessionState,
|
||||
openSessionTile,
|
||||
|
|
@ -557,6 +558,18 @@ export function useSessionActions({
|
|||
resetViewSync()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
// A session is EITHER the main thread OR a tile — never both. openSessionTile
|
||||
// enforces this from the tile side (it refuses to tile the selected session);
|
||||
// this enforces it from the main side. Loading an existing session into main
|
||||
// (cold-start restore, a pasted/⌘K route, a notification jump) while it's also
|
||||
// an open tile would paint the same transcript twice — the workspace pane from
|
||||
// the route and the tile pane in parallel, both fighting one runtime. Drop the
|
||||
// now-redundant tile so main owns it. Runs before the async awaits below (and
|
||||
// before the selection listener homes focus) so the tile is gone the same tick
|
||||
// the route takes over; the warm cache/runtime binding survives for main to reuse.
|
||||
if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
// Optimistically clear any prior resume-failure latch for this session:
|
||||
// we're attempting a fresh resume, so the self-heal in use-route-resume
|
||||
// must not keep treating it as stranded. It's re-armed below only if THIS
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue