mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
feat(settings): redesign Agent Session Search for many computers
Renames the pane, splits the list into this computer and paired Orca servers, and puts a count of what is on above it with a Turn on all that skips offline and too-old hosts and keeps going past a host that refuses. Consenting once persists a standing consent so a server that later becomes reachable turns on without another dialog; turning one off by hand drops it. Rows past the sixth fold away, ordered by what the user can act on. Status sentences now say how much is searchable instead of Ready, and an off computer says so with its switch alone.
This commit is contained in:
@@ -10,6 +10,7 @@ import { ConfirmationDialogContext } from '@/components/confirmation-dialog-cont
|
||||
import { SessionHistoryComputerRow } from './SessionHistoryComputerRow'
|
||||
import { SessionHistoryServerRow } from './SessionHistoryServerRow'
|
||||
import type { RuntimeHostDetails } from './runtime-environment-host-details'
|
||||
import type { SessionSearchComputerState } from './session-search-computer-rollup'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
visible: true,
|
||||
@@ -38,6 +39,7 @@ const runningIndex: AiVaultSearchStatus = {
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 4_880,
|
||||
messagesIndexed: 1_400_000,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
const environment: PublicKnownRuntimeEnvironment = {
|
||||
@@ -71,11 +73,17 @@ function connectedDetails(appVersion = '1.4.202'): RuntimeHostDetails {
|
||||
function serverRow(
|
||||
details: RuntimeHostDetails | undefined,
|
||||
confirm = vi.fn().mockResolvedValue(true),
|
||||
onError = vi.fn()
|
||||
onError = vi.fn(),
|
||||
onStateChange?: (environmentId: string, state: SessionSearchComputerState) => void
|
||||
) {
|
||||
return render(
|
||||
<ConfirmationDialogContext.Provider value={confirm}>
|
||||
<SessionHistoryServerRow environment={environment} details={details} onError={onError} />
|
||||
<SessionHistoryServerRow
|
||||
environment={environment}
|
||||
details={details}
|
||||
onError={onError}
|
||||
{...(onStateChange ? { onStateChange } : {})}
|
||||
/>
|
||||
</ConfirmationDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -107,7 +115,7 @@ it('renders a computer as icon, name, version, one status line and a switch', ()
|
||||
kind="server"
|
||||
name="build-box"
|
||||
version="1.4.202"
|
||||
status="Ready · 12 sessions searchable"
|
||||
status="12 sessions · 1.4K messages searchable"
|
||||
details={['2 sessions could not be read and will be retried.']}
|
||||
checked
|
||||
onToggle={vi.fn()}
|
||||
@@ -115,7 +123,7 @@ it('renders a computer as icon, name, version, one status line and a switch', ()
|
||||
)
|
||||
expect(screen.getByText('build-box')).toBeInTheDocument()
|
||||
expect(screen.getByText('Orca v1.4.202')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Ready · 12 sessions searchable')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('12 sessions · 1.4K messages searchable')
|
||||
expect(screen.getByText('2 sessions could not be read and will be retried.')).toBeInTheDocument()
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
@@ -131,19 +139,60 @@ 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('Ready · 4880 sessions searchable')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('4,880 sessions · 1.4M messages searchable')
|
||||
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 () => {
|
||||
it('leaves a reachable server with search off to its switch, with no status sentence', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
serverRow(connectedDetails())
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Off')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('publishes each server state the pane counts and orders by', async () => {
|
||||
const onStateChange = vi.fn()
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
serverRow(connectedDetails(), undefined, undefined, onStateChange)
|
||||
await act(async () => {})
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'off')
|
||||
mocks.status.mockResolvedValue(runningIndex)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'on')
|
||||
})
|
||||
|
||||
it('publishes offline and needs-update states without claiming either too early', async () => {
|
||||
const onStateChange = vi.fn()
|
||||
const { unmount } = serverRow(undefined, undefined, undefined, onStateChange)
|
||||
await act(async () => {})
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'checking')
|
||||
unmount()
|
||||
serverRow(
|
||||
{
|
||||
status: 'error',
|
||||
runtimeStatus: null,
|
||||
remoteControl: null,
|
||||
compatibility: null,
|
||||
error: 'unreachable'
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
onStateChange
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'offline')
|
||||
|
||||
cleanup()
|
||||
mocks.status.mockRejectedValue(new Error("Error invoking remote method 'x': Error: host-too-old"))
|
||||
serverRow(connectedDetails('1.4.190'), undefined, undefined, onStateChange)
|
||||
await act(async () => {})
|
||||
expect(onStateChange).toHaveBeenLastCalledWith('env-1', 'needs-update')
|
||||
})
|
||||
|
||||
it('keeps an offline server dimmed, disabled and honest about its index', async () => {
|
||||
serverRow({
|
||||
status: 'error',
|
||||
@@ -177,7 +226,7 @@ it('asks for consent naming the server before enabling it', async () => {
|
||||
})
|
||||
)
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Ready · 4880 sessions searchable')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('4,880 sessions · 1.4M messages searchable')
|
||||
})
|
||||
|
||||
it('leaves a server untouched when the consent is declined', async () => {
|
||||
@@ -200,7 +249,7 @@ it('turns a server off without asking again', async () => {
|
||||
})
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', false)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Off')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('turns a host-too-old rejection into the update-server state', async () => {
|
||||
|
||||
@@ -36,7 +36,9 @@ export function SessionHistoryComputerRow({
|
||||
}: SessionHistoryComputerRowProps): React.JSX.Element {
|
||||
const Icon = kind === 'local' ? Laptop : Server
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 py-3', dimmed && 'opacity-60')}>
|
||||
<div
|
||||
className={cn('flex items-center gap-3 border-t border-border 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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type RuntimeHostDetails
|
||||
} from './runtime-environment-host-details'
|
||||
import { SessionHistoryComputerRow } from './SessionHistoryComputerRow'
|
||||
import type { SessionSearchComputerState } from './session-search-computer-rollup'
|
||||
import {
|
||||
isHostTooOldError,
|
||||
sessionSearchCheckingMessage,
|
||||
@@ -23,11 +24,19 @@ import { useSessionSearchStatus } from './use-session-search-status'
|
||||
export function SessionHistoryServerRow({
|
||||
environment,
|
||||
details,
|
||||
onError
|
||||
refresh = 0,
|
||||
onError,
|
||||
onStateChange,
|
||||
onUserToggle
|
||||
}: {
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
details: RuntimeHostDetails | undefined
|
||||
/** Bumped by the pane after it changes this host from outside the row. */
|
||||
refresh?: number
|
||||
onError: (message: string | null) => void
|
||||
/** Lets the pane count and order computers it does not itself poll. */
|
||||
onStateChange?: (environmentId: string, state: SessionSearchComputerState) => void
|
||||
onUserToggle?: (environmentId: string, enabled: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
const confirm = useConfirmationDialog()
|
||||
@@ -40,11 +49,23 @@ export function SessionHistoryServerRow({
|
||||
const connected = isRuntimeServerTransportConnected(connectionState)
|
||||
const { status, failed, hostTooOld, adopt } = useSessionSearchStatus({
|
||||
executionHostId: hostId,
|
||||
active: connected && !tooOldOnSet
|
||||
active: connected && !tooOldOnSet,
|
||||
refresh
|
||||
})
|
||||
// A status read or a set call can each prove the server predates session search.
|
||||
const tooOld = tooOldOnSet || hostTooOld
|
||||
const enabled = status?.enabled === true
|
||||
const state = resolveServerState({
|
||||
tooOld,
|
||||
connected,
|
||||
checking: connectionState === 'checking',
|
||||
enabled,
|
||||
answered: Boolean(status)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onStateChange?.(environment.id, state)
|
||||
}, [environment.id, onStateChange, state])
|
||||
|
||||
async function setEnabled(next: boolean): Promise<void> {
|
||||
setBusy(true)
|
||||
@@ -74,6 +95,7 @@ export function SessionHistoryServerRow({
|
||||
}
|
||||
|
||||
async function toggle(): Promise<void> {
|
||||
onUserToggle?.(environment.id, !enabled)
|
||||
if (enabled) {
|
||||
await setEnabled(false)
|
||||
return
|
||||
@@ -148,21 +170,44 @@ export function SessionHistoryServerRow({
|
||||
/>
|
||||
)
|
||||
}
|
||||
let statusText = sessionSearchCheckingMessage()
|
||||
// An off computer says so with its switch; a sentence repeating it is noise.
|
||||
let statusText: string | undefined = sessionSearchCheckingMessage()
|
||||
if (failed) {
|
||||
statusText = sessionSearchReadErrorMessage()
|
||||
} else if (status) {
|
||||
statusText = enabled
|
||||
? sessionSearchStatusMessage(status)
|
||||
: translate('sessionHistory.settings.serverOff', 'Off')
|
||||
statusText = enabled ? sessionSearchStatusMessage(status) : undefined
|
||||
}
|
||||
return (
|
||||
<SessionHistoryComputerRow
|
||||
{...row}
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
status={statusText}
|
||||
{...(statusText === undefined ? {} : { status: statusText })}
|
||||
details={sessionSearchStatusDetails(status)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** What the pane needs to count and order this row, from what the row already knows. */
|
||||
function resolveServerState(args: {
|
||||
tooOld: boolean
|
||||
connected: boolean
|
||||
checking: boolean
|
||||
enabled: boolean
|
||||
answered: boolean
|
||||
}): SessionSearchComputerState {
|
||||
if (args.tooOld) {
|
||||
return 'needs-update'
|
||||
}
|
||||
// A probe still in flight is not evidence of an unreachable host.
|
||||
if (args.checking) {
|
||||
return 'checking'
|
||||
}
|
||||
if (!args.connected) {
|
||||
return 'offline'
|
||||
}
|
||||
if (!args.answered) {
|
||||
return 'checking'
|
||||
}
|
||||
return args.enabled ? 'on' : 'off'
|
||||
}
|
||||
|
||||
@@ -11,20 +11,26 @@ import { SessionHistorySettingsPane } from './SessionHistorySettingsPane'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const environments: { id: string; name: string }[] = []
|
||||
const statusByHost: Record<string, unknown> = {}
|
||||
const details: Record<string, unknown> = {}
|
||||
return {
|
||||
web: false,
|
||||
visible: true,
|
||||
status: vi.fn(),
|
||||
statusByHost,
|
||||
clear: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
environments
|
||||
environments,
|
||||
details,
|
||||
closeSettingsPage: vi.fn(),
|
||||
showAiVaultSearch: vi.fn()
|
||||
}
|
||||
})
|
||||
vi.mock('./use-runtime-environment-catalog', () => ({
|
||||
useRuntimeEnvironmentCatalog: () => ({
|
||||
environments: mocks.environments,
|
||||
isLoading: false,
|
||||
detailsByEnvironmentId: {},
|
||||
detailsByEnvironmentId: mocks.details,
|
||||
setDetailsByEnvironmentId: vi.fn(),
|
||||
mountedRef: { current: true },
|
||||
loadEnvironments: vi.fn()
|
||||
@@ -32,7 +38,12 @@ vi.mock('./use-runtime-environment-catalog', () => ({
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ openSettingsPage: vi.fn(), openSettingsTarget: vi.fn() })
|
||||
selector({
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
closeSettingsPage: mocks.closeSettingsPage,
|
||||
showAiVaultSearch: mocks.showAiVaultSearch
|
||||
})
|
||||
}))
|
||||
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
|
||||
vi.mock('@/hooks/use-window-stream-visibility', () => ({
|
||||
@@ -48,20 +59,58 @@ function pane(
|
||||
enabled = false,
|
||||
confirm = vi.fn().mockResolvedValue(true),
|
||||
save = vi.fn().mockResolvedValue(undefined),
|
||||
historyDays: number | null = null
|
||||
historyDays: number | null = null,
|
||||
autoEnableNewComputers = false
|
||||
) {
|
||||
return render(
|
||||
<ConfirmationDialogContext.Provider value={confirm}>
|
||||
<SessionHistorySettingsPane
|
||||
settings={{
|
||||
...getDefaultSettings('/synthetic'),
|
||||
aiVaultSearch: { enabled, historyDays }
|
||||
aiVaultSearch: { enabled, historyDays },
|
||||
aiVaultSearchAutoEnableNewComputers: autoEnableNewComputers
|
||||
}}
|
||||
updateSettings={save}
|
||||
/>
|
||||
</ConfirmationDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
const CONNECTED_DETAILS = {
|
||||
status: 'ready',
|
||||
runtimeStatus: {
|
||||
runtimeId: 'runtime-1',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 1,
|
||||
liveTabCount: 1,
|
||||
liveLeafCount: 1,
|
||||
appVersion: '1.4.202'
|
||||
},
|
||||
remoteControl: null,
|
||||
compatibility: { kind: 'ok', clientProtocolVersion: 1, serverProtocolVersion: 1 },
|
||||
error: null
|
||||
}
|
||||
const OFFLINE_DETAILS = {
|
||||
status: 'error',
|
||||
runtimeStatus: null,
|
||||
remoteControl: null,
|
||||
compatibility: null,
|
||||
error: 'unreachable'
|
||||
}
|
||||
/** Answers status per host so one pane can hold servers in different states. */
|
||||
function statusByHost(): void {
|
||||
mocks.status.mockImplementation(async (hostId: string) => {
|
||||
const answer = mocks.statusByHost[hostId]
|
||||
if (answer === undefined) {
|
||||
return unavailableSessionSearchStatus()
|
||||
}
|
||||
if (answer === 'too-old') {
|
||||
throw new Error("Error invoking remote method 'x': Error: host-too-old")
|
||||
}
|
||||
return answer
|
||||
})
|
||||
}
|
||||
const summaryLine = (): string => screen.getByText(/computers/).textContent ?? ''
|
||||
async function openAdvanced(): Promise<void> {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Advanced/ }))
|
||||
@@ -72,13 +121,19 @@ const current: AiVaultSearchStatus = {
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 12,
|
||||
messagesIndexed: 3_400,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
const off: AiVaultSearchStatus = unavailableSessionSearchStatus()
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.web = false
|
||||
mocks.visible = true
|
||||
mocks.environments = []
|
||||
mocks.details = {}
|
||||
mocks.statusByHost = {}
|
||||
mocks.closeSettingsPage.mockReset()
|
||||
mocks.showAiVaultSearch.mockReset()
|
||||
mocks.status.mockReset().mockResolvedValue(current)
|
||||
mocks.clear.mockReset().mockResolvedValue(undefined)
|
||||
mocks.setEnabled.mockReset().mockResolvedValue(current)
|
||||
@@ -124,6 +179,16 @@ it('requires opt-in and saves the existing policy without touching transcripts o
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
|
||||
})
|
||||
|
||||
it('sends the user to the sidebar panel with one click', async () => {
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
|
||||
})
|
||||
expect(mocks.showAiVaultSearch).toHaveBeenCalledOnce()
|
||||
expect(mocks.closeSettingsPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('leaves search off when the indexing consent is declined', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(false, vi.fn().mockResolvedValue(false), save)
|
||||
@@ -276,11 +341,11 @@ it('keeps the last index status visible while a save is in flight', async () =>
|
||||
)
|
||||
pane(true, vi.fn().mockResolvedValue(true), save)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Ready · 12 sessions searchable')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('12 sessions · 3.4K messages searchable')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Ready · 12 sessions searchable')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('12 sessions · 3.4K messages searchable')
|
||||
await act(async () => {
|
||||
finishSave()
|
||||
})
|
||||
@@ -298,9 +363,8 @@ it('lists one row per paired Orca server under this computer, and says where SSH
|
||||
expect(screen.getByRole('switch', { name: 'Search sessions on build-box' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch', { name: 'Search sessions on office-mini' })).toBeInTheDocument()
|
||||
expect(mocks.status).toHaveBeenCalledWith('local')
|
||||
expect(
|
||||
screen.getByText('Search from the Agent Session History panel in the sidebar.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('This computer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Orca remote servers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers only this computer to a paired client, with no server rows', async () => {
|
||||
@@ -311,5 +375,181 @@ it('offers only this computer to a paired client, with no server rows', async ()
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
||||
expect(screen.getByRole('switch')).toBeDisabled()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Turn on all' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument()
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/** Local on, one server on, one off, one offline, one too old. */
|
||||
function mixedFleet(): void {
|
||||
mocks.environments = [
|
||||
{ id: 'on', name: 'build-01' },
|
||||
{ id: 'off', name: 'gpu-a' },
|
||||
{ id: 'gone', name: 'linux 1' },
|
||||
{ id: 'old', name: 'm4 air' }
|
||||
]
|
||||
mocks.details = {
|
||||
on: CONNECTED_DETAILS,
|
||||
off: CONNECTED_DETAILS,
|
||||
gone: OFFLINE_DETAILS,
|
||||
old: CONNECTED_DETAILS
|
||||
}
|
||||
mocks.statusByHost = {
|
||||
local: current,
|
||||
'runtime:on': current,
|
||||
'runtime:off': off,
|
||||
'runtime:old': 'too-old'
|
||||
}
|
||||
statusByHost()
|
||||
}
|
||||
|
||||
it('counts every computer in one line, leaving out the segments worth zero', async () => {
|
||||
mixedFleet()
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
expect(summaryLine()).toBe('On 2 of 5 computers · 1 offline · 1 need an update')
|
||||
mocks.environments = [{ id: 'off', name: 'gpu-a' }]
|
||||
mocks.details = { off: CONNECTED_DETAILS }
|
||||
mocks.statusByHost = { local: current, 'runtime:off': off }
|
||||
statusByHost()
|
||||
cleanup()
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
expect(summaryLine()).toBe('On 1 of 2 computers')
|
||||
})
|
||||
|
||||
it('turns on every reachable computer and skips the ones it cannot', async () => {
|
||||
mixedFleet()
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(true, confirm, save)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Turn on all' }))
|
||||
})
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Turn on session search on every computer?' })
|
||||
)
|
||||
expect(mocks.setEnabled.mock.calls.map((call) => call[0])).toEqual(['runtime:off'])
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearchAutoEnableNewComputers: true })
|
||||
})
|
||||
|
||||
it('turns this computer on as part of turning them all on', async () => {
|
||||
mocks.environments = [{ id: 'off', name: 'gpu-a' }]
|
||||
mocks.details = { off: CONNECTED_DETAILS }
|
||||
mocks.statusByHost = { local: off, 'runtime:off': off }
|
||||
statusByHost()
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(false, vi.fn().mockResolvedValue(true), save)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Turn on all' }))
|
||||
})
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:off', true)
|
||||
})
|
||||
|
||||
it('keeps going after a host refuses, and withholds the standing consent', async () => {
|
||||
mocks.environments = [
|
||||
{ id: 'a', name: 'gpu-a' },
|
||||
{ id: 'b', name: 'gpu-b' }
|
||||
]
|
||||
mocks.details = { a: CONNECTED_DETAILS, b: CONNECTED_DETAILS }
|
||||
mocks.statusByHost = { local: current, 'runtime:a': off, 'runtime:b': off }
|
||||
statusByHost()
|
||||
mocks.setEnabled.mockRejectedValueOnce(new Error('relay down')).mockResolvedValue(current)
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(true, vi.fn().mockResolvedValue(true), save)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Turn on all' }))
|
||||
})
|
||||
expect(mocks.setEnabled.mock.calls.map((call) => call[0])).toEqual(['runtime:a', 'runtime:b'])
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not change session search on gpu-a')
|
||||
expect(save).not.toHaveBeenCalledWith({ aiVaultSearchAutoEnableNewComputers: true })
|
||||
})
|
||||
|
||||
it('hides the button and says so once nothing is left to turn on', async () => {
|
||||
mocks.environments = [
|
||||
{ id: 'a', name: 'gpu-a' },
|
||||
{ id: 'gone', name: 'linux 1' }
|
||||
]
|
||||
mocks.details = { a: CONNECTED_DETAILS, gone: OFFLINE_DETAILS }
|
||||
mocks.statusByHost = { local: current, 'runtime:a': current }
|
||||
statusByHost()
|
||||
pane(true, undefined, undefined, null, true)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('button', { name: 'Turn on all' })).not.toBeInTheDocument()
|
||||
expect(summaryLine()).toBe('On 2 of 3 computers · 1 offline New computers turn on when they can.')
|
||||
})
|
||||
|
||||
it('turns on a newly reachable server while the standing consent holds', async () => {
|
||||
mocks.environments = [{ id: 'a', name: 'gpu-a' }]
|
||||
mocks.details = { a: CONNECTED_DETAILS }
|
||||
mocks.statusByHost = { local: current, 'runtime:a': off }
|
||||
statusByHost()
|
||||
pane(true, undefined, undefined, null, true)
|
||||
await act(async () => {})
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:a', true)
|
||||
})
|
||||
|
||||
it('drops the standing consent when a server is turned off by hand', async () => {
|
||||
mocks.environments = [{ id: 'a', name: 'gpu-a' }]
|
||||
mocks.details = { a: CONNECTED_DETAILS }
|
||||
mocks.statusByHost = { local: current, 'runtime:a': current }
|
||||
statusByHost()
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(true, vi.fn().mockResolvedValue(true), save, null, true)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Search sessions on gpu-a' }))
|
||||
})
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearchAutoEnableNewComputers: false })
|
||||
})
|
||||
|
||||
it('folds the list past six computers and orders it by what the user can act on', async () => {
|
||||
mocks.environments = [
|
||||
{ id: 'gone', name: 'zz-offline' },
|
||||
{ id: 'old', name: 'aa-old' },
|
||||
{ id: 'off1', name: 'bb-off' },
|
||||
{ id: 'off2', name: 'aa-off' },
|
||||
{ id: 'on1', name: 'zz-on' },
|
||||
{ id: 'on2', name: 'aa-on' }
|
||||
]
|
||||
mocks.details = {
|
||||
gone: OFFLINE_DETAILS,
|
||||
old: CONNECTED_DETAILS,
|
||||
off1: CONNECTED_DETAILS,
|
||||
off2: CONNECTED_DETAILS,
|
||||
on1: CONNECTED_DETAILS,
|
||||
on2: CONNECTED_DETAILS
|
||||
}
|
||||
mocks.statusByHost = {
|
||||
local: current,
|
||||
'runtime:old': 'too-old',
|
||||
'runtime:off1': off,
|
||||
'runtime:off2': off,
|
||||
'runtime:on1': current,
|
||||
'runtime:on2': current
|
||||
}
|
||||
statusByHost()
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
// The local row's label is the host's own name, which differs per platform; the servers are the order under test.
|
||||
const serverNames = (): string[] =>
|
||||
screen
|
||||
.getAllByRole('switch')
|
||||
.map((element) => element.getAttribute('aria-label') ?? '')
|
||||
.filter((label) => label.startsWith('Search sessions on '))
|
||||
.map((label) => label.replace('Search sessions on ', ''))
|
||||
.slice(1)
|
||||
expect(serverNames()).toEqual(['aa-on', 'zz-on', 'aa-off', 'bb-off', 'aa-old'])
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 1 more' }))
|
||||
})
|
||||
expect(serverNames()).toEqual(['aa-on', 'zz-on', 'aa-off', 'bb-off', 'aa-old', 'zz-offline'])
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show fewer' }))
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Show 1 more' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import {
|
||||
AiVaultSearchSettingsSchema,
|
||||
@@ -8,25 +6,35 @@ import {
|
||||
} from '../../../../shared/ai-vault-search-settings'
|
||||
import {
|
||||
getLocalExecutionHostLabel,
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId
|
||||
} 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 { useAppStore } from '@/store'
|
||||
import { SettingsRow } from './SettingsFormControls'
|
||||
import { SessionSearchAdvancedSection } from './SessionSearchAdvancedSection'
|
||||
import { SessionHistoryComputerRow } from './SessionHistoryComputerRow'
|
||||
import { SessionHistoryServerRow } from './SessionHistoryServerRow'
|
||||
import { SessionSearchComputerList } from './SessionSearchComputerList'
|
||||
import {
|
||||
isTurnOnableSessionSearchState,
|
||||
orderSessionSearchServers,
|
||||
sessionSearchSummarySentence,
|
||||
summarizeSessionSearchComputers,
|
||||
type SessionSearchComputerEntry,
|
||||
type SessionSearchComputerState
|
||||
} from './session-search-computer-rollup'
|
||||
import {
|
||||
sessionSearchCheckingMessage,
|
||||
sessionSearchOffMessage,
|
||||
sessionSearchReadErrorMessage,
|
||||
sessionSearchStatusDetails,
|
||||
sessionSearchStatusMessage
|
||||
} from './session-history-status-copy'
|
||||
import { useSessionSearchAutoEnable } from './use-session-search-auto-enable'
|
||||
import { useSessionSearchStatus } from './use-session-search-status'
|
||||
import { useRuntimeEnvironmentCatalog } from './use-runtime-environment-catalog'
|
||||
|
||||
@@ -38,12 +46,18 @@ export function SessionHistorySettingsPane({
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const policy = resolveAiVaultSearchSettings(settings)
|
||||
const autoEnableNewComputers = settings.aiVaultSearchAutoEnableNewComputers === true
|
||||
const isWebClient = isWebClientLocation()
|
||||
const confirm = useConfirmationDialog()
|
||||
const closeSettingsPage = useAppStore((state) => state.closeSettingsPage)
|
||||
const showAiVaultSearch = useAppStore((state) => state.showAiVaultSearch)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [serverStates, setServerStates] = useState<Record<string, SessionSearchComputerState>>({})
|
||||
const [userToggledServers, setUserToggledServers] = useState<ReadonlySet<string>>(
|
||||
() => new Set<string>()
|
||||
)
|
||||
const { environments, detailsByEnvironmentId } = useRuntimeEnvironmentCatalog()
|
||||
const localRead = useSessionSearchStatus({
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
@@ -58,6 +72,40 @@ export function SessionHistorySettingsPane({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const servers = isWebClient ? [] : environments
|
||||
const localEntry: SessionSearchComputerEntry = {
|
||||
id: LOCAL_EXECUTION_HOST_ID,
|
||||
name: getLocalExecutionHostLabel(),
|
||||
state: policy.enabled ? 'on' : 'off'
|
||||
}
|
||||
const serverEntries = servers.map((environment) => ({
|
||||
id: environment.id,
|
||||
name: environment.name,
|
||||
state: serverStates[environment.id] ?? 'checking',
|
||||
environment
|
||||
}))
|
||||
const summary = summarizeSessionSearchComputers([localEntry, ...serverEntries])
|
||||
const orderedServers = orderSessionSearchServers(serverEntries)
|
||||
// Rebuilt each render on purpose: the hook keys off the host ids, not this array.
|
||||
const autoEnableTargets = serverEntries
|
||||
.filter(
|
||||
(entry) => isTurnOnableSessionSearchState(entry.state) && !userToggledServers.has(entry.id)
|
||||
)
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
hostId: toRuntimeExecutionHostId(entry.id),
|
||||
name: entry.name
|
||||
}))
|
||||
|
||||
const handleServerState = useCallback(
|
||||
(environmentId: string, state: SessionSearchComputerState) => {
|
||||
setServerStates((current) =>
|
||||
current[environmentId] === state ? current : { ...current, [environmentId]: state }
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
function writePolicy(updates: Partial<typeof policy>): Promise<void> {
|
||||
return updateSettings({
|
||||
aiVaultSearch: AiVaultSearchSettingsSchema.parse({ ...policy, ...updates })
|
||||
@@ -107,6 +155,86 @@ export function SessionHistorySettingsPane({
|
||||
await save({ enabled: true })
|
||||
}
|
||||
|
||||
/** A hand-off the user made themselves overrides the standing "turn on new computers" consent. */
|
||||
function noteServerToggledByHand(environmentId: string, enabled: boolean): void {
|
||||
setUserToggledServers((current) => {
|
||||
if (current.has(environmentId)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.add(environmentId)
|
||||
return next
|
||||
})
|
||||
if (!enabled && autoEnableNewComputers) {
|
||||
void updateSettings({ aiVaultSearchAutoEnableNewComputers: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function turnOnEveryComputer(): Promise<void> {
|
||||
setBusy(true)
|
||||
let accepted = false
|
||||
try {
|
||||
accepted = await confirm({
|
||||
title: translate(
|
||||
'sessionHistory.settings.enableAllTitle',
|
||||
'Turn on session search on every computer?'
|
||||
),
|
||||
description: translate(
|
||||
'sessionHistory.settings.enableAllConsent',
|
||||
'Orca will make the agent conversations and tool output on this computer and on every reachable paired server searchable from Agent Session History. Each searchable copy stays on the computer that made it; results are sent here when you search. Offline servers and servers that need an update are skipped. The first pass runs in the background and can take a few minutes.'
|
||||
),
|
||||
confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Turn on')
|
||||
})
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
let failed = false
|
||||
try {
|
||||
if (!policy.enabled) {
|
||||
try {
|
||||
await writePolicy({ enabled: true })
|
||||
} catch {
|
||||
failed = true
|
||||
setError(saveErrorMessage())
|
||||
}
|
||||
}
|
||||
// One host at a time: a failure is that host's, and it must not stop the rest.
|
||||
for (const entry of orderedServers) {
|
||||
if (!isTurnOnableSessionSearchState(entry.state)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await window.api.aiVault.setSearchEnabled(toRuntimeExecutionHostId(entry.id), true)
|
||||
} catch {
|
||||
failed = true
|
||||
setError(serverToggleErrorMessage(entry.name))
|
||||
}
|
||||
}
|
||||
if (!failed) {
|
||||
await updateSettings({ aiVaultSearchAutoEnableNewComputers: true })
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
setRefresh((value) => value + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useSessionSearchAutoEnable({
|
||||
active: autoEnableNewComputers && !isWebClient,
|
||||
targets: autoEnableTargets,
|
||||
onError: setError,
|
||||
onSettled: () => setRefresh((value) => value + 1)
|
||||
})
|
||||
|
||||
/** False when the settings write failed or the pane went away, so the delete is skipped. */
|
||||
async function turnSearchOffBeforeDelete(): Promise<boolean> {
|
||||
try {
|
||||
@@ -120,55 +248,9 @@ export function SessionHistorySettingsPane({
|
||||
return mounted.current
|
||||
}
|
||||
|
||||
async function deleteIndex(): Promise<void> {
|
||||
const wasEnabled = policy.enabled
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const accepted = await confirm({
|
||||
title: translate(
|
||||
'sessionHistory.settings.deleteTitle',
|
||||
'Clear search data on this computer?'
|
||||
),
|
||||
description: deleteDescription(wasEnabled),
|
||||
confirmLabel: translate('sessionHistory.settings.delete', 'Clear'),
|
||||
confirmVariant: 'destructive'
|
||||
})
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
}
|
||||
// Clearing while search is on makes the host rebuild the index immediately; turn it off first.
|
||||
if (wasEnabled && !(await turnSearchOffBeforeDelete())) {
|
||||
return
|
||||
}
|
||||
await window.api.aiVault.clearSearchIndex()
|
||||
if (mounted.current) {
|
||||
setRefresh((value) => value + 1)
|
||||
toast.success(
|
||||
wasEnabled
|
||||
? translate(
|
||||
'sessionHistory.settings.clearedAndTurnedOff',
|
||||
'Search turned off and search data cleared.'
|
||||
)
|
||||
: translate('sessionHistory.settings.cleared', 'Search data cleared.')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(
|
||||
translate('sessionHistory.settings.clearError', 'Could not clear search data. Try again.')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stale answer from before the switch went off must not keep reporting progress.
|
||||
const localStatus = policy.enabled ? localRead.status : null
|
||||
let localStatusText = sessionSearchOffMessage()
|
||||
let localStatusText: string | undefined
|
||||
if (policy.enabled) {
|
||||
localStatusText = localRead.failed
|
||||
? sessionSearchReadErrorMessage()
|
||||
@@ -178,82 +260,103 @@ export function SessionHistorySettingsPane({
|
||||
}
|
||||
|
||||
return (
|
||||
<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', 'Search agent sessions')}
|
||||
</Label>
|
||||
<p className="select-text text-xs text-muted-foreground">
|
||||
{isWebClient
|
||||
? translate(
|
||||
'sessionHistory.settings.webUnsupported',
|
||||
'Turn on session search from the Orca desktop app on that computer.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.computersConsent',
|
||||
'Each computer keeps a searchable copy of its own agent conversations and tool output. Nothing leaves that computer.'
|
||||
)}
|
||||
</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="mt-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="xs" className="-ml-2">
|
||||
{translate('sessionHistory.settings.advanced', 'Advanced')}
|
||||
<ChevronDown
|
||||
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SettingsRow
|
||||
label={translate('sessionHistory.settings.deleteIndexCopy', 'Clear search data')}
|
||||
description={deleteDescription(policy.enabled)}
|
||||
control={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || isWebClient}
|
||||
onClick={() => void deleteIndex()}
|
||||
>
|
||||
{translate('sessionHistory.settings.delete', 'Clear')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{error ? (
|
||||
<p role="alert" className="pt-3 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="space-y-1 py-3">
|
||||
<Label className="select-text">
|
||||
{translate('sessionHistory.settings.indexComputers', 'Search inside sessions')}
|
||||
</Label>
|
||||
<p className="select-text text-xs text-muted-foreground">
|
||||
{isWebClient
|
||||
? translate(
|
||||
'sessionHistory.settings.webUnsupported',
|
||||
'Turn on session search from the Orca desktop app on that computer.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.computersConsent',
|
||||
'Each computer keeps a searchable copy of its own agent conversations and tool output. Nothing leaves that computer.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'sessionHistory.settings.panelHint',
|
||||
'Search from the Agent Session History panel in the sidebar.'
|
||||
)}
|
||||
</p>
|
||||
{isWebClient ? null : (
|
||||
<div className="flex items-center justify-between gap-4 pt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sessionSearchSummarySentence(summary, autoEnableNewComputers)}
|
||||
</p>
|
||||
{summary.turnOnable > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void turnOnEveryComputer()}
|
||||
>
|
||||
{translate('sessionHistory.settings.turnOnAll', 'Turn on all')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<SessionSearchComputerList
|
||||
local={
|
||||
<SessionHistoryComputerRow
|
||||
kind="local"
|
||||
name={localEntry.name}
|
||||
checked={policy.enabled}
|
||||
disabled={busy || isWebClient}
|
||||
onToggle={() => void toggleEnabled()}
|
||||
{...(isWebClient || localStatusText === undefined
|
||||
? {}
|
||||
: { status: localStatusText, details: sessionSearchStatusDetails(localStatus) })}
|
||||
/>
|
||||
}
|
||||
servers={orderedServers.map((entry) => ({
|
||||
id: entry.id,
|
||||
node: (
|
||||
<SessionHistoryServerRow
|
||||
environment={entry.environment}
|
||||
details={detailsByEnvironmentId[entry.id]}
|
||||
refresh={refresh}
|
||||
onError={setError}
|
||||
onStateChange={handleServerState}
|
||||
onUserToggle={noteServerToggledByHand}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
{isWebClient ? null : (
|
||||
<SettingsRow
|
||||
className="border-t border-border"
|
||||
label={translate('sessionHistory.settings.openInSidebar', 'Open in the sidebar')}
|
||||
description={translate(
|
||||
'sessionHistory.settings.openInSidebarCopy',
|
||||
'Type what you remember, or ask an agent: “find the session where we fixed the login timeout.”'
|
||||
)}
|
||||
control={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
showAiVaultSearch()
|
||||
closeSettingsPage()
|
||||
}}
|
||||
>
|
||||
{translate('sessionHistory.settings.open', 'Open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SessionSearchAdvancedSection
|
||||
enabled={policy.enabled}
|
||||
disabled={isWebClient}
|
||||
turnSearchOff={turnSearchOffBeforeDelete}
|
||||
onError={setError}
|
||||
onCleared={() => setRefresh((value) => value + 1)}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="pt-3 text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -262,15 +365,10 @@ function saveErrorMessage(): string {
|
||||
return translate('sessionHistory.settings.saveError', 'Could not save. Try again.')
|
||||
}
|
||||
|
||||
/** Shared by the Advanced row and its confirm dialog so both promise the same thing. */
|
||||
function deleteDescription(enabled: boolean): string {
|
||||
return enabled
|
||||
? translate(
|
||||
'sessionHistory.settings.deleteEnabled',
|
||||
'Turns off search and removes the searchable copy from this computer. Your agent sessions are not affected.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.deleteDisabled',
|
||||
'Removes the searchable copy from this computer. Your agent sessions are not affected.'
|
||||
)
|
||||
function serverToggleErrorMessage(host: string): string {
|
||||
return translate(
|
||||
'sessionHistory.settings.serverToggleError',
|
||||
'Could not change session search on {{host}}. Try again.',
|
||||
{ host }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SettingsRow } from './SettingsFormControls'
|
||||
|
||||
/** Shared by the Clear row and its confirm dialog so both promise the same thing. */
|
||||
export function sessionSearchClearDescription(enabled: boolean): string {
|
||||
return enabled
|
||||
? translate(
|
||||
'sessionHistory.settings.deleteEnabled',
|
||||
'Turns off search and removes the searchable copy from this computer. Your agent sessions are not affected.'
|
||||
)
|
||||
: translate(
|
||||
'sessionHistory.settings.deleteDisabled',
|
||||
'Removes the searchable copy from this computer. Your agent sessions are not affected.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clearing this computer's index, behind Advanced because it is the one action
|
||||
* here that destroys something.
|
||||
*/
|
||||
export function SessionSearchAdvancedSection({
|
||||
enabled,
|
||||
disabled,
|
||||
turnSearchOff,
|
||||
onError,
|
||||
onCleared
|
||||
}: {
|
||||
enabled: boolean
|
||||
disabled: boolean
|
||||
/** Returns false when the write failed or the pane went away, so the delete is skipped. */
|
||||
turnSearchOff: () => Promise<boolean>
|
||||
onError: (message: string | null) => void
|
||||
onCleared: () => void
|
||||
}): React.JSX.Element {
|
||||
const confirm = useConfirmationDialog()
|
||||
const mounted = useMountedRef()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function clearIndex(): Promise<void> {
|
||||
const wasEnabled = enabled
|
||||
setBusy(true)
|
||||
onError(null)
|
||||
try {
|
||||
const accepted = await confirm({
|
||||
title: translate(
|
||||
'sessionHistory.settings.deleteTitle',
|
||||
'Clear search data on this computer?'
|
||||
),
|
||||
description: sessionSearchClearDescription(wasEnabled),
|
||||
confirmLabel: translate('sessionHistory.settings.delete', 'Clear'),
|
||||
confirmVariant: 'destructive'
|
||||
})
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
}
|
||||
// Clearing while search is on makes the host rebuild the index immediately; turn it off first.
|
||||
if (wasEnabled && !(await turnSearchOff())) {
|
||||
return
|
||||
}
|
||||
await window.api.aiVault.clearSearchIndex()
|
||||
if (mounted.current) {
|
||||
onCleared()
|
||||
toast.success(
|
||||
wasEnabled
|
||||
? translate(
|
||||
'sessionHistory.settings.clearedAndTurnedOff',
|
||||
'Search turned off and search data cleared.'
|
||||
)
|
||||
: translate('sessionHistory.settings.cleared', 'Search data cleared.')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
onError(
|
||||
translate('sessionHistory.settings.clearError', 'Could not clear search data. Try again.')
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="xs">
|
||||
{translate('sessionHistory.settings.advanced', 'Advanced')}
|
||||
<ChevronDown className={cn('size-4 transition-transform', open && 'rotate-180')} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SettingsRow
|
||||
label={translate('sessionHistory.settings.deleteIndexCopy', 'Clear search data')}
|
||||
description={sessionSearchClearDescription(enabled)}
|
||||
control={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || disabled}
|
||||
onClick={() => void clearIndex()}
|
||||
>
|
||||
{translate('sessionHistory.settings.delete', 'Clear')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** Local computer plus five servers. Past that the list stops being a list and becomes a wall. */
|
||||
const VISIBLE_COMPUTER_LIMIT = 6
|
||||
|
||||
export type SessionSearchServerRowEntry = { id: string; node: React.ReactNode }
|
||||
|
||||
/**
|
||||
* Presentation only: the caller has already decided which servers exist and in
|
||||
* what order, so the list owns nothing but the two subheads and the fold.
|
||||
*/
|
||||
export function SessionSearchComputerList({
|
||||
local,
|
||||
servers
|
||||
}: {
|
||||
local: React.ReactNode
|
||||
servers: readonly SessionSearchServerRowEntry[]
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visibleServerCount = VISIBLE_COMPUTER_LIMIT - 1
|
||||
const hiddenCount = Math.max(0, servers.length - visibleServerCount)
|
||||
const shownServers = expanded ? servers : servers.slice(0, visibleServerCount)
|
||||
return (
|
||||
<div>
|
||||
<p className="pt-4 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{translate('sessionHistory.settings.thisComputer', 'This computer')}
|
||||
</p>
|
||||
{local}
|
||||
{servers.length > 0 ? (
|
||||
<p className="pt-4 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{translate('sessionHistory.settings.remoteServers', 'Orca remote servers')}
|
||||
</p>
|
||||
) : null}
|
||||
{shownServers.map((server) => (
|
||||
<div key={server.id}>{server.node}</div>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
{translate('sessionHistory.settings.showFewer', 'Show fewer')}
|
||||
<ChevronUp />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{translate('sessionHistory.settings.showMore', 'Show {{count}} more', {
|
||||
count: hiddenCount
|
||||
})}
|
||||
<ChevronDown />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatMessageCount, formatSessionCount } from './session-search-count-format'
|
||||
|
||||
export const SESSION_SEARCH_SWEEPING_POLL_MS = 2_000
|
||||
export const SESSION_SEARCH_SETTLED_POLL_MS = 10_000
|
||||
@@ -18,27 +19,49 @@ export function sessionSearchPollIntervalMs(status: AiVaultSearchStatus | null):
|
||||
: 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.
|
||||
/** Messages are optional on the wire: a host that predates the field reports sessions only. */
|
||||
function searchableMessage(status: AiVaultSearchStatus): string {
|
||||
const sessions = formatSessionCount(status.filesIndexed)
|
||||
if (status.messagesIndexed === undefined) {
|
||||
return translate(
|
||||
'sessionHistory.status.firstScan',
|
||||
'Preparing search · {{indexed}} sessions so far',
|
||||
'sessionHistory.status.searchableSessions',
|
||||
'{{sessions}} sessions searchable',
|
||||
{
|
||||
indexed: status.filesIndexed
|
||||
sessions
|
||||
}
|
||||
)
|
||||
}
|
||||
const total = status.filesIndexed + status.filesDue + status.filesFailed
|
||||
const percent = total > 0 ? Math.floor((status.filesIndexed / total) * 100) : 0
|
||||
return translate(
|
||||
'sessionHistory.status.progress',
|
||||
'Preparing search · {{percent}}% · {{indexed}} of {{total}} sessions',
|
||||
{ percent, indexed: status.filesIndexed, total }
|
||||
'sessionHistory.status.searchable',
|
||||
'{{sessions}} sessions · {{messages}} messages searchable',
|
||||
{ sessions, messages: formatMessageCount(status.messagesIndexed) }
|
||||
)
|
||||
}
|
||||
|
||||
/** The one status sentence every computer row shows, local or paired server. */
|
||||
/**
|
||||
* A sweep in flight. The denominator is what the pass knows about so far, which
|
||||
* is why it is a plain fraction and never a percentage. Messages are the count
|
||||
* already searchable, not a total: nothing knows how many a file holds until it
|
||||
* is read.
|
||||
*/
|
||||
function catchingUpMessage(status: AiVaultSearchStatus): string {
|
||||
const indexed = formatSessionCount(status.filesIndexed)
|
||||
const total = formatSessionCount(status.filesIndexed + status.filesDue + status.filesFailed)
|
||||
if (status.messagesIndexed === undefined) {
|
||||
return translate(
|
||||
'sessionHistory.status.catchingUpSessions',
|
||||
'{{indexed}} of {{total}} sessions searchable',
|
||||
{ indexed, total }
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'sessionHistory.status.catchingUp',
|
||||
'{{indexed}} of {{total}} sessions · {{messages}} messages searchable',
|
||||
{ indexed, total, messages: formatMessageCount(status.messagesIndexed) }
|
||||
)
|
||||
}
|
||||
|
||||
/** The one status sentence a computer row shows while its search is on. */
|
||||
export function sessionSearchStatusMessage(status: AiVaultSearchStatus): string {
|
||||
if (!status.enabled || status.phase === 'idle' || status.phase === 'closed') {
|
||||
return translate(
|
||||
@@ -46,12 +69,7 @@ export function sessionSearchStatusMessage(status: AiVaultSearchStatus): string
|
||||
'Search is not available on this computer right now.'
|
||||
)
|
||||
}
|
||||
if (isSweepingSessionSearch(status)) {
|
||||
return sweepMessage(status)
|
||||
}
|
||||
return translate('sessionHistory.status.upToDate', 'Ready · {{indexed}} sessions searchable', {
|
||||
indexed: status.filesIndexed
|
||||
})
|
||||
return isSweepingSessionSearch(status) ? catchingUpMessage(status) : searchableMessage(status)
|
||||
}
|
||||
|
||||
/** Lines shown under the status sentence when something needs the user's attention. */
|
||||
@@ -83,10 +101,6 @@ export function sessionSearchCheckingMessage(): string {
|
||||
return translate('sessionHistory.status.checking', 'Checking…')
|
||||
}
|
||||
|
||||
export function sessionSearchOffMessage(): string {
|
||||
return translate('sessionHistory.status.off', 'Off')
|
||||
}
|
||||
|
||||
export function sessionSearchReadErrorMessage(): string {
|
||||
return translate('sessionHistory.status.error', 'Could not check status. Retrying…')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isTurnOnableSessionSearchState,
|
||||
orderSessionSearchServers,
|
||||
sessionSearchSummarySentence,
|
||||
summarizeSessionSearchComputers,
|
||||
type SessionSearchComputerEntry
|
||||
} from './session-search-computer-rollup'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
|
||||
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
|
||||
}))
|
||||
|
||||
const fleet: SessionSearchComputerEntry[] = [
|
||||
{ id: 'local', name: 'Local Mac', state: 'on' },
|
||||
{ id: 'a', name: 'build-01', state: 'on' },
|
||||
{ id: 'b', name: 'gpu-a', state: 'off' },
|
||||
{ id: 'c', name: 'linux 1', state: 'offline' },
|
||||
{ id: 'd', name: 'nas', state: 'offline' },
|
||||
{ id: 'e', name: 'm4 air', state: 'needs-update' },
|
||||
{ id: 'f', name: 'probing', state: 'checking' }
|
||||
]
|
||||
|
||||
it('counts what the user can see and what they could act on', () => {
|
||||
expect(summarizeSessionSearchComputers(fleet)).toEqual({
|
||||
on: 2,
|
||||
total: 7,
|
||||
offline: 2,
|
||||
needUpdate: 1,
|
||||
turnOnable: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('will not offer to turn on a computer it cannot reach or that is too old', () => {
|
||||
expect(isTurnOnableSessionSearchState('off')).toBe(true)
|
||||
for (const state of ['on', 'offline', 'needs-update', 'checking'] as const) {
|
||||
expect(isTurnOnableSessionSearchState(state)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a zero segment out of the sentence rather than printing it', () => {
|
||||
expect(sessionSearchSummarySentence(summarizeSessionSearchComputers(fleet), false)).toBe(
|
||||
'On 2 of 7 computers · 2 offline · 1 need an update'
|
||||
)
|
||||
const onlyLocal = summarizeSessionSearchComputers([fleet[0]])
|
||||
expect(sessionSearchSummarySentence(onlyLocal, false)).toBe('On 1 of 1 computers')
|
||||
})
|
||||
|
||||
it('promises to keep new computers turned on only when that is the standing consent', () => {
|
||||
const summary = summarizeSessionSearchComputers([fleet[0]])
|
||||
expect(sessionSearchSummarySentence(summary, true)).toBe(
|
||||
'On 1 of 1 computers New computers turn on when they can.'
|
||||
)
|
||||
expect(sessionSearchSummarySentence(summary, false)).not.toContain('New computers')
|
||||
})
|
||||
|
||||
it('orders reachable and working first, then by name inside each group', () => {
|
||||
const ordered = orderSessionSearchServers([
|
||||
{ id: 'f', name: 'probing', state: 'checking' },
|
||||
{ id: 'c', name: 'linux 1', state: 'offline' },
|
||||
{ id: 'e', name: 'm4 air', state: 'needs-update' },
|
||||
{ id: 'b', name: 'gpu-a', state: 'off' },
|
||||
{ id: 'a', name: 'build-01', state: 'on' },
|
||||
{ id: 'z', name: 'aa-on', state: 'on' }
|
||||
])
|
||||
expect(ordered.map((entry) => entry.name)).toEqual([
|
||||
'aa-on',
|
||||
'build-01',
|
||||
'gpu-a',
|
||||
'probing',
|
||||
'm4 air',
|
||||
'linux 1'
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/**
|
||||
* What one computer in the pane is doing, as far as this client can tell.
|
||||
*
|
||||
* `checking` is not `offline`: a probe still in flight is no evidence the host
|
||||
* is unreachable, so it is neither counted as offline nor skipped as one.
|
||||
*/
|
||||
export type SessionSearchComputerState = 'on' | 'off' | 'checking' | 'needs-update' | 'offline'
|
||||
|
||||
export type SessionSearchComputerEntry = {
|
||||
id: string
|
||||
name: string
|
||||
state: SessionSearchComputerState
|
||||
}
|
||||
|
||||
export type SessionSearchFleetSummary = {
|
||||
on: number
|
||||
total: number
|
||||
offline: number
|
||||
needUpdate: number
|
||||
/** Reachable, new enough, and still off: exactly what Turn on all would act on. */
|
||||
turnOnable: number
|
||||
}
|
||||
|
||||
export function isTurnOnableSessionSearchState(state: SessionSearchComputerState): boolean {
|
||||
return state === 'off'
|
||||
}
|
||||
|
||||
export function summarizeSessionSearchComputers(
|
||||
entries: readonly SessionSearchComputerEntry[]
|
||||
): SessionSearchFleetSummary {
|
||||
const count = (state: SessionSearchComputerState): number =>
|
||||
entries.filter((entry) => entry.state === state).length
|
||||
return {
|
||||
on: count('on'),
|
||||
total: entries.length,
|
||||
offline: count('offline'),
|
||||
needUpdate: count('needs-update'),
|
||||
turnOnable: entries.filter((entry) => isTurnOnableSessionSearchState(entry.state)).length
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentence above the list. A segment worth zero is left out rather than printed as "0". */
|
||||
export function sessionSearchSummarySentence(
|
||||
summary: SessionSearchFleetSummary,
|
||||
autoEnableNewComputers: boolean
|
||||
): string {
|
||||
const segments = [
|
||||
translate('sessionHistory.settings.summaryOn', 'On {{on}} of {{total}} computers', {
|
||||
on: summary.on,
|
||||
total: summary.total
|
||||
})
|
||||
]
|
||||
if (summary.offline > 0) {
|
||||
segments.push(
|
||||
translate('sessionHistory.settings.summaryOffline', '{{offline}} offline', {
|
||||
offline: summary.offline
|
||||
})
|
||||
)
|
||||
}
|
||||
if (summary.needUpdate > 0) {
|
||||
segments.push(
|
||||
translate('sessionHistory.settings.summaryNeedUpdate', '{{needUpdate}} need an update', {
|
||||
needUpdate: summary.needUpdate
|
||||
})
|
||||
)
|
||||
}
|
||||
const sentence = segments.join(' · ')
|
||||
return autoEnableNewComputers
|
||||
? `${sentence} ${translate('sessionHistory.settings.summaryAutoEnable', 'New computers turn on when they can.')}`
|
||||
: sentence
|
||||
}
|
||||
|
||||
// Reachable and working first, then what the user could act on, then what they cannot.
|
||||
const STATE_RANK: Record<SessionSearchComputerState, number> = {
|
||||
on: 0,
|
||||
off: 1,
|
||||
checking: 1,
|
||||
'needs-update': 2,
|
||||
offline: 3
|
||||
}
|
||||
|
||||
/** Stable order for the server list: by state group, then by name within a group. */
|
||||
export function orderSessionSearchServers<T extends SessionSearchComputerEntry>(
|
||||
entries: readonly T[]
|
||||
): T[] {
|
||||
return [...entries].sort((left, right) => {
|
||||
const byState = STATE_RANK[left.state] - STATE_RANK[right.state]
|
||||
return byState === 0 ? left.name.localeCompare(right.name) : byState
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Counts for the session-search status sentences.
|
||||
*
|
||||
* Sessions keep their grouping separators because a user recognises their own
|
||||
* transcript count; messages run to millions, where the exact figure carries
|
||||
* nothing the compact form does not.
|
||||
*/
|
||||
const sessionFormatter = new Intl.NumberFormat(undefined, { useGrouping: true })
|
||||
const messageFormatter = new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
})
|
||||
|
||||
export function formatSessionCount(sessions: number): string {
|
||||
return sessionFormatter.format(Math.max(0, Math.trunc(sessions)))
|
||||
}
|
||||
|
||||
export function formatMessageCount(messages: number): string {
|
||||
return messageFormatter.format(Math.max(0, Math.trunc(messages)))
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export function renderSessionHistorySettingsSection(
|
||||
return (
|
||||
<SettingsSection
|
||||
id="session-history"
|
||||
title={translate('sessionHistory.settings.title', 'Agent Session History')}
|
||||
title={translate('sessionHistory.settings.title', 'Agent Session Search')}
|
||||
description={translate(
|
||||
'sessionHistory.settings.description',
|
||||
'Search everything your agents have said and done, on this computer and on any paired Orca server.'
|
||||
|
||||
Binary file not shown.
@@ -25,6 +25,7 @@ const current: AiVaultSearchStatus = {
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 12,
|
||||
messagesIndexed: 3_400,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
function poll(active = true, refresh = 0) {
|
||||
@@ -61,25 +62,26 @@ it('keeps polling a settled index so counts stay live between sweeps', async ()
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledWith('local')
|
||||
expect(message(view.result.current.status)).toBe('Ready · 12 sessions searchable')
|
||||
mocks.status.mockResolvedValue({ ...current, filesIndexed: 30 })
|
||||
expect(message(view.result.current.status)).toBe('12 sessions · 3.4K messages searchable')
|
||||
mocks.status.mockResolvedValue({ ...current, filesIndexed: 30, messagesIndexed: 9_000 })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(message(view.result.current.status)).toBe('Ready · 30 sessions searchable')
|
||||
expect(message(view.result.current.status)).toBe('30 sessions · 9K messages searchable')
|
||||
})
|
||||
|
||||
it('reports a first scan by count and later sweeps by percentage', async () => {
|
||||
it('counts a sweep in flight against what it knows about so far', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 4,
|
||||
filesDue: 6,
|
||||
messagesIndexed: 410_000,
|
||||
lastSweepCompletedAt: null
|
||||
})
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(message(view.result.current.status)).toBe('Preparing search · 4 sessions so far')
|
||||
expect(message(view.result.current.status)).toBe('4 of 10 sessions · 410K messages searchable')
|
||||
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([])
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
@@ -87,12 +89,31 @@ it('reports a first scan by count and later sweeps by percentage', async () => {
|
||||
filesIndexed: 4,
|
||||
filesDue: 5,
|
||||
filesFailed: 1,
|
||||
messagesIndexed: 420_000,
|
||||
lastSweepCompletedAt: 1
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
})
|
||||
expect(message(view.result.current.status)).toBe('Preparing search · 40% · 4 of 10 sessions')
|
||||
expect(message(view.result.current.status)).toBe('4 of 10 sessions · 420K messages searchable')
|
||||
})
|
||||
|
||||
it('degrades to a session count when the host is too old to report messages', async () => {
|
||||
const { messagesIndexed: _messagesIndexed, ...withoutMessages } = current
|
||||
mocks.status.mockResolvedValue(withoutMessages)
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(message(view.result.current.status)).toBe('12 sessions searchable')
|
||||
mocks.status.mockResolvedValue({
|
||||
...withoutMessages,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 4,
|
||||
filesDue: 6
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(message(view.result.current.status)).toBe('4 of 10 sessions searchable')
|
||||
})
|
||||
|
||||
it('polls a sweep faster than a settled index', async () => {
|
||||
@@ -106,7 +127,7 @@ it('polls a sweep faster than a settled index', async () => {
|
||||
expect(mocks.status.mock.calls.length - started).toBe(3)
|
||||
})
|
||||
|
||||
it('names unreadable files while degraded and still reports progress', async () => {
|
||||
it('names unreadable files while degraded and still counts what is searchable', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'degraded',
|
||||
@@ -117,7 +138,7 @@ it('names unreadable files while degraded and still reports progress', async ()
|
||||
})
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(message(view.result.current.status)).toBe('Preparing search · 80% · 8 of 10 sessions')
|
||||
expect(message(view.result.current.status)).toBe('8 of 10 sessions · 3.4K messages searchable')
|
||||
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([
|
||||
'1 sessions could not be read and will be retried.',
|
||||
'1 session folders could not be checked.'
|
||||
@@ -134,7 +155,7 @@ it('calls a drained degraded index up to date', async () => {
|
||||
})
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(message(view.result.current.status)).toBe('Ready · 9 sessions searchable')
|
||||
expect(message(view.result.current.status)).toBe('9 sessions · 3.4K messages searchable')
|
||||
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([
|
||||
'2 sessions could not be read and will be retried.'
|
||||
])
|
||||
|
||||
@@ -71,7 +71,7 @@ export function buildWorkflowSettingsSections(
|
||||
},
|
||||
{
|
||||
id: 'session-history',
|
||||
title: translate('sessionHistory.settings.title', 'Agent Session History'),
|
||||
title: translate('sessionHistory.settings.title', 'Agent Session Search'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.description',
|
||||
'Search everything your agents have said and done, on this computer and on any paired Orca server.'
|
||||
@@ -79,7 +79,7 @@ export function buildWorkflowSettingsSections(
|
||||
icon: History,
|
||||
searchEntries: [
|
||||
{
|
||||
title: translate('sessionHistory.settings.indexComputers', 'Search agent sessions'),
|
||||
title: translate('sessionHistory.settings.indexComputers', 'Search inside sessions'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.searchDescription',
|
||||
'Turn on session search for this computer and paired servers, or clear search data.'
|
||||
|
||||
@@ -17945,14 +17945,14 @@
|
||||
"sessionHistory": {
|
||||
"status": {
|
||||
"checking": "Checking…",
|
||||
"off": "Off",
|
||||
"error": "Could not check status. Retrying…",
|
||||
"unavailable": "Search is not available on this computer right now.",
|
||||
"firstScan": "Preparing search · {{indexed}} sessions so far",
|
||||
"progress": "Preparing search · {{percent}}% · {{indexed}} of {{total}} sessions",
|
||||
"upToDate": "Ready · {{indexed}} sessions searchable",
|
||||
"unreadable": "{{failed}} sessions could not be read and will be retried.",
|
||||
"roots": "{{roots}} session folders could not be checked."
|
||||
"roots": "{{roots}} session folders could not be checked.",
|
||||
"searchable": "{{sessions}} sessions · {{messages}} messages searchable",
|
||||
"searchableSessions": "{{sessions}} sessions searchable",
|
||||
"catchingUp": "{{indexed}} of {{total}} sessions · {{messages}} messages searchable",
|
||||
"catchingUpSessions": "{{indexed}} of {{total}} sessions searchable"
|
||||
},
|
||||
"settings": {
|
||||
"saveError": "Could not save. Try again.",
|
||||
@@ -17968,22 +17968,34 @@
|
||||
"cleared": "Search data cleared.",
|
||||
"clearError": "Could not clear search data. Try again.",
|
||||
"webUnsupported": "Turn on session search from the Orca desktop app on that computer.",
|
||||
"title": "Agent Session History",
|
||||
"title": "Agent Session Search",
|
||||
"description": "Search everything your agents have said and done, on this computer and on any paired Orca server.",
|
||||
"searchDescription": "Turn on session search for this computer and paired servers, or clear search data.",
|
||||
"indexComputers": "Search agent sessions",
|
||||
"indexComputers": "Search inside sessions",
|
||||
"computersConsent": "Each computer keeps a searchable copy of its own agent conversations and tool output. Nothing leaves that computer.",
|
||||
"serverVersion": "Orca v{{version}}",
|
||||
"rowSwitchLabel": "Search sessions on {{host}}",
|
||||
"serverOffline": "Offline",
|
||||
"serverTooOld": "Needs a newer version of Orca.",
|
||||
"updateServer": "Update server",
|
||||
"serverOff": "Off",
|
||||
"serverEnableTitle": "Turn on session search on {{host}}?",
|
||||
"serverEnableConsent": "Orca will make the agent conversations and tool output on {{host}} searchable from Agent Session History. The searchable copy stays on {{host}}; results are sent to this computer when you search. The first pass runs in the background and can take a few minutes.",
|
||||
"serverToggleError": "Could not change session search on {{host}}. Try again.",
|
||||
"clearedAndTurnedOff": "Search turned off and search data cleared.",
|
||||
"panelHint": "Search from the Agent Session History panel in the sidebar."
|
||||
"thisComputer": "This computer",
|
||||
"remoteServers": "Orca remote servers",
|
||||
"summaryOn": "On {{on}} of {{total}} computers",
|
||||
"summaryOffline": "{{offline}} offline",
|
||||
"summaryNeedUpdate": "{{needUpdate}} need an update",
|
||||
"summaryAutoEnable": "New computers turn on when they can.",
|
||||
"turnOnAll": "Turn on all",
|
||||
"enableAllTitle": "Turn on session search on every computer?",
|
||||
"enableAllConsent": "Orca will make the agent conversations and tool output on this computer and on every reachable paired server searchable from Agent Session History. Each searchable copy stays on the computer that made it; results are sent here when you search. Offline servers and servers that need an update are skipped. The first pass runs in the background and can take a few minutes.",
|
||||
"showMore": "Show {{count}} more",
|
||||
"showFewer": "Show fewer",
|
||||
"openInSidebar": "Open in the sidebar",
|
||||
"openInSidebarCopy": "Type what you remember, or ask an agent: “find the session where we fixed the login timeout.”",
|
||||
"open": "Open"
|
||||
}
|
||||
},
|
||||
"aiVault": {
|
||||
|
||||
@@ -491,6 +491,13 @@ export type GlobalSettings = {
|
||||
voice?: VoiceSettings
|
||||
/** Transcript full-text search consent + retention. Absent means off; nothing indexes until the user opts in. */
|
||||
aiVaultSearch?: AiVaultSearchSettings
|
||||
/**
|
||||
* Standing consent from "Turn on all": a paired server that becomes reachable
|
||||
* and new enough gets session search turned on without another dialog. Kept
|
||||
* out of `aiVaultSearch` because it changes no indexer configuration, so it
|
||||
* must never close and reconstruct one. Cleared when a server is turned off by hand.
|
||||
*/
|
||||
aiVaultSearchAutoEnableNewComputers?: boolean
|
||||
}
|
||||
|
||||
export type OrcaWorkspaceLayout = {
|
||||
|
||||
Reference in New Issue
Block a user