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:
Jinwoo Hong
2026-10-18 15:51:52 -04:00
committed by GitHub
co-authored by Claude
parent 63f0cb9570
commit 91b696b6cf
22 changed files with 430 additions and 1521 deletions
+1 -2
View File
@@ -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)
}
}
@@ -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([])
})
})
@@ -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
}
+7 -15
View File
@@ -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>
+1 -1
View File
@@ -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(