mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +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,5 +1,6 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -8,8 +9,7 @@ import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import { openPreview } from '@/store/preview'
|
||||
|
||||
export function AttachmentList({
|
||||
attachments,
|
||||
|
|
@ -31,13 +31,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
// The tile's cwd when this pill lives in a tile composer, not the primary's:
|
||||
// a relative attachment path has to resolve against its own session's root.
|
||||
const cwd = useStore(useSessionView().$cwd)
|
||||
const isUploading = attachment.uploadState === 'uploading'
|
||||
const hasUploadError = attachment.uploadState === 'error'
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
|
||||
async function openPreview() {
|
||||
async function openAttachmentPreview() {
|
||||
if (!canPreview) {
|
||||
return
|
||||
}
|
||||
|
|
@ -70,7 +72,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
|
||||
: preview
|
||||
|
||||
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
|
||||
openPreview(withBytes, 'manual')
|
||||
} catch (error) {
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
|
|
@ -89,7 +91,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||
)}
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
onClick={() => void openAttachmentPreview()}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react'
|
|||
import { playSpeechText } from '@/lib/voice-playback'
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
interface AutoSpeakReply {
|
||||
id: string
|
||||
pending: boolean
|
||||
|
|
@ -40,6 +41,9 @@ export function useAutoSpeakReplies({
|
|||
sessionId
|
||||
}: UseAutoSpeakReplies) {
|
||||
const enabled = useStore($autoSpeakReplies)
|
||||
// Wake on THIS composer's transcript: a tile subscribed to the primary's
|
||||
// would never fire on its own replies (and would fire on someone else's).
|
||||
const { $messages } = useComposerScope()
|
||||
const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply })
|
||||
latest.current = { conversationActive, failureLabel, markSpoken, pendingReply }
|
||||
|
||||
|
|
@ -83,5 +87,5 @@ export function useAutoSpeakReplies({
|
|||
const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
|
||||
|
||||
return () => stops.forEach(f => f())
|
||||
}, [enabled, sessionId])
|
||||
}, [$messages, enabled, sessionId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
|||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
|
||||
import type { ComposerTarget } from '../focus'
|
||||
import { onComposerVoiceToggleRequest } from '../focus'
|
||||
import { useComposerScope } from '../scope'
|
||||
import type { ChatBarProps } from '../types'
|
||||
|
||||
import { useAutoSpeakReplies } from './use-auto-speak-replies'
|
||||
|
|
@ -50,6 +50,8 @@ export function useComposerVoice({
|
|||
target
|
||||
}: UseComposerVoiceArgs) {
|
||||
const { t } = useI18n()
|
||||
// A tile's composer speaks ITS transcript, not the primary chat's.
|
||||
const { $messages } = useComposerScope()
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -11,7 +19,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
|||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import { sessionCompacting } from '@/store/compaction'
|
||||
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
|
||||
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
|
||||
|
|
@ -108,7 +116,7 @@ export function ChatBar({
|
|||
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
|
||||
const scope = useComposerScope()
|
||||
const attachments = useStore(scope.attachments.$attachments)
|
||||
const compacting = useStore($compactionActive)
|
||||
const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId]))
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
|
|
@ -637,7 +645,7 @@ export function ChatBar({
|
|||
|
||||
// $messages is read imperatively (not subscribed) so the composer
|
||||
// doesn't re-render on every streaming delta flush.
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.$messages.get(), chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
|
|
@ -662,7 +670,7 @@ export function ChatBar({
|
|||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
|
||||
const history = deriveUserHistory(scope.$messages.get(), chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
|
|
|
|||
|
|
@ -25,18 +25,19 @@ export interface ComposerScope {
|
|||
attachments: ComposerAttachmentScope
|
||||
/** Only the main scope may pop out (the floating composer is a singleton). */
|
||||
popoutAllowed: boolean
|
||||
/** Imperative read of this scope's transcript (input-history browse) —
|
||||
* never subscribed, so streaming stays out of the composer's renders. */
|
||||
readMessages: () => ChatMessage[]
|
||||
/** This scope's transcript. Read it imperatively (input-history browse) to
|
||||
* keep streaming out of the composer's renders; subscribe only off-render
|
||||
* (auto-speak) where the reply edge is the whole point. */
|
||||
$messages: ReadableAtom<ChatMessage[]>
|
||||
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
|
||||
$awaitingInput: $activeSessionAwaitingInput,
|
||||
$messages,
|
||||
attachments: mainComposerScope,
|
||||
popoutAllowed: true,
|
||||
readMessages: () => $messages.get(),
|
||||
target: 'main'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,9 +126,9 @@ function TileChat({
|
|||
const scope = useMemo<ComposerScope>(
|
||||
() => ({
|
||||
$awaitingInput: sessionAwaitingInput(runtimeId),
|
||||
$messages: view.$messages,
|
||||
attachments,
|
||||
popoutAllowed: false,
|
||||
readMessages: () => view.$messages.get(),
|
||||
target: `tile:${storedSessionId}`
|
||||
}),
|
||||
[attachments, runtimeId, storedSessionId, view.$messages]
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction'
|
||||
import { $activeSessionId } from './session'
|
||||
import { $compactingSessions, sessionCompacting, setSessionCompacting } from './compaction'
|
||||
|
||||
describe('compaction store', () => {
|
||||
beforeEach(() => {
|
||||
$compactingSessions.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
beforeEach(() => $compactingSessions.set({}))
|
||||
|
||||
afterEach(() => {
|
||||
$compactingSessions.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
afterEach(() => $compactingSessions.set({}))
|
||||
|
||||
it('tracks compaction per session independently', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
|
|
@ -21,16 +14,11 @@ describe('compaction store', () => {
|
|||
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
|
||||
})
|
||||
|
||||
it('exposes only the active session via the focus-scoped view', () => {
|
||||
it('scopes the view to the session asked for, not whichever is active', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
|
||||
expect($compactionActive.get()).toBe(false)
|
||||
|
||||
$activeSessionId.set('session-a')
|
||||
expect($compactionActive.get()).toBe(true)
|
||||
|
||||
$activeSessionId.set('session-b')
|
||||
expect($compactionActive.get()).toBe(false)
|
||||
expect(sessionCompacting('session-a').get()).toBe(true)
|
||||
expect(sessionCompacting('session-b').get()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a session without disturbing the others', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
// Per-session flag while auto-compaction runs mid-turn. Without it the
|
||||
// transcript looks like it reset; per-session so a background chat can't
|
||||
// clobber the foreground view.
|
||||
|
|
@ -9,10 +7,11 @@ const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
|
|||
|
||||
export const $compactingSessions = atom<Record<string, true>>({})
|
||||
|
||||
export const $compactionActive = computed(
|
||||
[$compactingSessions, $activeSessionId],
|
||||
(sessions, activeId) => keyFor(activeId) in sessions
|
||||
)
|
||||
/** Is `sessionId` compacting? Per-session because a transcript may be a tile,
|
||||
* and a tile must never wear the primary chat's compaction state. */
|
||||
export function sessionCompacting(sessionId: null | string) {
|
||||
return computed($compactingSessions, sessions => keyFor(sessionId) in sessions)
|
||||
}
|
||||
|
||||
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
|
||||
const key = keyFor(sessionId)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue