From 44c3847f241a2cd37f2ba7ffbf79b598c045cf68 Mon Sep 17 00:00:00 2001 From: just-zzp Date: Sat, 24 Oct 2026 16:31:07 +0900 Subject: [PATCH] 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 --- .../components/right-sidebar/AiVaultPanel.tsx | 18 +-- .../ai-vault-panel-transient-state.test.tsx | 138 ++++++++++++++++++ .../ai-vault-panel-transient-state.ts | 65 +++++++++ .../ai-vault-scope-state.test.ts | 101 +------------ .../right-sidebar/ai-vault-scope-state.ts | 63 -------- .../right-sidebar/use-ai-vault-panel-scope.ts | 61 ++------ 6 files changed, 221 insertions(+), 225 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.ts diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 3cf05c68134..9fd62f3484b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -101,7 +101,6 @@ export default function AiVaultPanel({ setAllAgentsEnabled, resetViewOptions } = usePersistedAiVaultViewOptions(initialAgents) - const [collapsedGroups, setCollapsedGroups] = useState>(() => 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 diff --git a/src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.test.tsx new file mode 100644 index 00000000000..cd0ebfb5eda --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.test.tsx @@ -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'])) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.ts new file mode 100644 index 00000000000..6529de621eb --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-panel-transient-state.ts @@ -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 +} + +function toggleCollapsedGroup(current: ReadonlySet, key: string): ReadonlySet { + 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 + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts index 8f9ae01339e..a58c4975b68 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts @@ -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() - }) -}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts index 5890fcf97e7..ea3a2171607 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts @@ -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 -} diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-scope.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-scope.ts index 2865b84d74a..fbfb7e3289c 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-scope.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-scope.ts @@ -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(DEFAULT_AI_VAULT_SCOPE) - const userChangedScopeRef = useRef(false) - const preferredScopeRef = useRef(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 + } }