fix(ai-vault): preserve transient panel state (#9419)

* fix(ai-vault): preserve transient panel state

Refs #9418

* fix(ai-vault): synchronize transient panel state

* fix(ai-vault): keep session expansion local to the virtual list

Restore expandedSessionIds inside AiVaultSessionVirtualList so the
history/search resetKey still gives each list fresh expansions; only
scope and collapsed groups are retained across sidebar remounts.

---------

Co-authored-by: just-zzp <264744947+just-zzp@users.noreply.github.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
just-zzp
2026-10-24 00:31:07 -07:00
committed by GitHub
co-authored by just-zzp Brennan Benson Neil
parent 55b08b65df
commit 44c3847f24
6 changed files with 221 additions and 225 deletions
@@ -101,7 +101,6 @@ export default function AiVaultPanel({
setAllAgentsEnabled,
resetViewOptions
} = usePersistedAiVaultViewOptions(initialAgents)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
const { executionHostScope, onExecutionHostScopeChange, hostScopeOptions } =
useAiVaultPanelHostScope(activeWorktreeId ?? null, resumeTargetState)
const activeWorktreePath = activeWorktree?.path ?? null
@@ -123,9 +122,10 @@ export default function AiVaultPanel({
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos]
)
const activeProjectKey = projectScopeContext.activeProjectKey
const { scope, handleScopeChange } = useAiVaultPanelScope({
const { scope, handleScopeChange, collapsedGroups, toggleGroup } = useAiVaultPanelScope({
activeProjectKey,
activeWorktreePath
activeWorktreePath,
local: workspace !== undefined
})
const projectLabelByKey = projectScopeContext.projectLabelByKey
// Sent to the scanner so scoped views surface sessions older than the global cap.
@@ -240,18 +240,6 @@ export default function AiVaultPanel({
setFocusSearchRequestId
})
const toggleGroup = useCallback((key: string) => {
setCollapsedGroups((current) => {
const next = new Set(current)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}, [])
const requestDelete = useAiVaultSessionDeleteAction({
refresh,
onDeleted: search.onDeleted
@@ -0,0 +1,138 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { useAiVaultPanelScope as useScope } from './use-ai-vault-panel-scope'
let useAiVaultPanelScope: typeof useScope
type AiVaultPanelContext = {
activeProjectKey: string | null
activeWorktreePath: string | null
}
const AVAILABLE_CONTEXT: AiVaultPanelContext = {
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo'
}
const UNAVAILABLE_PROJECT_CONTEXT: AiVaultPanelContext = {
activeProjectKey: null,
activeWorktreePath: '/repo'
}
const UNAVAILABLE_WORKSPACE_CONTEXT: AiVaultPanelContext = {
activeProjectKey: 'project:orca',
activeWorktreePath: null
}
beforeEach(async () => {
vi.resetModules()
useAiVaultPanelScope = (await import('./use-ai-vault-panel-scope')).useAiVaultPanelScope
})
afterEach(cleanup)
describe('useAiVaultPanelScope', () => {
it('restores scope and collapsed groups when the panel remounts', () => {
const first = renderHook(() => useAiVaultPanelScope(AVAILABLE_CONTEXT))
act(() => {
first.result.current.selectScope('project')
first.result.current.toggleGroup('project:orca')
})
first.unmount()
const restored = renderHook(() => useAiVaultPanelScope(AVAILABLE_CONTEXT))
expect(restored.result.current.scope).toBe('project')
expect(restored.result.current.collapsedGroups).toEqual(new Set(['project:orca']))
})
it('falls back to all without forgetting a preferred project scope', () => {
const first = renderHook((context: AiVaultPanelContext) => useAiVaultPanelScope(context), {
initialProps: AVAILABLE_CONTEXT
})
act(() => first.result.current.selectScope('project'))
first.unmount()
const restored = renderHook((context: AiVaultPanelContext) => useAiVaultPanelScope(context), {
initialProps: UNAVAILABLE_PROJECT_CONTEXT
})
expect(restored.result.current.scope).toBe('all')
restored.rerender(AVAILABLE_CONTEXT)
expect(restored.result.current.scope).toBe('project')
})
it('restores the default workspace scope after an unavailable-context remount', () => {
const first = renderHook((context: AiVaultPanelContext) => useAiVaultPanelScope(context), {
initialProps: AVAILABLE_CONTEXT
})
expect(first.result.current.scope).toBe('workspace')
first.rerender(UNAVAILABLE_WORKSPACE_CONTEXT)
expect(first.result.current.scope).toBe('all')
first.unmount()
const restored = renderHook((context: AiVaultPanelContext) => useAiVaultPanelScope(context), {
initialProps: UNAVAILABLE_WORKSPACE_CONTEXT
})
expect(restored.result.current.scope).toBe('all')
restored.rerender(AVAILABLE_CONTEXT)
expect(restored.result.current.scope).toBe('workspace')
})
it('keeps an explicit all scope sticky across context changes and remounts', () => {
const first = renderHook(() => useAiVaultPanelScope(AVAILABLE_CONTEXT))
act(() => first.result.current.selectScope('all'))
first.unmount()
const restored = renderHook(() =>
useAiVaultPanelScope({ activeProjectKey: null, activeWorktreePath: null })
)
expect(restored.result.current.scope).toBe('all')
restored.unmount()
const availableAgain = renderHook(() => useAiVaultPanelScope(AVAILABLE_CONTEXT))
expect(availableAgain.result.current.scope).toBe('all')
})
it('bounds collapsed groups and evicts the oldest entries', () => {
const hook = renderHook(() => useAiVaultPanelScope(AVAILABLE_CONTEXT))
act(() => {
for (let index = 0; index <= 512; index += 1) {
hook.result.current.toggleGroup(`group-${index}`)
}
})
expect(hook.result.current.collapsedGroups.size).toBe(512)
expect(hook.result.current.collapsedGroups.has('group-0')).toBe(false)
expect(hook.result.current.collapsedGroups.has('group-512')).toBe(true)
})
it('keeps a dialog local without inheriting or changing sidebar choices', () => {
const sidebar = renderHook(() => useAiVaultPanelScope(AVAILABLE_CONTEXT))
act(() => {
sidebar.result.current.handleScopeChange('project')
sidebar.result.current.toggleGroup('project:orca')
})
const dialog = renderHook(() => useAiVaultPanelScope({ ...AVAILABLE_CONTEXT, local: true }))
expect(dialog.result.current.scope).toBe('workspace')
expect(dialog.result.current.collapsedGroups.size).toBe(0)
act(() => {
dialog.result.current.handleScopeChange('all')
dialog.result.current.toggleGroup('project:dialog')
})
expect(sidebar.result.current.scope).toBe('project')
expect(sidebar.result.current.collapsedGroups).toEqual(new Set(['project:orca']))
dialog.unmount()
const reopened = renderHook(() => useAiVaultPanelScope({ ...AVAILABLE_CONTEXT, local: true }))
expect(reopened.result.current.scope).toBe('workspace')
expect(reopened.result.current.collapsedGroups.size).toBe(0)
sidebar.unmount()
const returned = renderHook(() => useAiVaultPanelScope(AVAILABLE_CONTEXT))
expect(returned.result.current.scope).toBe('project')
expect(returned.result.current.collapsedGroups).toEqual(new Set(['project:orca']))
})
})
@@ -0,0 +1,65 @@
import { useState, useSyncExternalStore } from 'react'
import type { AiVaultScope } from '../../../../shared/ai-vault-types'
import { DEFAULT_AI_VAULT_SCOPE } from './ai-vault-scope-state'
const MAX_COLLAPSED_GROUPS = 512
type AiVaultPanelTransientState = {
preferredScope: AiVaultScope
collapsedGroups: ReadonlySet<string>
}
function toggleCollapsedGroup(current: ReadonlySet<string>, key: string): ReadonlySet<string> {
const next = new Set(current)
if (next.has(key)) {
next.delete(key)
return next
}
next.add(key)
const oldest = next.values().next().value
if (next.size > MAX_COLLAPSED_GROUPS && oldest !== undefined) {
next.delete(oldest)
}
return next
}
function createPanelState() {
let snapshot: AiVaultPanelTransientState = {
preferredScope: DEFAULT_AI_VAULT_SCOPE,
collapsedGroups: new Set()
}
const listeners = new Set<() => void>()
const update = (next: AiVaultPanelTransientState): void => {
snapshot = next
for (const listener of listeners) {
listener()
}
}
return {
getSnapshot: () => snapshot,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
selectScope: (preferredScope: AiVaultScope) => {
if (snapshot.preferredScope !== preferredScope) {
update({ ...snapshot, preferredScope })
}
},
toggleGroup: (key: string) =>
update({ ...snapshot, collapsedGroups: toggleCollapsedGroup(snapshot.collapsedGroups, key) })
}
}
// Sidebar navigation unmounts the panel; its choices last until this renderer reloads.
const sidebarState = createPanelState()
export function useAiVaultPanelTransientState(local = false) {
const [state] = useState(() => (local ? createPanelState() : sidebarState))
const snapshot = useSyncExternalStore(state.subscribe, state.getSnapshot)
return {
...snapshot,
selectScope: state.selectScope,
toggleGroup: state.toggleGroup
}
}
@@ -1,9 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
getRestorableAiVaultScope,
normalizeAiVaultScopeForContext,
shouldRestoreDefaultAiVaultScope
} from './ai-vault-scope-state'
import { normalizeAiVaultScopeForContext } from './ai-vault-scope-state'
describe('normalizeAiVaultScopeForContext', () => {
it('falls back from project to all when no active project is available', () => {
@@ -44,98 +40,3 @@ describe('normalizeAiVaultScopeForContext', () => {
).toBe('workspace')
})
})
describe('shouldRestoreDefaultAiVaultScope', () => {
it('restores workspace after automatic fallback when a workspace becomes available', () => {
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
userChangedScope: false
})
).toBe(true)
})
it('does not restore workspace while the workspace is unavailable', () => {
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: null,
userChangedScope: false
})
).toBe(false)
})
it('does not restore workspace after the user manually chose project or all', () => {
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
userChangedScope: true
})
).toBe(false)
})
it('can restore a project default only when a project becomes available', () => {
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: null,
activeWorktreePath: '/repo',
userChangedScope: false,
defaultScope: 'project'
})
).toBe(false)
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
userChangedScope: false,
defaultScope: 'project'
})
).toBe(true)
})
})
describe('getRestorableAiVaultScope', () => {
it('restores the workspace preference after automatic fallback', () => {
expect(
getRestorableAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
preferredScope: 'workspace',
userChangedScope: false
})
).toBe('workspace')
})
it('restores a manual project preference when a project becomes available again', () => {
expect(
getRestorableAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
preferredScope: 'project',
userChangedScope: true
})
).toBe('project')
})
it('keeps a manual all preference sticky', () => {
expect(
getRestorableAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
preferredScope: 'all',
userChangedScope: true
})
).toBeNull()
})
})
@@ -15,66 +15,3 @@ export function normalizeAiVaultScopeForContext(args: {
}
return args.scope
}
function isAiVaultScopeAvailable(args: {
scope: AiVaultScope
activeProjectKey: string | null
activeWorktreePath: string | null
}): boolean {
return normalizeAiVaultScopeForContext(args) === args.scope
}
export function shouldRestoreDefaultAiVaultScope(args: {
scope: AiVaultScope
activeProjectKey: string | null
activeWorktreePath: string | null
userChangedScope: boolean
defaultScope?: AiVaultScope
}): boolean {
const defaultScope = args.defaultScope ?? DEFAULT_AI_VAULT_SCOPE
return (
args.scope === 'all' &&
!args.userChangedScope &&
isAiVaultScopeAvailable({
scope: defaultScope,
activeProjectKey: args.activeProjectKey,
activeWorktreePath: args.activeWorktreePath
})
)
}
export function getRestorableAiVaultScope(args: {
scope: AiVaultScope
activeProjectKey: string | null
activeWorktreePath: string | null
preferredScope: AiVaultScope
userChangedScope: boolean
defaultScope?: AiVaultScope
}): AiVaultScope | null {
const defaultScope = args.defaultScope ?? DEFAULT_AI_VAULT_SCOPE
if (args.preferredScope === defaultScope) {
return shouldRestoreDefaultAiVaultScope({
scope: args.scope,
activeProjectKey: args.activeProjectKey,
activeWorktreePath: args.activeWorktreePath,
userChangedScope: args.userChangedScope,
defaultScope
})
? defaultScope
: null
}
if (args.scope !== 'all' || args.preferredScope === 'all') {
return null
}
return isAiVaultScopeAvailable({
scope: args.preferredScope,
activeProjectKey: args.activeProjectKey,
activeWorktreePath: args.activeWorktreePath
})
? args.preferredScope
: null
}
@@ -1,56 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AiVaultScope } from '../../../../shared/ai-vault-types'
import {
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
import { normalizeAiVaultScopeForContext } from './ai-vault-scope-state'
import { useAiVaultPanelTransientState } from './ai-vault-panel-transient-state'
/**
* The panel's scope, kept honest against the active context: an unavailable
* Workspace or Project falls back to All, and the user's choice returns when
* its context does.
*/
export function useAiVaultPanelScope({
activeProjectKey,
activeWorktreePath
activeWorktreePath,
local = false
}: {
activeProjectKey: string | null
activeWorktreePath: string | null
}): { scope: AiVaultScope; handleScopeChange: (scope: AiVaultScope) => void } {
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
useEffect(() => {
const normalizedScope = normalizeAiVaultScopeForContext({
scope,
local?: boolean
}) {
const state = useAiVaultPanelTransientState(local)
return {
...state,
scope: normalizeAiVaultScopeForContext({
scope: state.preferredScope,
activeProjectKey,
activeWorktreePath
})
if (normalizedScope !== scope) {
setScope(normalizedScope)
}
}, [activeProjectKey, activeWorktreePath, scope])
useEffect(() => {
const restorableScope = getRestorableAiVaultScope({
scope,
activeProjectKey,
activeWorktreePath,
preferredScope: preferredScopeRef.current,
userChangedScope: userChangedScopeRef.current
})
if (restorableScope) {
setScope(restorableScope)
}
}, [activeProjectKey, activeWorktreePath, scope])
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
preferredScopeRef.current = nextScope
userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE
setScope(nextScope)
}, [])
return { scope, handleScopeChange }
}),
handleScopeChange: state.selectScope
}
}