From 91b696b6cf09c354cae9ec3747359afdb631539b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:51:52 -0400 Subject: [PATCH] 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 * test(sidebar): stamp sort hook reads no live sources; stronger switch-on tests Co-Authored-By: Claude * test(sidebar): clear the order-save mock between tests Co-Authored-By: Claude * 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 * 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 * fix(sidebar): every retention listing shares one sequence; publish after the first Co-Authored-By: Claude * test(sidebar): the first listing bumps the retention revision Co-Authored-By: Claude --------- Co-authored-by: Claude --- config/tsconfig.tc.web.json | 3 +- .../sidebar-inputs/catalog-listing-change.ts | 59 --- .../orca-runtime-sidebar-inputs-feed.test.ts | 2 +- .../orca-runtime-sidebar-temporal.ts | 109 +----- .../runtime-smart-sort-sources.test.ts | 64 ---- .../runtime-smart-sort-sources.ts | 75 ---- .../sidebar-inputs-feed-domains.ts | 4 +- .../sidebar-retention-owner.test.ts | 143 ++++++++ .../sidebar-inputs/sidebar-retention-owner.ts | 159 ++++++++ .../sidebar-temporal-owner-stamps.test.ts | 113 ------ .../sidebar-temporal-owner.test.ts | 321 ---------------- .../sidebar-inputs/sidebar-temporal-owner.ts | 346 ------------------ .../smart-sort-agent-entries.test.ts | 44 --- .../smart-sort-agent-entries.ts | 95 ----- ...eping-sweep-retention.main-parity.test.tsx | 88 +++++ .../use-sort-order.main-parity.test.tsx | 229 ------------ .../worktree-list/listing/use-sort-order.ts | 6 +- src/renderer/src/lib/sidebar-list-session.ts | 22 +- .../sidebar-shadow/sidebar-temporal-shadow.ts | 18 +- .../window-sidebar-temporal-readout.ts | 32 +- .../sidebar-list/sidebar-temporal-snapshot.ts | 17 - tests/e2e/sidebar-inputs-feed.spec.ts | 2 +- 22 files changed, 430 insertions(+), 1521 deletions(-) delete mode 100644 src/main/runtime/sidebar-inputs/catalog-listing-change.ts delete mode 100644 src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.test.ts delete mode 100644 src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.ts create mode 100644 src/main/runtime/sidebar-inputs/sidebar-retention-owner.test.ts create mode 100644 src/main/runtime/sidebar-inputs/sidebar-retention-owner.ts delete mode 100644 src/main/runtime/sidebar-inputs/sidebar-temporal-owner-stamps.test.ts delete mode 100644 src/main/runtime/sidebar-inputs/sidebar-temporal-owner.test.ts delete mode 100644 src/main/runtime/sidebar-inputs/sidebar-temporal-owner.ts delete mode 100644 src/main/runtime/sidebar-inputs/smart-sort-agent-entries.test.ts delete mode 100644 src/main/runtime/sidebar-inputs/smart-sort-agent-entries.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.main-parity.test.tsx delete mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.main-parity.test.tsx diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 7583b8811a3..47c166c0b44 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -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", diff --git a/src/main/runtime/sidebar-inputs/catalog-listing-change.ts b/src/main/runtime/sidebar-inputs/catalog-listing-change.ts deleted file mode 100644 index 473587d3660..00000000000 --- a/src/main/runtime/sidebar-inputs/catalog-listing-change.ts +++ /dev/null @@ -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; 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 -} diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-inputs-feed.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-inputs-feed.test.ts index 236d6445d55..c9ff32e2855 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-inputs-feed.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-inputs-feed.test.ts @@ -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', diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts index 20ab1b6ee2e..99025f7455d 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts @@ -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) { - 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 { - const active = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.activeWorktreeId - const causes = new Map() - 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 { 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 { - 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 { diff --git a/src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.test.ts b/src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.test.ts deleted file mode 100644 index 8309a9dc519..00000000000 --- a/src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.test.ts +++ /dev/null @@ -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' - }) - }) -}) diff --git a/src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.ts b/src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.ts deleted file mode 100644 index 416c40e0bd9..00000000000 --- a/src/main/runtime/sidebar-inputs/runtime-smart-sort-sources.ts +++ /dev/null @@ -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 - leaves: Iterable< - Pick - > - sessionTabs: Iterable< - [worktreeId: string, snapshot: Pick] - > - /** The window holds no unverifiable state: a binding whose host lost contact is live there. */ - isUnverifiablePty: (ptyId: string) => boolean - agentStatusByPaneKey: Record - migrationUnsupported: Iterable -}): SmartSortSources { - const tabsByWorktree: Record = {} - const tabIds = new Set() - const addTab = (worktreeId: string, id: string, title: string): void => { - if (!tabIds.has(id)) { - tabIds.add(id) - ;(tabsByWorktree[worktreeId] ??= []).push({ id, title }) - } - } - const terminalLayoutsByTabId: NonNullable = {} - 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 = {} - const runtimePaneTitlesByTabId: Record> = {} - 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 = {} - for (const entry of records.migrationUnsupported) { - migrationUnsupportedByPtyId[entry.ptyId] = entry - } - return { - tabsByWorktree, - agentStatusByPaneKey: records.agentStatusByPaneKey, - runtimePaneTitlesByTabId, - ptyIdsByTabId, - migrationUnsupportedByPtyId, - terminalLayoutsByTabId - } -} diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts index 34569412244..4ac4a433242 100644 --- a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts @@ -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) }, diff --git a/src/main/runtime/sidebar-inputs/sidebar-retention-owner.test.ts b/src/main/runtime/sidebar-inputs/sidebar-retention-owner.test.ts new file mode 100644 index 00000000000..0061ce08063 --- /dev/null +++ b/src/main/runtime/sidebar-inputs/sidebar-retention-owner.test.ts @@ -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 { + 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 + tabsOpen: Set + /** 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((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 => { + 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)]) + }) +}) diff --git a/src/main/runtime/sidebar-inputs/sidebar-retention-owner.ts b/src/main/runtime/sidebar-inputs/sidebar-retention-owner.ts new file mode 100644 index 00000000000..8b1008c9bb9 --- /dev/null +++ b/src/main/runtime/sidebar-inputs/sidebar-retention-owner.ts @@ -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 + 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 + 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() + 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() + } + } +} diff --git a/src/main/runtime/sidebar-inputs/sidebar-temporal-owner-stamps.test.ts b/src/main/runtime/sidebar-inputs/sidebar-temporal-owner-stamps.test.ts deleted file mode 100644 index 21eed2df10d..00000000000 --- a/src/main/runtime/sidebar-inputs/sidebar-temporal-owner-stamps.test.ts +++ /dev/null @@ -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 { - 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']) - }) -}) diff --git a/src/main/runtime/sidebar-inputs/sidebar-temporal-owner.test.ts b/src/main/runtime/sidebar-inputs/sidebar-temporal-owner.test.ts deleted file mode 100644 index 44bf35b730f..00000000000 --- a/src/main/runtime/sidebar-inputs/sidebar-temporal-owner.test.ts +++ /dev/null @@ -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 { - 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 - tabsOpen: Set - gate: Promise | 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(), - tabsOpen: new Set(), - 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 => { - 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((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((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() - }) -}) diff --git a/src/main/runtime/sidebar-inputs/sidebar-temporal-owner.ts b/src/main/runtime/sidebar-inputs/sidebar-temporal-owner.ts deleted file mode 100644 index d3613a6f9bb..00000000000 --- a/src/main/runtime/sidebar-inputs/sidebar-temporal-owner.ts +++ /dev/null @@ -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> - 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 - 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 - 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() - 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() - } - } -} diff --git a/src/main/runtime/sidebar-inputs/smart-sort-agent-entries.test.ts b/src/main/runtime/sidebar-inputs/smart-sort-agent-entries.test.ts deleted file mode 100644 index d107c74a17e..00000000000 --- a/src/main/runtime/sidebar-inputs/smart-sort-agent-entries.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/main/runtime/sidebar-inputs/smart-sort-agent-entries.ts b/src/main/runtime/sidebar-inputs/smart-sort-agent-entries.ts deleted file mode 100644 index 5f05cdfe5d8..00000000000 --- a/src/main/runtime/sidebar-inputs/smart-sort-agent-entries.ts +++ /dev/null @@ -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() - 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 { - 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( - (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) - } -} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.main-parity.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.main-parity.test.tsx new file mode 100644 index 00000000000..91287c394dc --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sleeping-sweep-retention.main-parity.test.tsx @@ -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 { + 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([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.main-parity.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.main-parity.test.tsx deleted file mode 100644 index 68c031e6097..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.main-parity.test.tsx +++ /dev/null @@ -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> -) { - act(() => useAppStore.setState((s) => ({ ...patch, sortEpoch: s.sortEpoch + 1 }))) - owner.noteSortInputChange() -} - -async function advance(ms: number): Promise { - 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([]) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts index 00dc155b8e5..87fda6655b6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts @@ -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 } diff --git a/src/renderer/src/lib/sidebar-list-session.ts b/src/renderer/src/lib/sidebar-list-session.ts index 19c83eea640..6fa20617f0f 100644 --- a/src/renderer/src/lib/sidebar-list-session.ts +++ b/src/renderer/src/lib/sidebar-list-session.ts @@ -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(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]) } diff --git a/src/renderer/src/lib/sidebar-shadow/sidebar-temporal-shadow.ts b/src/renderer/src/lib/sidebar-shadow/sidebar-temporal-shadow.ts index 57791d04c05..f3a62bcad82 100644 --- a/src/renderer/src/lib/sidebar-shadow/sidebar-temporal-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/sidebar-temporal-shadow.ts @@ -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 { @@ -26,8 +26,9 @@ function readComparableIdentities(): Map { } /** - * 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({ name: 'sidebar-temporal', @@ -41,9 +42,6 @@ export const sidebarTemporalShadow = defineSidebarShadowDomain({ const identities = readComparableIdentities() const toIdentities = (ids: Iterable): 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({ } const main = parsed.data const values = new Map() - // 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) }) } diff --git a/src/renderer/src/lib/sidebar-shadow/window-sidebar-temporal-readout.ts b/src/renderer/src/lib/sidebar-shadow/window-sidebar-temporal-readout.ts index 26fe54eb9a2..77ae5c3bb9c 100644 --- a/src/renderer/src/lib/sidebar-shadow/window-sidebar-temporal-readout.ts +++ b/src/renderer/src/lib/sidebar-shadow/window-sidebar-temporal-readout.ts @@ -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 | 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 | 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 | undefined): void { - if (readout) { - readout = { ...readout, retainedIds } - } +export function recordWindowSleepingRetention(ids: ReadonlySet | 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 } } diff --git a/src/shared/sidebar-list/sidebar-temporal-snapshot.ts b/src/shared/sidebar-list/sidebar-temporal-snapshot.ts index ebe9b64c411..e92cb31423f 100644 --- a/src/shared/sidebar-list/sidebar-temporal-snapshot.ts +++ b/src/shared/sidebar-list/sidebar-temporal-snapshot.ts @@ -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 export type SidebarRetention = z.infer export type SidebarTemporalSnapshot = z.infer diff --git a/tests/e2e/sidebar-inputs-feed.spec.ts b/tests/e2e/sidebar-inputs-feed.spec.ts index a5566301a60..65938eae1e6 100644 --- a/tests/e2e/sidebar-inputs-feed.spec.ts +++ b/tests/e2e/sidebar-inputs-feed.spec.ts @@ -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(