mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(settings): connected computers rows for session history indexing
Agent Session History now lists every computer that can hold an index -- this computer first, then each paired Orca server -- as one row with an icon, a name, a single status line and its own switch. Indexing consent is stated once above the list, and each row carries the switch for the host it names, so turning search on for a server no longer means finding that server's own settings. Server rows poll aiVault.searchStatus on the same 2s/10s cadence as the local one while the pane is visible, and report what the host actually answered: Off, a sweep in progress, or an up-to-date count. A server that is not connected stays listed but dimmed, with its last known switch position and no claim about its index. A host that refuses the set call with host-too-old flips to an update prompt that links to Remote Servers. The old "Enable session history search" switch and the separate index-status row are gone; their status copy moved to session-history-status-copy.ts and their polling to use-session-search-status.ts, so every row shares one message builder and one poll. Advanced > Delete index copy is unchanged and still local-only, and a paired web client still sees this computer alone. window.api.aiVault.setSearchEnabled is declared and bridged here but implemented by the parallel backend PR.
This commit is contained in:
@@ -33,6 +33,14 @@ export type AiVaultApi = {
|
||||
) => Promise<AiVaultSearchResponse>
|
||||
/** Status describes one index, so it never accepts the `all` scope. */
|
||||
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
|
||||
/**
|
||||
* Turns indexing on or off on one host and answers with that host's status.
|
||||
* Rejects with `host-too-old` when the server predates this RPC.
|
||||
*/
|
||||
setSearchEnabled: (
|
||||
executionHostId: ExecutionHostId,
|
||||
enabled: boolean
|
||||
) => Promise<AiVaultSearchStatus>
|
||||
/** Deletes and rebuilds this desktop's local search index. */
|
||||
clearSearchIndex: () => Promise<void>
|
||||
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
|
||||
|
||||
@@ -42,6 +42,8 @@ export const aiVaultApi = {
|
||||
searchClient(executionHostScope).searchSessions(request),
|
||||
searchStatus: (executionHostScope?: ExecutionHostId) =>
|
||||
searchClient(executionHostScope).searchStatus(),
|
||||
setSearchEnabled: (executionHostId: ExecutionHostId, enabled: boolean) =>
|
||||
ipcRenderer.invoke('aiVault:setSearchEnabled', executionHostId, enabled),
|
||||
clearSearchIndex: (): Promise<void> => ipcRenderer.invoke('aiVault:clearSearchIndex'),
|
||||
listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args),
|
||||
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) =>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, fireEvent, 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 type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
|
||||
import { SessionHistoryComputerRow } from './SessionHistoryComputerRow'
|
||||
import { SessionHistoryServerRow } from './SessionHistoryServerRow'
|
||||
import type { RuntimeHostDetails } from './runtime-environment-host-details'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
visible: true,
|
||||
status: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: 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]))
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
openSettingsPage: mocks.openSettingsPage,
|
||||
openSettingsTarget: mocks.openSettingsTarget
|
||||
})
|
||||
}))
|
||||
|
||||
const runningIndex: AiVaultSearchStatus = {
|
||||
...unavailableSessionSearchStatus(),
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 4_880,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
const environment: PublicKnownRuntimeEnvironment = {
|
||||
id: 'env-1',
|
||||
name: 'build-box',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [{ id: 'e1', kind: 'websocket', label: 'lan', endpoint: 'wss://build-box' }],
|
||||
preferredEndpointId: 'e1'
|
||||
}
|
||||
function connectedDetails(appVersion = '1.4.202'): RuntimeHostDetails {
|
||||
const runtimeStatus: RuntimeStatus = {
|
||||
runtimeId: 'runtime-1',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 1,
|
||||
liveLeafCount: 1,
|
||||
appVersion
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
runtimeStatus,
|
||||
remoteControl: null,
|
||||
compatibility: { kind: 'ok', clientProtocolVersion: 1, serverProtocolVersion: 1 },
|
||||
error: null
|
||||
}
|
||||
}
|
||||
function serverRow(
|
||||
details: RuntimeHostDetails | undefined,
|
||||
confirm = vi.fn().mockResolvedValue(true),
|
||||
onError = vi.fn()
|
||||
) {
|
||||
return render(
|
||||
<ConfirmationDialogContext.Provider value={confirm}>
|
||||
<SessionHistoryServerRow environment={environment} details={details} onError={onError} />
|
||||
</ConfirmationDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
const serverSwitch = (): HTMLElement =>
|
||||
screen.getByRole('switch', { name: 'Index sessions on build-box' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.visible = true
|
||||
mocks.status.mockReset().mockResolvedValue(runningIndex)
|
||||
mocks.setEnabled.mockReset().mockResolvedValue(runningIndex)
|
||||
mocks.openSettingsPage.mockReset()
|
||||
mocks.openSettingsTarget.mockReset()
|
||||
vi.stubGlobal('api', undefined)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { aiVault: { searchStatus: mocks.status, setSearchEnabled: mocks.setEnabled } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders a computer as icon, name, version, one status line and a switch', () => {
|
||||
render(
|
||||
<SessionHistoryComputerRow
|
||||
kind="server"
|
||||
name="build-box"
|
||||
version="1.4.202"
|
||||
status="Up to date · 12 files indexed"
|
||||
details={['2 files could not be read and will be retried.']}
|
||||
checked
|
||||
onToggle={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('build-box')).toBeInTheDocument()
|
||||
expect(screen.getByText('Orca v1.4.202')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
expect(screen.getByText('2 files could not be read and will be retried.')).toBeInTheDocument()
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('omits the status line when this client cannot read the host index', () => {
|
||||
render(
|
||||
<SessionHistoryComputerRow kind="local" name="Local Mac" checked={false} onToggle={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a reachable server by its live index status and version', async () => {
|
||||
serverRow(connectedDetails())
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledWith('runtime:env-1')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 4880 files indexed')
|
||||
expect(screen.getByText('Orca v1.4.202')).toBeInTheDocument()
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('calls a server with indexing switched off Off rather than unavailable', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
serverRow(connectedDetails())
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Off')
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('keeps an offline server dimmed, disabled and honest about its index', async () => {
|
||||
serverRow({
|
||||
status: 'error',
|
||||
runtimeStatus: null,
|
||||
remoteControl: null,
|
||||
compatibility: null,
|
||||
error: 'unreachable'
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline · index kept as it was')
|
||||
expect(serverSwitch()).toBeDisabled()
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('asks for consent naming the server before enabling it', async () => {
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
serverRow(connectedDetails(), confirm)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(serverSwitch())
|
||||
})
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Start indexing agent sessions on build-box?',
|
||||
description: expect.stringContaining('This client only receives search results'),
|
||||
confirmLabel: 'Start indexing'
|
||||
})
|
||||
)
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 4880 files indexed')
|
||||
})
|
||||
|
||||
it('leaves a server untouched when the consent is declined', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
serverRow(connectedDetails(), vi.fn().mockResolvedValue(false))
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(serverSwitch())
|
||||
})
|
||||
expect(mocks.setEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('turns a server off without asking again', async () => {
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
serverRow(connectedDetails(), confirm)
|
||||
await act(async () => {})
|
||||
mocks.setEnabled.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
await act(async () => {
|
||||
fireEvent.click(serverSwitch())
|
||||
})
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', false)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Off')
|
||||
})
|
||||
|
||||
it('turns a host-too-old rejection into the update-server state', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
mocks.setEnabled.mockRejectedValue(
|
||||
new Error("Error invoking remote method 'aiVault:setSearchEnabled': Error: host-too-old")
|
||||
)
|
||||
const onError = vi.fn()
|
||||
serverRow(connectedDetails('1.4.190'), vi.fn().mockResolvedValue(true), onError)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(serverSwitch())
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update this server to enable session search'
|
||||
)
|
||||
expect(serverSwitch()).toBeDisabled()
|
||||
expect(onError).not.toHaveBeenCalledWith(expect.stringContaining('Could not change'))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update server' }))
|
||||
})
|
||||
expect(mocks.openSettingsPage).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSettingsTarget).toHaveBeenCalledWith({
|
||||
pane: 'servers',
|
||||
repoId: null,
|
||||
sectionId: 'env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces any other failure to change a server through the pane alert', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
mocks.setEnabled.mockRejectedValue(new Error('relay down'))
|
||||
const onError = vi.fn()
|
||||
serverRow(connectedDetails(), vi.fn().mockResolvedValue(true), onError)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(serverSwitch())
|
||||
})
|
||||
expect(onError).toHaveBeenLastCalledWith(
|
||||
'Could not change session search on build-box. Try again.'
|
||||
)
|
||||
expect(serverSwitch()).toBeEnabled()
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Laptop, Server } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SettingsSwitch } from './SettingsFormControls'
|
||||
|
||||
const NO_DETAILS: readonly string[] = []
|
||||
|
||||
export type SessionHistoryComputerRowProps = {
|
||||
kind: 'local' | 'server'
|
||||
name: string
|
||||
/** Paired servers only; a host that never answered has no version to claim. */
|
||||
version?: string | null
|
||||
/** Empty when this client cannot read the host's index at all, as in the browser. */
|
||||
status?: string
|
||||
details?: readonly string[]
|
||||
/** An offline or too-old computer is still listed, just visibly out of play. */
|
||||
dimmed?: boolean
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
action?: { label: string; onClick: () => void } | null
|
||||
}
|
||||
|
||||
export function SessionHistoryComputerRow({
|
||||
kind,
|
||||
name,
|
||||
version,
|
||||
status,
|
||||
details = NO_DETAILS,
|
||||
dimmed = false,
|
||||
checked,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
action = null
|
||||
}: SessionHistoryComputerRowProps): React.JSX.Element {
|
||||
const Icon = kind === 'local' ? Laptop : Server
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 py-3', dimmed && 'opacity-60')}>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="truncate text-sm font-medium">{name}</span>
|
||||
{version ? (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{translate('sessionHistory.settings.serverVersion', 'Orca v{{version}}', { version })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{status ? (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{status}
|
||||
{action ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="ml-1 h-auto p-0 align-baseline text-xs"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{details.map((line) => (
|
||||
<p key={line} className="text-xs text-muted-foreground">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<SettingsSwitch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={onToggle}
|
||||
ariaLabel={translate(
|
||||
'sessionHistory.settings.rowSwitchLabel',
|
||||
'Index sessions on {{host}}',
|
||||
{
|
||||
host: name
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
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 { 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
|
||||
}: {
|
||||
enabled: boolean
|
||||
refresh: number
|
||||
}): React.JSX.Element {
|
||||
const visible = useWindowStreamVisible(0)
|
||||
const [status, setStatus] = useState<AiVaultSearchStatus | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const intervalMs = isSweeping(status) ? SWEEPING_POLL_MS : SETTLED_POLL_MS
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus(null)
|
||||
setFailed(false)
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
let inFlight = false
|
||||
async function read(): Promise<void> {
|
||||
if (inFlight || disposed) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
try {
|
||||
const next = await Promise.resolve().then(() =>
|
||||
window.api.aiVault.searchStatus(LOCAL_EXECUTION_HOST_ID)
|
||||
)
|
||||
if (!disposed) {
|
||||
setStatus(next)
|
||||
setFailed(false)
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setStatus(null)
|
||||
setFailed(true)
|
||||
}
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs })
|
||||
return () => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
}
|
||||
}, [enabled, visible, refresh, intervalMs])
|
||||
|
||||
let message = translate('sessionHistory.status.checking', 'Checking index…')
|
||||
if (!enabled) {
|
||||
message = translate(
|
||||
'sessionHistory.status.off',
|
||||
'Search is off. Any existing index copy is kept.'
|
||||
)
|
||||
} else if (failed) {
|
||||
message = translate('sessionHistory.status.error', 'Could not read index status. Retrying…')
|
||||
} else if (status) {
|
||||
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>
|
||||
{live && status.phase === 'degraded' && status.filesFailed > 0 ? (
|
||||
<span className="block">
|
||||
{translate(
|
||||
'sessionHistory.status.unreadable',
|
||||
'{{failed}} files could not be read and will be retried.',
|
||||
{ failed: status.filesFailed }
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{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
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
control={null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
getRuntimeServerConnectionState,
|
||||
isRuntimeServerTransportConnected,
|
||||
type RuntimeHostDetails
|
||||
} from './runtime-environment-host-details'
|
||||
import { SessionHistoryComputerRow } from './SessionHistoryComputerRow'
|
||||
import {
|
||||
sessionSearchCheckingMessage,
|
||||
sessionSearchReadErrorMessage,
|
||||
sessionSearchStatusDetails,
|
||||
sessionSearchStatusMessage
|
||||
} from './session-history-status-copy'
|
||||
import { useSessionSearchStatus } from './use-session-search-status'
|
||||
|
||||
// IPC wraps a rejection's message, so the host-too-old marker arrives inside a longer string.
|
||||
function isHostTooOldError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes('host-too-old')
|
||||
}
|
||||
|
||||
export function SessionHistoryServerRow({
|
||||
environment,
|
||||
details,
|
||||
onError
|
||||
}: {
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
details: RuntimeHostDetails | undefined
|
||||
onError: (message: string | null) => void
|
||||
}): React.JSX.Element {
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
const confirm = useConfirmationDialog()
|
||||
const mounted = useMountedRef()
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const [tooOld, setTooOld] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const connectionState = getRuntimeServerConnectionState(details)
|
||||
const connected = isRuntimeServerTransportConnected(connectionState)
|
||||
const { status, failed, adopt } = useSessionSearchStatus({
|
||||
executionHostId: hostId,
|
||||
active: connected && !tooOld
|
||||
})
|
||||
const enabled = status?.enabled === true
|
||||
|
||||
async function setEnabled(next: boolean): Promise<void> {
|
||||
setBusy(true)
|
||||
onError(null)
|
||||
try {
|
||||
adopt(await window.api.aiVault.setSearchEnabled(hostId, next))
|
||||
} catch (error) {
|
||||
if (!mounted.current) {
|
||||
return
|
||||
}
|
||||
if (isHostTooOldError(error)) {
|
||||
setTooOld(true)
|
||||
return
|
||||
}
|
||||
onError(
|
||||
translate(
|
||||
'sessionHistory.settings.serverToggleError',
|
||||
'Could not change session search on {{host}}. Try again.',
|
||||
{ host: environment.name }
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(): Promise<void> {
|
||||
if (enabled) {
|
||||
await setEnabled(false)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
let accepted = false
|
||||
try {
|
||||
accepted = await confirm({
|
||||
title: translate(
|
||||
'sessionHistory.settings.serverEnableTitle',
|
||||
'Start indexing agent sessions on {{host}}?',
|
||||
{ host: environment.name }
|
||||
),
|
||||
description: translate(
|
||||
'sessionHistory.settings.serverEnableConsent',
|
||||
'Orca will build a search index on {{host}}. This client only receives search results. 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.',
|
||||
{ host: environment.name }
|
||||
),
|
||||
confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Start indexing')
|
||||
})
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
}
|
||||
await setEnabled(true)
|
||||
}
|
||||
|
||||
function openServerSettings(): void {
|
||||
openSettingsPage()
|
||||
openSettingsTarget({ pane: 'servers', repoId: null, sectionId: environment.id })
|
||||
}
|
||||
|
||||
const row = {
|
||||
kind: 'server' as const,
|
||||
name: environment.name,
|
||||
version: details?.runtimeStatus?.appVersion ?? null,
|
||||
onToggle: () => void toggle()
|
||||
}
|
||||
if (tooOld) {
|
||||
return (
|
||||
<SessionHistoryComputerRow
|
||||
{...row}
|
||||
dimmed
|
||||
checked={false}
|
||||
disabled
|
||||
status={translate(
|
||||
'sessionHistory.settings.serverTooOld',
|
||||
'Update this server to enable session search.'
|
||||
)}
|
||||
action={{
|
||||
label: translate('sessionHistory.settings.updateServer', 'Update server'),
|
||||
onClick: openServerSettings
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (!connected) {
|
||||
// Checking is not yet evidence of an unreachable host, so it does not claim the index was left behind.
|
||||
const checking = connectionState === 'checking'
|
||||
return (
|
||||
<SessionHistoryComputerRow
|
||||
{...row}
|
||||
dimmed={!checking}
|
||||
checked={enabled}
|
||||
disabled
|
||||
status={
|
||||
checking
|
||||
? sessionSearchCheckingMessage()
|
||||
: translate('sessionHistory.settings.serverOffline', 'Offline · index kept as it was')
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
let statusText = sessionSearchCheckingMessage()
|
||||
if (failed) {
|
||||
statusText = sessionSearchReadErrorMessage()
|
||||
} else if (status) {
|
||||
statusText = enabled
|
||||
? sessionSearchStatusMessage(status)
|
||||
: translate('sessionHistory.settings.serverOff', 'Off')
|
||||
}
|
||||
return (
|
||||
<SessionHistoryComputerRow
|
||||
{...row}
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
status={statusText}
|
||||
details={sessionSearchStatusDetails(status)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,31 @@ import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-typ
|
||||
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
|
||||
import { SessionHistorySettingsPane } from './SessionHistorySettingsPane'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ web: false, visible: true, status: vi.fn(), clear: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => {
|
||||
const environments: { id: string; name: string }[] = []
|
||||
return {
|
||||
web: false,
|
||||
visible: true,
|
||||
status: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
environments
|
||||
}
|
||||
})
|
||||
vi.mock('./use-runtime-environment-catalog', () => ({
|
||||
useRuntimeEnvironmentCatalog: () => ({
|
||||
environments: mocks.environments,
|
||||
isLoading: false,
|
||||
detailsByEnvironmentId: {},
|
||||
setDetailsByEnvironmentId: vi.fn(),
|
||||
mountedRef: { current: true },
|
||||
loadEnvironments: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ openSettingsPage: vi.fn(), openSettingsTarget: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
|
||||
vi.mock('@/hooks/use-window-stream-visibility', () => ({
|
||||
useWindowStreamVisible: () => mocks.visible
|
||||
@@ -53,12 +77,20 @@ beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.web = false
|
||||
mocks.visible = true
|
||||
mocks.environments = []
|
||||
mocks.status.mockReset().mockResolvedValue(current)
|
||||
mocks.clear.mockReset().mockResolvedValue(undefined)
|
||||
mocks.setEnabled.mockReset().mockResolvedValue(current)
|
||||
vi.stubGlobal('api', undefined)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { aiVault: { searchStatus: mocks.status, clearSearchIndex: mocks.clear } }
|
||||
value: {
|
||||
aiVault: {
|
||||
searchStatus: mocks.status,
|
||||
clearSearchIndex: mocks.clear,
|
||||
setSearchEnabled: mocks.setEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -208,3 +240,31 @@ it('keeps the last index status visible while a save is in flight', async () =>
|
||||
finishSave()
|
||||
})
|
||||
})
|
||||
|
||||
it('lists one row per paired Orca server under this computer, and says where SSH stands', async () => {
|
||||
mocks.environments = [
|
||||
{ id: 'env-1', name: 'build-box' },
|
||||
{ id: 'env-2', name: 'office-mini' }
|
||||
]
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
const switches = screen.getAllByRole('switch')
|
||||
expect(switches).toHaveLength(3)
|
||||
expect(screen.getByRole('switch', { name: 'Index sessions on build-box' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch', { name: 'Index sessions on office-mini' })).toBeInTheDocument()
|
||||
expect(mocks.status).toHaveBeenCalledWith('local')
|
||||
expect(
|
||||
screen.getByText('SSH hosts appear here once indexing is available on SSH.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers only this computer to a paired client, with no server rows', async () => {
|
||||
mocks.web = true
|
||||
mocks.environments = [{ id: 'env-1', name: 'build-box' }]
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
||||
expect(screen.getByRole('switch')).toBeDisabled()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -6,14 +6,29 @@ import {
|
||||
AiVaultSearchSettingsSchema,
|
||||
resolveAiVaultSearchSettings
|
||||
} from '../../../../shared/ai-vault-search-settings'
|
||||
import {
|
||||
getLocalExecutionHostLabel,
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
} from '../../../../shared/execution-host'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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'
|
||||
import { SettingsRow } from './SettingsFormControls'
|
||||
import { SessionHistoryComputerRow } from './SessionHistoryComputerRow'
|
||||
import { SessionHistoryServerRow } from './SessionHistoryServerRow'
|
||||
import {
|
||||
sessionSearchCheckingMessage,
|
||||
sessionSearchOffMessage,
|
||||
sessionSearchReadErrorMessage,
|
||||
sessionSearchStatusDetails,
|
||||
sessionSearchStatusMessage
|
||||
} from './session-history-status-copy'
|
||||
import { useSessionSearchStatus } from './use-session-search-status'
|
||||
import { useRuntimeEnvironmentCatalog } from './use-runtime-environment-catalog'
|
||||
|
||||
export function SessionHistorySettingsPane({
|
||||
settings,
|
||||
@@ -29,6 +44,12 @@ export function SessionHistorySettingsPane({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const { environments, detailsByEnvironmentId } = useRuntimeEnvironmentCatalog()
|
||||
const localRead = useSessionSearchStatus({
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
active: policy.enabled && !isWebClient,
|
||||
refresh
|
||||
})
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
@@ -134,69 +155,104 @@ export function SessionHistorySettingsPane({
|
||||
}
|
||||
}
|
||||
|
||||
// A stale answer from before the switch went off must not keep reporting progress.
|
||||
const localStatus = policy.enabled ? localRead.status : null
|
||||
let localStatusText = sessionSearchOffMessage()
|
||||
if (policy.enabled) {
|
||||
localStatusText = localRead.failed
|
||||
? sessionSearchReadErrorMessage()
|
||||
: localStatus
|
||||
? sessionSearchStatusMessage(localStatus)
|
||||
: sessionSearchCheckingMessage()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
<SettingsSwitchRow
|
||||
label={translate('sessionHistory.settings.enable', 'Enable session history search')}
|
||||
description={
|
||||
isWebClient
|
||||
? translate(
|
||||
'sessionHistory.settings.webUnsupported',
|
||||
'Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.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.'
|
||||
)
|
||||
}
|
||||
checked={policy.enabled}
|
||||
disabled={busy || isWebClient}
|
||||
onChange={() => void toggleEnabled()}
|
||||
/>
|
||||
{!isWebClient ? (
|
||||
<SessionHistoryIndexStatus enabled={policy.enabled} refresh={refresh} />
|
||||
) : null}
|
||||
<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')}
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border">
|
||||
<div className="space-y-1 py-3">
|
||||
<Label className="select-text">
|
||||
{translate('sessionHistory.settings.indexComputers', 'Index agent sessions')}
|
||||
</Label>
|
||||
<p className="select-text text-xs text-muted-foreground">
|
||||
{isWebClient
|
||||
? translate(
|
||||
'sessionHistory.settings.webUnsupported',
|
||||
'Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.computersConsent',
|
||||
'Each computer keeps a local index of its own transcripts, including conversation text and tool output as written. Content is not redacted. Turning a computer off stops indexing and keeps its index.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<SessionHistoryComputerRow
|
||||
kind="local"
|
||||
name={getLocalExecutionHostLabel()}
|
||||
checked={policy.enabled}
|
||||
disabled={busy || isWebClient}
|
||||
onToggle={() => void toggleEnabled()}
|
||||
{...(isWebClient
|
||||
? {}
|
||||
: { status: localStatusText, details: sessionSearchStatusDetails(localStatus) })}
|
||||
/>
|
||||
{isWebClient
|
||||
? null
|
||||
: environments.map((environment) => (
|
||||
<SessionHistoryServerRow
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
details={detailsByEnvironmentId[environment.id]}
|
||||
onError={setError}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</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}
|
||||
</p>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{error ? (
|
||||
<p role="alert" className="pt-3 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'sessionHistory.settings.sshNote',
|
||||
'SSH hosts appear here once indexing is available on SSH.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export const SESSION_SEARCH_SWEEPING_POLL_MS = 2_000
|
||||
export const SESSION_SEARCH_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.
|
||||
export function isSweepingSessionSearch(status: AiVaultSearchStatus | null): boolean {
|
||||
if (!status?.enabled) {
|
||||
return false
|
||||
}
|
||||
return status.phase === 'indexing' || (status.phase === 'degraded' && status.filesDue > 0)
|
||||
}
|
||||
|
||||
export function sessionSearchPollIntervalMs(status: AiVaultSearchStatus | null): number {
|
||||
return isSweepingSessionSearch(status)
|
||||
? SESSION_SEARCH_SWEEPING_POLL_MS
|
||||
: SESSION_SEARCH_SETTLED_POLL_MS
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
/** The one status sentence every computer row shows, local or paired server. */
|
||||
export function sessionSearchStatusMessage(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 (isSweepingSessionSearch(status)) {
|
||||
return sweepMessage(status)
|
||||
}
|
||||
return translate('sessionHistory.status.upToDate', 'Up to date · {{indexed}} files indexed', {
|
||||
indexed: status.filesIndexed
|
||||
})
|
||||
}
|
||||
|
||||
/** Lines shown under the status sentence while a host is actually indexing. */
|
||||
export function sessionSearchStatusDetails(status: AiVaultSearchStatus | null): string[] {
|
||||
if (!status?.enabled) {
|
||||
return []
|
||||
}
|
||||
const lines: string[] = []
|
||||
if (status.phase === 'degraded' && status.filesFailed > 0) {
|
||||
lines.push(
|
||||
translate(
|
||||
'sessionHistory.status.unreadable',
|
||||
'{{failed}} files could not be read and will be retried.',
|
||||
{ failed: status.filesFailed }
|
||||
)
|
||||
)
|
||||
}
|
||||
if (isSweepingSessionSearch(status)) {
|
||||
lines.push(
|
||||
translate(
|
||||
'sessionHistory.status.stopHint',
|
||||
'Turn off search to stop. Progress is kept and resumes when you turn it back on.'
|
||||
)
|
||||
)
|
||||
}
|
||||
if (status.degradedRoots.length > 0) {
|
||||
lines.push(
|
||||
translate('sessionHistory.status.roots', 'Unverified source roots: {{roots}}', {
|
||||
roots: status.degradedRoots.length
|
||||
})
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
export function sessionSearchCheckingMessage(): string {
|
||||
return translate('sessionHistory.status.checking', 'Checking index…')
|
||||
}
|
||||
|
||||
export function sessionSearchOffMessage(): string {
|
||||
return translate('sessionHistory.status.off', 'Search is off. Any existing index copy is kept.')
|
||||
}
|
||||
|
||||
export function sessionSearchReadErrorMessage(): string {
|
||||
return translate('sessionHistory.status.error', 'Could not read index status. Retrying…')
|
||||
}
|
||||
@@ -188,13 +188,12 @@ export function renderSessionHistorySettingsSection(
|
||||
title={translate('sessionHistory.settings.title', 'Agent Session History')}
|
||||
description={translate(
|
||||
'sessionHistory.settings.description',
|
||||
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
|
||||
'Each computer keeps its own index of the transcripts it owns. Turn on indexing here for this computer and for any paired Orca server.'
|
||||
)}
|
||||
searchEntries={navigation.getSectionSearchEntries('session-history')}
|
||||
>
|
||||
{view.isSectionMounted('session-history') ? (
|
||||
<SessionHistorySettingsPane
|
||||
key={JSON.stringify(model.settings.aiVaultSearch)}
|
||||
settings={model.settings}
|
||||
updateSettings={model.updateSettingsOrThrow}
|
||||
/>
|
||||
|
||||
+82
-55
@@ -1,10 +1,15 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, renderHook } 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'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import {
|
||||
sessionSearchStatusDetails,
|
||||
sessionSearchStatusMessage
|
||||
} from './session-history-status-copy'
|
||||
import { useSessionSearchStatus } from './use-session-search-status'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ visible: true, status: vi.fn() }))
|
||||
vi.mock('@/hooks/use-window-stream-visibility', () => ({
|
||||
@@ -22,6 +27,20 @@ const current: AiVaultSearchStatus = {
|
||||
filesIndexed: 12,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
function poll(active = true, refresh = 0) {
|
||||
return renderHook(
|
||||
(props: { active: boolean; refresh: number }) =>
|
||||
useSessionSearchStatus({
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
active: props.active,
|
||||
refresh: props.refresh
|
||||
}),
|
||||
{ initialProps: { active, refresh } }
|
||||
)
|
||||
}
|
||||
function message(status: AiVaultSearchStatus | null): string {
|
||||
return status ? sessionSearchStatusMessage(status) : 'no status read yet'
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.visible = true
|
||||
@@ -39,15 +58,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
it('keeps polling a settled index so counts stay live between sweeps', async () => {
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledWith('local')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
expect(message(view.result.current.status)).toBe('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')
|
||||
expect(message(view.result.current.status)).toBe('Up to date · 30 files indexed')
|
||||
})
|
||||
|
||||
it('reports a first scan by count and later sweeps by percentage', async () => {
|
||||
@@ -58,10 +77,12 @@ it('reports a first scan by count and later sweeps by percentage', async () => {
|
||||
filesDue: 6,
|
||||
lastSweepCompletedAt: null
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Indexing… 4 files so far')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Turn off search to stop')
|
||||
expect(message(view.result.current.status)).toBe('Indexing… 4 files so far')
|
||||
expect(sessionSearchStatusDetails(view.result.current.status)).toContain(
|
||||
'Turn off search to stop. Progress is kept and resumes when you turn it back on.'
|
||||
)
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'indexing',
|
||||
@@ -73,12 +94,12 @@ it('reports a first scan by count and later sweeps by percentage', async () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Indexing · 40% · 4 of 10 files')
|
||||
expect(message(view.result.current.status)).toBe('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} />)
|
||||
poll()
|
||||
await act(async () => {})
|
||||
const started = mocks.status.mock.calls.length
|
||||
await act(async () => {
|
||||
@@ -96,12 +117,14 @@ it('names unreadable files while degraded and still reports progress', async ()
|
||||
filesFailed: 1,
|
||||
degradedRoots: [{ reason: 'unreadable' }]
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
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')
|
||||
expect(message(view.result.current.status)).toBe('Indexing · 80% · 8 of 10 files')
|
||||
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([
|
||||
'1 files could not be read and will be retried.',
|
||||
'Turn off search to stop. Progress is kept and resumes when you turn it back on.',
|
||||
'Unverified source roots: 1'
|
||||
])
|
||||
})
|
||||
|
||||
it('calls a drained degraded index up to date', async () => {
|
||||
@@ -112,78 +135,82 @@ it('calls a drained degraded index up to date', async () => {
|
||||
filesDue: 0,
|
||||
filesFailed: 2
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
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()
|
||||
expect(message(view.result.current.status)).toBe('Up to date · 9 files indexed')
|
||||
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([
|
||||
'2 files could not be read and will be retried.'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not describe an absent service as an empty current index', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'not ready or the search service is unavailable'
|
||||
expect(message(view.result.current.status)).toBe(
|
||||
'Index is 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} />)
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
|
||||
expect(view.result.current.failed).toBe(true)
|
||||
expect(view.result.current.status).toBeNull()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
expect(view.result.current.failed).toBe(false)
|
||||
expect(view.result.current.status).toEqual(current)
|
||||
})
|
||||
|
||||
it('handles a synchronous bridge failure without losing the poll', async () => {
|
||||
mocks.status.mockImplementationOnce(() => {
|
||||
throw new Error('bridge unavailable')
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
|
||||
expect(view.result.current.failed).toBe(true)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
expect(view.result.current.status).toEqual(current)
|
||||
})
|
||||
|
||||
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()
|
||||
it('stops polling while inactive or hidden and re-reads on a refresh bump', async () => {
|
||||
mocks.visible = false
|
||||
view.rerender(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
mocks.visible = true
|
||||
mocks.status.mockResolvedValue(current)
|
||||
view.rerender(<SessionHistoryIndexStatus enabled refresh={1} />)
|
||||
view.rerender({ active: false, refresh: 0 })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
view.rerender({ active: true, refresh: 1 })
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the last answer when a host goes inactive so an offline row reports it', async () => {
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
view.rerender({ active: false, refresh: 0 })
|
||||
expect(view.result.current.status).toEqual(current)
|
||||
expect(view.result.current.failed).toBe(false)
|
||||
})
|
||||
|
||||
it('adopts a status handed to it without waiting for the next poll', async () => {
|
||||
const view = poll(false)
|
||||
act(() => {
|
||||
view.result.current.adopt({ ...current, filesIndexed: 99 })
|
||||
})
|
||||
expect(view.result.current.status?.filesIndexed).toBe(99)
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not overlap slow status requests and stops polling on unmount', async () => {
|
||||
@@ -193,7 +220,7 @@ it('does not overlap slow status requests and stops polling on unmount', async (
|
||||
answer = resolve
|
||||
})
|
||||
)
|
||||
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
const view = poll()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { useWindowStreamVisible } from '@/hooks/use-window-stream-visibility'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { sessionSearchPollIntervalMs } from './session-history-status-copy'
|
||||
|
||||
export type SessionSearchStatusRead = {
|
||||
status: AiVaultSearchStatus | null
|
||||
failed: boolean
|
||||
/** Adopt a status the caller already holds, e.g. the answer to a set call. */
|
||||
adopt: (status: AiVaultSearchStatus) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls one host's index status while the pane is visible: fast during a sweep,
|
||||
* slow once settled. An inactive host keeps its last answer, which is what an
|
||||
* offline row reports rather than inventing "off".
|
||||
*/
|
||||
export function useSessionSearchStatus(args: {
|
||||
executionHostId: ExecutionHostId
|
||||
active: boolean
|
||||
refresh?: number
|
||||
}): SessionSearchStatusRead {
|
||||
const { executionHostId, active } = args
|
||||
const refresh = args.refresh ?? 0
|
||||
const visible = useWindowStreamVisible(0)
|
||||
const [status, setStatus] = useState<AiVaultSearchStatus | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const intervalMs = sessionSearchPollIntervalMs(status)
|
||||
const adopt = useCallback((next: AiVaultSearchStatus) => {
|
||||
setStatus(next)
|
||||
setFailed(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setFailed(false)
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
let inFlight = false
|
||||
async function read(): Promise<void> {
|
||||
if (inFlight || disposed) {
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
try {
|
||||
const next = await Promise.resolve().then(() =>
|
||||
window.api.aiVault.searchStatus(executionHostId)
|
||||
)
|
||||
if (!disposed) {
|
||||
setStatus(next)
|
||||
setFailed(false)
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setStatus(null)
|
||||
setFailed(true)
|
||||
}
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs })
|
||||
return () => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
}
|
||||
}, [executionHostId, active, visible, refresh, intervalMs])
|
||||
|
||||
return { status, failed, adopt }
|
||||
}
|
||||
@@ -74,15 +74,15 @@ export function buildWorkflowSettingsSections(
|
||||
title: translate('sessionHistory.settings.title', 'Agent Session History'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.description',
|
||||
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
|
||||
'Each computer keeps its own index of the transcripts it owns. Turn on indexing here for this computer and for any paired Orca server.'
|
||||
),
|
||||
icon: History,
|
||||
searchEntries: [
|
||||
{
|
||||
title: translate('sessionHistory.settings.enable', 'Enable session history search'),
|
||||
title: translate('sessionHistory.settings.indexComputers', 'Index agent sessions'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.searchDescription',
|
||||
'Transcript indexing, index status and delete index.'
|
||||
'Transcript indexing on this computer and paired servers, index status and delete index.'
|
||||
)
|
||||
}
|
||||
],
|
||||
|
||||
@@ -17908,7 +17908,6 @@
|
||||
"off": "Search is off. Any existing index copy is kept.",
|
||||
"error": "Could not read index status. Retrying…",
|
||||
"unavailable": "Index is not ready or the search service is unavailable.",
|
||||
"title": "Index status",
|
||||
"firstScan": "Indexing… {{indexed}} files so far",
|
||||
"progress": "Indexing · {{percent}}% · {{indexed}} of {{total}} files",
|
||||
"upToDate": "Up to date · {{indexed}} files indexed",
|
||||
@@ -17929,12 +17928,22 @@
|
||||
"delete": "Delete index",
|
||||
"cleared": "Search index cleared. Original transcripts were kept.",
|
||||
"clearError": "Could not clear the index. Try again.",
|
||||
"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.",
|
||||
"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, index status and delete index."
|
||||
"description": "Each computer keeps its own index of the transcripts it owns. Turn on indexing here for this computer and for any paired Orca server.",
|
||||
"searchDescription": "Transcript indexing on this computer and paired servers, index status and delete index.",
|
||||
"indexComputers": "Index agent sessions",
|
||||
"computersConsent": "Each computer keeps a local index of its own transcripts, including conversation text and tool output as written. Content is not redacted. Turning a computer off stops indexing and keeps its index.",
|
||||
"sshNote": "SSH hosts appear here once indexing is available on SSH.",
|
||||
"serverVersion": "Orca v{{version}}",
|
||||
"rowSwitchLabel": "Index sessions on {{host}}",
|
||||
"serverOffline": "Offline · index kept as it was",
|
||||
"serverTooOld": "Update this server to enable session search.",
|
||||
"updateServer": "Update server",
|
||||
"serverOff": "Off",
|
||||
"serverEnableTitle": "Start indexing agent sessions on {{host}}?",
|
||||
"serverEnableConsent": "Orca will build a search index on {{host}}. This client only receives search results. 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.",
|
||||
"serverToggleError": "Could not change session search on {{host}}. Try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault'
|
||||
addressesOwnRuntime(executionHostScope)
|
||||
? search.searchStatus()
|
||||
: Promise.resolve(unavailableSessionSearchStatus()),
|
||||
// A browser never owns an index, so it cannot consent on a host's behalf.
|
||||
setSearchEnabled: () => Promise.reject(new Error('unsupported')),
|
||||
clearSearchIndex: () =>
|
||||
Promise.reject(new Error('Clearing Agent Session History is unavailable in the browser.')),
|
||||
listSessions: (args?: AiVaultListArgs) => {
|
||||
|
||||
Reference in New Issue
Block a user