mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(sidebar): main keeps sleeping retention only, no order of its own (dark) (#30404)
* refactor(sidebar): the stamp-sorted window list runs no settle, latch or saved order (dark) With ORCA_SIDEBAR_INPUTS=main the window's sort hook is a pure read of the rows' stamps: no settled-epoch store listener, no Smart latch, no saved Smart order writes, no class telemetry over the old attention map. Switch off: the existing hook, unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(sidebar): stamp sort hook reads no live sources; stronger switch-on tests Co-Authored-By: Claude <noreply@anthropic.com> * test(sidebar): clear the order-save mock between tests Co-Authored-By: Claude <noreply@anthropic.com> * wip: main keeps sleeping retention only * fix(sidebar): stamp sort reads a shared empty-sources constant, not a renderer layout literal The layout-writer ratchet counts layout field keys written in the renderer; the empty sources object now lives beside SmartSortSources in shared code. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(sidebar): main keeps sleeping retention only; no order of its own (dark) Clients sort by the owning host's stamps, so main's temporal owner loses its settle window, Smart latch, create grace and catalog-listing classifier, and the shadow check stops comparing order. Retention, its clock and its shadow comparison stay. Co-Authored-By: Claude <noreply@anthropic.com> * fix(sidebar): every retention listing shares one sequence; publish after the first Co-Authored-By: Claude <noreply@anthropic.com> * test(sidebar): the first listing bumps the retention revision Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,8 +27,7 @@
|
||||
"../src/packages/process-host/src/__fixtures__/**/*",
|
||||
"../src/main/gitlab/mappers.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-clock.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-temporal-owner.ts",
|
||||
"../src/main/runtime/sidebar-inputs/catalog-listing-change.ts",
|
||||
"../src/main/runtime/sidebar-inputs/sidebar-retention-owner.ts",
|
||||
"../src/main/runtime/sidebar-inputs/producer-revision.ts",
|
||||
"../src/main/jira/adf-markdown.ts",
|
||||
"../src/main/jira/adf-text-marks.ts",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
import { structuralValuesEqualIgnoringUndefined } from '../../../shared/structural-value-equality'
|
||||
|
||||
/** One listing, as the window's catalog would hold it: each row's fields apart from sortOrder. */
|
||||
export type CatalogListing = ReadonlyMap<
|
||||
string,
|
||||
{ row: Record<string, unknown>; sortOrder: number }
|
||||
>
|
||||
|
||||
// Fields main's listing carries that the window's catalog rows do not.
|
||||
const MAIN_ONLY_FIELDS = new Set([
|
||||
'sortOrder',
|
||||
'git',
|
||||
'lineage',
|
||||
'parentWorktreeId',
|
||||
'childWorktreeIds'
|
||||
])
|
||||
|
||||
export function indexCatalogListing(worktrees: readonly Worktree[]): CatalogListing {
|
||||
return new Map(
|
||||
worktrees.map((worktree) => [
|
||||
getWorktreeHostIdentity(worktree),
|
||||
{
|
||||
row: Object.fromEntries(
|
||||
Object.entries(worktree).filter(([key]) => !MAIN_ONLY_FIELDS.has(key))
|
||||
),
|
||||
sortOrder: worktree.sortOrder
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How the window's catalog merge would read a new listing: a row change bumps its sort, a save
|
||||
* that only moved sortOrder does not (#27268), and an identical listing writes nothing. Rows
|
||||
* match by identity; the window also bumps on a reorder, which a listing's fixed scan order avoids.
|
||||
*/
|
||||
export function classifyCatalogListingChange(
|
||||
previous: CatalogListing,
|
||||
next: CatalogListing
|
||||
): 'none' | 'sort-order' | 'rows' {
|
||||
if (previous.size !== next.size) {
|
||||
return 'rows'
|
||||
}
|
||||
let sortOrderMoved = false
|
||||
for (const [identity, entry] of next) {
|
||||
const before = previous.get(identity)
|
||||
if (!before || !structuralValuesEqualIgnoringUndefined(before.row, entry.row)) {
|
||||
return 'rows'
|
||||
}
|
||||
sortOrderMoved ||= before.sortOrder !== entry.sortOrder
|
||||
}
|
||||
return sortOrderMoved ? 'sort-order' : 'none'
|
||||
}
|
||||
|
||||
export function countLiveRows(worktrees: readonly Worktree[]): number {
|
||||
return worktrees.filter((worktree) => !worktree.isArchived).length
|
||||
}
|
||||
@@ -26,7 +26,7 @@ describe('the runtime sidebar inputs feed', () => {
|
||||
const phone = new TestPhone(feed, 'conn-1')
|
||||
phone.refresh()
|
||||
await phone.settle()
|
||||
expect(phone.view().temporal).toEqual({ sessionId: null, sortSnapshot: null, retention: null })
|
||||
expect(phone.view().temporal).toEqual({ sessionId: null, retention: null })
|
||||
|
||||
const creation = requireSwitchedOn(runtime.workspaceCreations).open({
|
||||
creationId: 'c1',
|
||||
|
||||
@@ -7,21 +7,12 @@ import {
|
||||
isLocalExecutionHost,
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
} from '../../../shared/execution-host'
|
||||
import { detectAgentStatusFromTitle } from '../../../shared/agent-detection'
|
||||
import type { SidebarTemporalSnapshot } from '../../../shared/sidebar-list/sidebar-temporal-snapshot'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { getMigrationUnsupportedPtySnapshot } from '../../agent-hooks/migration-unsupported-pty-state'
|
||||
import {
|
||||
SidebarTemporalOwner,
|
||||
type SidebarRetentionCandidates,
|
||||
type SidebarSortSample
|
||||
} from './sidebar-temporal-owner'
|
||||
import { SmartSortAgentEntries } from './smart-sort-agent-entries'
|
||||
import { buildRuntimeSmartSortSources } from './runtime-smart-sort-sources'
|
||||
import { SidebarRetentionOwner, type SidebarRetentionCandidates } from './sidebar-retention-owner'
|
||||
import type { AgentStatusStoreView } from './workspace-activity-facts-producer'
|
||||
import { SortStampsProducer } from './sort-stamps-producer'
|
||||
import { WORKTREE_METADATA_DOMAINS } from '../../persistence/loading-store/worktree-identity-metadata'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import { resolveAgentWorkspaceExecutionHostId } from '../../agent-hooks/restored-status-dead-terminal-sweep'
|
||||
@@ -30,20 +21,16 @@ import {
|
||||
wslMoveProjectRefForWorkspace
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
type Runtime = OrcaRuntimeWithWorkspaceReviewFacts
|
||||
|
||||
/**
|
||||
* Feeds main's temporal owner. Its sort inputs restart the settle window where the window's
|
||||
* sortEpoch bumps and main can see the cause: catalog changes, agent rows, and pane titles.
|
||||
* Feeds main's sleeping retention, and owns the host's sort stamps: clients sort by those, so main
|
||||
* keeps no order of its own.
|
||||
*/
|
||||
export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWorkspaceReviewFacts {
|
||||
private readonly smartSortAgentEntries = new SmartSortAgentEntries()
|
||||
private readonly sidebarTemporal = new SidebarTemporalOwner(this.sidebarClock, {
|
||||
sortBy: () => this.store?.getUI?.().sortBy ?? 'smart',
|
||||
private readonly sidebarTemporal = new SidebarRetentionOwner(this.sidebarClock, {
|
||||
// The durable file keeps the hide form; the window reads it the same way at hydration.
|
||||
showSleepingWorkspaces: () =>
|
||||
!(this.store?.getUI?.().hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES),
|
||||
readSortSample: () => this.readSidebarSortSample(),
|
||||
listRows: () => this.listSidebarRows(),
|
||||
retentionCandidates: () => this.readSidebarRetentionCandidates()
|
||||
})
|
||||
readonly sortStamps = new SortStampsProducer({
|
||||
@@ -61,39 +48,17 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork
|
||||
super(...args)
|
||||
this.workspaceActivityFacts.revision.onChange(() => this.sidebarTemporal.noteActivityChange())
|
||||
this.store?.onUIChanged?.(() => this.sidebarTemporal.noteViewChange())
|
||||
// Switch point: the window's metadata writes (a rename, a manual order) send no catalog event.
|
||||
this.store?.onStateWritten?.((domains) => {
|
||||
if (
|
||||
ORCA_SIDEBAR_INPUTS_IS_MAIN &&
|
||||
domains &&
|
||||
WORKTREE_METADATA_DOMAINS.some((domain) => domains.includes(domain))
|
||||
) {
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.sidebarTemporal.noteRowsWritten()
|
||||
}
|
||||
})
|
||||
// Every catalog change, from IPC or the runtime, reaches clients through this one bus.
|
||||
this.clientEvents.on(
|
||||
(event) => {
|
||||
if (event.type === 'worktreesChanged' || event.type === 'reposChanged') {
|
||||
this.noteSidebarCatalogChange(event.type)
|
||||
this.sidebarTemporal.noteCatalogChange()
|
||||
}
|
||||
},
|
||||
{ consumesTerminalSideEffects: false }
|
||||
)
|
||||
}
|
||||
|
||||
// Listed after the change lands, as the window re-lists; only rows, the sort's sources wait.
|
||||
private noteSidebarCatalogChange(type: 'worktreesChanged' | 'reposChanged'): void {
|
||||
if (!this.sidebarTemporal.running) {
|
||||
return
|
||||
}
|
||||
void this.listSidebarRows().then(
|
||||
(worktrees) => this.sidebarTemporal.noteCatalogListing(worktrees, type === 'reposChanged'),
|
||||
(error: unknown) => console.warn('[sidebar-temporal] could not list rows', error)
|
||||
)
|
||||
}
|
||||
|
||||
getSidebarTemporalSnapshot(): SidebarTemporalSnapshot {
|
||||
return this.sidebarTemporal.snapshot()
|
||||
}
|
||||
@@ -163,58 +128,14 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork
|
||||
attachSidebarAgentStatusStore(store: AgentStatusStoreView): () => void {
|
||||
const detachActivity = this.workspaceActivityFacts.attachAgentStatusStore(store)
|
||||
const detachStamps = this.sortStamps.attachAgentStatusStore(store)
|
||||
const detachEntries = this.smartSortAgentEntries.attach(store, () =>
|
||||
this.sidebarTemporal.noteSortInputChange()
|
||||
)
|
||||
return () => {
|
||||
detachActivity()
|
||||
detachStamps()
|
||||
detachEntries()
|
||||
this.sidebarTemporal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
override syncWindowGraph(...args: Parameters<Runtime['syncWindowGraph']>) {
|
||||
if (!this.sidebarTemporal.running) {
|
||||
return super.syncWindowGraph(...args)
|
||||
}
|
||||
const before = this.readSidebarGraphSortCauses()
|
||||
const result = super.syncWindowGraph(...args)
|
||||
const after = this.readSidebarGraphSortCauses()
|
||||
// A title's reading counts both ways; a tab only re-sorts when it gains its first terminal.
|
||||
const resorts = (key: string): boolean =>
|
||||
key.startsWith('title:')
|
||||
? (before.get(key) ?? '') !== (after.get(key) ?? '')
|
||||
: after.has(key) && !before.has(key)
|
||||
if ([...new Set([...before.keys(), ...after.keys()])].some(resorts)) {
|
||||
this.sidebarTemporal.noteSortInputChange()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* What a graph sync can change that the window re-sorts on: a pane title's agent reading on a
|
||||
* workspace not in front, and the first live terminal of a tab in the workspace in front.
|
||||
*/
|
||||
private readSidebarGraphSortCauses(): Map<string, string> {
|
||||
const active = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.activeWorktreeId
|
||||
const causes = new Map<string, string>()
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (leaf.worktreeId === active) {
|
||||
if (leaf.ptyId) {
|
||||
causes.set(`pty:${leaf.tabId}`, 'live')
|
||||
}
|
||||
} else if (leaf.paneTitle) {
|
||||
causes.set(
|
||||
`title:${leaf.tabId}:${leaf.paneRuntimeId}`,
|
||||
detectAgentStatusFromTitle(leaf.paneTitle) ?? ''
|
||||
)
|
||||
}
|
||||
}
|
||||
return causes
|
||||
}
|
||||
|
||||
// Ranks are host-qualified; a row without a stamped host belongs to its repo's host.
|
||||
// Retained ids are host-qualified; a row without a stamped host belongs to its repo's host.
|
||||
private async listSidebarRows(repoById = this.readSidebarRepoMap()): Promise<Worktree[]> {
|
||||
const { worktrees } = await this.listResolvedWorktreeSnapshot()
|
||||
return worktrees.map((worktree) => {
|
||||
@@ -229,22 +150,6 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork
|
||||
return new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo]))
|
||||
}
|
||||
|
||||
private async readSidebarSortSample(): Promise<SidebarSortSample> {
|
||||
const repoMap = this.readSidebarRepoMap()
|
||||
return {
|
||||
worktrees: await this.listSidebarRows(repoMap),
|
||||
repoMap,
|
||||
sources: buildRuntimeSmartSortSources({
|
||||
tabs: this.tabs.values(),
|
||||
leaves: this.leaves.values(),
|
||||
sessionTabs: this.mobileSessionTabsByWorktree,
|
||||
isUnverifiablePty: (ptyId) => this.getPtyLivenessVerdict(ptyId)?.status === 'unverifiable',
|
||||
agentStatusByPaneKey: this.smartSortAgentEntries.byPaneKey(),
|
||||
migrationUnsupported: getMigrationUnsupportedPtySnapshot()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private readSidebarRetentionCandidates(): SidebarRetentionCandidates {
|
||||
const { facts } = this.workspaceActivityFacts.snapshot()
|
||||
return {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildRuntimeSmartSortSources } from './runtime-smart-sort-sources'
|
||||
|
||||
describe('buildRuntimeSmartSortSources', () => {
|
||||
it('rebuilds tabs, live bindings, pane titles and layouts from runtime records', () => {
|
||||
const sources = buildRuntimeSmartSortSources({
|
||||
tabs: [
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-a', title: 'zsh', activeLeafId: 'leaf-1', layout: null }
|
||||
],
|
||||
leaves: [
|
||||
{ tabId: 'tab-1', ptyId: 'pty-1', connected: true, paneRuntimeId: 1, paneTitle: 'claude' },
|
||||
{ tabId: 'tab-1', ptyId: 'pty-2', connected: false, paneRuntimeId: 2, paneTitle: null },
|
||||
{ tabId: 'tab-1', ptyId: 'pty-3', connected: false, paneRuntimeId: 3, paneTitle: null }
|
||||
],
|
||||
sessionTabs: [
|
||||
[
|
||||
'wt-b',
|
||||
{
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-2::leaf',
|
||||
title: 'slept',
|
||||
parentTabId: 'tab-2',
|
||||
leafId: 'leaf',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
// A mounted tab listed again by its session snapshot is not counted twice.
|
||||
[
|
||||
'wt-a',
|
||||
{
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::leaf-1',
|
||||
title: 'zsh',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
isUnverifiablePty: (ptyId) => ptyId === 'pty-3',
|
||||
agentStatusByPaneKey: {},
|
||||
migrationUnsupported: []
|
||||
})
|
||||
expect(sources.tabsByWorktree).toEqual({
|
||||
'wt-a': [{ id: 'tab-1', title: 'zsh' }],
|
||||
'wt-b': [{ id: 'tab-2', title: 'slept' }]
|
||||
})
|
||||
// A binding whose host only lost contact stays live, as it does in the window.
|
||||
expect(sources.ptyIdsByTabId).toEqual({ 'tab-1': ['pty-1', 'pty-3'] })
|
||||
expect(sources.runtimePaneTitlesByTabId).toEqual({ 'tab-1': { 1: 'claude' } })
|
||||
expect(sources.terminalLayoutsByTabId?.['tab-1']).toMatchObject({
|
||||
root: null,
|
||||
activeLeafId: 'leaf-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import type {
|
||||
AgentStatusEntry,
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../../../shared/agent-status-types'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsSnapshot,
|
||||
RuntimeSyncedTab
|
||||
} from '../../../shared/runtime-types'
|
||||
import type { SmartSortSources } from '../../../shared/sidebar-list/smart-sort'
|
||||
import type { RuntimeLeafRecord } from '../runtime-terminal-state-records'
|
||||
|
||||
/**
|
||||
* The window's Smart inputs, rebuilt from a runtime's records: graph tabs carry titles and pane
|
||||
* trees, leaves carry bindings and pane titles, and session snapshots list tabs nothing mounts.
|
||||
*/
|
||||
export function buildRuntimeSmartSortSources(records: {
|
||||
tabs: Iterable<RuntimeSyncedTab>
|
||||
leaves: Iterable<
|
||||
Pick<RuntimeLeafRecord, 'tabId' | 'ptyId' | 'connected' | 'paneRuntimeId' | 'paneTitle'>
|
||||
>
|
||||
sessionTabs: Iterable<
|
||||
[worktreeId: string, snapshot: Pick<RuntimeMobileSessionTabsSnapshot, 'tabs'>]
|
||||
>
|
||||
/** The window holds no unverifiable state: a binding whose host lost contact is live there. */
|
||||
isUnverifiablePty: (ptyId: string) => boolean
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>
|
||||
migrationUnsupported: Iterable<MigrationUnsupportedPtyEntry>
|
||||
}): SmartSortSources {
|
||||
const tabsByWorktree: Record<string, { id: string; title: string }[]> = {}
|
||||
const tabIds = new Set<string>()
|
||||
const addTab = (worktreeId: string, id: string, title: string): void => {
|
||||
if (!tabIds.has(id)) {
|
||||
tabIds.add(id)
|
||||
;(tabsByWorktree[worktreeId] ??= []).push({ id, title })
|
||||
}
|
||||
}
|
||||
const terminalLayoutsByTabId: NonNullable<SmartSortSources['terminalLayoutsByTabId']> = {}
|
||||
for (const tab of records.tabs) {
|
||||
addTab(tab.worktreeId, tab.tabId, tab.title ?? '')
|
||||
terminalLayoutsByTabId[tab.tabId] = {
|
||||
root: tab.layout,
|
||||
activeLeafId: tab.activeLeafId,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
for (const [worktreeId, snapshot] of records.sessionTabs) {
|
||||
for (const tab of snapshot.tabs) {
|
||||
if (tab.type === 'terminal') {
|
||||
addTab(worktreeId, tab.parentTabId, tab.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||
const runtimePaneTitlesByTabId: Record<string, Record<number, string>> = {}
|
||||
for (const leaf of records.leaves) {
|
||||
if (leaf.ptyId && (leaf.connected || records.isUnverifiablePty(leaf.ptyId))) {
|
||||
;(ptyIdsByTabId[leaf.tabId] ??= []).push(leaf.ptyId)
|
||||
}
|
||||
if (leaf.paneTitle) {
|
||||
;(runtimePaneTitlesByTabId[leaf.tabId] ??= {})[leaf.paneRuntimeId] = leaf.paneTitle
|
||||
}
|
||||
}
|
||||
const migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry> = {}
|
||||
for (const entry of records.migrationUnsupported) {
|
||||
migrationUnsupportedByPtyId[entry.ptyId] = entry
|
||||
}
|
||||
return {
|
||||
tabsByWorktree,
|
||||
agentStatusByPaneKey: records.agentStatusByPaneKey,
|
||||
runtimePaneTitlesByTabId,
|
||||
ptyIdsByTabId,
|
||||
migrationUnsupportedByPtyId,
|
||||
terminalLayoutsByTabId
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,8 @@ export function sidebarInputsFeedDomains(
|
||||
{
|
||||
name: 'temporal',
|
||||
read: () => {
|
||||
const { sessionId, sortSnapshot, retention } = facts.getSidebarTemporalSnapshot()
|
||||
return { sessionId, sortSnapshot, retention }
|
||||
const { sessionId, retention } = facts.getSidebarTemporalSnapshot()
|
||||
return { sessionId, retention }
|
||||
},
|
||||
watch: (changed) => facts.onSidebarTemporalChange(changed)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { SLEEPING_SWEEP_SETTLE_MS } from '../../../shared/sidebar-list/sleeping-sweep-retention'
|
||||
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
import { SidebarClock } from './sidebar-clock'
|
||||
import { SidebarRetentionOwner } from './sidebar-retention-owner'
|
||||
|
||||
function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo-1',
|
||||
path: `/tmp/${id}`,
|
||||
head: 'abc',
|
||||
branch: `refs/heads/${id}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: id,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function harness(worktrees: Worktree[]) {
|
||||
const world: {
|
||||
showSleeping: boolean
|
||||
worktrees: Worktree[]
|
||||
active: Set<string>
|
||||
tabsOpen: Set<string>
|
||||
/** When set, the next listing waits for its own release. */
|
||||
holdNextListing: boolean
|
||||
releases: (() => void)[]
|
||||
} = {
|
||||
showSleeping: false,
|
||||
worktrees,
|
||||
active: new Set(),
|
||||
tabsOpen: new Set(),
|
||||
holdNextListing: false,
|
||||
releases: []
|
||||
}
|
||||
const owner = new SidebarRetentionOwner(new SidebarClock(), {
|
||||
showSleepingWorkspaces: () => world.showSleeping,
|
||||
listRows: async () => {
|
||||
const rows = world.worktrees
|
||||
if (world.holdNextListing) {
|
||||
world.holdNextListing = false
|
||||
await new Promise<void>((resolve) => world.releases.push(resolve))
|
||||
}
|
||||
return rows
|
||||
},
|
||||
retentionCandidates: () => ({
|
||||
ids: world.active,
|
||||
isActive: (id) => world.active.has(id),
|
||||
isUnverifiableGap: (id) => world.tabsOpen.has(id)
|
||||
})
|
||||
})
|
||||
return { world, owner }
|
||||
}
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
describe('SidebarRetentionOwner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000_000)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('stays idle until a window begins a session, and after dispose', async () => {
|
||||
const { world, owner } = harness([worktree('a')])
|
||||
owner.noteCatalogChange()
|
||||
world.active = new Set(['a'])
|
||||
owner.noteActivityChange()
|
||||
await flush()
|
||||
expect(owner.snapshot()).toEqual({ revision: 0, sessionId: null, retention: null })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
owner.beginSession('s1')
|
||||
owner.dispose()
|
||||
owner.noteActivityChange()
|
||||
await flush()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('holds a live row through a rebind gap, then lets it go on its own deadline', async () => {
|
||||
const { world, owner } = harness([worktree('a'), worktree('b')])
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
world.active = new Set(['a', 'b'])
|
||||
world.tabsOpen = new Set(['a'])
|
||||
owner.noteActivityChange()
|
||||
world.active = new Set()
|
||||
owner.noteActivityChange()
|
||||
// b had no terminal tab left, so its inactivity is definitive and gets no grace.
|
||||
expect(owner.snapshot().retention?.retainedIds).toEqual([
|
||||
getWorktreeHostIdentity(worktree('a'))
|
||||
])
|
||||
await vi.advanceTimersByTimeAsync(SLEEPING_SWEEP_SETTLE_MS)
|
||||
expect(owner.snapshot().retention?.retainedIds).toEqual([])
|
||||
|
||||
world.showSleeping = true
|
||||
owner.noteViewChange()
|
||||
expect(owner.snapshot().retention).toBeNull()
|
||||
})
|
||||
|
||||
it('publishes nothing until a listing lands, then host-qualifies from the newest one', async () => {
|
||||
const remote = worktree('a', { hostId: 'ssh:box' })
|
||||
const { world, owner } = harness([])
|
||||
// The session's own listing is slow and read no rows.
|
||||
world.holdNextListing = true
|
||||
owner.beginSession('s1')
|
||||
world.active = new Set(['a'])
|
||||
world.tabsOpen = new Set(['a'])
|
||||
owner.noteActivityChange()
|
||||
expect(owner.snapshot().retention).toBeNull()
|
||||
|
||||
// A catalog change lists again, and that newer listing lands first.
|
||||
world.worktrees = [remote]
|
||||
const before = owner.snapshot().revision
|
||||
owner.noteCatalogChange()
|
||||
await flush()
|
||||
// Readers learn that retention is now published.
|
||||
expect(owner.snapshot().revision).toBeGreaterThan(before)
|
||||
world.active = new Set()
|
||||
owner.noteActivityChange()
|
||||
expect(owner.snapshot().retention?.retainedIds).toEqual([getWorktreeHostIdentity(remote)])
|
||||
|
||||
world.releases.forEach((release) => release())
|
||||
await flush()
|
||||
expect(owner.snapshot().retention?.retainedIds).toEqual([getWorktreeHostIdentity(remote)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { ProducerRevision } from './producer-revision'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
createSleepingSweepRetentionState,
|
||||
updateSleepingSweepRetention
|
||||
} from '../../../shared/sidebar-list/sleeping-sweep-retention'
|
||||
import type {
|
||||
SidebarRetention,
|
||||
SidebarTemporalSnapshot
|
||||
} from '../../../shared/sidebar-list/sidebar-temporal-snapshot'
|
||||
import type { SidebarClock } from './sidebar-clock'
|
||||
|
||||
/** The sweep's view of one workspace, by bare id. */
|
||||
export type SidebarRetentionCandidates = {
|
||||
/** Active ids; the last listing's rows are added to them. */
|
||||
ids: Iterable<string>
|
||||
isActive: (worktreeId: string) => boolean
|
||||
/** False when the inactivity is definitive: a sleep this host started, or no terminal tab left. */
|
||||
isUnverifiableGap: (worktreeId: string) => boolean
|
||||
}
|
||||
|
||||
export type SidebarRetentionInputs = {
|
||||
showSleepingWorkspaces: () => boolean
|
||||
/** Every row this host lists; it host-qualifies the retained ids. */
|
||||
listRows: () => Promise<Worktree[]>
|
||||
retentionCandidates: () => SidebarRetentionCandidates
|
||||
}
|
||||
|
||||
const RETENTION_DEADLINE = 'sleeping-retention'
|
||||
|
||||
/**
|
||||
* Main's copy of which sleeping rows "Hide sleeping" still keeps through a rebind gap. The window
|
||||
* runs its own; this one is compared against it. It stays idle until a window begins a session.
|
||||
*/
|
||||
export class SidebarRetentionOwner {
|
||||
readonly revision = new ProducerRevision()
|
||||
private sessionId: string | null = null
|
||||
private disposed = false
|
||||
// A listing started before a reset or a later one must not land.
|
||||
private listingSeq = 0
|
||||
private identityById = new Map<string, string>()
|
||||
private retentionState = createSleepingSweepRetentionState()
|
||||
private retention: SidebarRetention | null = null
|
||||
// Until a listing lands no retained id can be host-qualified, so nothing is published.
|
||||
private listed = false
|
||||
|
||||
constructor(
|
||||
private readonly clock: SidebarClock,
|
||||
private readonly inputs: SidebarRetentionInputs
|
||||
) {}
|
||||
|
||||
snapshot(): SidebarTemporalSnapshot {
|
||||
return {
|
||||
revision: this.revision.value,
|
||||
sessionId: this.sessionId,
|
||||
retention: this.listed ? this.retention : null
|
||||
}
|
||||
}
|
||||
|
||||
/** The window mounted its list: retention forgets, as on today's remount. */
|
||||
beginSession(sessionId: string): void {
|
||||
if (sessionId === this.sessionId) {
|
||||
return
|
||||
}
|
||||
this.sessionId = sessionId
|
||||
this.retentionState = createSleepingSweepRetentionState()
|
||||
this.revision.bump()
|
||||
this.retentionPass()
|
||||
this.relist()
|
||||
}
|
||||
|
||||
/** A worktree or repo change: list again, so new rows' hosts are known. */
|
||||
noteCatalogChange(): void {
|
||||
if (this.running) {
|
||||
this.relist()
|
||||
}
|
||||
}
|
||||
|
||||
noteActivityChange(): void {
|
||||
this.retentionPass()
|
||||
}
|
||||
|
||||
/** The sleeping filter may have changed. */
|
||||
noteViewChange(): void {
|
||||
this.retentionPass()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.listingSeq += 1
|
||||
this.clock.cancel(RETENTION_DEADLINE)
|
||||
}
|
||||
|
||||
/** False until a window begins a session, and after dispose. */
|
||||
get running(): boolean {
|
||||
return this.sessionId !== null && !this.disposed
|
||||
}
|
||||
|
||||
private relist(): void {
|
||||
const seq = ++this.listingSeq
|
||||
void this.inputs.listRows().then(
|
||||
(worktrees) => {
|
||||
if (seq === this.listingSeq && this.running) {
|
||||
this.applyListing(worktrees)
|
||||
}
|
||||
},
|
||||
(error: unknown) => console.warn('[sidebar-retention] could not list rows', error)
|
||||
)
|
||||
}
|
||||
|
||||
private applyListing(worktrees: readonly Worktree[]): void {
|
||||
if (!this.listed) {
|
||||
this.listed = true
|
||||
this.revision.bump()
|
||||
}
|
||||
this.identityById = new Map(
|
||||
worktrees.map((worktree) => [worktree.id, getWorktreeHostIdentity(worktree)])
|
||||
)
|
||||
this.retentionPass()
|
||||
}
|
||||
|
||||
private retentionPass(): void {
|
||||
if (!this.running) {
|
||||
return
|
||||
}
|
||||
if (this.inputs.showSleepingWorkspaces()) {
|
||||
this.retentionState = createSleepingSweepRetentionState()
|
||||
this.clock.cancel(RETENTION_DEADLINE)
|
||||
if (this.retention !== null) {
|
||||
this.retention = null
|
||||
this.revision.bump()
|
||||
}
|
||||
return
|
||||
}
|
||||
const candidates = this.inputs.retentionCandidates()
|
||||
const now = Date.now()
|
||||
const { retainedIds, nextExpiryInMs } = updateSleepingSweepRetention({
|
||||
state: this.retentionState,
|
||||
// The last listing's rows too, so an id that just went idle is not pruned out of its grace.
|
||||
candidateWorktreeIds: [...new Set([...candidates.ids, ...this.identityById.keys()])],
|
||||
isActive: candidates.isActive,
|
||||
isUnverifiableGap: candidates.isUnverifiableGap,
|
||||
nowMs: now
|
||||
})
|
||||
if (nextExpiryInMs === null) {
|
||||
this.clock.cancel(RETENTION_DEADLINE)
|
||||
} else {
|
||||
this.clock.schedule(RETENTION_DEADLINE, now + nextExpiryInMs, () => this.retentionPass())
|
||||
}
|
||||
// An id no listing has carried yet has no host to qualify it with, so it waits for one.
|
||||
const ids = [...retainedIds].flatMap((id) => this.identityById.get(id) ?? []).sort()
|
||||
const previous = this.retention?.retainedIds
|
||||
if (!previous || previous.length !== ids.length || previous.some((id, i) => id !== ids[i])) {
|
||||
this.retention = { retainedIds: ids, retentionDecidedAt: now }
|
||||
this.revision.bump()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { WorkspaceSortBy } from '../../../shared/workspace-view/workspace-row-sort'
|
||||
import { CREATE_GRACE_MS } from '../../../shared/workspace-view/workspace-row-sort'
|
||||
import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
import { SidebarClock } from './sidebar-clock'
|
||||
import { SidebarTemporalOwner } from './sidebar-temporal-owner'
|
||||
|
||||
vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
|
||||
function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo-1',
|
||||
path: `/tmp/${id}`,
|
||||
head: 'abc',
|
||||
branch: `refs/heads/${id}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: id,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function harness(sortBy: WorkspaceSortBy, initial: Worktree[]) {
|
||||
const world = { worktrees: initial }
|
||||
const owner = new SidebarTemporalOwner(new SidebarClock(), {
|
||||
sortBy: () => sortBy,
|
||||
showSleepingWorkspaces: () => false,
|
||||
readSortSample: async () => ({
|
||||
worktrees: world.worktrees,
|
||||
repoMap: new Map(),
|
||||
sources: {
|
||||
tabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
ptyIdsByTabId: {}
|
||||
}
|
||||
}),
|
||||
retentionCandidates: () => ({ ids: [], isActive: () => false, isUnverifiableGap: () => false })
|
||||
})
|
||||
const snapshot = () => owner.snapshot().sortSnapshot
|
||||
const ranked = () => snapshot()?.rankedIds.map(getWorktreeIdFromHostIdentity)
|
||||
return { world, owner, ranked, snapshot }
|
||||
}
|
||||
|
||||
const flush = () => vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
describe('SidebarTemporalOwner with the switch on', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000_000)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('re-sorts at once on any re-list, as the window does', async () => {
|
||||
const { world, owner, ranked } = harness('smart', [
|
||||
worktree('a', { lastActivityAt: 2 }),
|
||||
worktree('b', { lastActivityAt: 1 })
|
||||
])
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
owner.noteCatalogListing(world.worktrees, false)
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
|
||||
world.worktrees = [
|
||||
worktree('a', { lastActivityAt: 2 }),
|
||||
worktree('b', { lastActivityAt: 1, smartTier: 'needs-you', smartAt: 1 })
|
||||
]
|
||||
owner.noteCatalogListing(world.worktrees, false)
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('re-sorts at once on a saved write no catalog event announced', async () => {
|
||||
const { world, owner, ranked, snapshot } = harness('recent', [
|
||||
worktree('a', { lastActivityAt: 2 }),
|
||||
worktree('b', { lastActivityAt: 1 })
|
||||
])
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
world.worktrees = [worktree('a', { lastActivityAt: 2 }), worktree('b', { lastActivityAt: 3 })]
|
||||
owner.noteRowsWritten()
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['b', 'a'])
|
||||
expect(snapshot()?.cause).toBe('settle')
|
||||
})
|
||||
|
||||
it('schedules no create grace: a new row ranks by its stamp alone', async () => {
|
||||
const { owner, snapshot } = harness('recent', [
|
||||
worktree('a', { lastActivityAt: 2 }),
|
||||
worktree('new', { createdAt: Date.now(), lastActivityAt: 1 })
|
||||
])
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
const epoch = snapshot()?.epoch
|
||||
await vi.advanceTimersByTimeAsync(CREATE_GRACE_MS + 1)
|
||||
expect(snapshot()?.epoch).toBe(epoch)
|
||||
expect(snapshot()?.rankedIds.map(getWorktreeIdFromHostIdentity)).toEqual(['a', 'new'])
|
||||
})
|
||||
})
|
||||
@@ -1,321 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { WorkspaceSortBy } from '../../../shared/workspace-view/workspace-row-sort'
|
||||
import { CREATE_GRACE_MS } from '../../../shared/workspace-view/workspace-row-sort'
|
||||
import { SORT_SETTLE_MS } from '../../../shared/sidebar-list/sort-settle'
|
||||
import { SLEEPING_SWEEP_SETTLE_MS } from '../../../shared/sidebar-list/sleeping-sweep-retention'
|
||||
import type { SmartSortSources } from '../../../shared/sidebar-list/smart-sort'
|
||||
import {
|
||||
getWorktreeHostIdentity,
|
||||
getWorktreeIdFromHostIdentity
|
||||
} from '../../../shared/worktree/host-qualified-identity'
|
||||
import { SidebarClock } from './sidebar-clock'
|
||||
import { SidebarTemporalOwner } from './sidebar-temporal-owner'
|
||||
|
||||
function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo-1',
|
||||
path: `/tmp/${id}`,
|
||||
head: 'abc',
|
||||
branch: `refs/heads/${id}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: id,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const NO_SOURCES: SmartSortSources = {
|
||||
tabsByWorktree: {},
|
||||
agentStatusByPaneKey: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
ptyIdsByTabId: {}
|
||||
}
|
||||
|
||||
type World = {
|
||||
sortBy: WorkspaceSortBy
|
||||
showSleeping: boolean
|
||||
worktrees: Worktree[]
|
||||
sources: SmartSortSources
|
||||
active: Set<string>
|
||||
tabsOpen: Set<string>
|
||||
gate: Promise<void> | null
|
||||
}
|
||||
|
||||
function harness(initial: { sortBy: WorkspaceSortBy; worktrees: Worktree[] }) {
|
||||
const world: World = {
|
||||
sortBy: initial.sortBy,
|
||||
showSleeping: false,
|
||||
worktrees: initial.worktrees,
|
||||
sources: NO_SOURCES,
|
||||
active: new Set<string>(),
|
||||
tabsOpen: new Set<string>(),
|
||||
gate: null
|
||||
}
|
||||
const owner = new SidebarTemporalOwner(new SidebarClock(), {
|
||||
sortBy: () => world.sortBy,
|
||||
showSleepingWorkspaces: () => world.showSleeping,
|
||||
readSortSample: async () => {
|
||||
const worktrees = world.worktrees
|
||||
await world.gate
|
||||
return { worktrees, repoMap: new Map(), sources: world.sources }
|
||||
},
|
||||
retentionCandidates: () => ({
|
||||
ids: world.active,
|
||||
isActive: (id) => world.active.has(id),
|
||||
isUnverifiableGap: (id) => world.tabsOpen.has(id)
|
||||
})
|
||||
})
|
||||
const ranked = (): string[] | undefined =>
|
||||
owner.snapshot().sortSnapshot?.rankedIds.map(getWorktreeIdFromHostIdentity)
|
||||
return { world, owner, ranked }
|
||||
}
|
||||
|
||||
const flush = async (): Promise<void> => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
describe('SidebarTemporalOwner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000_000)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('coalesces a burst into one re-sort three seconds after its last change', async () => {
|
||||
const { world, owner, ranked } = harness({
|
||||
sortBy: 'recent',
|
||||
worktrees: [worktree('a', { lastActivityAt: 2 }), worktree('b', { lastActivityAt: 1 })]
|
||||
})
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
|
||||
world.worktrees = [worktree('a', { lastActivityAt: 2 }), worktree('b', { lastActivityAt: 3 })]
|
||||
owner.noteSortInputChange()
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
owner.noteSortInputChange()
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS - 1)
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(ranked()).toEqual(['b', 'a'])
|
||||
expect(owner.snapshot().sortSnapshot?.epoch).toBe(2)
|
||||
})
|
||||
|
||||
it('settles at once when the row count moves or the mode switches', async () => {
|
||||
const { world, owner, ranked } = harness({ sortBy: 'name', worktrees: [worktree('b')] })
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
world.worktrees = [worktree('b'), worktree('a')]
|
||||
owner.noteCatalogListing(world.worktrees, false)
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
|
||||
world.worktrees = [worktree('b', { lastActivityAt: 9 }), worktree('a')]
|
||||
world.sortBy = 'recent'
|
||||
owner.noteViewChange()
|
||||
await flush()
|
||||
expect(owner.snapshot().sortSnapshot?.mode).toBe('recent')
|
||||
expect(ranked()).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('keeps Smart cold on saved order until a live signal, then latched until a new session', async () => {
|
||||
const { world, owner, ranked } = harness({
|
||||
sortBy: 'smart',
|
||||
worktrees: [
|
||||
worktree('a', { sortOrder: 2, lastActivityAt: 1 }),
|
||||
worktree('b', { sortOrder: 1, lastActivityAt: 5 })
|
||||
]
|
||||
})
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
expect(owner.snapshot().sortSnapshot?.smartLatched).toBe(false)
|
||||
|
||||
world.sources = {
|
||||
...NO_SOURCES,
|
||||
tabsByWorktree: { b: [{ id: 'tab-b', title: 'zsh' }] },
|
||||
ptyIdsByTabId: { 'tab-b': ['pty-b'] }
|
||||
}
|
||||
owner.noteSortInputChange()
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS)
|
||||
expect(ranked()).toEqual(['b', 'a'])
|
||||
expect(owner.snapshot().sortSnapshot?.smartLatched).toBe(true)
|
||||
|
||||
// The signal ending does not unlatch: recency still ranks b first.
|
||||
world.sources = NO_SOURCES
|
||||
owner.noteSortInputChange()
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS)
|
||||
expect(ranked()).toEqual(['b', 'a'])
|
||||
|
||||
owner.beginSession('s2')
|
||||
expect(owner.snapshot()).toMatchObject({ sessionId: 's2', sortSnapshot: null })
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
expect(owner.snapshot().sortSnapshot?.smartLatched).toBe(false)
|
||||
})
|
||||
|
||||
it('re-sorts when a new workspace leaves its Recent create grace', async () => {
|
||||
const now = Date.now()
|
||||
const { owner, ranked } = harness({
|
||||
sortBy: 'recent',
|
||||
worktrees: [
|
||||
worktree('old', { lastActivityAt: now - 10 }),
|
||||
worktree('new', { lastActivityAt: now - 1_000, createdAt: now - 1_000 })
|
||||
]
|
||||
})
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['new', 'old'])
|
||||
await vi.advanceTimersByTimeAsync(CREATE_GRACE_MS - 1_000)
|
||||
expect(ranked()).toEqual(['old', 'new'])
|
||||
expect(owner.snapshot().sortSnapshot?.cause).toBe('create-grace')
|
||||
})
|
||||
|
||||
it('stays idle until a window begins a session, and after dispose', async () => {
|
||||
const { world, owner } = harness({ sortBy: 'name', worktrees: [worktree('a')] })
|
||||
owner.noteCatalogListing([worktree('a')], false)
|
||||
owner.noteSortInputChange()
|
||||
world.active = new Set(['a'])
|
||||
owner.noteActivityChange()
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS)
|
||||
expect(owner.snapshot()).toEqual({
|
||||
revision: 0,
|
||||
sessionId: null,
|
||||
sortSnapshot: null,
|
||||
retention: null
|
||||
})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
owner.beginSession('s1')
|
||||
owner.dispose()
|
||||
owner.noteSortInputChange()
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS)
|
||||
expect(owner.snapshot().sortSnapshot).toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('takes the session catalog as its row baseline, so a later listing waits out the burst', async () => {
|
||||
const { world, owner, ranked } = harness({
|
||||
sortBy: 'recent',
|
||||
worktrees: [worktree('a', { lastActivityAt: 2 }), worktree('b', { lastActivityAt: 1 })]
|
||||
})
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
// A bump with no row count, then a listing with the same rows and fresher activity.
|
||||
owner.noteSortInputChange()
|
||||
world.worktrees = [worktree('a', { lastActivityAt: 2 }), worktree('b', { lastActivityAt: 3 })]
|
||||
owner.noteCatalogListing(world.worktrees, false)
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS)
|
||||
expect(ranked()).toEqual(['b', 'a'])
|
||||
|
||||
// A listing that changed nothing the sort reads is no bump, so it cannot delay a settle.
|
||||
world.worktrees = [worktree('a', { lastActivityAt: 4 }), worktree('b', { lastActivityAt: 3 })]
|
||||
owner.noteSortInputChange()
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS - 1_000)
|
||||
owner.noteCatalogListing(
|
||||
[worktree('a', { lastActivityAt: 2 }), worktree('b', { lastActivityAt: 3 })],
|
||||
false
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('reads a sortOrder-only save and a repo change as writes that never restart the burst', async () => {
|
||||
const rows = (sortOrder: number): Worktree[] => [
|
||||
worktree('a', { lastActivityAt: 2, sortOrder }),
|
||||
worktree('b', { lastActivityAt: 1 })
|
||||
]
|
||||
const { world, owner } = harness({ sortBy: 'recent', worktrees: rows(0) })
|
||||
const epoch = (): number | undefined => owner.snapshot().sortSnapshot?.epoch
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
owner.noteSortInputChange()
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
world.worktrees = rows(5)
|
||||
owner.noteCatalogListing(world.worktrees, false)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(epoch()).toBe(2)
|
||||
|
||||
// A repo edit re-sorts at once and arms nothing.
|
||||
owner.noteCatalogListing(world.worktrees, true)
|
||||
await flush()
|
||||
expect(epoch()).toBe(3)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('takes a listing that lands before the first sample as the baseline', async () => {
|
||||
const { world, owner } = harness({ sortBy: 'recent', worktrees: [worktree('a')] })
|
||||
let release: () => void = () => {}
|
||||
world.gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
owner.beginSession('s1')
|
||||
world.worktrees = [worktree('a'), worktree('b')]
|
||||
owner.noteCatalogListing(world.worktrees, false)
|
||||
world.gate = null
|
||||
release()
|
||||
await flush()
|
||||
expect(owner.snapshot().sortSnapshot?.epoch).toBe(1)
|
||||
// Same count as the baseline, so a later row change waits out the burst.
|
||||
owner.noteCatalogListing([worktree('a'), worktree('b', { lastActivityAt: 7 })], false)
|
||||
await flush()
|
||||
expect(owner.snapshot().sortSnapshot?.epoch).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(SORT_SETTLE_MS)
|
||||
expect(owner.snapshot().sortSnapshot?.epoch).toBe(2)
|
||||
})
|
||||
|
||||
it('drops a sample a newer settle overtook', async () => {
|
||||
const { world, owner, ranked } = harness({ sortBy: 'name', worktrees: [worktree('a')] })
|
||||
// The session's first read stalls on the one-row catalog; a mode switch's read overtakes it.
|
||||
let release: () => void = () => {}
|
||||
world.gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
owner.beginSession('s1')
|
||||
world.gate = null
|
||||
world.worktrees = [worktree('a'), worktree('b')]
|
||||
world.sortBy = 'recent'
|
||||
owner.noteViewChange()
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
release()
|
||||
await flush()
|
||||
expect(ranked()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('holds a live row through a rebind gap, then lets it go on its own deadline', async () => {
|
||||
const { world, owner } = harness({ sortBy: 'name', worktrees: [worktree('a'), worktree('b')] })
|
||||
owner.beginSession('s1')
|
||||
await flush()
|
||||
world.active = new Set(['a', 'b'])
|
||||
world.tabsOpen = new Set(['a'])
|
||||
owner.noteActivityChange()
|
||||
world.active = new Set()
|
||||
owner.noteActivityChange()
|
||||
// b had no terminal tab left, so its inactivity is definitive and gets no grace.
|
||||
expect(owner.snapshot().retention?.retainedIds).toEqual([
|
||||
getWorktreeHostIdentity(worktree('a'))
|
||||
])
|
||||
await vi.advanceTimersByTimeAsync(SLEEPING_SWEEP_SETTLE_MS)
|
||||
expect(owner.snapshot().retention?.retainedIds).toEqual([])
|
||||
|
||||
world.showSleeping = true
|
||||
owner.noteViewChange()
|
||||
expect(owner.snapshot().retention).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,346 +0,0 @@
|
||||
import { ProducerRevision } from './producer-revision'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
import {
|
||||
CREATE_GRACE_MS,
|
||||
type WorkspaceSortBy
|
||||
} from '../../../shared/workspace-view/workspace-row-sort'
|
||||
import {
|
||||
INITIAL_SORT_SETTLE_STATE,
|
||||
reduceSortSettle,
|
||||
type SortSettleEvent
|
||||
} from '../../../shared/sidebar-list/sort-settle'
|
||||
import { ranksFollowPersistedOrder } from '../../../shared/sidebar-list/smart-sort-phase'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-switch'
|
||||
import {
|
||||
rankWorktreesForSort,
|
||||
type SmartSortSources
|
||||
} from '../../../shared/sidebar-list/smart-sort'
|
||||
import {
|
||||
createSleepingSweepRetentionState,
|
||||
updateSleepingSweepRetention
|
||||
} from '../../../shared/sidebar-list/sleeping-sweep-retention'
|
||||
import type {
|
||||
SidebarRetention,
|
||||
SidebarSortSnapshot,
|
||||
SidebarTemporalSnapshot
|
||||
} from '../../../shared/sidebar-list/sidebar-temporal-snapshot'
|
||||
import type { SidebarClock } from './sidebar-clock'
|
||||
import {
|
||||
classifyCatalogListingChange,
|
||||
countLiveRows,
|
||||
indexCatalogListing,
|
||||
type CatalogListing
|
||||
} from './catalog-listing-change'
|
||||
|
||||
/** What one settle sorts: every row this host lists, and the records Smart scores. */
|
||||
export type SidebarSortSample = {
|
||||
worktrees: Worktree[]
|
||||
repoMap: Map<string, Pick<Repo, 'displayName'>>
|
||||
sources: SmartSortSources
|
||||
}
|
||||
|
||||
/** The sweep's view of one workspace, by bare id. */
|
||||
export type SidebarRetentionCandidates = {
|
||||
/** Active ids; the last sample's rows are added to them. */
|
||||
ids: Iterable<string>
|
||||
isActive: (worktreeId: string) => boolean
|
||||
/** False when the inactivity is definitive: a sleep this host started, or no terminal tab left. */
|
||||
isUnverifiableGap: (worktreeId: string) => boolean
|
||||
}
|
||||
|
||||
export type SidebarTemporalInputs = {
|
||||
sortBy: () => WorkspaceSortBy
|
||||
showSleepingWorkspaces: () => boolean
|
||||
/** May scan the host's worktrees; a later settle supersedes a slow read. */
|
||||
readSortSample: () => Promise<SidebarSortSample>
|
||||
retentionCandidates: () => SidebarRetentionCandidates
|
||||
}
|
||||
|
||||
const SETTLE_DEADLINE = 'sort-settle'
|
||||
|
||||
const CREATE_GRACE_DEADLINE = 'create-grace'
|
||||
const RETENTION_DEADLINE = 'sleeping-retention'
|
||||
|
||||
/**
|
||||
* Main's copy of the list's time rules: when the frozen order settles, whether Smart has latched,
|
||||
* and which sleeping rows the sweep still keeps. The window still runs its own; this one is
|
||||
* compared against it and never persists ranks. It stays idle until a window begins a session.
|
||||
*/
|
||||
export class SidebarTemporalOwner {
|
||||
readonly revision = new ProducerRevision()
|
||||
private sessionId: string | null = null
|
||||
private disposed = false
|
||||
private settleState = INITIAL_SORT_SETTLE_STATE
|
||||
private sortBy: WorkspaceSortBy
|
||||
private smartLatched = false
|
||||
private epoch = 0
|
||||
private sortSnapshot: SidebarSortSnapshot | null = null
|
||||
// A sample started before a reset or a later settle must not land.
|
||||
private sampleSeq = 0
|
||||
private identityById = new Map<string, string>()
|
||||
private retentionState = createSleepingSweepRetentionState()
|
||||
private retention: SidebarRetention | null = null
|
||||
// The last listing, as the window's catalog would hold it.
|
||||
private listing: CatalogListing = new Map()
|
||||
// The window's settle baseline is the catalog it already holds, so a session's first rows set main's.
|
||||
private seedRows = false
|
||||
|
||||
constructor(
|
||||
private readonly clock: SidebarClock,
|
||||
private readonly inputs: SidebarTemporalInputs
|
||||
) {
|
||||
this.sortBy = inputs.sortBy()
|
||||
}
|
||||
|
||||
snapshot(): SidebarTemporalSnapshot {
|
||||
return {
|
||||
revision: this.revision.value,
|
||||
sessionId: this.sessionId,
|
||||
sortSnapshot: this.sortSnapshot,
|
||||
retention: this.retention
|
||||
}
|
||||
}
|
||||
|
||||
/** The window mounted its list: Smart goes cold and retention forgets, as on today's remount. */
|
||||
beginSession(sessionId: string): void {
|
||||
if (sessionId === this.sessionId) {
|
||||
return
|
||||
}
|
||||
this.sessionId = sessionId
|
||||
this.sortBy = this.inputs.sortBy()
|
||||
this.smartLatched = false
|
||||
this.epoch = 0
|
||||
// Until the new session's first sample lands, a reader must not take the old warm ranks.
|
||||
this.sortSnapshot = null
|
||||
this.retentionState = createSleepingSweepRetentionState()
|
||||
this.seedRows = true
|
||||
this.revision.bump()
|
||||
this.retentionPass()
|
||||
this.settleNow()
|
||||
}
|
||||
|
||||
/** A change the sort reads; it settles after the burst, or at once when the row count moved. */
|
||||
noteSortInputChange(liveWorktreeCount?: number): boolean {
|
||||
return this.dispatchSettle({
|
||||
kind: 'write',
|
||||
change: {
|
||||
epochBumped: true,
|
||||
liveCount: liveWorktreeCount ?? this.settleState.liveCountAtLastBump
|
||||
},
|
||||
settled: false,
|
||||
sortBy: this.sortBy,
|
||||
previousSortBy: this.sortBy
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog was listed again after a worktree or repo change. Mirrors the window's merge: a
|
||||
* row change bumps the sort, a sortOrder-only save re-ranks only Manual and cold Smart, and a
|
||||
* repo change re-sorts at once since repo labels are a sort key.
|
||||
*/
|
||||
noteCatalogListing(worktrees: readonly Worktree[], repoChanged: boolean): void {
|
||||
if (!this.running) {
|
||||
return
|
||||
}
|
||||
const previous = this.listing
|
||||
this.listing = indexCatalogListing(worktrees)
|
||||
const liveCount = countLiveRows(worktrees)
|
||||
if (this.takeRowsSeed(liveCount)) {
|
||||
// The session's first sample may have read older rows; these replace it.
|
||||
this.settleNow()
|
||||
return
|
||||
}
|
||||
// Switch point: the order is a pure read of the rows' stamps, so any re-list re-sorts, as the window does.
|
||||
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
|
||||
this.settleNow()
|
||||
return
|
||||
}
|
||||
const change = classifyCatalogListingChange(previous, this.listing)
|
||||
// Every row change bumps here, so the count can only move on a bump; no other write settles.
|
||||
const settled = change === 'rows' && this.noteSortInputChange(liveCount)
|
||||
const followsSave =
|
||||
change === 'sort-order' && ranksFollowPersistedOrder(this.sortBy, this.smartLatched)
|
||||
if (!settled && (repoChanged || followsSave)) {
|
||||
this.settleNow()
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch on: a saved metadata write no catalog event announced. */
|
||||
noteRowsWritten(): void {
|
||||
this.settleNow()
|
||||
}
|
||||
|
||||
/** Activity alone never re-sorts; its sort-relevant causes arrive as their own changes. */
|
||||
noteActivityChange(): void {
|
||||
this.retentionPass()
|
||||
}
|
||||
|
||||
/** The sort mode or the sleeping filter may have changed. */
|
||||
noteViewChange(): void {
|
||||
const previousSortBy = this.sortBy
|
||||
this.sortBy = this.inputs.sortBy()
|
||||
if (this.sortBy !== previousSortBy) {
|
||||
this.dispatchSettle({
|
||||
kind: 'write',
|
||||
change: null,
|
||||
settled: false,
|
||||
sortBy: this.sortBy,
|
||||
previousSortBy
|
||||
})
|
||||
}
|
||||
this.retentionPass()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.sampleSeq += 1
|
||||
for (const key of [SETTLE_DEADLINE, CREATE_GRACE_DEADLINE, RETENTION_DEADLINE]) {
|
||||
this.clock.cancel(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** True once per session, for its first rows: they become the baseline, as the window's are. */
|
||||
private takeRowsSeed(liveCount: number): boolean {
|
||||
if (!this.seedRows) {
|
||||
return false
|
||||
}
|
||||
this.seedRows = false
|
||||
this.settleState = { ...this.settleState, liveCountAtLastBump: liveCount }
|
||||
return true
|
||||
}
|
||||
|
||||
/** False until a window begins a session, and after dispose. */
|
||||
get running(): boolean {
|
||||
return this.sessionId !== null && !this.disposed
|
||||
}
|
||||
|
||||
/** True when this event settled at once. */
|
||||
private dispatchSettle(event: SortSettleEvent): boolean {
|
||||
if (!this.running) {
|
||||
return false
|
||||
}
|
||||
const step = reduceSortSettle(this.settleState, event, Date.now())
|
||||
const deadline = step.state.deadline
|
||||
if (deadline !== this.settleState.deadline) {
|
||||
if (deadline === null) {
|
||||
this.clock.cancel(SETTLE_DEADLINE)
|
||||
} else {
|
||||
this.clock.schedule(SETTLE_DEADLINE, deadline, () =>
|
||||
this.dispatchSettle({ kind: 'deadline' })
|
||||
)
|
||||
}
|
||||
}
|
||||
this.settleState = step.state
|
||||
if (step.settle) {
|
||||
this.settleNow()
|
||||
}
|
||||
return step.settle
|
||||
}
|
||||
|
||||
private settleNow(cause: SidebarSortSnapshot['cause'] = 'settle'): void {
|
||||
if (!this.running) {
|
||||
return
|
||||
}
|
||||
const seq = ++this.sampleSeq
|
||||
void this.inputs.readSortSample().then(
|
||||
(sample) => {
|
||||
if (seq === this.sampleSeq) {
|
||||
this.applySample(sample, cause)
|
||||
}
|
||||
},
|
||||
(error: unknown) => console.warn('[sidebar-temporal] could not read the rows to sort', error)
|
||||
)
|
||||
}
|
||||
|
||||
private applySample(sample: SidebarSortSample, cause: SidebarSortSnapshot['cause']): void {
|
||||
const now = Date.now()
|
||||
const ranking = rankWorktreesForSort({
|
||||
worktrees: sample.worktrees.filter((worktree) => !worktree.isArchived),
|
||||
sortBy: this.sortBy,
|
||||
latched: this.smartLatched,
|
||||
repoMap: sample.repoMap,
|
||||
now,
|
||||
sources: sample.sources
|
||||
})
|
||||
this.smartLatched ||= ranking.detectedLiveSmartSignal
|
||||
this.epoch += 1
|
||||
this.sortSnapshot = {
|
||||
mode: this.sortBy,
|
||||
epoch: this.epoch,
|
||||
sampledAt: now,
|
||||
rankedIds: ranking.sorted.map(getWorktreeHostIdentity),
|
||||
smartLatched: this.smartLatched,
|
||||
cause
|
||||
}
|
||||
if (this.takeRowsSeed(countLiveRows(sample.worktrees))) {
|
||||
this.listing = indexCatalogListing(sample.worktrees)
|
||||
}
|
||||
this.identityById = new Map(
|
||||
sample.worktrees.map((worktree) => [worktree.id, getWorktreeHostIdentity(worktree)])
|
||||
)
|
||||
this.revision.bump()
|
||||
this.scheduleCreateGraceBoundary(ranking.sorted, now)
|
||||
}
|
||||
|
||||
// Recent ranks a new row at its grace floor until the window closes; re-sort when it does.
|
||||
private scheduleCreateGraceBoundary(worktrees: readonly Worktree[], now: number): void {
|
||||
let next = Infinity
|
||||
// Stamps carry no create grace.
|
||||
const readsRecency =
|
||||
!ORCA_SIDEBAR_INPUTS_IS_MAIN &&
|
||||
(this.sortBy === 'recent' ||
|
||||
(this.sortBy === 'smart' && !ranksFollowPersistedOrder(this.sortBy, this.smartLatched)))
|
||||
if (readsRecency) {
|
||||
for (const { createdAt } of worktrees) {
|
||||
const closesAt = (createdAt ?? -Infinity) + CREATE_GRACE_MS
|
||||
if (closesAt > now && closesAt < next) {
|
||||
next = closesAt
|
||||
}
|
||||
}
|
||||
}
|
||||
if (next === Infinity) {
|
||||
this.clock.cancel(CREATE_GRACE_DEADLINE)
|
||||
} else {
|
||||
this.clock.schedule(CREATE_GRACE_DEADLINE, next, () => this.settleNow('create-grace'))
|
||||
}
|
||||
}
|
||||
|
||||
private retentionPass(): void {
|
||||
if (!this.running) {
|
||||
return
|
||||
}
|
||||
if (this.inputs.showSleepingWorkspaces()) {
|
||||
this.retentionState = createSleepingSweepRetentionState()
|
||||
this.clock.cancel(RETENTION_DEADLINE)
|
||||
if (this.retention !== null) {
|
||||
this.retention = null
|
||||
this.revision.bump()
|
||||
}
|
||||
return
|
||||
}
|
||||
const candidates = this.inputs.retentionCandidates()
|
||||
const now = Date.now()
|
||||
const { retainedIds, nextExpiryInMs } = updateSleepingSweepRetention({
|
||||
state: this.retentionState,
|
||||
// The last sample's rows too, so an id that just went idle is not pruned out of its grace.
|
||||
candidateWorktreeIds: [...new Set([...candidates.ids, ...this.identityById.keys()])],
|
||||
isActive: candidates.isActive,
|
||||
isUnverifiableGap: candidates.isUnverifiableGap,
|
||||
nowMs: now
|
||||
})
|
||||
if (nextExpiryInMs === null) {
|
||||
this.clock.cancel(RETENTION_DEADLINE)
|
||||
} else {
|
||||
this.clock.schedule(RETENTION_DEADLINE, now + nextExpiryInMs, () => this.retentionPass())
|
||||
}
|
||||
// An id no sample has listed yet has no host to qualify it with, so it waits for one.
|
||||
const ids = [...retainedIds].flatMap((id) => this.identityById.get(id) ?? []).sort()
|
||||
const previous = this.retention?.retainedIds
|
||||
if (!previous || previous.length !== ids.length || previous.some((id, i) => id !== ids[i])) {
|
||||
this.retention = { retainedIds: ids, retentionDecidedAt: now }
|
||||
this.revision.bump()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../../agent-hooks/server'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { SmartSortAgentEntries } from './smart-sort-agent-entries'
|
||||
|
||||
const WT = 'repo-1::/tmp/a'
|
||||
const PANE = makePaneKey('tab-1', '33333333-3333-4333-8333-333333333333')
|
||||
|
||||
function status(server: AgentHookServer, state: 'working' | 'waiting' | 'done'): void {
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: WT,
|
||||
payload: { state, prompt: 'task', agentType: 'codex' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('SmartSortAgentEntries', () => {
|
||||
it('keeps one entry per pane and records each state switch into its history', () => {
|
||||
const server = new AgentHookServer()
|
||||
const entries = new SmartSortAgentEntries()
|
||||
const onChange = vi.fn()
|
||||
const detach = entries.attach(server, onChange)
|
||||
|
||||
status(server, 'working')
|
||||
status(server, 'waiting')
|
||||
status(server, 'working')
|
||||
const entry = entries.byPaneKey()[PANE]
|
||||
expect(entry).toMatchObject({ state: 'working', worktreeId: WT, tabId: 'tab-1' })
|
||||
expect(entry?.stateHistory.map((h) => h.state)).toEqual(['working', 'waiting'])
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
|
||||
detach()
|
||||
expect(entries.byPaneKey()).toEqual({})
|
||||
})
|
||||
|
||||
it('seeds from rows the store already holds', () => {
|
||||
const server = new AgentHookServer()
|
||||
status(server, 'waiting')
|
||||
const entries = new SmartSortAgentEntries()
|
||||
entries.attach(server, () => {})
|
||||
expect(entries.byPaneKey()[PANE]?.state).toBe('waiting')
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry,
|
||||
type AgentStatusIpcPayload
|
||||
} from '../../../shared/agent-status-types'
|
||||
import { isAgentStatusFresh } from '../../../shared/agent-status-freshness'
|
||||
import { isSmartSortRelevantAgentChange } from '../../../shared/sidebar-list/smart-sort-agent-change'
|
||||
import { resolveAgentStatusLiveEntryStateHistory } from '../../../shared/agent-status-live-entry-state-history'
|
||||
import type { AgentStatusStoreView } from './workspace-activity-facts-producer'
|
||||
|
||||
/**
|
||||
* The hook store's rows as the entries Smart scores, one per pane. The store keeps no history,
|
||||
* so state switches are recorded here, as the window records them from the same rows.
|
||||
*/
|
||||
export class SmartSortAgentEntries {
|
||||
private readonly entries = new Map<string, AgentStatusEntry>()
|
||||
private store: AgentStatusStoreView | null = null
|
||||
|
||||
attach(store: AgentStatusStoreView, onChange: () => void): () => void {
|
||||
this.store = store
|
||||
for (const row of store.getStatusSnapshot()) {
|
||||
this.refreshPane(row.paneKey)
|
||||
}
|
||||
const unsubscribe = store.subscribeStatusRowMutations(({ before, after }) => {
|
||||
let relevant = false
|
||||
for (const paneKey of new Set([before?.paneKey, after?.paneKey])) {
|
||||
if (paneKey) {
|
||||
relevant = this.refreshPane(paneKey) || relevant
|
||||
}
|
||||
}
|
||||
if (relevant) {
|
||||
onChange()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
this.store = null
|
||||
this.entries.clear()
|
||||
}
|
||||
}
|
||||
|
||||
byPaneKey(): Record<string, AgentStatusEntry> {
|
||||
return Object.fromEntries(this.entries)
|
||||
}
|
||||
|
||||
/** True when the change can move the pane in the Smart order, as the window decides. */
|
||||
private refreshPane(paneKey: string): boolean {
|
||||
// The window applies a pane's rows in arrival order, so the newest is the one it holds.
|
||||
const rows = (this.store?.getStatusSnapshotForPane(paneKey) ?? []).filter(
|
||||
(row) => !row.providerSessionOnly
|
||||
)
|
||||
const latest = rows.reduce<AgentStatusIpcPayload | null>(
|
||||
(newest, row) => (newest && newest.receivedAt >= row.receivedAt ? newest : row),
|
||||
null
|
||||
)
|
||||
const existing = this.entries.get(paneKey)
|
||||
if (!latest) {
|
||||
this.entries.delete(paneKey)
|
||||
return (
|
||||
existing !== undefined &&
|
||||
isAgentStatusFresh(existing, Date.now(), AGENT_STATUS_STALE_AFTER_MS)
|
||||
)
|
||||
}
|
||||
const { history, stateObservedAt } = resolveAgentStatusLiveEntryStateHistory(
|
||||
existing,
|
||||
latest,
|
||||
latest.receivedAt
|
||||
)
|
||||
const entry: AgentStatusEntry = {
|
||||
state: latest.state,
|
||||
...(latest.workingMode !== undefined ? { workingMode: latest.workingMode } : {}),
|
||||
prompt: latest.prompt,
|
||||
updatedAt: latest.receivedAt,
|
||||
...(latest.evidenceObservedAt !== undefined
|
||||
? { evidenceObservedAt: latest.evidenceObservedAt }
|
||||
: {}),
|
||||
...(latest.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}),
|
||||
stateStartedAt: latest.stateStartedAt,
|
||||
stateObservedAt,
|
||||
...(latest.turnStartedAt !== undefined ? { turnStartedAt: latest.turnStartedAt } : {}),
|
||||
agentType: latest.agentType,
|
||||
paneKey,
|
||||
...(latest.worktreeId ? { worktreeId: latest.worktreeId } : {}),
|
||||
...(latest.tabId ? { tabId: latest.tabId } : {}),
|
||||
stateHistory: history,
|
||||
...(latest.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
|
||||
interrupted: latest.interrupted,
|
||||
sessionBoundary: latest.sessionBoundary,
|
||||
...(latest.mainAgent ? { mainAgent: latest.mainAgent } : {})
|
||||
}
|
||||
this.entries.set(paneKey, entry)
|
||||
// The window also re-sorts on a Command Code prompt; that turn moves stateStartedAt here.
|
||||
return isSmartSortRelevantAgentChange(existing, entry, latest.receivedAt)
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { SLEEPING_SWEEP_SETTLE_MS } from '../../../../../../shared/sidebar-list/sleeping-sweep-retention'
|
||||
import { getWorktreeIdFromHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import { SidebarClock } from '../../../../../../main/runtime/sidebar-inputs/sidebar-clock'
|
||||
import { SidebarRetentionOwner } from '../../../../../../main/runtime/sidebar-inputs/sidebar-retention-owner'
|
||||
import { makeRepo, makeWorktree } from '../../../worktree-jump-palette-test-fixtures'
|
||||
import { makeTerminalTab } from '../../../../store/slices/worktrees-slice-test-fixtures'
|
||||
import { useSleepingSweepRetention } from './use-sleeping-sweep-retention'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
const repo = makeRepo()
|
||||
|
||||
/** Main's retention over the window's own store, so only the time rules can differ. */
|
||||
function mainOwner(): SidebarRetentionOwner {
|
||||
return new SidebarRetentionOwner(new SidebarClock(), {
|
||||
showSleepingWorkspaces: () => useAppStore.getState().showSleepingWorkspaces,
|
||||
listRows: async () => useAppStore.getState().worktreesByRepo[repo.id] ?? [],
|
||||
retentionCandidates: () => {
|
||||
const state = useAppStore.getState()
|
||||
const live = (id: string): boolean =>
|
||||
(state.tabsByWorktree[id] ?? []).some((tab) => (state.ptyIdsByTabId[tab.id] ?? []).length)
|
||||
return {
|
||||
ids: (state.worktreesByRepo[repo.id] ?? []).map((w) => w.id),
|
||||
isActive: live,
|
||||
isUnverifiableGap: (id) => (state.tabsByWorktree[id]?.length ?? 0) > 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function advance(ms: number): Promise<void> {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
describe('main sleeping retention against the window, on one fake clock', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(5_000_000)
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('keeps and releases the same sleeping rows', async () => {
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: { [repo.id]: [makeWorktree('a', 'A'), makeWorktree('b', 'B')] }
|
||||
})
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
showSleepingWorkspaces: false,
|
||||
tabsByWorktree: { a: [makeTerminalTab({ id: 'tab-a', worktreeId: 'a' })] },
|
||||
ptyIdsByTabId: { 'tab-a': ['pty-a'] }
|
||||
})
|
||||
)
|
||||
const owner = mainOwner()
|
||||
owner.beginSession('s1')
|
||||
await advance(0)
|
||||
const window = renderHook(() =>
|
||||
useSleepingSweepRetention({
|
||||
enabled: !useAppStore((s) => s.showSleepingWorkspaces),
|
||||
worktreesByRepo: useAppStore((s) => s.worktreesByRepo),
|
||||
tabsByWorktree: useAppStore((s) => s.tabsByWorktree),
|
||||
ptyIdsByTabId: useAppStore((s) => s.ptyIdsByTabId),
|
||||
browserTabsByWorktree: {},
|
||||
worktreeIdsWithLiveAgent: new Set(),
|
||||
worktreeIdsWithStructuredChat: new Set()
|
||||
})
|
||||
)
|
||||
const mainRetained = (): string[] =>
|
||||
(owner.snapshot().retention?.retainedIds ?? []).map(getWorktreeIdFromHostIdentity)
|
||||
|
||||
act(() => useAppStore.setState({ ptyIdsByTabId: {} }))
|
||||
owner.noteActivityChange()
|
||||
expect([...(window.result.current ?? [])]).toEqual(['a'])
|
||||
expect(mainRetained()).toEqual(['a'])
|
||||
|
||||
await advance(SLEEPING_SWEEP_SETTLE_MS)
|
||||
expect([...(window.result.current ?? [])]).toEqual([])
|
||||
expect(mainRetained()).toEqual([])
|
||||
})
|
||||
})
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Worktree } from '../../../../../../shared/worktree/types'
|
||||
import type { SortBy } from '../../../../../../shared/sidebar-list/smart-sort'
|
||||
import { SORT_SETTLE_MS } from '../../../../../../shared/sidebar-list/sort-settle'
|
||||
import { SLEEPING_SWEEP_SETTLE_MS } from '../../../../../../shared/sidebar-list/sleeping-sweep-retention'
|
||||
import { getWorktreeIdFromHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import { SidebarClock } from '../../../../../../main/runtime/sidebar-inputs/sidebar-clock'
|
||||
import { SidebarTemporalOwner } from '../../../../../../main/runtime/sidebar-inputs/sidebar-temporal-owner'
|
||||
import { makeRepo, makeWorktree } from '../../../worktree-jump-palette-test-fixtures'
|
||||
import { makeTerminalTab } from '../../../../store/slices/worktrees-slice-test-fixtures'
|
||||
import { useSidebarWorktreeSortOrder } from './use-sort-order'
|
||||
import { useSleepingSweepRetention } from './use-sleeping-sweep-retention'
|
||||
|
||||
// Main never persists ranks in shadow, so the window's write is not part of the comparison.
|
||||
vi.mock('@/lib/worktree-sort-order-persistence', () => ({
|
||||
persistWorktreeSortOrderByHost: vi.fn()
|
||||
}))
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
const repo = makeRepo()
|
||||
const repoMap = new Map([[repo.id, repo]])
|
||||
|
||||
/** Main's owner over the window's own store, so only the time rules can differ. */
|
||||
function mainOwner(): SidebarTemporalOwner {
|
||||
return new SidebarTemporalOwner(new SidebarClock(), {
|
||||
sortBy: () => useAppStore.getState().sortBy,
|
||||
showSleepingWorkspaces: () => useAppStore.getState().showSleepingWorkspaces,
|
||||
readSortSample: async () => {
|
||||
const state = useAppStore.getState()
|
||||
return { worktrees: state.worktreesByRepo[repo.id] ?? [], repoMap, sources: state }
|
||||
},
|
||||
retentionCandidates: () => {
|
||||
const state = useAppStore.getState()
|
||||
const live = (id: string): boolean =>
|
||||
(state.tabsByWorktree[id] ?? []).some((tab) => (state.ptyIdsByTabId[tab.id] ?? []).length)
|
||||
return {
|
||||
ids: (state.worktreesByRepo[repo.id] ?? []).map((w) => w.id),
|
||||
isActive: live,
|
||||
isUnverifiableGap: (id) => (state.tabsByWorktree[id]?.length ?? 0) > 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderWindow() {
|
||||
return renderHook(() => {
|
||||
const sortBy = useAppStore((s) => s.sortBy)
|
||||
return useSidebarWorktreeSortOrder({ repoMap, sortBy })
|
||||
})
|
||||
}
|
||||
|
||||
const mainRanks = (owner: SidebarTemporalOwner): string[] | undefined =>
|
||||
owner.snapshot().sortSnapshot?.rankedIds.map(getWorktreeIdFromHostIdentity)
|
||||
|
||||
/** One sort-input change, landing in both: the window's sortEpoch and main's settle window. */
|
||||
function change(
|
||||
owner: SidebarTemporalOwner,
|
||||
patch: Partial<ReturnType<typeof useAppStore.getState>>
|
||||
) {
|
||||
act(() => useAppStore.setState((s) => ({ ...patch, sortEpoch: s.sortEpoch + 1 })))
|
||||
owner.noteSortInputChange()
|
||||
}
|
||||
|
||||
async function advance(ms: number): Promise<void> {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
function seed(worktrees: Worktree[], sortBy: SortBy): void {
|
||||
useAppStore.setState({
|
||||
sortBy,
|
||||
sortEpoch: 1,
|
||||
settledSortEpoch: 1,
|
||||
worktreesByRepo: { [repo.id]: worktrees }
|
||||
})
|
||||
}
|
||||
|
||||
describe('main temporal owner against the window, on one fake clock', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(5_000_000)
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('settles a Recent burst at the same moment and to the same order', async () => {
|
||||
seed(
|
||||
[
|
||||
makeWorktree('a', 'A', { lastActivityAt: 3 }),
|
||||
makeWorktree('b', 'B', { lastActivityAt: 2 }),
|
||||
makeWorktree('c', 'C', { lastActivityAt: 1 })
|
||||
],
|
||||
'recent'
|
||||
)
|
||||
const owner = mainOwner()
|
||||
const window = renderWindow()
|
||||
owner.beginSession('s1')
|
||||
owner.noteCatalogListing(useAppStore.getState().worktreesByRepo[repo.id] ?? [], false)
|
||||
await advance(0)
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
|
||||
const bumped = (id: string, at: number): Worktree[] =>
|
||||
(useAppStore.getState().worktreesByRepo[repo.id] ?? []).map((w) =>
|
||||
w.id === id ? { ...w, lastActivityAt: at } : w
|
||||
)
|
||||
change(owner, { worktreesByRepo: { [repo.id]: bumped('c', 10) } })
|
||||
await advance(1_000)
|
||||
change(owner, { worktreesByRepo: { [repo.id]: bumped('b', 11) } })
|
||||
await advance(SORT_SETTLE_MS - 1)
|
||||
expect(window.result.current).toEqual(['a', 'b', 'c'])
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
await advance(1)
|
||||
expect(window.result.current).toEqual(['b', 'c', 'a'])
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
})
|
||||
|
||||
it('waits out a burst when a listing brings only fresher activity, as the window does', async () => {
|
||||
seed(
|
||||
[
|
||||
makeWorktree('a', 'A', { lastActivityAt: 2 }),
|
||||
makeWorktree('b', 'B', { lastActivityAt: 1 })
|
||||
],
|
||||
'recent'
|
||||
)
|
||||
const owner = mainOwner()
|
||||
const window = renderWindow()
|
||||
owner.beginSession('s1')
|
||||
await advance(0)
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
|
||||
// The window bumps once; the activity write on the row it shows reorders nothing by itself.
|
||||
change(owner, {})
|
||||
act(() =>
|
||||
useAppStore.setState((s) => ({
|
||||
worktreesByRepo: {
|
||||
[repo.id]: (s.worktreesByRepo[repo.id] ?? []).map((w) =>
|
||||
w.id === 'b' ? { ...w, lastActivityAt: 9 } : w
|
||||
)
|
||||
}
|
||||
}))
|
||||
)
|
||||
owner.noteCatalogListing(useAppStore.getState().worktreesByRepo[repo.id] ?? [], false)
|
||||
await advance(SORT_SETTLE_MS - 1)
|
||||
expect(window.result.current).toEqual(['a', 'b'])
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
await advance(1)
|
||||
expect(window.result.current).toEqual(['b', 'a'])
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
})
|
||||
|
||||
it('latches Smart on the same live signal and both go cold on a remount', async () => {
|
||||
seed(
|
||||
[
|
||||
makeWorktree('a', 'A', { sortOrder: 2, lastActivityAt: 1 }),
|
||||
makeWorktree('b', 'B', { sortOrder: 1, lastActivityAt: 9 })
|
||||
],
|
||||
'smart'
|
||||
)
|
||||
const owner = mainOwner()
|
||||
let window = renderWindow()
|
||||
owner.beginSession('s1')
|
||||
owner.noteCatalogListing(useAppStore.getState().worktreesByRepo[repo.id] ?? [], false)
|
||||
await advance(0)
|
||||
expect(window.result.current).toEqual(['a', 'b'])
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
|
||||
change(owner, {
|
||||
tabsByWorktree: { b: [makeTerminalTab({ id: 'tab-b', worktreeId: 'b' })] },
|
||||
ptyIdsByTabId: { 'tab-b': ['pty-b'] }
|
||||
})
|
||||
await advance(SORT_SETTLE_MS)
|
||||
expect(window.result.current).toEqual(['b', 'a'])
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
|
||||
change(owner, { ptyIdsByTabId: {} })
|
||||
await advance(SORT_SETTLE_MS)
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
|
||||
window.unmount()
|
||||
window = renderWindow()
|
||||
owner.beginSession('s2')
|
||||
await advance(0)
|
||||
expect(window.result.current).toEqual(['a', 'b'])
|
||||
expect(mainRanks(owner)).toEqual(window.result.current)
|
||||
})
|
||||
|
||||
it('keeps and releases the same sleeping rows', async () => {
|
||||
seed([makeWorktree('a', 'A'), makeWorktree('b', 'B')], 'name')
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
showSleepingWorkspaces: false,
|
||||
tabsByWorktree: { a: [makeTerminalTab({ id: 'tab-a', worktreeId: 'a' })] },
|
||||
ptyIdsByTabId: { 'tab-a': ['pty-a'] }
|
||||
})
|
||||
)
|
||||
const owner = mainOwner()
|
||||
owner.beginSession('s1')
|
||||
await advance(0)
|
||||
const window = renderHook(() =>
|
||||
useSleepingSweepRetention({
|
||||
enabled: !useAppStore((s) => s.showSleepingWorkspaces),
|
||||
worktreesByRepo: useAppStore((s) => s.worktreesByRepo),
|
||||
tabsByWorktree: useAppStore((s) => s.tabsByWorktree),
|
||||
ptyIdsByTabId: useAppStore((s) => s.ptyIdsByTabId),
|
||||
browserTabsByWorktree: {},
|
||||
worktreeIdsWithLiveAgent: new Set(),
|
||||
worktreeIdsWithStructuredChat: new Set()
|
||||
})
|
||||
)
|
||||
const mainRetained = (): string[] =>
|
||||
(owner.snapshot().retention?.retainedIds ?? []).map(getWorktreeIdFromHostIdentity)
|
||||
|
||||
act(() => useAppStore.setState({ ptyIdsByTabId: {} }))
|
||||
owner.noteActivityChange()
|
||||
expect([...(window.result.current ?? [])]).toEqual(['a'])
|
||||
expect(mainRetained()).toEqual(['a'])
|
||||
|
||||
await advance(SLEEPING_SWEEP_SETTLE_MS)
|
||||
expect([...(window.result.current ?? [])]).toEqual([])
|
||||
expect(mainRetained()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -92,8 +92,8 @@ function useSettledSidebarWorktreeSortOrder(args: {
|
||||
// Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot.
|
||||
const sortedIds = useReusedArrayIdentity(recomputedSort.sortedIds)
|
||||
|
||||
// Why: main's copy of the latch and retention lives as long as this list, as the ref above does.
|
||||
useSidebarListSession(sortBy, sortedIds)
|
||||
// Why: main's sleeping retention lives as long as this list.
|
||||
useSidebarListSession()
|
||||
|
||||
// Why after commit: a discarded render must not latch Smart onto a live signal that never committed.
|
||||
useEffect(() => {
|
||||
@@ -199,7 +199,7 @@ function useStampSidebarWorktreeSortOrder(args: {
|
||||
)
|
||||
const sortedIds = useReusedArrayIdentity(ranked)
|
||||
// Why: main's sleeping retention lives as long as this list.
|
||||
useSidebarListSession(sortBy, sortedIds)
|
||||
useSidebarListSession()
|
||||
useTrackSmartToRecentSwitch(sortBy)
|
||||
return sortedIds
|
||||
}
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-switch'
|
||||
import type { WorkspaceSortBy } from '../../../shared/workspace-view/workspace-row-sort'
|
||||
import { isPairedWebClientWindow } from './desktop-window-chrome'
|
||||
import { createBrowserUuid } from './browser-uuid'
|
||||
import {
|
||||
clearWindowSidebarTemporalReadout,
|
||||
recordWindowSortOrder
|
||||
beginWindowSidebarTemporalReadout,
|
||||
clearWindowSidebarTemporalReadout
|
||||
} from './sidebar-shadow/window-sidebar-temporal-readout'
|
||||
|
||||
/**
|
||||
* Switch point. Tells main the list mounted, so main's Smart latch and sleeping retention start
|
||||
* cold as the window's do, and records the window's order for the shadow check. A paired web
|
||||
* client's list is its own view and never resets the host's.
|
||||
* Switch point. Tells main the list mounted, so main's sleeping retention starts cold as the
|
||||
* window's does. A paired web client's list is its own view and never resets the host's.
|
||||
*/
|
||||
export function useSidebarListSession(sortBy: WorkspaceSortBy, sortedIds: readonly string[]): void {
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
export function useSidebarListSession(): void {
|
||||
useEffect(() => {
|
||||
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN || isPairedWebClientWindow()) {
|
||||
return
|
||||
}
|
||||
const sessionId = createBrowserUuid()
|
||||
sessionIdRef.current = sessionId
|
||||
beginWindowSidebarTemporalReadout(sessionId)
|
||||
void window.api.runtime
|
||||
.call({ method: 'sidebarSession.begin', params: { sessionId } })
|
||||
.catch(() => {})
|
||||
return () => clearWindowSidebarTemporalReadout(sessionId)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (ORCA_SIDEBAR_INPUTS_IS_MAIN && sessionIdRef.current) {
|
||||
recordWindowSortOrder(sessionIdRef.current, sortBy, sortedIds)
|
||||
}
|
||||
}, [sortBy, sortedIds])
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { defineSidebarShadowDomain } from './sidebar-shadow-comparison'
|
||||
import { toLocallyOwnedRows } from './workspace-activity-shadow'
|
||||
import { readWindowSidebarTemporal } from './window-sidebar-temporal-readout'
|
||||
|
||||
type TemporalValue = { mode?: string; ids: string[] }
|
||||
type TemporalValue = { ids: string[] }
|
||||
|
||||
// Ids only this host's runtime answers for, each with one row, so it maps to one identity.
|
||||
function readComparableIdentities(): Map<string, string> {
|
||||
@@ -26,8 +26,9 @@ function readComparableIdentities(): Map<string, string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Main's frozen order and sweep retention against the window's own, for the list session the
|
||||
* window began. Rows a paired server answers for, and rows the window does not list, are left out.
|
||||
* Main's sweep retention against the window's own, for the list session the window began. Rows a
|
||||
* paired server answers for, and rows the window does not list, are left out. Order is not
|
||||
* compared: every client sorts by the owning host's stamps.
|
||||
*/
|
||||
export const sidebarTemporalShadow = defineSidebarShadowDomain<TemporalValue>({
|
||||
name: 'sidebar-temporal',
|
||||
@@ -41,9 +42,6 @@ export const sidebarTemporalShadow = defineSidebarShadowDomain<TemporalValue>({
|
||||
const identities = readComparableIdentities()
|
||||
const toIdentities = (ids: Iterable<string>): string[] =>
|
||||
[...ids].flatMap((id) => identities.get(id) ?? [])
|
||||
if (main.values.has('sort')) {
|
||||
values.set('sort', { mode: window.sortBy, ids: toIdentities(window.sortedIds) })
|
||||
}
|
||||
if (window.retainedIds) {
|
||||
values.set('retention', { ids: toIdentities(window.retainedIds).sort() })
|
||||
}
|
||||
@@ -57,16 +55,12 @@ export const sidebarTemporalShadow = defineSidebarShadowDomain<TemporalValue>({
|
||||
}
|
||||
const main = parsed.data
|
||||
const values = new Map<string, TemporalValue>()
|
||||
// Another session's state, or one main has not sampled yet, has nothing to agree with.
|
||||
if (main.sessionId !== readWindowSidebarTemporal()?.sessionId || !main.sortSnapshot) {
|
||||
// Another session's state has nothing to agree with.
|
||||
if (main.sessionId !== readWindowSidebarTemporal()?.sessionId) {
|
||||
return { revision: main.revision, values }
|
||||
}
|
||||
const comparable = new Set(readComparableIdentities().values())
|
||||
const scoped = (ids: readonly string[]): string[] => ids.filter((id) => comparable.has(id))
|
||||
// The window keeps its order past a create grace until its next settle, by design for now.
|
||||
if (main.sortSnapshot.cause === 'settle') {
|
||||
values.set('sort', { mode: main.sortSnapshot.mode, ids: scoped(main.sortSnapshot.rankedIds) })
|
||||
}
|
||||
if (main.retention) {
|
||||
values.set('retention', { ids: scoped(main.retention.retainedIds) })
|
||||
}
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
import type { WorkspaceSortBy } from '../../../../shared/workspace-view/workspace-row-sort'
|
||||
|
||||
/** What the mounted list last committed, for the shadow check against main's temporal owner. */
|
||||
/** What the mounted list last committed, for the shadow check against main's retention. */
|
||||
export type WindowSidebarTemporalReadout = {
|
||||
sessionId: string
|
||||
sortBy: WorkspaceSortBy
|
||||
sortedIds: readonly string[]
|
||||
/** Undefined while sleeping workspaces are shown. */
|
||||
retainedIds: ReadonlySet<string> | undefined
|
||||
}
|
||||
|
||||
let readout: WindowSidebarTemporalReadout | null = null
|
||||
let sessionId: string | null = null
|
||||
// Kept apart from the session: the sweep may commit before the list's session begins.
|
||||
let retainedIds: ReadonlySet<string> | undefined
|
||||
|
||||
export function recordWindowSortOrder(
|
||||
sessionId: string,
|
||||
sortBy: WorkspaceSortBy,
|
||||
sortedIds: readonly string[]
|
||||
): void {
|
||||
readout = { sessionId, sortBy, sortedIds, retainedIds: readout?.retainedIds }
|
||||
export function beginWindowSidebarTemporalReadout(nextSessionId: string): void {
|
||||
sessionId = nextSessionId
|
||||
}
|
||||
|
||||
export function recordWindowSleepingRetention(retainedIds: ReadonlySet<string> | undefined): void {
|
||||
if (readout) {
|
||||
readout = { ...readout, retainedIds }
|
||||
}
|
||||
export function recordWindowSleepingRetention(ids: ReadonlySet<string> | undefined): void {
|
||||
retainedIds = ids
|
||||
}
|
||||
|
||||
/** The list unmounted; nothing is compared until the next one begins. */
|
||||
export function clearWindowSidebarTemporalReadout(sessionId: string): void {
|
||||
if (readout?.sessionId === sessionId) {
|
||||
readout = null
|
||||
export function clearWindowSidebarTemporalReadout(endedSessionId: string): void {
|
||||
if (sessionId === endedSessionId) {
|
||||
sessionId = null
|
||||
}
|
||||
}
|
||||
|
||||
export function readWindowSidebarTemporal(): WindowSidebarTemporalReadout | null {
|
||||
return readout
|
||||
return sessionId === null ? null : { sessionId, retainedIds }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const SortModeSchema = z.enum(['name', 'smart', 'recent', 'repo', 'manual'])
|
||||
|
||||
/** The frozen order main sampled at its last settle, as host-qualified ids. */
|
||||
const SortSnapshotSchema = z.object({
|
||||
mode: SortModeSchema,
|
||||
/** Counts samples since the list session began. */
|
||||
epoch: z.number(),
|
||||
sampledAt: z.number(),
|
||||
rankedIds: z.array(z.string()),
|
||||
/** Smart has seen a live signal this session, so it ranks by attention, not saved order. */
|
||||
smartLatched: z.boolean(),
|
||||
/** A create-grace re-sort is the one deadline the window does not act on yet. */
|
||||
cause: z.enum(['settle', 'create-grace'])
|
||||
})
|
||||
|
||||
/** Workspaces "Hide sleeping" keeps through a rebind gap; null while sleeping ones are shown. */
|
||||
const RetentionSchema = z.object({
|
||||
retainedIds: z.array(z.string()),
|
||||
@@ -25,10 +10,8 @@ export const SidebarTemporalSnapshotSchema = z.object({
|
||||
revision: z.number(),
|
||||
/** The list session the window last began; null until one begins (a headless host). */
|
||||
sessionId: z.string().nullable(),
|
||||
sortSnapshot: SortSnapshotSchema.nullable(),
|
||||
retention: RetentionSchema.nullable()
|
||||
})
|
||||
|
||||
export type SidebarSortSnapshot = z.infer<typeof SortSnapshotSchema>
|
||||
export type SidebarRetention = z.infer<typeof RetentionSchema>
|
||||
export type SidebarTemporalSnapshot = z.infer<typeof SidebarTemporalSnapshotSchema>
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('switch on', () => {
|
||||
const first = await settle(socket, refreshSidebarInputs(INITIAL_SIDEBAR_INPUTS_FEED_STATE))
|
||||
expect(first.kinds.every((kind) => kind === 'snapshot')).toBe(true)
|
||||
const installed = first.state.installed
|
||||
expect(installed?.domains.get('temporal')?.has('sortSnapshot')).toBe(true)
|
||||
expect(installed?.domains.get('temporal')?.has('retention')).toBe(true)
|
||||
expect(pinned(first.state)).not.toContain(worktreeId)
|
||||
|
||||
await orcaPage.evaluate(
|
||||
|
||||
Reference in New Issue
Block a user