mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
fix(desktop): render the workspace pane from its own session slice
The workspace pane read the global $messages/$busy atoms — a mirror of whichever session was active — while every ⌘T tile rendered from its own $sessionStates slice. With two turns in flight, navigating away from a still-streaming session left it painting into the surface now showing a different conversation: the wrong transcript under the right route. Point the primary view at the active session's own slice, keeping the global atoms as the draft surface for a chat that has no runtime id yet.
This commit is contained in:
parent
dacd8d5416
commit
c489480cbb
2 changed files with 141 additions and 18 deletions
88
apps/desktop/src/app/chat/session-view.test.ts
Normal file
88
apps/desktop/src/app/chat/session-view.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $activeSessionId, $busy, $messages } from '@/store/session'
|
||||
import { $sessionStates, dropSessionState, publishSessionState } from '@/store/session-states'
|
||||
|
||||
import { PRIMARY_SESSION_VIEW } from './session-view'
|
||||
|
||||
const message = (id: string, text: string) => ({
|
||||
id,
|
||||
parts: [{ type: 'text' as const, text }],
|
||||
role: 'assistant' as const
|
||||
})
|
||||
|
||||
const stateWith = (runtimeId: string, text: string, busy: boolean) => ({
|
||||
...createClientSessionState(`stored-${runtimeId}`),
|
||||
messages: [message(`${runtimeId}-msg`, text)],
|
||||
busy
|
||||
})
|
||||
|
||||
/**
|
||||
* The workspace pane is just the first tab: it renders from the active
|
||||
* session's own `$sessionStates` slice, exactly like a ⌘T tile.
|
||||
*
|
||||
* The regression this guards: the pane used to render straight off the global
|
||||
* `$messages`/`$busy` atoms — a mirror of whichever session was active. With
|
||||
* two turns in flight, navigating away from a still-streaming session left it
|
||||
* painting into the surface now showing a different conversation.
|
||||
*/
|
||||
describe('primary session view reads its own session slice', () => {
|
||||
beforeEach(() => {
|
||||
$sessionStates.set({})
|
||||
$activeSessionId.set(null)
|
||||
$messages.set([])
|
||||
$busy.set(false)
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('shows the active session transcript, not a background session still streaming', () => {
|
||||
publishSessionState('runtime-background', stateWith('runtime-background', 'background turn', true))
|
||||
publishSessionState('runtime-foreground', stateWith('runtime-foreground', 'foreground turn', false))
|
||||
|
||||
$activeSessionId.set('runtime-foreground')
|
||||
|
||||
expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('runtime-foreground-msg', 'foreground turn')])
|
||||
expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a background session that keeps streaming after the user switches away', () => {
|
||||
publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', true))
|
||||
$activeSessionId.set('runtime-b')
|
||||
publishSessionState('runtime-b', stateWith('runtime-b', 'session B turn', false))
|
||||
|
||||
// Session A streams on: another delta lands for the session the user left.
|
||||
publishSessionState('runtime-a', {
|
||||
...stateWith('runtime-a', 'session A turn', true),
|
||||
messages: [message('runtime-a-msg', 'session A turn'), message('runtime-a-late', 'late delta')]
|
||||
})
|
||||
|
||||
expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('runtime-b-msg', 'session B turn')])
|
||||
expect(PRIMARY_SESSION_VIEW.$lastVisibleIsUser.get()).toBe(false)
|
||||
expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the draft atoms while the chat has no runtime session yet', () => {
|
||||
$messages.set([message('draft-msg', 'unsent draft')])
|
||||
$busy.set(true)
|
||||
|
||||
expect(PRIMARY_SESSION_VIEW.$runtimeId.get()).toBeNull()
|
||||
expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('draft-msg', 'unsent draft')])
|
||||
expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(true)
|
||||
expect(PRIMARY_SESSION_VIEW.$messagesEmpty.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns to the draft atoms when the active session state is dropped', () => {
|
||||
publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', true))
|
||||
$activeSessionId.set('runtime-a')
|
||||
|
||||
expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('runtime-a-msg', 'session A turn')])
|
||||
|
||||
dropSessionState('runtime-a')
|
||||
|
||||
expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([])
|
||||
expect(PRIMARY_SESSION_VIEW.$messagesEmpty.get()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReadableAtom } from 'nanostores'
|
||||
import { computed, type ReadableAtom } from 'nanostores'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$activeSessionId,
|
||||
|
|
@ -11,18 +12,28 @@ import {
|
|||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$selectedStoredSessionId
|
||||
} from '@/store/session'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
|
||||
import { lastVisibleMessageIsUser } from './thread-loading'
|
||||
|
||||
/**
|
||||
* SESSION VIEW — the store surface a ChatView renders from. The PRIMARY view
|
||||
* is the app's classic global atoms (route-driven active session, untouched
|
||||
* fast path). A session TILE provides the same shape computed from its
|
||||
* session's slice of `$sessionStates`, so the identical ChatView tree renders
|
||||
* either — one chat surface, N sessions on screen.
|
||||
* SESSION VIEW — the store surface a ChatView renders from. Every session,
|
||||
* including the one in the workspace pane, renders from ITS OWN slice of
|
||||
* `$sessionStates`. The workspace pane is just the first tab: a session
|
||||
* surface with no privileged state of its own.
|
||||
*
|
||||
* That symmetry is load-bearing. The pane used to render off the global
|
||||
* `$messages`/`$busy` atoms — a mirror of whichever session was active — so
|
||||
* with two turns in flight (⌘T tabs made that routine), navigating away from
|
||||
* a still-streaming session left it painting into the surface now showing a
|
||||
* different conversation. Reading the per-session slice makes that
|
||||
* structurally impossible rather than merely guarded.
|
||||
*
|
||||
* The global atoms stay the DRAFT surface: a new chat has no runtime id, and
|
||||
* therefore no slice, until its first turn creates one.
|
||||
*
|
||||
* Everything is atoms (not values) so subscription granularity survives:
|
||||
* ChatView subscribes only to the coarse edges; `$messages` stays boundary-
|
||||
|
|
@ -44,18 +55,42 @@ export interface SessionView {
|
|||
$reasoningEffort: ReadableAtom<string>
|
||||
}
|
||||
|
||||
/** The active session's own slice, or `undefined` while it's a draft. */
|
||||
const $primaryState = computed([$activeSessionId, $sessionStates], (runtimeId, states) =>
|
||||
runtimeId ? states[runtimeId] : undefined
|
||||
)
|
||||
|
||||
/**
|
||||
* Read one field from the active session's slice, falling back to the global
|
||||
* draft atom while no runtime exists yet. Once a session HAS a slice, that
|
||||
* slice is authoritative — a background session publishing its own state can
|
||||
* never reach this view.
|
||||
*/
|
||||
function primaryField<T>(
|
||||
select: (state: ClientSessionState) => T,
|
||||
$draft: ReadableAtom<T>
|
||||
): ReadableAtom<T> {
|
||||
const $field: ReadableAtom<T> = computed([$primaryState, $draft], (state, draft: T) =>
|
||||
state ? select(state) : draft
|
||||
)
|
||||
|
||||
return $field
|
||||
}
|
||||
|
||||
const $primaryMessages = primaryField<ChatMessage[]>(state => state.messages, $messages)
|
||||
|
||||
export const PRIMARY_SESSION_VIEW: SessionView = {
|
||||
kind: 'primary',
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$cwd: $currentCwd,
|
||||
$fast: $currentFastMode,
|
||||
$lastVisibleIsUser: $lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$model: $currentModel,
|
||||
$provider: $currentProvider,
|
||||
$reasoningEffort: $currentReasoningEffort,
|
||||
$awaitingResponse: primaryField<boolean>(state => state.awaitingResponse, $awaitingResponse),
|
||||
$busy: primaryField<boolean>(state => state.busy, $busy),
|
||||
$cwd: primaryField<string>(state => state.cwd, $currentCwd),
|
||||
$fast: primaryField<boolean>(state => state.fast, $currentFastMode),
|
||||
$lastVisibleIsUser: computed($primaryMessages, lastVisibleMessageIsUser),
|
||||
$messages: $primaryMessages,
|
||||
$messagesEmpty: computed($primaryMessages, messages => messages.length === 0),
|
||||
$model: primaryField<string>(state => state.model, $currentModel),
|
||||
$provider: primaryField<string>(state => state.provider, $currentProvider),
|
||||
$reasoningEffort: primaryField<string>(state => state.reasoningEffort, $currentReasoningEffort),
|
||||
$runtimeId: $activeSessionId,
|
||||
$storedId: $selectedStoredSessionId
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue