mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
co-authored by
just-zzp
Brennan Benson
Neil
parent
55b08b65df
commit
44c3847f24
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user