feat(settings): live index status, enable confirm, advanced delete

This commit is contained in:
Jinwoo-H
2026-09-14 17:06:56 -04:00
parent 73b6d02fd8
commit bbd2eff615
6 changed files with 458 additions and 272 deletions
@@ -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<string, unknown>) =>
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
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<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
view.rerender(<SessionHistoryIndexStatus enabled={false} refresh={0} />)
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(<SessionHistoryIndexStatus enabled refresh={0} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).toHaveBeenCalledTimes(1)
mocks.visible = true
mocks.status.mockResolvedValue(current)
view.rerender(<SessionHistoryIndexStatus enabled refresh={1} />)
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<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
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)
})
@@ -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<AiVaultSearchStatus | null>(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 (
<SettingsRow
label={translate('sessionHistory.status.title', 'Index status')}
description={
<span role="status" className="space-y-1 block">
<span className="block">{message}</span>
{enabled && status?.enabled && status.phase !== 'idle' && status.phase !== 'closed' ? (
{live && status.phase === 'degraded' && status.filesFailed > 0 ? (
<span className="block">
{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 }
)}
</span>
) : null}
{enabled && status?.enabled && status.degradedRoots.length > 0 ? (
{live && isSweeping(status) ? (
<span className="block text-muted-foreground">
{translate(
'sessionHistory.status.stopHint',
'Turn off search to stop. Progress is kept and resumes when you turn it back on.'
)}
</span>
) : null}
{live && status.degradedRoots.length > 0 ? (
<span className="block">
{translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', {
roots: status.degradedRoots.length
@@ -137,16 +143,7 @@ export function SessionHistoryIndexStatus({
) : null}
</span>
}
control={
<Button
variant="outline"
size="sm"
disabled={!enabled || busy || loading || !visible}
onClick={() => setRequested((value) => value + 1)}
>
{translate('sessionHistory.status.refresh', 'Refresh')}
</Button>
}
control={null}
/>
)
}
@@ -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(
<ConfirmationDialogContext.Provider value={confirm}>
<SessionHistorySettingsPane
settings={{
...getDefaultSettings('/synthetic'),
aiVaultSearch: { enabled, historyDays: null }
aiVaultSearch: { enabled, historyDays }
}}
updateSettings={save}
/>
</ConfirmationDialogContext.Provider>
)
}
async function openAdvanced(): Promise<void> {
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 computers 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(<SessionHistoryIndexStatus enabled refresh={0} busy={false} />)
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<AiVaultSearchStatus>((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<void>((resolve) => {
finishSave = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} busy={false} />)
view.rerender(<SessionHistoryIndexStatus enabled={false} refresh={0} busy={false} />)
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(<SessionHistoryIndexStatus enabled refresh={0} busy={false} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(mocks.status).toHaveBeenCalledTimes(1)
mocks.visible = true
mocks.status.mockResolvedValue(current)
view.rerender(<SessionHistoryIndexStatus enabled refresh={0} busy />)
expect(mocks.status).toHaveBeenCalledTimes(1)
view.rerender(<SessionHistoryIndexStatus enabled refresh={1} busy={false} />)
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(<SessionHistoryIndexStatus enabled refresh={0} busy={false} />)
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<AiVaultSearchStatus>((resolve) => {
answer = resolve
})
)
const view = render(<SessionHistoryIndexStatus enabled refresh={0} busy={false} />)
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(<SessionHistoryIndexStatus enabled refresh={0} busy={false} />)
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)
})
@@ -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<string | null>(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<void> {
async function toggleEnabled(): Promise<void> {
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<void> {
setBusy(true)
setError(null)
try {
const accepted = await confirm({
title: translate(
'sessionHistory.settings.clearTitle',
'Clear this computers search index?'
'sessionHistory.settings.deleteTitle',
'Delete this computers 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 (
<div className="divide-y divide-border">
<SettingsSwitchRow
@@ -128,68 +151,47 @@ export function SessionHistorySettingsPane({
}
checked={policy.enabled}
disabled={busy || isWebClient}
onChange={() => void save({ enabled: !policy.enabled })}
/>
<SettingsRow
label={retentionLabel}
description={translate(
'sessionHistory.settings.retentionDescription',
'Include transcripts modified within this period. Older content is removed from the index on the next sweep; original transcripts are never deleted.'
)}
control={
<Select
value={String(policy.historyDays ?? 'all')}
disabled={busy || isWebClient}
onValueChange={(value) =>
void save({ historyDays: value === 'all' ? null : Number(value) })
}
>
<SelectTrigger aria-label={retentionLabel} className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{translate('sessionHistory.settings.all', 'All history')}
</SelectItem>
<SelectItem value="90">
{translate('sessionHistory.settings.days90', '90 days')}
</SelectItem>
<SelectItem value="30">
{translate('sessionHistory.settings.days30', '30 days')}
</SelectItem>
{policy.historyDays !== null &&
policy.historyDays !== 90 &&
policy.historyDays !== 30 ? (
<SelectItem value={String(policy.historyDays)}>
{translate('sessionHistory.settings.customDays', '{{days}} days', {
days: policy.historyDays
})}
</SelectItem>
) : null}
</SelectContent>
</Select>
}
onChange={() => void toggleEnabled()}
/>
{!isWebClient ? (
<SessionHistoryIndexStatus enabled={policy.enabled} refresh={refresh} busy={busy} />
<SessionHistoryIndexStatus enabled={policy.enabled} refresh={refresh} />
) : null}
<SettingsRow
label={translate('sessionHistory.settings.indexCopy', 'Index copy')}
description={translate(
'sessionHistory.settings.clearDescription',
'Clear only the search index on this computer. If search is enabled, Orca builds it again.'
)}
control={
<Button
variant="outline"
size="sm"
disabled={busy || isWebClient}
onClick={() => void clearIndex()}
>
{translate('sessionHistory.settings.clear', 'Clear index')}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="pt-2">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="-ml-2 text-xs">
{translate('sessionHistory.settings.advanced', 'Advanced')}
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
}
/>
</CollapsibleTrigger>
<CollapsibleContent className="collapsible-height-content">
<SettingsRow
label={translate('sessionHistory.settings.deleteIndexCopy', 'Delete index copy')}
description={
policy.enabled
? translate(
'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.deleteDisabled',
'Remove the search index from this computer. Original transcripts are not touched. Search stays off.'
)
}
control={
<Button
variant="outline"
size="sm"
disabled={busy || isWebClient}
onClick={() => void deleteIndex()}
>
{translate('sessionHistory.settings.delete', 'Delete index')}
</Button>
}
/>
</CollapsibleContent>
</Collapsible>
{error ? (
<p role="alert" className="pt-3 text-xs text-destructive">
{error}
@@ -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.'
)
}
],
+17 -20
View File
@@ -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 computers 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 computers 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."
}
}
}