mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): scope composer and transcript state to their own session
`$activeSessionId` only ever holds the primary chat's session, but surfaces that render once per transcript were reading it as if it meant "the session on screen." A preview produced inside a session tile was recorded under the main chat's key and surfaced in the main chat's composer, which is what prompted this. The tool row now records under its own `SessionView`, and the same fix applies to the other readers of that atom that render per surface: attachment pills and inline preview links resolve relative paths against their session's cwd, composer voice and auto-speak read and subscribe to their own transcript, and the thread's compaction label, prompt-wait gate and turn timer follow the session that mounted them. `ComposerScope` now carries a `$messages` atom rather than a read closure so both the imperative read and the subscription come from one place.
This commit is contained in:
parent
96999b116b
commit
003ff53fb4
12 changed files with 165 additions and 86 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import { useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useEffect, useState } from 'react'
|
||||
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -9,9 +10,9 @@ import { Loader } from '@/components/ui/loader'
|
|||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundResume } from '@/store/background-delegation'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { $activeSessionId, $turnStartedAt } from '@/store/session'
|
||||
import { sessionCompacting } from '@/store/compaction'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import { $turnStartedAt } from '@/store/session'
|
||||
|
||||
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
|
||||
children,
|
||||
|
|
@ -37,11 +38,23 @@ const CompactionHint: FC = () => (
|
|||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
|
||||
)
|
||||
|
||||
function useActiveTurnTimerKey(): string | undefined {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
/** These indicators render inside whichever transcript mounted them, so every
|
||||
* session-scoped signal comes from that surface's view — a tile must never
|
||||
* show the primary chat's compaction, prompt-wait, or turn timer. */
|
||||
function useThreadSessionStatus() {
|
||||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
|
||||
// A pending clarify / approval / sudo / secret means the turn is paused on the
|
||||
// user, not working — so don't resurrect the "thinking" timer while they
|
||||
// decide (matches the pet's awaitingInput pose taking priority over busy).
|
||||
const awaitingInput = useStore(useMemo(() => sessionAwaitingInput(sessionId), [sessionId]))
|
||||
|
||||
return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined
|
||||
return {
|
||||
awaitingInput,
|
||||
compacting,
|
||||
turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const CenteredThreadSpinner: FC = () => {
|
||||
|
|
@ -67,9 +80,8 @@ export const CenteredThreadSpinner: FC = () => {
|
|||
|
||||
export const ResponseLoadingIndicator: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const timerKey = useActiveTurnTimerKey()
|
||||
const elapsed = useElapsedSeconds(true, timerKey)
|
||||
const compacting = useStore($compactionActive)
|
||||
const { compacting, turnTimerKey } = useThreadSessionStatus()
|
||||
const elapsed = useElapsedSeconds(true, turnTimerKey)
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
|
|
@ -147,12 +159,7 @@ export const StreamStallIndicator: FC = () => {
|
|||
// what lets the timer read "quiet for 12s" rather than the age of this
|
||||
// component, which is the whole turn so far.
|
||||
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
|
||||
const compacting = useStore($compactionActive)
|
||||
const turnTimerKey = useActiveTurnTimerKey()
|
||||
// A pending clarify / approval / sudo / secret means the turn is paused on the
|
||||
// user, not working — so don't resurrect the "thinking" timer while they
|
||||
// decide (matches the pet's awaitingInput pose taking priority over busy).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
const { awaitingInput, compacting, turnTimerKey } = useThreadSessionStatus()
|
||||
|
||||
useEffect(() => {
|
||||
setQuietSince(undefined)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { cleanup, render } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { $activeSessionId, $currentCwd } from '@/store/session'
|
||||
|
||||
vi.mock('@assistant-ui/react', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useAuiState: (select: (state: unknown) => unknown) =>
|
||||
select({ message: { id: 'msg-1', status: { type: 'complete' } }, thread: { isRunning: false } })
|
||||
}))
|
||||
|
||||
const { ToolFallback } = await import('./fallback')
|
||||
|
||||
const PRIMARY_ID = 'primary-session'
|
||||
const TILE_ID = 'tile-session'
|
||||
|
||||
/** Minimal tile view: only the fields the tool row reads. */
|
||||
function tileView(): SessionView {
|
||||
return {
|
||||
...({} as SessionView),
|
||||
$cwd: atom('/tile/work'),
|
||||
$messages: atom([]),
|
||||
$runtimeId: atom<null | string>(TILE_ID),
|
||||
kind: 'tile'
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolRow(wrap: (node: ReactNode) => ReactNode) {
|
||||
const props = {
|
||||
args: { path: '/tile/work/report.html' },
|
||||
result: { path: '/tile/work/report.html' },
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'write_file'
|
||||
} as unknown as ComponentProps<typeof ToolFallback>
|
||||
|
||||
render(<>{wrap(<ToolFallback {...props} />)}</>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$previewStatusBySession.set({})
|
||||
$activeSessionId.set(null)
|
||||
$currentCwd.set('')
|
||||
})
|
||||
|
||||
describe('tool row preview recording', () => {
|
||||
// The row used to record under the global (primary-only) $activeSessionId, so
|
||||
// a preview produced inside a session TILE surfaced in the main chat's
|
||||
// composer instead of the tile's own.
|
||||
it('records into the session whose transcript the row is in, not the primary', () => {
|
||||
$activeSessionId.set(PRIMARY_ID)
|
||||
$currentCwd.set('/primary/work')
|
||||
|
||||
const view = tileView()
|
||||
|
||||
renderToolRow(node => <SessionViewProvider value={view}>{node}</SessionViewProvider>)
|
||||
|
||||
const recorded = $previewStatusBySession.get()
|
||||
|
||||
expect(Object.keys(recorded)).toEqual([TILE_ID])
|
||||
expect(recorded[TILE_ID]?.[0]?.cwd).toBe('/tile/work')
|
||||
})
|
||||
|
||||
it('still records into the primary session for the main chat', () => {
|
||||
$activeSessionId.set(PRIMARY_ID)
|
||||
$currentCwd.set('/primary/work')
|
||||
|
||||
renderToolRow(node => node)
|
||||
|
||||
expect(Object.keys($previewStatusBySession.get())).toEqual([PRIMARY_ID])
|
||||
})
|
||||
})
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
useState
|
||||
} from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
|
|
@ -39,7 +40,6 @@ import { normalize } from '@/lib/text'
|
|||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { recordPreviewArtifact } from '@/store/preview-status'
|
||||
import { $activeSessionId, $currentCwd } from '@/store/session'
|
||||
import { $toolInlineDiff } from '@/store/tool-diffs'
|
||||
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
|
@ -364,9 +364,12 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
|
||||
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
|
||||
// in the composer status stack rather than a bulky inline card. Uses the same
|
||||
// detected target the old inline card did, keyed to the active session the
|
||||
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
|
||||
// detected target the old inline card did. Idempotent + dedup'd, so re-renders
|
||||
// don't churn.
|
||||
const previewTarget = view.previewTarget
|
||||
// The session whose transcript this row is IN, which is not necessarily the
|
||||
// primary one: a tool row inside a session tile must feed that tile's composer.
|
||||
const { $cwd: $sessionCwd, $runtimeId: $sessionRuntimeId } = useSessionView()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || !previewTarget || !isPreviewableTarget(previewTarget)) {
|
||||
|
|
@ -376,12 +379,12 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
// Read (don't subscribe) session/cwd: this only fires when a previewable
|
||||
// target appears, and subscribing re-rendered every tool row on any session
|
||||
// or cwd change.
|
||||
const activeSessionId = $activeSessionId.get()
|
||||
const sessionId = $sessionRuntimeId.get()
|
||||
|
||||
if (activeSessionId) {
|
||||
recordPreviewArtifact(activeSessionId, previewTarget, $currentCwd.get() || '')
|
||||
if (sessionId) {
|
||||
recordPreviewArtifact(sessionId, previewTarget, $sessionCwd.get() || '')
|
||||
}
|
||||
}, [isPending, previewTarget])
|
||||
}, [$sessionCwd, $sessionRuntimeId, isPending, previewTarget])
|
||||
|
||||
const detailSections = useMemo(() => {
|
||||
if (!view.detail) {
|
||||
|
|
|
|||
|
|
@ -1,33 +1,28 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { MonitorPlay } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$previewTarget,
|
||||
dismissPreviewTarget,
|
||||
type PreviewRecordSource,
|
||||
setCurrentSessionPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import { $previewTabSources, closePreviewForSource, openPreview, type PreviewRecordSource } from '@/store/preview'
|
||||
|
||||
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
|
||||
const { t } = useI18n()
|
||||
const cwd = useStore($currentCwd)
|
||||
const activePreview = useStore($previewTarget)
|
||||
// This link lives in one session's transcript; resolve it against THAT
|
||||
// session's cwd, not the primary chat's.
|
||||
const cwd = useStore(useSessionView().$cwd)
|
||||
const openSources = useStore($previewTabSources)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const activePreviewRef = useRef(activePreview)
|
||||
const cwdRef = useRef(cwd)
|
||||
const mountedRef = useRef(false)
|
||||
const requestTokenRef = useRef(0)
|
||||
const targetRef = useRef(target)
|
||||
const name = previewName(target)
|
||||
const isActive = activePreview?.source === target
|
||||
const isActive = openSources.includes(target)
|
||||
|
||||
activePreviewRef.current = activePreview
|
||||
cwdRef.current = cwd
|
||||
targetRef.current = target
|
||||
|
||||
|
|
@ -53,7 +48,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
|
|||
}
|
||||
|
||||
if (isActive) {
|
||||
dismissPreviewTarget()
|
||||
closePreviewForSource(target)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -80,13 +75,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
|
|||
throw new Error(`Could not open preview target: ${requestTarget}`)
|
||||
}
|
||||
|
||||
const currentPreview = activePreviewRef.current
|
||||
|
||||
if (currentPreview?.source === preview.source && currentPreview.url === preview.url) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, source, requestTarget)
|
||||
openPreview(preview, source)
|
||||
} catch (error) {
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue