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 (#20887)
* feat(session-search): add ranked history panel search and consent * test: wait for initial session indexing before refreshing results * feat(session-history): add local search settings and index controls * Use shared local host identifier for session index status * feat(session-search): merge all-computers search across hosts The `all` scope on `aiVault:searchSessions` now fans out from the desktop to every host the session list enumerates and merges the pages into one. Legs run in parallel: the local index through the search service, SSH and runtime hosts through the existing remote search client. Two fixed orders, because relevance scores from independent indexes are not comparable. `newest` asks every leg for recency and k-way merges on `updatedAt`, nulls last, ties broken on execution host id. `relevance` rotates hosts in host-id order by their own rank. The merged cursor is an opaque base64url payload holding each host's cursor, how many of its current page were already emitted, and the generation that offset counts into, plus the page size and sort the cursor belongs to. A host whose index moved is fenced to `stale` and stops contributing; the rest keep paging. Per-host outcomes ride back on one new optional `hosts` field on the results response. `aiVault:searchStatus` with `all` stays refused, and neither the runtime RPC nor the CLI gains the scope, so a fan-out is never two hops. * fix(preload): let the search bridge address the all-computers scope * feat(settings): live index status, enable confirm, advanced delete * feat(session-search): search every computer from the history panel The panel's "All computers" scope produced no request: the hook parsed the scope into a single host id and stopped when that was null, so the panel answered "Choose one computer to search its sessions." The desktop already merges every enumerated host behind `aiVault:searchSessions`, so pass the scope straight through and stamp each hit with the host it came back on. Hosts the merge could not search are named under the results header with a short reason, since a silent partial answer reads as "no such session". (cherry picked from commitc6b9179316) * feat(session-search): enable indexing on paired servers from a client Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's transcript index on or off and have the server apply it without a restart. The runtime method refuses any caller without a `pairedDeviceId` with a `forbidden`-class error, writes the whole resolved policy through the runtime store so retention rides along untouched, then reaches the index through a host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the in-process instance's new `apply` on orcad. The relay is unchanged. Wire compatibility is Rule 1 shaped: a new optional method. A server that predates it answers method-not-found, which the desktop IPC handler maps to an error whose message is exactly `host-too-old`. Old clients never call it. The method is deliberately absent from the mobile allowlist, and `aiVaultSearch` stays out of the paired settings projection. (cherry picked from commit640c715fbd) * fix(session-search): report a paired server without session search as host-too-old on status reads (cherry picked from commit1463e8a4bc) * 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. (cherry picked from commit0497e6fe93) * fix(settings): treat a host-too-old status read as an outdated server (cherry picked from commita3d751f6e7) * fix(settings): turn search off before deleting its index Delete index cleared the index while search was on, so the host closed, removed and immediately reconstructed it and everything reindexed. Turn local search off first, then clear, so the rebuild only happens when the user switches search back on. (cherry picked from commit0515588681) * feat(settings): product-facing copy for session search Say search, not index or transcript; lead with what the user gets and where it shows up; one plain privacy sentence; count sessions, not files; drop the mechanics that change no decision (stop hint, SSH note, source roots jargon). (cherry picked from commit267af1afb3) * fix(settings): let Button and Collapsible own their spacing and type * fix(settings): let Button and Collapsible own their spacing and type * feat(session-search): report how many messages an index holds The status contract gains an optional messagesIndexed, read from the store beside the file-state counts and cached the same way, so a settings row can say what is searchable rather than how many files were opened. Optional on the wire: a paired server that predates the field degrades to a session count. * feat(right-sidebar): let a caller open the session panel ready to type showAiVaultSearch opens the sidebar on the vault tab and sets one flag. The panel takes the flag, widens its scope to every computer, focuses the search box and clears the flag, so a later remount stays where the user left it. * 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. * fix(settings): hide the fleet roll-up when no server is paired With only this computer, the count, the Turn on all button and the two subheads all restate the single switch under them. Show them once a paired Orca server exists, which is the first point at which they say anything. * fix(settings): turn session search on without a confirmation dialog Each switch and Turn on all now act on the click. The dialogs restated the row they sat under and stood between the user and a preference they can reverse with the same control. Clearing search data keeps its dialog: that one destroys something. * fix(settings): say how many computers Turn on would reach Drops the summary sentence: every row already states whether it is offline or needs an update, so counting those again above the list said nothing new. What is left is the one thing the list cannot say, the size of the action, carried by the button's own label. With nothing left to turn on, the standing consent speaks in its place, and only when it is armed. * Revert "fix(settings): say how many computers Turn on would reach" This reverts commit42a4320ae1. The roll-up row's design is still open, so the branch keeps the summary sentence and the plain Turn on all button until it is settled. The dialog removal in30b0786cc6stands. * feat(settings): offer one stateless Enable on all computers button The row above the list is now just that button. It appears when a paired server is reachable, new enough and off, acts on exactly those plus this computer, and disappears when there is nothing left to do. What it offers is read off the rows each render, so it cannot disagree with them. Deletes the standing auto-enable consent with it: the persisted flag, the code that armed and cleared it, the per-host memory of which switches the user had touched, and the line that promised future computers would turn themselves on. A preference that acts on hosts the user never sees is worse than a button they press when they mean it. * fix(right-sidebar): keep the focus-request callback out of render React Doctor flagged the ref written during render; useEffectEvent is the codebase's pattern for a latest-callback the effect reads.
This commit is contained in:
@@ -61,6 +61,14 @@ function newIndexer(
|
||||
return indexer
|
||||
}
|
||||
|
||||
/** What the index holds, counted the way `status()` counts it. */
|
||||
function indexedMessageCount(): number {
|
||||
const row = harness.read((db: SyncDatabase) =>
|
||||
db.prepare('SELECT count(*) AS n FROM messages').get()
|
||||
)
|
||||
return row && typeof row === 'object' && 'n' in row && typeof row.n === 'number' ? row.n : -1
|
||||
}
|
||||
|
||||
/** Sessions a published-view read returns for one term, the only legal shape. */
|
||||
function sessionsMatching(term: string): string[] {
|
||||
return harness.read((db: SyncDatabase) =>
|
||||
@@ -256,6 +264,9 @@ it('resumes after close and reopen without re-reading what it already indexed',
|
||||
// `filesIndexed` is the count of rows the index holds at their current stat,
|
||||
// so it stays 2. That nothing was opened again is the read loop's own test.
|
||||
expect(reopened.status()).toMatchObject({ filesIndexed: 2, filesDue: 0 })
|
||||
// The same number the pane shows as "messages searchable", read from the rows rather than counted as they land.
|
||||
expect(indexedMessageCount()).toBeGreaterThan(0)
|
||||
expect(reopened.status().messagesIndexed).toBe(indexedMessageCount())
|
||||
expect(
|
||||
harness.read((db: SyncDatabase) => db.prepare('SELECT count(*) AS n FROM messages').get())
|
||||
).toEqual(indexedRows)
|
||||
|
||||
@@ -40,6 +40,8 @@ export type SessionSearchIndexStatus = {
|
||||
filesDue: number
|
||||
/** Rows whose last read did not commit. */
|
||||
filesFailed: number
|
||||
/** Messages the index holds across every indexed row. */
|
||||
messagesIndexed: number
|
||||
degradedRoots: SessionSearchDegradedRoot[]
|
||||
lastReconcileAt: number | null
|
||||
/** When a whole-machine sweep last finished; null until one has. */
|
||||
@@ -198,13 +200,15 @@ export class SessionSearchIndexer {
|
||||
current: 0,
|
||||
due: 0,
|
||||
failed: 0,
|
||||
sessionsByAgent: {}
|
||||
sessionsByAgent: {},
|
||||
messages: 0
|
||||
}
|
||||
return {
|
||||
phase: this.phase(settled),
|
||||
filesIndexed: settled.current,
|
||||
filesDue: settled.due + this.left,
|
||||
filesFailed: settled.failed,
|
||||
messagesIndexed: settled.messages,
|
||||
degradedRoots: this.degradedRoots.map((root) => ({ ...root })),
|
||||
lastReconcileAt: this.lastReconcileAt,
|
||||
lastSweepCompletedAt: this.lastSweepCompletedAt,
|
||||
|
||||
@@ -24,7 +24,8 @@ async function fixture() {
|
||||
// degradedRoots is re-stated because the contract type leaves `root` optional
|
||||
// for relay redaction, while the indexer always names the root it degraded.
|
||||
const indexer = {
|
||||
status: () => ({ ...status, degradedRoots: [], sessionsByAgent: {} }),
|
||||
// messagesIndexed is optional on the wire and required of an indexer, which has read the rows.
|
||||
status: () => ({ ...status, messagesIndexed: 0, degradedRoots: [], sessionsByAgent: {} }),
|
||||
reconcile: vi.fn(async () => {})
|
||||
}
|
||||
const service = createSessionSearchService({ engine: harness.engine, indexer })
|
||||
|
||||
@@ -42,7 +42,7 @@ export type SessionSearchFileRow = {
|
||||
failedMtimeMs: number | null
|
||||
}
|
||||
|
||||
/** How many rows are in each state; the whole of the indexer's progress report. */
|
||||
/** How many rows are in each state, sessions per agent, and the indexed message total; the whole of the indexer's progress report. */
|
||||
export type SessionSearchStateCounts = {
|
||||
current: number
|
||||
due: number
|
||||
@@ -56,6 +56,7 @@ export type SessionSearchStateCounts = {
|
||||
* file-state count could show it.
|
||||
*/
|
||||
sessionsByAgent: Record<string, number>
|
||||
messages: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +281,7 @@ export class SessionSearchStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows per state. The status is this query and the pass's own degraded roots. */
|
||||
/** Rows per state and indexed messages. The status is these queries and the pass's own degraded roots. */
|
||||
stateCounts(): SessionSearchStateCounts {
|
||||
const rows = this.db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() as {
|
||||
state: SessionSearchFileState
|
||||
@@ -290,11 +291,13 @@ export class SessionSearchStore {
|
||||
current: 0,
|
||||
due: 0,
|
||||
failed: 0,
|
||||
sessionsByAgent: this.sessionsByAgent()
|
||||
sessionsByAgent: this.sessionsByAgent(),
|
||||
messages: 0
|
||||
}
|
||||
for (const row of rows) {
|
||||
counts[row.state] = Number(row.n)
|
||||
}
|
||||
counts.messages = this.messageCount()
|
||||
return counts
|
||||
}
|
||||
|
||||
@@ -314,6 +317,15 @@ export class SessionSearchStore {
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Messages the index holds. Read with the file states so both describe one moment. */
|
||||
private messageCount(): number {
|
||||
const row: unknown = this.db.prepare('SELECT count(*) AS n FROM messages').get()
|
||||
if (row && typeof row === 'object' && 'n' in row && typeof row.n === 'number') {
|
||||
return row.n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a source's rows. Only a proven deletion may call this: an unreadable
|
||||
* source is `unverifiable`, not `missing`, and keeps its rows
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
buildRuntimeAiVaultHostScopeOptions,
|
||||
useAiVaultExecutionHostScope
|
||||
} from './ai-vault-host-scope'
|
||||
import { useAiVaultSearchFocusRequest } from './use-ai-vault-search-focus-request'
|
||||
import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options'
|
||||
import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog'
|
||||
import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners'
|
||||
@@ -298,6 +299,11 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
setScope(nextScope)
|
||||
}, [])
|
||||
|
||||
// Settings asks for "everything, ready to type".
|
||||
const focusSearchRequestId = useAiVaultSearchFocusRequest(
|
||||
useCallback(() => handleScopeChange('all'), [handleScopeChange])
|
||||
)
|
||||
|
||||
const toggleGroup = useCallback((key: string) => {
|
||||
setCollapsedGroups((current) => {
|
||||
const next = new Set(current)
|
||||
@@ -332,6 +338,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
hideEmptySessions={hideEmptySessions}
|
||||
sessionLimit={sessionLimit}
|
||||
adjustmentCount={viewAdjustmentCount}
|
||||
focusSearchRequestId={focusSearchRequestId}
|
||||
onQueryChange={setQuery}
|
||||
onScopeChange={handleScopeChange}
|
||||
onExecutionHostScopeChange={onExecutionHostScopeChange}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
|
||||
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
|
||||
}))
|
||||
vi.mock('./AiVaultPanelControls', () => ({
|
||||
VaultHostScopeMenu: () => null,
|
||||
VaultScopeSwitch: () => null,
|
||||
VaultViewMenu: () => null
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function header(focusSearchRequestId: number) {
|
||||
return render(
|
||||
<AiVaultPanelHeader
|
||||
query=""
|
||||
loading={false}
|
||||
shownCount={0}
|
||||
sessionCount={0}
|
||||
hasScanResult={false}
|
||||
activeWorktreePath={null}
|
||||
activeProjectKey={null}
|
||||
scope="all"
|
||||
executionHostScope="local"
|
||||
hostScopeOptions={[]}
|
||||
agents={[]}
|
||||
sort="updated"
|
||||
group="project"
|
||||
hideEmptySessions={false}
|
||||
sessionLimit={250}
|
||||
adjustmentCount={0}
|
||||
focusSearchRequestId={focusSearchRequestId}
|
||||
onQueryChange={vi.fn()}
|
||||
onScopeChange={vi.fn()}
|
||||
onExecutionHostScopeChange={vi.fn()}
|
||||
onAgentEnabledChange={vi.fn()}
|
||||
onAllAgentsEnabledChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onGroupChange={vi.fn()}
|
||||
onHideEmptySessionsChange={vi.fn()}
|
||||
onSessionLimitChange={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
it('leaves focus alone until someone asks for the search box', () => {
|
||||
header(0)
|
||||
expect(screen.getByRole('textbox', { name: 'Search sessions' })).not.toHaveFocus()
|
||||
})
|
||||
|
||||
it('focuses the search box when Settings sends the user here', () => {
|
||||
header(1)
|
||||
expect(screen.getByRole('textbox', { name: 'Search sessions' })).toHaveFocus()
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { LoaderCircle, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -30,6 +31,8 @@ type AiVaultPanelHeaderProps = {
|
||||
hideEmptySessions: boolean
|
||||
sessionLimit: AiVaultSessionLimit
|
||||
adjustmentCount: number
|
||||
/** Bumped by a caller that sent the user here, e.g. Settings; focuses the search box once. */
|
||||
focusSearchRequestId?: number
|
||||
onQueryChange: (query: string) => void
|
||||
onScopeChange: (scope: AiVaultScope) => void
|
||||
onExecutionHostScopeChange: (scope: ExecutionHostScope) => void
|
||||
@@ -61,6 +64,7 @@ export function AiVaultPanelHeader({
|
||||
hideEmptySessions,
|
||||
sessionLimit,
|
||||
adjustmentCount,
|
||||
focusSearchRequestId = 0,
|
||||
onQueryChange,
|
||||
onScopeChange,
|
||||
onExecutionHostScopeChange,
|
||||
@@ -73,6 +77,13 @@ export function AiVaultPanelHeader({
|
||||
onReset,
|
||||
onRefresh
|
||||
}: AiVaultPanelHeaderProps): React.JSX.Element {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (focusSearchRequestId > 0) {
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
}, [focusSearchRequestId])
|
||||
return (
|
||||
<div className="shrink-0 border-b border-sidebar-border px-2.5 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -173,6 +184,7 @@ export function AiVaultPanelHeader({
|
||||
<div className="mt-2 flex h-8 items-center gap-1.5 rounded-md border border-sidebar-border bg-input/50 px-2 focus-within:border-sidebar-ring focus-within:ring-[2px] focus-within:ring-sidebar-ring/30">
|
||||
<Search className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={translate(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAiVaultSearchFocusRequest } from './use-ai-vault-search-focus-request'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.getState().clearAiVaultSearchFocusRequest()
|
||||
})
|
||||
|
||||
it('opens the sidebar on the session panel when Settings asks for it', () => {
|
||||
act(() => {
|
||||
useAppStore.getState().showAiVaultSearch()
|
||||
})
|
||||
const state = useAppStore.getState()
|
||||
expect(state.rightSidebarOpen).toBe(true)
|
||||
expect(state.rightSidebarTab).toBe('vault')
|
||||
expect(state.aiVaultSearchFocusRequested).toBe(true)
|
||||
})
|
||||
|
||||
it('widens the scope once and clears the request so a remount stays put', () => {
|
||||
const onRequest = vi.fn()
|
||||
const view = renderHook(() => useAiVaultSearchFocusRequest(onRequest))
|
||||
expect(view.result.current).toBe(0)
|
||||
act(() => {
|
||||
useAppStore.getState().showAiVaultSearch()
|
||||
})
|
||||
expect(onRequest).toHaveBeenCalledOnce()
|
||||
expect(view.result.current).toBe(1)
|
||||
expect(useAppStore.getState().aiVaultSearchFocusRequested).toBe(false)
|
||||
|
||||
view.unmount()
|
||||
const remounted = renderHook(() => useAiVaultSearchFocusRequest(onRequest))
|
||||
expect(onRequest).toHaveBeenCalledOnce()
|
||||
expect(remounted.result.current).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useEffectEvent, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
/**
|
||||
* Consumes Settings' "open the panel, ready to type" request.
|
||||
*
|
||||
* Returns an id the header focuses its box on, and runs `onRequest` once so the
|
||||
* panel can widen its own scope. The request is cleared as it is taken, so a
|
||||
* later remount of the panel stays where the user left it.
|
||||
*/
|
||||
export function useAiVaultSearchFocusRequest(onRequest: () => void): number {
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const requested = useAppStore((state) => state.aiVaultSearchFocusRequested)
|
||||
const clearRequest = useAppStore((state) => state.clearAiVaultSearchFocusRequest)
|
||||
const runRequest = useEffectEvent(onRequest)
|
||||
|
||||
useEffect(() => {
|
||||
if (!requested) {
|
||||
return
|
||||
}
|
||||
runRequest()
|
||||
setFocusRequestId((value) => value + 1)
|
||||
clearRequest()
|
||||
}, [clearRequest, requested])
|
||||
|
||||
return focusRequestId
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// @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'
|
||||
import type { SessionSearchComputerState } from './session-search-computer-rollup'
|
||||
|
||||
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,
|
||||
messagesIndexed: 1_400_000,
|
||||
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(),
|
||||
onStateChange?: (environmentId: string, state: SessionSearchComputerState) => void
|
||||
) {
|
||||
return render(
|
||||
<ConfirmationDialogContext.Provider value={confirm}>
|
||||
<SessionHistoryServerRow
|
||||
environment={environment}
|
||||
details={details}
|
||||
onError={onError}
|
||||
{...(onStateChange ? { onStateChange } : {})}
|
||||
/>
|
||||
</ConfirmationDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
const serverSwitch = (): HTMLElement =>
|
||||
screen.getByRole('switch', { name: 'Search 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="12 sessions · 1.4K messages searchable"
|
||||
details={['2 sessions 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('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')
|
||||
})
|
||||
|
||||
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('4,880 sessions · 1.4M messages searchable')
|
||||
expect(screen.getByText('Orca v1.4.202')).toBeInTheDocument()
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
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.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',
|
||||
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')
|
||||
expect(serverSwitch()).toBeDisabled()
|
||||
expect(serverSwitch()).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('turns a server on straight from its switch, with nothing to confirm', 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).not.toHaveBeenCalled()
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:env-1', true)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('4,880 sessions · 1.4M messages searchable')
|
||||
})
|
||||
|
||||
it('turns a server off straight from its switch', 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.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
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('Needs a newer version of Orca.')
|
||||
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,90 @@
|
||||
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 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">
|
||||
<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 align-baseline"
|
||||
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',
|
||||
'Search sessions on {{host}}',
|
||||
{
|
||||
host: name
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import { SessionHistoryIndexStatus } from './SessionHistoryIndexStatus'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ visible: true, status: vi.fn() }))
|
||||
vi.mock('@/hooks/use-window-stream-visibility', () => ({
|
||||
useWindowStreamVisible: () => mocks.visible
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
|
||||
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
|
||||
}))
|
||||
|
||||
const current: AiVaultSearchStatus = {
|
||||
...unavailableSessionSearchStatus(),
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 12,
|
||||
lastSweepCompletedAt: 1
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.visible = true
|
||||
mocks.status.mockReset().mockResolvedValue(current)
|
||||
vi.stubGlobal('api', undefined)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { aiVault: { searchStatus: mocks.status } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps polling a settled index so counts stay live between sweeps', async () => {
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledWith('local')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
mocks.status.mockResolvedValue({ ...current, filesIndexed: 30 })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 30 files indexed')
|
||||
})
|
||||
|
||||
it('reports a first scan by count and later sweeps by percentage', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 4,
|
||||
filesDue: 6,
|
||||
lastSweepCompletedAt: null
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Indexing… 4 files so far')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Turn off search to stop')
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 4,
|
||||
filesDue: 5,
|
||||
filesFailed: 1,
|
||||
lastSweepCompletedAt: 1
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Indexing · 40% · 4 of 10 files')
|
||||
})
|
||||
|
||||
it('polls a sweep faster than a settled index', async () => {
|
||||
mocks.status.mockResolvedValue({ ...current, phase: 'indexing', filesDue: 3 })
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
const started = mocks.status.mock.calls.length
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
})
|
||||
expect(mocks.status.mock.calls.length - started).toBe(3)
|
||||
})
|
||||
|
||||
it('names unreadable files while degraded and still reports progress', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'degraded',
|
||||
filesIndexed: 8,
|
||||
filesDue: 1,
|
||||
filesFailed: 1,
|
||||
degradedRoots: [{ reason: 'unreadable' }]
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('Indexing · 80% · 8 of 10 files')
|
||||
expect(status).toHaveTextContent('1 files could not be read and will be retried.')
|
||||
expect(status).toHaveTextContent('Unverified source roots: 1')
|
||||
})
|
||||
|
||||
it('calls a drained degraded index up to date', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'degraded',
|
||||
filesIndexed: 9,
|
||||
filesDue: 0,
|
||||
filesFailed: 2
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 9 files indexed')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('2 files could not be read')
|
||||
expect(screen.queryByText(/Turn off search to stop/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no refresh control now that status is live', async () => {
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not describe an absent service as an empty current index', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'not ready or the search service is unavailable'
|
||||
)
|
||||
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recovers on its own after a failed read', async () => {
|
||||
mocks.status.mockRejectedValueOnce(new Error('offline'))
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
})
|
||||
|
||||
it('handles a synchronous bridge failure without losing the poll', async () => {
|
||||
mocks.status.mockImplementationOnce(() => {
|
||||
throw new Error('bridge unavailable')
|
||||
})
|
||||
render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Could not read index status')
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
})
|
||||
|
||||
it('fences pending responses across disable and hiding', async () => {
|
||||
let answer: (value: AiVaultSearchStatus) => void = () => undefined
|
||||
mocks.status.mockReturnValue(
|
||||
new Promise<AiVaultSearchStatus>((resolve) => {
|
||||
answer = resolve
|
||||
})
|
||||
)
|
||||
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
view.rerender(<SessionHistoryIndexStatus enabled={false} refresh={0} />)
|
||||
await act(async () => {
|
||||
answer(current)
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Search is off')
|
||||
expect(screen.queryByText(/files indexed/)).not.toBeInTheDocument()
|
||||
mocks.visible = false
|
||||
view.rerender(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1)
|
||||
mocks.visible = true
|
||||
mocks.status.mockResolvedValue(current)
|
||||
view.rerender(<SessionHistoryIndexStatus enabled refresh={1} />)
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not overlap slow status requests and stops polling on unmount', async () => {
|
||||
let answer: (value: AiVaultSearchStatus) => void = () => undefined
|
||||
mocks.status.mockReturnValue(
|
||||
new Promise<AiVaultSearchStatus>((resolve) => {
|
||||
answer = resolve
|
||||
})
|
||||
)
|
||||
const view = render(<SessionHistoryIndexStatus enabled refresh={0} />)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
answer({ ...current, phase: 'indexing', filesDue: 2 })
|
||||
})
|
||||
const beforeUnmount = mocks.status.mock.calls.length
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(beforeUnmount)
|
||||
})
|
||||
@@ -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,180 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
|
||||
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 type { SessionSearchComputerState } from './session-search-computer-rollup'
|
||||
import {
|
||||
isHostTooOldError,
|
||||
sessionSearchCheckingMessage,
|
||||
sessionSearchReadErrorMessage,
|
||||
sessionSearchStatusDetails,
|
||||
sessionSearchStatusMessage
|
||||
} from './session-history-status-copy'
|
||||
import { useSessionSearchStatus } from './use-session-search-status'
|
||||
|
||||
export function SessionHistoryServerRow({
|
||||
environment,
|
||||
details,
|
||||
refresh = 0,
|
||||
onError,
|
||||
onStateChange
|
||||
}: {
|
||||
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
|
||||
}): React.JSX.Element {
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
const mounted = useMountedRef()
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const [tooOldOnSet, setTooOldOnSet] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const connectionState = getRuntimeServerConnectionState(details)
|
||||
const connected = isRuntimeServerTransportConnected(connectionState)
|
||||
const { status, failed, hostTooOld, adopt } = useSessionSearchStatus({
|
||||
executionHostId: hostId,
|
||||
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)
|
||||
onError(null)
|
||||
try {
|
||||
adopt(await window.api.aiVault.setSearchEnabled(hostId, next))
|
||||
} catch (error) {
|
||||
if (!mounted.current) {
|
||||
return
|
||||
}
|
||||
if (isHostTooOldError(error)) {
|
||||
setTooOldOnSet(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(): Promise<void> {
|
||||
return setEnabled(!enabled)
|
||||
}
|
||||
|
||||
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', 'Needs a newer version of Orca.')}
|
||||
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')
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// 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) : undefined
|
||||
}
|
||||
return (
|
||||
<SessionHistoryComputerRow
|
||||
{...row}
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
{...(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'
|
||||
}
|
||||
@@ -2,13 +2,49 @@
|
||||
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 { toast } from 'sonner'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
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 }[] = []
|
||||
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,
|
||||
details,
|
||||
closeSettingsPage: vi.fn(),
|
||||
showAiVaultSearch: vi.fn()
|
||||
}
|
||||
})
|
||||
vi.mock('./use-runtime-environment-catalog', () => ({
|
||||
useRuntimeEnvironmentCatalog: () => ({
|
||||
environments: mocks.environments,
|
||||
isLoading: false,
|
||||
detailsByEnvironmentId: mocks.details,
|
||||
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(),
|
||||
closeSettingsPage: mocks.closeSettingsPage,
|
||||
showAiVaultSearch: mocks.showAiVaultSearch
|
||||
})
|
||||
}))
|
||||
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
|
||||
vi.mock('@/hooks/use-window-stream-visibility', () => ({
|
||||
useWindowStreamVisible: () => mocks.visible
|
||||
@@ -37,6 +73,42 @@ function pane(
|
||||
</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 enableAllButton = () => screen.queryByRole('button', { name: 'Enable on all computers' })
|
||||
async function openAdvanced(): Promise<void> {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Advanced/ }))
|
||||
@@ -47,18 +119,33 @@ 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)
|
||||
vi.mocked(toast.success).mockClear()
|
||||
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(() => {
|
||||
@@ -67,12 +154,12 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('requires opt-in and saves the existing policy without touching transcripts or polling while off', async () => {
|
||||
it('turns search on from the switch alone, touching no transcript while it is off', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
pane(false, confirm, save)
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByText(/Content is not redacted/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Nothing leaves that computer/)).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
@@ -80,27 +167,21 @@ it('requires opt-in and saves the existing policy without touching transcripts o
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Start indexing agent sessions?',
|
||||
description: expect.stringContaining('content is not redacted'),
|
||||
confirmLabel: 'Start indexing'
|
||||
})
|
||||
)
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
|
||||
})
|
||||
|
||||
it('leaves search off when the indexing consent is declined', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(false, vi.fn().mockResolvedValue(false), save)
|
||||
it('sends the user to the sidebar panel with one click', async () => {
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
|
||||
})
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(mocks.showAiVaultSearch).toHaveBeenCalledOnce()
|
||||
expect(mocks.closeSettingsPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('turns search off without asking again', async () => {
|
||||
it('turns search off from the switch alone', async () => {
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(true, confirm, save)
|
||||
@@ -133,10 +214,10 @@ it('shows failed saves inline and unlocks controls', async () => {
|
||||
|
||||
it('hides the delete control behind Advanced', async () => {
|
||||
pane(false)
|
||||
expect(screen.queryByRole('button', { name: 'Delete index' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()
|
||||
await openAdvanced()
|
||||
expect(screen.getByRole('button', { name: 'Delete index' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/Search stays off/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/Removes the searchable copy/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes only after confirmation, supports deleting while disabled, and reports failures', async () => {
|
||||
@@ -144,25 +225,68 @@ it('deletes only after confirmation, supports deleting while disabled, and repor
|
||||
pane(false, confirm)
|
||||
await openAdvanced()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
})
|
||||
expect(mocks.clear).not.toHaveBeenCalled()
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Delete this computer’s search index?',
|
||||
description: expect.stringContaining('Search stays off'),
|
||||
confirmLabel: 'Delete index'
|
||||
title: 'Clear search data on this computer?',
|
||||
description: expect.stringContaining('Removes the searchable copy'),
|
||||
confirmLabel: 'Clear'
|
||||
})
|
||||
)
|
||||
confirm.mockResolvedValue(true)
|
||||
mocks.clear.mockRejectedValue(new Error('service unavailable'))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
})
|
||||
expect(mocks.clear).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not clear')
|
||||
})
|
||||
|
||||
it('turns search off before deleting so the host does not rebuild the index', async () => {
|
||||
const order: string[] = []
|
||||
const save = vi.fn().mockImplementation(async () => {
|
||||
order.push('save')
|
||||
})
|
||||
mocks.clear.mockImplementation(async () => {
|
||||
order.push('clear')
|
||||
})
|
||||
pane(true, vi.fn().mockResolvedValue(true), save)
|
||||
await openAdvanced()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
})
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: false, historyDays: null } })
|
||||
expect(order).toEqual(['save', 'clear'])
|
||||
expect(screen.getAllByText(/Turns off search and removes/).length).toBeGreaterThan(0)
|
||||
expect(toast.success).toHaveBeenCalledWith('Search turned off and search data cleared.')
|
||||
})
|
||||
|
||||
it('deletes without a settings write when search is already off', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
pane(false, vi.fn().mockResolvedValue(true), save)
|
||||
await openAdvanced()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
})
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(mocks.clear).toHaveBeenCalledOnce()
|
||||
expect(toast.success).toHaveBeenCalledWith('Search data cleared.')
|
||||
})
|
||||
|
||||
it('keeps the index when turning search off fails', async () => {
|
||||
const save = vi.fn().mockRejectedValue(new Error('write failed'))
|
||||
pane(true, vi.fn().mockResolvedValue(true), save)
|
||||
await openAdvanced()
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
})
|
||||
expect(mocks.clear).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not save')
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('does not execute a confirmation after navigating away', async () => {
|
||||
let accept: (value: boolean) => void = () => undefined
|
||||
const confirmation = new Promise<boolean>((resolve) => {
|
||||
@@ -170,7 +294,7 @@ it('does not execute a confirmation after navigating away', async () => {
|
||||
})
|
||||
const view = pane(false, vi.fn().mockReturnValue(confirmation))
|
||||
await openAdvanced()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete index' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
accept(true)
|
||||
@@ -183,7 +307,7 @@ it('leaves paired-client controls unsupported without local calls', async () =>
|
||||
pane(true)
|
||||
expect(screen.getByRole('switch')).toBeDisabled()
|
||||
await openAdvanced()
|
||||
expect(screen.getByRole('button', { name: 'Delete index' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeDisabled()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
@@ -199,12 +323,209 @@ 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('Up to date · 12 files indexed')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('12 sessions · 3.4K messages searchable')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Up to date · 12 files indexed')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('12 sessions · 3.4K messages searchable')
|
||||
await act(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: '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('This computer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Orca remote servers')).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(enableAllButton()).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('leaves a lone computer to its own switch, with no roll-up above it', async () => {
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
||||
expect(enableAllButton()).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('This computer')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Orca remote servers')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the button only while a paired server is reachable and off', async () => {
|
||||
mixedFleet()
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
expect(enableAllButton()).toBeInTheDocument()
|
||||
|
||||
// gpu-a was the only eligible one; with it on, the offline and too-old rows leave nothing to do.
|
||||
mocks.statusByHost = { ...mocks.statusByHost, 'runtime:off': current }
|
||||
statusByHost()
|
||||
cleanup()
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
expect(enableAllButton()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer the button for a server whose state is still unknown', async () => {
|
||||
mocks.environments = [{ id: 'a', name: 'gpu-a' }]
|
||||
mocks.details = {}
|
||||
mocks.statusByHost = { local: current }
|
||||
statusByHost()
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
expect(enableAllButton()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enables every reachable server and skips the ones it cannot', async () => {
|
||||
mixedFleet()
|
||||
const confirm = vi.fn().mockResolvedValue(true)
|
||||
pane(true, confirm)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enable on all computers' }))
|
||||
})
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
// linux 1 is offline and m4 air is too old, so neither is asked; build-01 is already on.
|
||||
expect(mocks.setEnabled.mock.calls.map((call) => call[0])).toEqual(['runtime:off'])
|
||||
})
|
||||
|
||||
it('enables this computer as part of enabling them all', 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, undefined, save)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enable on all computers' }))
|
||||
})
|
||||
expect(save).toHaveBeenCalledWith({ aiVaultSearch: { enabled: true, historyDays: null } })
|
||||
expect(mocks.setEnabled).toHaveBeenCalledWith('runtime:off', true)
|
||||
})
|
||||
|
||||
it('keeps going after a host refuses, and names the one that did', 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)
|
||||
pane(true)
|
||||
await act(async () => {})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enable on all computers' }))
|
||||
})
|
||||
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')
|
||||
})
|
||||
|
||||
it('remembers nothing after a server is turned back 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, undefined, save)
|
||||
await act(async () => {})
|
||||
expect(enableAllButton()).not.toBeInTheDocument()
|
||||
mocks.setEnabled.mockResolvedValue(off)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Search sessions on gpu-a' }))
|
||||
})
|
||||
// The row went off, so the offer comes straight back; no preference was written either way.
|
||||
expect(enableAllButton()).toBeInTheDocument()
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,19 +1,38 @@
|
||||
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,
|
||||
resolveAiVaultSearchSettings
|
||||
} from '../../../../shared/ai-vault-search-settings'
|
||||
import {
|
||||
getLocalExecutionHostLabel,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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 { 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,
|
||||
type SessionSearchComputerEntry,
|
||||
type SessionSearchComputerState
|
||||
} from './session-search-computer-rollup'
|
||||
import {
|
||||
sessionSearchCheckingMessage,
|
||||
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,
|
||||
@@ -24,11 +43,18 @@ export function SessionHistorySettingsPane({
|
||||
}): React.JSX.Element {
|
||||
const policy = resolveAiVaultSearchSettings(settings)
|
||||
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 { environments, detailsByEnvironmentId } = useRuntimeEnvironmentCatalog()
|
||||
const localRead = useSessionSearchStatus({
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
active: policy.enabled && !isWebClient,
|
||||
refresh
|
||||
})
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
@@ -37,21 +63,47 @@ 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 orderedServers = orderSessionSearchServers(serverEntries)
|
||||
// The button's whole reason to exist: a paired server this client could switch on right now.
|
||||
const enableableServers = serverEntries.filter((entry) =>
|
||||
isTurnOnableSessionSearchState(entry.state)
|
||||
)
|
||||
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
async function save(updates: Partial<typeof policy>): Promise<void> {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateSettings({
|
||||
aiVaultSearch: AiVaultSearchSettingsSchema.parse({ ...policy, ...updates })
|
||||
})
|
||||
await writePolicy(updates)
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(
|
||||
translate(
|
||||
'sessionHistory.settings.saveError',
|
||||
'Could not save session search settings. Try again.'
|
||||
)
|
||||
)
|
||||
setError(saveErrorMessage())
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
@@ -60,138 +112,150 @@ export function SessionHistorySettingsPane({
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(): Promise<void> {
|
||||
if (policy.enabled) {
|
||||
await save({ enabled: false })
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
let accepted = false
|
||||
try {
|
||||
accepted = await confirm({
|
||||
title: translate('sessionHistory.settings.enableTitle', 'Start indexing agent sessions?'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.enableConsent',
|
||||
'Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.'
|
||||
),
|
||||
confirmLabel: translate('sessionHistory.settings.enableConfirm', 'Start indexing')
|
||||
})
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
}
|
||||
await save({ enabled: true })
|
||||
function toggleEnabled(): Promise<void> {
|
||||
return save({ enabled: !policy.enabled })
|
||||
}
|
||||
|
||||
async function deleteIndex(): Promise<void> {
|
||||
/** Remembers nothing: what it acts on is read off the rows at the moment it is clicked. */
|
||||
async function enableOnAllComputers(): Promise<void> {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const accepted = await confirm({
|
||||
title: translate(
|
||||
'sessionHistory.settings.deleteTitle',
|
||||
'Delete this computer’s search index?'
|
||||
),
|
||||
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.'
|
||||
),
|
||||
confirmLabel: translate('sessionHistory.settings.delete', 'Delete index'),
|
||||
confirmVariant: 'destructive'
|
||||
})
|
||||
if (!accepted || !mounted.current) {
|
||||
return
|
||||
if (!policy.enabled) {
|
||||
try {
|
||||
await writePolicy({ enabled: true })
|
||||
} catch {
|
||||
setError(saveErrorMessage())
|
||||
}
|
||||
}
|
||||
await window.api.aiVault.clearSearchIndex()
|
||||
if (mounted.current) {
|
||||
setRefresh((value) => value + 1)
|
||||
toast.success(
|
||||
translate(
|
||||
'sessionHistory.settings.cleared',
|
||||
'Search index cleared. Original transcripts were kept.'
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(
|
||||
translate('sessionHistory.settings.clearError', 'Could not clear the index. Try again.')
|
||||
)
|
||||
// One host at a time: a failure is that host's, and it must not stop the rest.
|
||||
for (const entry of enableableServers) {
|
||||
try {
|
||||
await window.api.aiVault.setSearchEnabled(toRuntimeExecutionHostId(entry.id), true)
|
||||
} catch {
|
||||
setError(serverToggleErrorMessage(entry.name))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
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 {
|
||||
await writePolicy({ enabled: false })
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(saveErrorMessage())
|
||||
}
|
||||
return false
|
||||
}
|
||||
return mounted.current
|
||||
}
|
||||
|
||||
// A stale answer from before the switch went off must not keep reporting progress.
|
||||
const localStatus = policy.enabled ? localRead.status : null
|
||||
let localStatusText: string | undefined
|
||||
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
|
||||
<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',
|
||||
'Manage indexing in the Orca desktop app on the computer that owns the transcripts. These controls are unavailable from a paired client.'
|
||||
'Turn on session search from the Orca desktop app on that computer.'
|
||||
)
|
||||
: 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="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')}
|
||||
/>
|
||||
'sessionHistory.settings.computersConsent',
|
||||
'Each computer keeps a searchable copy of its own agent conversations and tool output. Nothing leaves that computer.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Nothing left to switch on means nothing to offer: each row already speaks for itself. */}
|
||||
{enableableServers.length === 0 ? null : (
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void enableOnAllComputers()}
|
||||
>
|
||||
{translate('sessionHistory.settings.enableOnAll', 'Enable on all computers')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<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>
|
||||
}
|
||||
</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) })}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
}
|
||||
servers={orderedServers.map((entry) => ({
|
||||
id: entry.id,
|
||||
node: (
|
||||
<SessionHistoryServerRow
|
||||
environment={entry.environment}
|
||||
details={detailsByEnvironmentId[entry.id]}
|
||||
refresh={refresh}
|
||||
onError={setError}
|
||||
onStateChange={handleServerState}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
{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}
|
||||
@@ -200,3 +264,15 @@ export function SessionHistorySettingsPane({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function saveErrorMessage(): string {
|
||||
return translate('sessionHistory.settings.saveError', 'Could not save. Try again.')
|
||||
}
|
||||
|
||||
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,69 @@
|
||||
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)
|
||||
// One row needs no heading to tell it apart from the rest; the pair of subheads appears together or not at all.
|
||||
const showSubheads = servers.length > 0
|
||||
return (
|
||||
<div>
|
||||
{showSubheads ? (
|
||||
<p className="pt-4 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{translate('sessionHistory.settings.thisComputer', 'This computer')}
|
||||
</p>
|
||||
) : null}
|
||||
{local}
|
||||
{showSubheads ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/** 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.searchableSessions',
|
||||
'{{sessions}} sessions searchable',
|
||||
{
|
||||
sessions
|
||||
}
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'sessionHistory.status.searchable',
|
||||
'{{sessions}} sessions · {{messages}} messages searchable',
|
||||
{ sessions, messages: formatMessageCount(status.messagesIndexed) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
'sessionHistory.status.unavailable',
|
||||
'Search is not available on this computer right now.'
|
||||
)
|
||||
}
|
||||
return isSweepingSessionSearch(status) ? catchingUpMessage(status) : searchableMessage(status)
|
||||
}
|
||||
|
||||
/** Lines shown under the status sentence when something needs the user's attention. */
|
||||
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}} sessions could not be read and will be retried.',
|
||||
{ failed: status.filesFailed }
|
||||
)
|
||||
)
|
||||
}
|
||||
if (status.degradedRoots.length > 0) {
|
||||
lines.push(
|
||||
translate('sessionHistory.status.roots', '{{roots}} session folders could not be checked.', {
|
||||
roots: status.degradedRoots.length
|
||||
})
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
export function sessionSearchCheckingMessage(): string {
|
||||
return translate('sessionHistory.status.checking', 'Checking…')
|
||||
}
|
||||
|
||||
export function sessionSearchReadErrorMessage(): string {
|
||||
return translate('sessionHistory.status.error', 'Could not check status. Retrying…')
|
||||
}
|
||||
|
||||
// IPC wraps a rejection's message, so the host-too-old marker arrives inside a longer string.
|
||||
export function isHostTooOldError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes('host-too-old')
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import {
|
||||
isTurnOnableSessionSearchState,
|
||||
orderSessionSearchServers,
|
||||
type SessionSearchComputerEntry
|
||||
} from './session-search-computer-rollup'
|
||||
|
||||
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('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)
|
||||
}
|
||||
expect(fleet.filter((entry) => isTurnOnableSessionSearchState(entry.state))).toHaveLength(1)
|
||||
})
|
||||
|
||||
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,36 @@
|
||||
/**
|
||||
* 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 function isTurnOnableSessionSearchState(state: SessionSearchComputerState): boolean {
|
||||
return state === 'off'
|
||||
}
|
||||
|
||||
// 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,16 +185,15 @@ 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',
|
||||
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
|
||||
'Search everything your agents have said and done, on this computer and on 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}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
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 { 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', () => ({
|
||||
useWindowStreamVisible: () => mocks.visible
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, args?: Record<string, unknown>) =>
|
||||
fallback.replace(/{{(\w+)}}/g, (_, key: string) => String(args?.[key]))
|
||||
}))
|
||||
|
||||
const current: AiVaultSearchStatus = {
|
||||
...unavailableSessionSearchStatus(),
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 12,
|
||||
messagesIndexed: 3_400,
|
||||
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
|
||||
mocks.status.mockReset().mockResolvedValue(current)
|
||||
vi.stubGlobal('api', undefined)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { aiVault: { searchStatus: mocks.status } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps polling a settled index so counts stay live between sweeps', async () => {
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(mocks.status).toHaveBeenCalledWith('local')
|
||||
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('30 sessions · 9K messages searchable')
|
||||
})
|
||||
|
||||
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('4 of 10 sessions · 410K messages searchable')
|
||||
expect(sessionSearchStatusDetails(view.result.current.status)).toEqual([])
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'indexing',
|
||||
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('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 () => {
|
||||
mocks.status.mockResolvedValue({ ...current, phase: 'indexing', filesDue: 3 })
|
||||
poll()
|
||||
await act(async () => {})
|
||||
const started = mocks.status.mock.calls.length
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
})
|
||||
expect(mocks.status.mock.calls.length - started).toBe(3)
|
||||
})
|
||||
|
||||
it('names unreadable files while degraded and still counts what is searchable', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'degraded',
|
||||
filesIndexed: 8,
|
||||
filesDue: 1,
|
||||
filesFailed: 1,
|
||||
degradedRoots: [{ reason: 'unreadable' }]
|
||||
})
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
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.'
|
||||
])
|
||||
})
|
||||
|
||||
it('calls a drained degraded index up to date', async () => {
|
||||
mocks.status.mockResolvedValue({
|
||||
...current,
|
||||
phase: 'degraded',
|
||||
filesIndexed: 9,
|
||||
filesDue: 0,
|
||||
filesFailed: 2
|
||||
})
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
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.'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not describe an absent service as an empty current index', async () => {
|
||||
mocks.status.mockResolvedValue(unavailableSessionSearchStatus())
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(message(view.result.current.status)).toBe(
|
||||
'Search is not available on this computer right now.'
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers on its own after a failed read', async () => {
|
||||
mocks.status.mockRejectedValueOnce(new Error('offline'))
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(view.result.current.failed).toBe(true)
|
||||
expect(view.result.current.status).toBeNull()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
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')
|
||||
})
|
||||
const view = poll()
|
||||
await act(async () => {})
|
||||
expect(view.result.current.failed).toBe(true)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
})
|
||||
expect(view.result.current.status).toEqual(current)
|
||||
})
|
||||
|
||||
it('stops polling while inactive or hidden and re-reads on a refresh bump', async () => {
|
||||
mocks.visible = false
|
||||
const view = poll()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).not.toHaveBeenCalled()
|
||||
mocks.visible = true
|
||||
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(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 () => {
|
||||
let answer: (value: AiVaultSearchStatus) => void = () => undefined
|
||||
mocks.status.mockReturnValue(
|
||||
new Promise<AiVaultSearchStatus>((resolve) => {
|
||||
answer = resolve
|
||||
})
|
||||
)
|
||||
const view = poll()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
answer({ ...current, phase: 'indexing', filesDue: 2 })
|
||||
})
|
||||
const beforeUnmount = mocks.status.mock.calls.length
|
||||
view.unmount()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
expect(mocks.status).toHaveBeenCalledTimes(beforeUnmount)
|
||||
})
|
||||
|
||||
it('marks the host too old on a host-too-old rejection and stops polling', async () => {
|
||||
mocks.status.mockRejectedValue(new Error('Error invoking remote method: host-too-old'))
|
||||
const { result } = poll()
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(result.current.hostTooOld).toBe(true)
|
||||
expect(result.current.failed).toBe(true)
|
||||
const calls = mocks.status.mock.calls.length
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
expect(mocks.status.mock.calls.length).toBe(calls)
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { isHostTooOldError, sessionSearchPollIntervalMs } from './session-history-status-copy'
|
||||
|
||||
export type SessionSearchStatusRead = {
|
||||
status: AiVaultSearchStatus | null
|
||||
failed: boolean
|
||||
/** The host answered that it has no session search at all; polling stops. */
|
||||
hostTooOld: 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 [hostTooOld, setHostTooOld] = useState(false)
|
||||
const intervalMs = sessionSearchPollIntervalMs(status)
|
||||
const adopt = useCallback((next: AiVaultSearchStatus) => {
|
||||
setStatus(next)
|
||||
setFailed(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setFailed(false)
|
||||
return
|
||||
}
|
||||
if (!visible || hostTooOld) {
|
||||
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 (error) {
|
||||
if (!disposed) {
|
||||
setStatus(null)
|
||||
setFailed(true)
|
||||
if (isHostTooOldError(error)) {
|
||||
setHostTooOld(true)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
const stopPolling = installWindowVisibilityInterval({ run: () => void read(), intervalMs })
|
||||
return () => {
|
||||
disposed = true
|
||||
stopPolling()
|
||||
}
|
||||
}, [executionHostId, active, visible, refresh, intervalMs, hostTooOld])
|
||||
|
||||
return { status, failed, hostTooOld, adopt }
|
||||
}
|
||||
@@ -71,18 +71,18 @@ 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',
|
||||
'Manage session search on this computer. These settings do not enable indexing on SSH or paired hosts.'
|
||||
'Search everything your agents have said and done, on this computer and on any paired Orca server.'
|
||||
),
|
||||
icon: History,
|
||||
searchEntries: [
|
||||
{
|
||||
title: translate('sessionHistory.settings.enable', 'Enable session history search'),
|
||||
title: translate('sessionHistory.settings.indexComputers', 'Search inside sessions'),
|
||||
description: translate(
|
||||
'sessionHistory.settings.searchDescription',
|
||||
'Transcript indexing, index status and delete index.'
|
||||
'Turn on session search for this computer and paired servers, or clear search data.'
|
||||
)
|
||||
}
|
||||
],
|
||||
|
||||
@@ -17900,37 +17900,47 @@
|
||||
},
|
||||
"sessionHistory": {
|
||||
"status": {
|
||||
"checking": "Checking index…",
|
||||
"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",
|
||||
"unreadable": "{{failed}} files could not be read and will be retried.",
|
||||
"stopHint": "Turn off search to stop. Progress is kept and resumes when you turn it back on.",
|
||||
"roots": "Unverified source roots: {{roots}}"
|
||||
"checking": "Checking…",
|
||||
"error": "Could not check status. Retrying…",
|
||||
"unavailable": "Search is not available on this computer right now.",
|
||||
"unreadable": "{{failed}} sessions could not be read and will be retried.",
|
||||
"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 session search settings. Try again.",
|
||||
"enableTitle": "Start indexing agent sessions?",
|
||||
"enableConsent": "Orca will build a local search index on this computer. It copies conversation text and tool output from agent transcripts as written; content is not redacted. Indexing starts now, runs in the background, and the first scan can take several minutes. You can turn it off at any time; progress is kept.",
|
||||
"enableConfirm": "Start indexing",
|
||||
"saveError": "Could not save. Try again.",
|
||||
"advanced": "Advanced",
|
||||
"deleteIndexCopy": "Delete index copy",
|
||||
"deleteTitle": "Delete this computer’s search index?",
|
||||
"deleteEnabled": "Remove the search index from this computer. Original transcripts are not touched. Search is on, so Orca scans them again from scratch afterward.",
|
||||
"deleteDisabled": "Remove the search index from this computer. Original transcripts are not touched. Search stays off.",
|
||||
"delete": "Delete index",
|
||||
"cleared": "Search index cleared. Original transcripts were kept.",
|
||||
"clearError": "Could not clear the index. Try again.",
|
||||
"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."
|
||||
"deleteIndexCopy": "Clear search data",
|
||||
"deleteTitle": "Clear search data on this computer?",
|
||||
"deleteEnabled": "Turns off search and removes the searchable copy from this computer. Your agent sessions are not affected.",
|
||||
"deleteDisabled": "Removes the searchable copy from this computer. Your agent sessions are not affected.",
|
||||
"delete": "Clear",
|
||||
"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 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 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",
|
||||
"serverToggleError": "Could not change session search on {{host}}. Try again.",
|
||||
"clearedAndTurnedOff": "Search turned off and search data cleared.",
|
||||
"thisComputer": "This computer",
|
||||
"remoteServers": "Orca remote servers",
|
||||
"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",
|
||||
"enableOnAll": "Enable on all computers"
|
||||
}
|
||||
},
|
||||
"aiVault": {
|
||||
|
||||
@@ -12,6 +12,8 @@ export type RightSidebarState = {
|
||||
rightSidebarTab: ActiveRightSidebarTab
|
||||
rightSidebarExplorerView: RightSidebarExplorerView
|
||||
rightSidebarRouteRequestId: number
|
||||
/** Set to ask the Agent Session Search panel to widen to all computers and focus its box. */
|
||||
aiVaultSearchFocusRequested: boolean
|
||||
rightSidebarTabByWorktree: Record<string, ActiveRightSidebarTab>
|
||||
rightSidebarExplorerViewByWorktree: Record<string, RightSidebarExplorerView>
|
||||
activityBarPosition: ActivityBarPosition
|
||||
@@ -25,6 +27,8 @@ export type RightSidebarState = {
|
||||
query?: string | null
|
||||
includePattern?: string | null
|
||||
}) => void
|
||||
showAiVaultSearch: () => void
|
||||
clearAiVaultSearchFocusRequest: () => void
|
||||
setActivityBarPosition: (position: ActivityBarPosition) => void
|
||||
}
|
||||
|
||||
@@ -35,6 +39,7 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
|
||||
rightSidebarTab: 'explorer',
|
||||
rightSidebarExplorerView: 'files',
|
||||
rightSidebarRouteRequestId: 0,
|
||||
aiVaultSearchFocusRequested: false,
|
||||
rightSidebarTabByWorktree: {},
|
||||
rightSidebarExplorerViewByWorktree: {},
|
||||
activityBarPosition: 'top',
|
||||
@@ -124,6 +129,15 @@ export function createRightSidebarState(set: EditorSet, _get: EditorGet): RightS
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Settings sends the user here; the panel owns scope, so this asks rather than writes.
|
||||
showAiVaultSearch: () =>
|
||||
set((s) => ({
|
||||
rightSidebarOpen: true,
|
||||
rightSidebarTab: 'vault' as const,
|
||||
rightSidebarRouteRequestId: s.rightSidebarRouteRequestId + 1,
|
||||
aiVaultSearchFocusRequested: true
|
||||
})),
|
||||
clearAiVaultSearchFocusRequest: () => set({ aiVaultSearchFocusRequested: false }),
|
||||
setActivityBarPosition: (position) => set({ activityBarPosition: position })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ export const AiVaultSearchStatusSchema = z.object({
|
||||
filesIndexed: z.number().int().nonnegative(),
|
||||
filesDue: z.number().int().nonnegative(),
|
||||
filesFailed: z.number().int().nonnegative(),
|
||||
// Optional: a host that predates this field degrades to a session count only.
|
||||
messagesIndexed: z.number().int().nonnegative().optional(),
|
||||
// `root` is a host path, withheld over the relay; the array length is the count.
|
||||
degradedRoots: z.array(z.object({ root: z.string().optional(), reason: z.string() })),
|
||||
lastReconcileAt: z.number().nullable(),
|
||||
|
||||
Reference in New Issue
Block a user