From bbd2eff6157de818c071ae762b948d8d6bc363ac Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 14 Sep 2026 17:06:56 -0400 Subject: [PATCH] feat(settings): live index status, enable confirm, advanced delete --- .../SessionHistoryIndexStatus.test.tsx | 210 ++++++++++++++++++ .../settings/SessionHistoryIndexStatus.tsx | 147 ++++++------ .../SessionHistorySettingsPane.test.tsx | 184 +++++++-------- .../settings/SessionHistorySettingsPane.tsx | 150 +++++++------ .../settings-navigation-workflow-sections.ts | 2 +- src/renderer/src/i18n/locales/en.json | 37 ++- 6 files changed, 458 insertions(+), 272 deletions(-) create mode 100644 src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx diff --git a/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx b/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx new file mode 100644 index 00000000000..c47b02a9fd9 --- /dev/null +++ b/src/renderer/src/components/settings/SessionHistoryIndexStatus.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client' +import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types' +import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus' + +const mocks = vi.hoisted(() => ({ visible: true, status: vi.fn() })) +vi.mock('@/hooks/use-window-stream-visibility', () => ({ + useWindowStreamVisible: () => mocks.visible +})) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, args?: Record) => + fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key])) +})) + +const current: AiVaultSearchStatus = { + ...unavailableSessionSearchStatus(), + enabled: true, + phase: 'current', + filesIndexed: 12, + lastSweepCompletedAt: 1 +} +beforeEach(() => { + vi.useFakeTimers() + mocks.visible = true + mocks.status.mockReset().mockResolvedValue(current) + vi.stubGlobal('api', undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { aiVault: { searchStatus: mocks.status } } + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +it('keeps polling a settled index so counts stay live between sweeps', async () => { + render() + await act(async () => {}) + expect(mocks.status).toHaveBeenCalledWith('local') + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') + mocks.status.mockResolvedValue({ ...current, filesIndexed: 30 }) + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 30 files indexed') +}) + +it('reports a first scan by count and later sweeps by percentage', async () => { + mocks.status.mockResolvedValue({ + ...current, + phase: 'indexing', + filesIndexed: 4, + filesDue: 6, + lastSweepCompletedAt: null + }) + render() + await act(async () => {}) + expect(screen.getByRole('status')).toHaveTextContent('Indexing… 4 files so far') + expect(screen.getByRole('status')).toHaveTextContent('Turn off search to stop') + mocks.status.mockResolvedValue({ + ...current, + phase: 'indexing', + filesIndexed: 4, + filesDue: 5, + filesFailed: 1, + lastSweepCompletedAt: 1 + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect(screen.getByRole('status')).toHaveTextContent('Indexing · 40% · 4 of 10 files') +}) + +it('polls a sweep faster than a settled index', async () => { + mocks.status.mockResolvedValue({ ...current, phase: 'indexing', filesDue: 3 }) + render() + await act(async () => {}) + const started = mocks.status.mock.calls.length + await act(async () => { + await vi.advanceTimersByTimeAsync(6_000) + }) + expect(mocks.status.mock.calls.length - started).toBe(3) +}) + +it('names unreadable files while degraded and still reports progress', async () => { + mocks.status.mockResolvedValue({ + ...current, + phase: 'degraded', + filesIndexed: 8, + filesDue: 1, + filesFailed: 1, + degradedRoots: [{ reason: 'unreadable' }] + }) + render() + await act(async () => {}) + const status = screen.getByRole('status') + expect(status).toHaveTextContent('Indexing · 80% · 8 of 10 files') + expect(status).toHaveTextContent('1 files could not be read and will be retried.') + expect(status).toHaveTextContent('Unverified source roots: 1') +}) + +it('calls a drained degraded index up to date', async () => { + mocks.status.mockResolvedValue({ + ...current, + phase: 'degraded', + filesIndexed: 9, + filesDue: 0, + filesFailed: 2 + }) + render() + await act(async () => {}) + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 9 files indexed') + expect(screen.getByRole('status')).toHaveTextContent('2 files could not be read') + expect(screen.queryByText(/Turn off search to stop/)).not.toBeInTheDocument() +}) + +it('offers no refresh control now that status is live', async () => { + render() + await act(async () => {}) + expect(screen.queryByRole('button')).not.toBeInTheDocument() +}) + +it('does not describe an absent service as an empty current index', async () => { + mocks.status.mockResolvedValue(unavailableSessionSearchStatus()) + render() + await act(async () => {}) + expect(screen.getByRole('status')).toHaveTextContent( + 'not ready or the search service is unavailable' + ) + expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument() +}) + +it('recovers on its own after a failed read', async () => { + mocks.status.mockRejectedValueOnce(new Error('offline')) + render() + await act(async () => {}) + expect(screen.getByRole('status')).toHaveTextContent('Could not read index status') + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') +}) + +it('handles a synchronous bridge failure without losing the poll', async () => { + mocks.status.mockImplementationOnce(() => { + throw new Error('bridge unavailable') + }) + render() + await act(async () => {}) + expect(screen.getByRole('status')).toHaveTextContent('Could not read index status') + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') +}) + +it('fences pending responses across disable and hiding', async () => { + let answer: (value: AiVaultSearchStatus) => void = () => undefined + mocks.status.mockReturnValue( + new Promise((resolve) => { + answer = resolve + }) + ) + const view = render() + view.rerender() + await act(async () => { + answer(current) + }) + expect(screen.getByRole('status')).toHaveTextContent('Search is off') + expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument() + mocks.visible = false + view.rerender() + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(mocks.status).toHaveBeenCalledTimes(1) + mocks.visible = true + mocks.status.mockResolvedValue(current) + view.rerender() + await act(async () => {}) + expect(mocks.status).toHaveBeenCalledTimes(2) +}) + +it('does not overlap slow status requests and stops polling on unmount', async () => { + let answer: (value: AiVaultSearchStatus) => void = () => undefined + mocks.status.mockReturnValue( + new Promise((resolve) => { + answer = resolve + }) + ) + const view = render() + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(mocks.status).toHaveBeenCalledTimes(1) + await act(async () => { + answer({ ...current, phase: 'indexing', filesDue: 2 }) + }) + const beforeUnmount = mocks.status.mock.calls.length + view.unmount() + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(mocks.status).toHaveBeenCalledTimes(beforeUnmount) +}) diff --git a/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx b/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx index 7006304e8f5..5d9b407b9f5 100644 --- a/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx +++ b/src/renderer/src/components/settings/SessionHistoryIndexStatus.tsx @@ -3,28 +3,69 @@ import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-typ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { useWindowStreamVisible } from '@/hooks/use-window-stream-visibility' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { SettingsRow } from './SettingsFormControls' +const SWEEPING_POLL_MS = 2_000 +const SETTLED_POLL_MS = 10_000 + +// A pass still has files due, so counts move between polls; a settled index only changes on the next sweep. +function isSweeping(status: AiVaultSearchStatus | null): boolean { + if (!status?.enabled) { + return false + } + return status.phase === 'indexing' || (status.phase === 'degraded' && status.filesDue > 0) +} + +function sweepMessage(status: AiVaultSearchStatus): string { + if (status.lastSweepCompletedAt === null) { + // No completed sweep yet, so the denominator is still growing and a percentage would mislead. + return translate('sessionHistory.status.firstScan', 'Indexing… {{indexed}} files so far', { + indexed: status.filesIndexed + }) + } + const total = status.filesIndexed + status.filesDue + status.filesFailed + const percent = total > 0 ? Math.floor((status.filesIndexed / total) * 100) : 0 + return translate( + 'sessionHistory.status.progress', + 'Indexing · {{percent}}% · {{indexed}} of {{total}} files', + { percent, indexed: status.filesIndexed, total } + ) +} + +function statusMessage(status: AiVaultSearchStatus): string { + if (!status.enabled || status.phase === 'idle' || status.phase === 'closed') { + return translate( + 'sessionHistory.status.unavailable', + 'Index is not ready or the search service is unavailable.' + ) + } + if (isSweeping(status)) { + return sweepMessage(status) + } + return translate('sessionHistory.status.upToDate', 'Up to date · {{indexed}} files indexed', { + indexed: status.filesIndexed + }) +} + export function SessionHistoryIndexStatus({ enabled, - refresh, - busy + refresh }: { enabled: boolean refresh: number - busy: boolean }): React.JSX.Element { const visible = useWindowStreamVisible(0) const [status, setStatus] = useState(null) const [failed, setFailed] = useState(false) - const [loading, setLoading] = useState(false) - const [requested, setRequested] = useState(0) + const intervalMs = isSweeping(status) ? SWEEPING_POLL_MS : SETTLED_POLL_MS useEffect(() => { - setStatus(null) - setFailed(false) - if (!enabled || !visible || busy) { + if (!enabled) { + setStatus(null) + setFailed(false) + return + } + if (!visible) { return } let disposed = false @@ -34,41 +75,29 @@ export function SessionHistoryIndexStatus({ return } inFlight = true - setLoading(true) try { const next = await Promise.resolve().then(() => window.api.aiVault.searchStatus(LOCAL_EXECUTION_HOST_ID) ) - if (disposed) { - return - } - setStatus(next) - // Only an observed indexing pass needs live progress; idle state has no timer. - if (!next.enabled || next.phase !== 'indexing') { - stopPolling() + if (!disposed) { + setStatus(next) + setFailed(false) } } catch { if (!disposed) { setStatus(null) setFailed(true) - stopPolling() } } finally { inFlight = false - if (!disposed) { - setLoading(false) - } } } - const stopPolling = installWindowVisibilityInterval({ - run: () => void read(), - intervalMs: 5_000 - }) + const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs }) return () => { disposed = true stopPolling() } - }, [enabled, visible, busy, refresh, requested]) + }, [enabled, visible, refresh, intervalMs]) let message = translate('sessionHistory.status.checking', 'Checking index…') if (!enabled) { @@ -77,58 +106,35 @@ export function SessionHistoryIndexStatus({ 'Search is off. Any existing index copy is kept.' ) } else if (failed) { - message = translate( - 'sessionHistory.status.error', - 'Could not read index status. Try refreshing.' - ) + message = translate('sessionHistory.status.error', 'Could not read index status. Retrying…') } else if (status) { - switch (status.phase) { - case 'indexing': - message = translate('sessionHistory.status.indexing', 'Indexing transcripts…') - break - case 'current': - message = translate( - 'sessionHistory.status.current', - 'Index is up to date with the last scan.' - ) - break - case 'degraded': - message = translate( - 'sessionHistory.status.degraded', - 'Some transcript sources could not be indexed.' - ) - break - case 'idle': - case 'closed': - message = translate( - 'sessionHistory.status.unavailable', - 'Index is not ready or the search service is unavailable.' - ) - break - } - if (!status.enabled) { - message = translate( - 'sessionHistory.status.unavailable', - 'Index is not ready or the search service is unavailable.' - ) - } + message = statusMessage(status) } + const live = enabled && status?.enabled === true return ( {message} - {enabled && status?.enabled && status.phase !== 'idle' && status.phase !== 'closed' ? ( + {live && status.phase === 'degraded' && status.filesFailed > 0 ? ( {translate( - 'sessionHistory.status.counts', - 'Indexed files: {{indexed}} · Due: {{due}} · Failed: {{failed}}', - { indexed: status.filesIndexed, due: status.filesDue, failed: status.filesFailed } + 'sessionHistory.status.unreadable', + '{{failed}} files could not be read and will be retried.', + { failed: status.filesFailed } )} ) : null} - {enabled && status?.enabled && status.degradedRoots.length > 0 ? ( + {live && isSweeping(status) ? ( + + {translate( + 'sessionHistory.status.stopHint', + 'Turn off search to stop. Progress is kept and resumes when you turn it back on.' + )} + + ) : null} + {live && status.degradedRoots.length > 0 ? ( {translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', { roots: status.degradedRoots.length @@ -137,16 +143,7 @@ export function SessionHistoryIndexStatus({ ) : null} } - control={ - - } + control={null} /> ) } diff --git a/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx b/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx index 212558df754..5e0ad8ffbae 100644 --- a/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx +++ b/src/renderer/src/components/settings/SessionHistorySettingsPane.test.tsx @@ -7,7 +7,6 @@ import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-sear import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types' import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context' import { SessionHistorySettingsPane } from './SessionHistorySettingsPane' -import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus' const mocks = vi.hoisted(() => ({ web: false, visible: true, status: vi.fn(), clear: vi.fn() })) vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web })) @@ -23,25 +22,32 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn() } })) function pane( enabled = false, confirm = vi.fn().mockResolvedValue(true), - save = vi.fn().mockResolvedValue(undefined) + save = vi.fn().mockResolvedValue(undefined), + historyDays: number | null = null ) { return render( ) } +async function openAdvanced(): Promise { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Advanced/ })) + }) +} const current: AiVaultSearchStatus = { ...unavailableSessionSearchStatus(), enabled: true, phase: 'current', - filesIndexed: 12 + filesIndexed: 12, + lastSweepCompletedAt: 1 } beforeEach(() => { vi.useFakeTimers() @@ -63,7 +69,8 @@ afterEach(() => { it('requires opt-in and saves the existing policy without touching transcripts or polling while off', async () => { const save = vi.fn().mockResolvedValue(undefined) - pane(false, undefined, save) + const confirm = vi.fn().mockResolvedValue(true) + pane(false, confirm, save) expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') expect(screen.getByText(/Content is not redacted/)).toBeInTheDocument() await act(async () => { @@ -73,9 +80,48 @@ it('requires opt-in and saves the existing policy without touching transcripts o await act(async () => { fireEvent.click(screen.getByRole('switch')) }) + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Start indexing agent sessions?', + description: expect.stringContaining('content is not redacted'), + confirmLabel: 'Start indexing' + }) + ) expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } }) }) +it('leaves search off when the indexing consent is declined', async () => { + const save = vi.fn().mockResolvedValue(undefined) + pane(false, vi.fn().mockResolvedValue(false), save) + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + }) + expect(save).not.toHaveBeenCalled() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') +}) + +it('turns search off without asking again', async () => { + const confirm = vi.fn().mockResolvedValue(true) + const save = vi.fn().mockResolvedValue(undefined) + pane(true, confirm, save) + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + }) + expect(confirm).not.toHaveBeenCalled() + expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: false, historyDays: null } }) +}) + +it('keeps the stored retention window without offering a control for it', async () => { + const save = vi.fn().mockResolvedValue(undefined) + pane(false, undefined, save, 30) + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByText(/Searchable history/)).not.toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByRole('switch')) + }) + expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: 30 } }) +}) + it('shows failed saves inline and unlocks controls', async () => { pane(false, undefined, vi.fn().mockRejectedValue(new Error('write failed'))) await act(async () => { @@ -85,20 +131,33 @@ it('shows failed saves inline and unlocks controls', async () => { expect(screen.getByRole('switch')).toBeEnabled() }) -it('clears only after confirmation, supports clearing while disabled, and reports failures', async () => { +it('hides the delete control behind Advanced', async () => { + pane(false) + expect(screen.queryByRole('button', { name: 'Delete index' })).not.toBeInTheDocument() + await openAdvanced() + expect(screen.getByRole('button', { name: 'Delete index' })).toBeInTheDocument() + expect(screen.getByText(/Search stays off/)).toBeInTheDocument() +}) + +it('deletes only after confirmation, supports deleting while disabled, and reports failures', async () => { const confirm = vi.fn().mockResolvedValue(false) pane(false, confirm) + await openAdvanced() await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Clear index' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete index' })) }) expect(mocks.clear).not.toHaveBeenCalled() expect(confirm).toHaveBeenCalledWith( - expect.objectContaining({ description: expect.stringContaining('Search will stay off') }) + expect.objectContaining({ + title: 'Delete this computer’s search index?', + description: expect.stringContaining('Search stays off'), + confirmLabel: 'Delete index' + }) ) confirm.mockResolvedValue(true) mocks.clear.mockRejectedValue(new Error('service unavailable')) await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Clear index' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete index' })) }) expect(mocks.clear).toHaveBeenCalledOnce() expect(screen.getByRole('alert')).toHaveTextContent('Could not clear') @@ -110,7 +169,8 @@ it('does not execute a confirmation after navigating away', async () => { accept = resolve }) const view = pane(false, vi.fn().mockReturnValue(confirmation)) - fireEvent.click(screen.getByRole('button', { name: 'Clear index' })) + await openAdvanced() + fireEvent.click(screen.getByRole('button', { name: 'Delete index' })) view.unmount() await act(async () => { accept(true) @@ -122,109 +182,29 @@ it('leaves paired-client controls unsupported without local calls', async () => mocks.web = true pane(true) expect(screen.getByRole('switch')).toBeDisabled() - expect(screen.getByRole('combobox')).toBeDisabled() - expect(screen.getByRole('button', { name: 'Clear index' })).toBeDisabled() + await openAdvanced() + expect(screen.getByRole('button', { name: 'Delete index' })).toBeDisabled() await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) expect(mocks.status).not.toHaveBeenCalled() }) -it('polls only observed indexing and stops at current, with explicit local ownership', async () => { - mocks.status.mockResolvedValueOnce({ ...current, phase: 'indexing', filesDue: 4 }) - render() - await act(async () => {}) - expect(screen.getByRole('status')).toHaveTextContent('Due: 4') - expect(mocks.status).toHaveBeenCalledWith('local') - await act(async () => { - await vi.advanceTimersByTimeAsync(5_000) - }) - expect(screen.getByRole('status')).toHaveTextContent('up to date with the last scan') - await act(async () => { - await vi.advanceTimersByTimeAsync(60_000) - }) - expect(mocks.status).toHaveBeenCalledTimes(2) -}) - -it('fences pending responses across disable, hiding and clear operations', async () => { - let answer: (value: AiVaultSearchStatus) => void = () => undefined - mocks.status.mockReturnValue( - new Promise((resolve) => { - answer = resolve +it('keeps the last index status visible while a save is in flight', async () => { + let finishSave: () => void = () => undefined + const save = vi.fn().mockReturnValue( + new Promise((resolve) => { + finishSave = resolve }) ) - const view = render() - view.rerender() - await act(async () => { - answer(current) - }) - expect(screen.getByRole('status')).toHaveTextContent('Search is off') - expect(screen.queryByText(/Indexed files/)).not.toBeInTheDocument() - mocks.visible = false - view.rerender() - await act(async () => { - await vi.advanceTimersByTimeAsync(60_000) - }) - expect(mocks.status).toHaveBeenCalledTimes(1) - mocks.visible = true - mocks.status.mockResolvedValue(current) - view.rerender() - expect(mocks.status).toHaveBeenCalledTimes(1) - view.rerender() + pane(true, vi.fn().mockResolvedValue(true), save) await act(async () => {}) - expect(mocks.status).toHaveBeenCalledTimes(2) -}) - -it('does not describe an absent service as an empty current index and allows retry', async () => { - mocks.status.mockResolvedValueOnce(unavailableSessionSearchStatus()) - render() - await act(async () => {}) - expect(screen.getByRole('status')).toHaveTextContent( - 'not ready or the search service is unavailable' - ) - expect(screen.queryByText(/Indexed files/)).not.toBeInTheDocument() - mocks.status.mockRejectedValueOnce(new Error('offline')) + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + fireEvent.click(screen.getByRole('switch')) }) - expect(screen.getByRole('status')).toHaveTextContent('Could not read index status') + expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed') await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + finishSave() }) - expect(screen.getByRole('status')).toHaveTextContent('Indexed files: 12') -}) - -it('does not overlap slow status requests and cancels indexing refreshes on unmount', async () => { - let answer: (value: AiVaultSearchStatus) => void = () => undefined - mocks.status.mockReturnValue( - new Promise((resolve) => { - answer = resolve - }) - ) - const view = render() - await act(async () => { - await vi.advanceTimersByTimeAsync(20_000) - }) - expect(mocks.status).toHaveBeenCalledTimes(1) - await act(async () => { - answer({ ...current, phase: 'indexing' }) - }) - view.unmount() - await act(async () => { - await vi.advanceTimersByTimeAsync(60_000) - }) - expect(mocks.status).toHaveBeenCalledTimes(1) -}) - -it('handles synchronous unavailable-bridge errors without leaving a refresh loop', async () => { - mocks.status.mockImplementation(() => { - throw new Error('bridge unavailable') - }) - render() - await act(async () => {}) - expect(screen.getByRole('status')).toHaveTextContent('Could not read index status') - await act(async () => { - await vi.advanceTimersByTimeAsync(60_000) - }) - expect(mocks.status).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx b/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx index db35a11ddd1..c3d1326faa3 100644 --- a/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx +++ b/src/renderer/src/components/settings/SessionHistorySettingsPane.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' import { toast } from 'sonner' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { @@ -6,15 +7,10 @@ import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings' import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { isWebClientLocation } from '@/lib/web-client-location' +import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { SettingsRow, SettingsSwitchRow } from './SettingsFormControls' import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus' @@ -32,6 +28,7 @@ export function SessionHistorySettingsPane({ const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [refresh, setRefresh] = useState(0) + const [advancedOpen, setAdvancedOpen] = useState(false) const mounted = useRef(true) useEffect(() => { mounted.current = true @@ -63,25 +60,52 @@ export function SessionHistorySettingsPane({ } } - async function clearIndex(): Promise { + async function toggleEnabled(): Promise { + if (policy.enabled) { + await save({ enabled: false }) + return + } + setBusy(true) + let accepted = false + try { + accepted = await confirm({ + title: translate('sessionHistory.settings.enableTitle', 'Start indexing agent sessions?'), + description: translate( + 'sessionHistory.settings.enableConsent', + 'Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.' + ), + confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Start indexing') + }) + } finally { + if (mounted.current) { + setBusy(false) + } + } + if (!accepted || !mounted.current) { + return + } + await save({ enabled: true }) + } + + async function deleteIndex(): Promise { setBusy(true) setError(null) try { const accepted = await confirm({ title: translate( - 'sessionHistory.settings.clearTitle', - 'Clear this computer’s search index?' + 'sessionHistory.settings.deleteTitle', + 'Delete this computer’s search index?' ), description: policy.enabled ? translate( - 'sessionHistory.settings.clearEnabled', - 'The index copy will be deleted and rebuilt because search is enabled. Original transcripts will not be deleted.' + 'sessionHistory.settings.deleteEnabled', + 'Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.' ) : translate( - 'sessionHistory.settings.clearDisabled', - 'The index copy will be deleted. Original transcripts will not be deleted. Search will stay off.' + 'sessionHistory.settings.deleteDisabled', + 'Remove the search index from this computer. Original transcripts are not touched. Search stays off.' ), - confirmLabel: translate('sessionHistory.settings.clear', 'Clear index'), + confirmLabel: translate('sessionHistory.settings.delete', 'Delete index'), confirmVariant: 'destructive' }) if (!accepted || !mounted.current) { @@ -110,7 +134,6 @@ export function SessionHistorySettingsPane({ } } - const retentionLabel = translate('sessionHistory.settings.retention', 'Searchable history') return (
void save({ enabled: !policy.enabled })} - /> - - void save({ historyDays: value === 'all' ? null : Number(value) }) - } - > - - - - - - {translate('sessionHistory.settings.all', 'All history')} - - - {translate('sessionHistory.settings.days90', '90 days')} - - - {translate('sessionHistory.settings.days30', '30 days')} - - {policy.historyDays !== null && - policy.historyDays !== 90 && - policy.historyDays !== 30 ? ( - - {translate('sessionHistory.settings.customDays', '{{days}} days', { - days: policy.historyDays - })} - - ) : null} - - - } + onChange={() => void toggleEnabled()} /> {!isWebClient ? ( - + ) : null} - void clearIndex()} - > - {translate('sessionHistory.settings.clear', 'Clear index')} + + + - } - /> + + + void deleteIndex()} + > + {translate('sessionHistory.settings.delete', 'Delete index')} + + } + /> + + {error ? (

{error} diff --git a/src/renderer/src/hooks/settings-navigation-workflow-sections.ts b/src/renderer/src/hooks/settings-navigation-workflow-sections.ts index 80a36b72007..57ea41d0143 100644 --- a/src/renderer/src/hooks/settings-navigation-workflow-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-workflow-sections.ts @@ -82,7 +82,7 @@ export function buildWorkflowSettingsSections( title: translate('sessionHistory.settings.enable', 'Enable session history search'), description: translate( 'sessionHistory.settings.searchDescription', - 'Transcript indexing, searchable history retention, index status and clear index.' + 'Transcript indexing, index status and delete index.' ) } ], diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dce8c413b0a..0efe0bb65ed 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17880,38 +17880,35 @@ "status": { "checking": "Checking index…", "off": "Search is off. Any existing index copy is kept.", - "error": "Could not read index status. Try refreshing.", - "indexing": "Indexing transcripts…", - "current": "Index is up to date with the last scan.", - "degraded": "Some transcript sources could not be indexed.", + "error": "Could not read index status. Retrying…", "unavailable": "Index is not ready or the search service is unavailable.", "title": "Index status", - "counts": "Indexed files: {{indexed}} · Due: {{due}} · Failed: {{failed}}", - "roots": "Unverified source roots: {{roots}}", - "refresh": "Refresh" + "firstScan": "Indexing… {{indexed}} files so far", + "progress": "Indexing · {{percent}}% · {{indexed}} of {{total}} files", + "upToDate": "Up to date · {{indexed}} files indexed", + "unreadable": "{{failed}} files could not be read and will be retried.", + "stopHint": "Turn off search to stop. Progress is kept and resumes when you turn it back on.", + "roots": "Unverified source roots: {{roots}}" }, "settings": { "saveError": "Could not save session search settings. Try again.", - "clearTitle": "Clear this computer’s search index?", - "clearEnabled": "The index copy will be deleted and rebuilt because search is enabled. Original transcripts will not be deleted.", - "clearDisabled": "The index copy will be deleted. Original transcripts will not be deleted. Search will stay off.", - "clear": "Clear index", + "enableTitle": "Start indexing agent sessions?", + "enableConsent": "Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.", + "enableConfirm": "Start indexing", + "advanced": "Advanced", + "deleteIndexCopy": "Delete index copy", + "deleteTitle": "Delete this computer’s search index?", + "deleteEnabled": "Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.", + "deleteDisabled": "Remove the search index from this computer. Original transcripts are not touched. Search stays off.", + "delete": "Delete index", "cleared": "Search index cleared. Original transcripts were kept.", "clearError": "Could not clear the index. Try again.", - "retention": "Searchable history", "enable": "Enable session history search", "webUnsupported": "Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.", "consent": "Create a local index copy of agent transcripts on this computer, including conversation text and tool output as written. Content is not redacted. Turning search off stops indexing and keeps the index copy.", - "retentionDescription": "Include transcripts modified within this period. Older content is removed from the index on the next sweep; original transcripts are never deleted.", - "all": "All history", - "days90": "90 days", - "days30": "30 days", - "customDays": "{{days}} days", - "indexCopy": "Index copy", - "clearDescription": "Clear only the search index on this computer. If search is enabled, Orca builds it again.", "title": "Agent Session History", "description": "Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.", - "searchDescription": "Transcript indexing, searchable history retention, index status and clear index." + "searchDescription": "Transcript indexing, index status and delete index." } } }