mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Merge remote-tracking branch 'origin/session-search-settings-ui' into session-search-verify
This commit is contained in:
@@ -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 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(<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 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 (
|
||||
<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.'
|
||||
)
|
||||
}
|
||||
],
|
||||
|
||||
@@ -17906,38 +17906,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user