test(sidebar): the desktop window and a phone reading the feed draw the same list (dark) (#30426)

* test(sidebar): the desktop window and a feed reader draw the same list (dark)

* fix(sidebar): a feed reader lists a paired server's folders and nested workspaces as the window does (dark)

* fix(sidebar): the paired-server shadow compares the lineage the server's check keeps (dark)

* fix(sidebar): paired-server rows carry the unread flag Smart Done reads (dark)
This commit is contained in:
Jinwoo Hong
2026-10-18 18:01:45 -04:00
committed by GitHub
parent 4298ba998d
commit bad74e1b7c
19 changed files with 1459 additions and 63 deletions
+5
View File
@@ -29,6 +29,11 @@
"../src/main/runtime/sidebar-inputs/sidebar-clock.ts",
"../src/main/runtime/sidebar-inputs/sidebar-retention-owner.ts",
"../src/main/runtime/sidebar-inputs/producer-revision.ts",
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed.ts",
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed-log.ts",
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed-pages.ts",
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed-pins.ts",
"../src/main/runtime/sidebar-inputs/sidebar-inputs-feed.test-fixture.ts",
"../src/main/jira/adf-markdown.ts",
"../src/main/jira/adf-text-marks.ts",
"../src/main/jira/adf-media-destination.ts",
@@ -155,7 +155,10 @@ describe('PairedServerRowsProducer', () => {
branch: 'main',
displayName: 'repo::/a',
isArchived: false,
isPinned: false
isPinned: false,
isMainWorktree: false,
lineage: null,
workspaceStatus: 'in-progress'
}
]
})
@@ -303,6 +306,70 @@ describe('PairedServerRowsProducer', () => {
expect(call).toHaveBeenCalledTimes(1)
})
it('carries what the list sorts, groups and filters by, dropping a value this build cannot read', async () => {
const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }])
call.mockResolvedValueOnce(
psReply([
row('repo::/a', {
isMainWorktree: true,
workspaceStatus: 'in-review',
sortOrder: 4,
manualOrder: 2,
lastActivityAt: 900,
smartTier: 'done',
smartAt: 800,
createdAt: 100,
creatorProvenance: { kind: 'paired-device', deviceId: 'phone-1' },
defaultBranch: true,
cliCreated: true,
unread: true,
worktreeInstanceId: 'i-a',
parentWorktreeId: 'repo::/p',
lineageWorktreeInstanceId: 'i-a',
parentWorktreeInstanceId: 'i-p'
}),
row('repo::/b', { smartTier: 'a-tier-from-later', sortOrder: 'x' })
])
)
const producer = new PairedServerRowsProducer({
hasDemand: () => true,
hostLabelOverrides: () => new Map()
})
producer.attach(hosts)
watches.get('box')?.onChange()
await vi.advanceTimersByTimeAsync(0)
const [newer, unreadable] = rowsOf(producer, 'runtime:box')?.rows ?? []
expect(newer).toMatchObject({
isMainWorktree: true,
workspaceStatus: 'in-review',
sortOrder: 4,
manualOrder: 2,
recentAt: 900,
smartTier: 'done',
smartAt: 800,
createdAt: 100,
creatorProvenance: { kind: 'paired-device', deviceId: 'phone-1' },
defaultBranch: true,
cliCreated: true,
isUnread: true,
instanceId: 'i-a',
lineage: {
parentWorktreeId: 'repo::/p',
worktreeInstanceId: 'i-a',
parentWorktreeInstanceId: 'i-p'
}
})
expect(unreadable).toMatchObject({
key: 'runtime:box|repo::/b',
isMainWorktree: false,
lineage: null,
workspaceStatus: 'in-progress'
})
expect(unreadable?.smartTier).toBeUndefined()
expect(unreadable?.sortOrder).toBeUndefined()
})
it("keys rows by host, so two servers' same bare id never collide, and fills older servers' gaps", async () => {
const { hosts, call, watches } = fakeHosts([
{ id: 'one', live: true },
@@ -335,7 +402,10 @@ describe('PairedServerRowsProducer', () => {
branch: 'b',
displayName: '',
isArchived: false,
isPinned: false
isPinned: false,
isMainWorktree: false,
lineage: null,
workspaceStatus: 'in-progress'
}
])
expect(rowsOf(producer, 'runtime:two')?.rows.map((entry) => [entry.key, entry.kind])).toEqual([
@@ -9,6 +9,7 @@ import type { WorkspaceCreationRegistry } from './workspace-creation-registry'
import type { WorkspaceRowsProducer } from './workspace-rows-producer'
import type { SidebarInputsFeedDomain } from './sidebar-inputs-feed'
import { readDesktopSidebarFacts } from './desktop-sidebar-facts'
import { buildPairedServerReviewFacts } from '../../../shared/sidebar-list/workspace-review-facts'
/** This host's store and connection registries, read the way sidebarInputs.desktopFacts reads them. */
export function readRuntimeDesktopSidebarFacts(store: RuntimeStore): DesktopSidebarFacts {
@@ -122,7 +123,14 @@ export function sidebarInputsFeedDomains(
},
{
name: 'review',
read: async () => (await facts.getWorkspaceReviewFacts()).facts,
read: async () => ({
// A paired server's rows by the window's cache too; this host's own rows win an id both list.
...buildPairedServerReviewFacts(
pairedServerRows.snapshot().servers,
requireStore().getGitHubCache().pr
),
...(await facts.getWorkspaceReviewFacts()).facts
}),
// The window's PR cache saves announce no change.
watch: watchCatalog,
rereadOnRead: true
@@ -48,7 +48,7 @@ import {
export const initialState = useAppStore.getInitialState()
/** The WorktreeList row pipeline, hook for hook, without the virtualized viewport. */
function useGoldenSidebarPipeline() {
export function useGoldenSidebarPipeline() {
const repoMap = useRepoMap()
const worktreeMap = useWorktreeMap()
const repos = useAppStore((s) => s.repos)
@@ -0,0 +1,260 @@
import type { AppState } from '@/store/types'
import { getAllWorktreesFromState } from '@/store/selectors'
import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import {
getRepoExecutionHostId,
getSettingsFocusedExecutionHostId
} from '../../../../shared/execution-host'
import { withRepoHostOwnership } from '@/store/slices/worktrees/listing/worktree-host-ownership'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import { getPinnedWorktreeDisplayPolicy } from '../../../../shared/sidebar-list/row-types'
import type { HostSectionRow } from '../../../../shared/sidebar-list/host-section-rows'
import { buildWorkspaceRows } from '../../../../shared/sidebar-list/workspace-rows'
import {
buildPairedServerReviewFacts,
buildWorkspaceReviewFacts
} from '../../../../shared/sidebar-list/workspace-review-facts'
import type {
SidebarFeedCatalog,
SidebarFeedView
} from '../../../../shared/sidebar-list/sidebar-feed-rows'
import type { SmartStampFields } from '../../../../shared/sidebar-list/sort-stamps'
import { GOLDEN_NOW, folder, group, lineage } from './sidebar-list-golden-factories.test-fixture'
import { sidebarWorkspaceRowFilters } from './sidebar-filter-actions'
import {
readWindowWorkspaceActivityFacts,
toLocallyOwnedRows
} from '@/lib/sidebar-shadow/workspace-activity-shadow'
import { readWindowDesktopSidebarFacts } from '@/lib/sidebar-shadow/desktop-facts-shadow'
import {
readWindowPairedServerHosts,
readWindowPairedServerRows
} from '@/lib/sidebar-shadow/paired-server-rows-shadow'
import type { SidebarInputsFeedDomain } from '../../../../main/runtime/sidebar-inputs/sidebar-inputs-feed'
const SERVER_HOST_ID = 'runtime:env-1'
const buildServer: PublicKnownRuntimeEnvironment = {
id: 'env-1',
name: 'Build server',
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://127.0.0.1:1' }],
preferredEndpointId: 'ws'
}
// Stamps on every host's rows, so Recent and Smart order across local, SSH and the paired server.
const STAMPS: Record<string, SmartStampFields & { lastActivityAt?: number; isUnread?: boolean }> = {
Zeta: { smartTier: 'needs-you', smartAt: GOLDEN_NOW - 600_000 },
// Done holds only while unread: feat-a and api-y were not read yet.
'feat-a': { smartTier: 'done', smartAt: GOLDEN_NOW - 60_000, isUnread: true },
'api-y': {
smartTier: 'done',
smartAt: GOLDEN_NOW - 59_000,
lastActivityAt: GOLDEN_NOW - 2_000,
isUnread: true
},
'remote-x': { smartTier: 'working', smartAt: GOLDEN_NOW - 30_000 },
// Read since it finished, so it ranks as Rest.
'feat-b': { smartTier: 'done', smartAt: GOLDEN_NOW - 1_000 },
'gitlab-mr': { smartTier: 'working', smartAt: GOLDEN_NOW - 900_000 }
}
/**
* What the parity suite adds to the golden store: stamps, a paired server the desktop knows with
* a nested workspace and a folder workspace, an SSH connection, a saved host order and a creation
* this host runs.
*/
export function parityWorldOverrides(base: Partial<AppState>): Partial<AppState> {
const repoById = new Map((base.repos ?? []).map((repo) => [repo.id, repo]))
const worktreesByRepo: AppState['worktreesByRepo'] = {}
for (const [repoId, worktrees] of Object.entries(base.worktreesByRepo ?? {})) {
const repo = repoById.get(repoId)
worktreesByRepo[repoId] = worktrees.map((worktree) => {
const branch = worktree.branch.replace('refs/heads/', '')
const stamps = {
...STAMPS[branch],
...(branch === 'Zeta'
? { cliProvenance: { kind: 'created-by-cli' as const, createdAt: 1 } }
: {})
}
// Hosts stamped as the window's catalog fetch stamps them.
const listed = repo ? withRepoHostOwnership(worktree, getRepoExecutionHostId(repo)) : worktree
return { ...listed, ...stamps }
})
}
const pending = Object.values(base.pendingWorktreeCreations ?? {})[0]
const [serverMain, serverChild] = worktreesByRepo.api ?? []
return {
worktreesByRepo,
// A server workspace nested under the server's main, and a server folder workspace.
worktreeLineageById: {
...base.worktreeLineageById,
...(serverMain && serverChild ? { [serverChild.id]: lineage(serverChild, serverMain) } : {})
},
projectGroups: [
...(base.projectGroups ?? []),
group('pg-server-folders', 'Server folders', {
parentPath: '/srv/folders',
createdFrom: 'folder-scan',
executionHostId: SERVER_HOST_ID,
tabOrder: 3
})
],
folderWorkspaces: [
...(base.folderWorkspaces ?? []),
folder('fw-server', 'pg-server-folders', { executionHostId: SERVER_HOST_ID, sortOrder: 3 })
],
runtimeEnvironments: [buildServer],
sshConnectionStates: new Map([
['gpu-vm', { targetId: 'gpu-vm', status: 'connected', error: null, reconnectAttempt: 0 }]
]),
// A creation main runs: one on a paired server is that server's own (see exclusions).
pendingWorktreeCreations: pending
? { [pending.creationId]: { ...pending, request: { ...pending.request, repoId: 'orca' } } }
: {},
workspaceHostOrder: ['ssh:gpu-vm', SERVER_HOST_ID, 'local'],
// Desktop-only cards (imported, new external) are no feed input, so neither side gets them.
detectedWorktreesByRepo: {}
}
}
/** The occurrence the parity suite opens; a pinned duplicate highlights in both places. */
export function parityActiveState(
worktreeId: string,
hostId: AppState['activeWorkspaceExecutionHostId']
): Partial<AppState> {
return { activeWorktreeId: worktreeId, activeWorkspaceExecutionHostId: hostId }
}
/**
* The feed domains main would publish for this store, read through the same projections the
* shadow comparison holds equal to main's producers in every e2e run.
*/
export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDomain[] {
const worktrees = getAllWorktreesFromState(state)
const servers = readWindowPairedServerHosts(state, new Set([SERVER_HOST_ID]))
const serverRows = readWindowPairedServerRows(state, new Set(servers.keys()))
const domain = (name: string, read: () => Record<string, unknown>): SidebarInputsFeedDomain => ({
name,
read,
watch: () => () => {}
})
return [
domain('workspaces', () =>
Object.fromEntries(
buildWorkspaceRows({
worktrees,
folderWorkspaces: state.folderWorkspaces,
repos: state.repos,
lineageById: state.worktreeLineageById
})
)
),
domain('activity', () =>
Object.fromEntries(
toLocallyOwnedRows(state, readWindowWorkspaceActivityFacts(state, GOLDEN_NOW))
)
),
domain('review', () => ({
...buildPairedServerReviewFacts(
[{ hostId: SERVER_HOST_ID, rows: [...serverRows.values()] }],
state.prCache
),
...Object.fromEntries(
toLocallyOwnedRows(
state,
new Map(Object.entries(buildWorkspaceReviewFacts(worktrees, state.repos, state.prCache)))
)
)
})),
domain('creations', () =>
Object.fromEntries(
Object.values(state.pendingWorktreeCreations ?? {}).map((creation) => [
creation.creationId,
{ repoId: creation.request.repoId }
])
)
),
domain('temporal', () => ({ sessionId: 'session-1', retention: null })),
domain('pairedServerHosts', () =>
Object.fromEntries(
[...servers].map(([hostId, host]) => [hostId, { freshness: 'live', host }])
)
),
domain('pairedServerRows', () => Object.fromEntries(serverRows)),
domain('desktop', () => readWindowDesktopSidebarFacts(state))
]
}
/** What the host's own phone reads beside the feed: repo.list, groups and ui.get/settings.get. */
export function phoneInputsFromWindowState(state: AppState): {
catalog: SidebarFeedCatalog
view: SidebarFeedView
} {
const projection = getProjectHostSetupProjectionFromState(state)
return {
catalog: {
repos: state.repos,
projectGroups: state.projectGroups ?? [],
// The projection the window publishes for the phone (mobileRelay.projectSections).
projectGrouping: {
projects: projection.projects,
projectHostSetups: projection.setups
}
},
view: {
groupBy: state.groupBy,
sortBy: state.sortBy,
projectOrderBy: state.projectOrderBy,
collapsedGroups: state.collapsedGroups,
filters: sidebarWorkspaceRowFilters(state),
workspaceHostScope: state.workspaceHostScope,
visibleWorkspaceHostIds: state.visibleWorkspaceHostIds,
workspaceStatuses: state.workspaceStatuses,
pinnedDisplayPolicy: getPinnedWorktreeDisplayPolicy(state.settings),
defaultHostId: getSettingsFocusedExecutionHostId(state.settings),
hostLabelOverrides: getHostDisplayLabelOverrides(state.settings)
}
}
}
/**
* What both adapters must agree on, one line per row in occurrence order: headers with their
* counts and collapse keys, lineage keys, and which occurrences are active. Labels a client draws
* from its own icons, host health and card contents are presentation and left out.
*/
export function projectParityRows(
rows: readonly HostSectionRow[],
activeRowKeys: ReadonlySet<string>
): string[] {
return rows.flatMap((row): string[] => {
switch (row.type) {
case 'host-header':
return [
`HOST ${row.key} host=${row.hostId} label=${row.label} count=${row.count}${row.collapsed ? ' collapsed' : ''}`
]
case 'header':
return [
`HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'}`
]
case 'item':
return [
`ITEM ${row.rowKey} section=${row.sectionKey} depth=${row.depth}/${row.groupDepth} lineageKey=${row.lineageGroupKey ?? '-'}${row.lineageCollapsed ? ' lineageCollapsed' : ''} children=${row.lineageChildCount}${activeRowKeys.has(row.rowKey) ? ' ACTIVE' : ''}`
]
case 'folder-workspace':
return [
`FOLDER ${row.key} group=${row.projectGroup.id} depth=${row.depth}/${row.groupDepth}${activeRowKeys.has(row.key) ? ' ACTIVE' : ''}`
]
case 'pending-creation':
return [`PENDING ${row.key}`]
// Desktop-only cards: the feed never carries detected worktrees (see exclusions).
case 'imported-worktrees-card':
case 'new-external-worktrees-inbox':
return []
}
})
}
@@ -0,0 +1,253 @@
// @vitest-environment happy-dom
/**
* One store goes in; the desktop window's row pipeline (switch on) and a phone reading main's feed
* (feed pages, the shared installer, buildSidebarFeedRows) must draw the same list. The goldens
* check that list is right; this checks only that the two adapters agree.
*
* Never compared, as no feed input carries them: the window's drag previews, search, hover hold
* and agent-send reveal; its imported-worktrees and new-external-worktrees cards; its create
* preflight; host-header health and detail.
* Known gaps the phone shows differently, so the fixture leaves them out: a creation a paired
* server runs; a paired server's activity under "Hide sleeping" (the window reads its own tabs);
* a PR unlinked on a server workspace; an SSH workspace reached through a paired server.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, renderHook } from '@testing-library/react'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { SidebarInputsFeed } from '../../../../main/runtime/sidebar-inputs/sidebar-inputs-feed'
import { TestPhone } from '../../../../main/runtime/sidebar-inputs/sidebar-inputs-feed.test-fixture'
import { isActiveSidebarWorkspace } from '../../../../shared/sidebar-list/active-workspace'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import type { HostSectionRow } from '../../../../shared/sidebar-list/host-section-rows'
import type { readSidebarFeedDomains } from '../../../../shared/sidebar-list/sidebar-feed-domains'
import type { buildSidebarFeedRows } from '../../../../shared/sidebar-list/sidebar-feed-rows'
import { GOLDEN_NOW } from './sidebar-list-golden-factories.test-fixture'
import {
GOLDEN_WORKTREE_IDS,
buildGoldenStoreState,
goldenLiveTerminalState,
type GoldenView
} from './sidebar-list-golden-store.test-fixture'
import {
GROUP_BYS,
initialState,
useGoldenSidebarPipeline
} from './sidebar-list-golden-suite.test-fixture'
import {
feedDomainsFromWindowState,
parityActiveState,
parityWorldOverrides,
phoneInputsFromWindowState,
projectParityRows
} from './sidebar-list-parity.test-fixture'
const SWITCH = '../../../../shared/sidebar-inputs-switch'
vi.mock('../../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
vi.mock('@/lib/sidebar-list-session', () => ({ useSidebarListSession: () => {} }))
// The phone page is built without the switch, so its adapter loads in a module graph with it off.
let phoneAdapter: {
readSidebarFeedDomains: typeof readSidebarFeedDomains
buildSidebarFeedRows: typeof buildSidebarFeedRows
}
beforeAll(async () => {
vi.resetModules()
vi.doUnmock(SWITCH)
expect(
(await import('../../../../shared/sidebar-inputs-switch')).ORCA_SIDEBAR_INPUTS_IS_MAIN
).toBe(false)
phoneAdapter = {
...(await import('../../../../shared/sidebar-list/sidebar-feed-domains')),
...(await import('../../../../shared/sidebar-list/sidebar-feed-rows'))
}
})
const SORT_BYS: AppState['sortBy'][] = ['manual', 'name', 'recent', 'smart', 'repo']
const PROJECT_ORDER_BYS: AppState['projectOrderBy'][] = ['manual', 'recent']
function desktopActiveRowKeys(rows: readonly HostSectionRow[], state: AppState): Set<string> {
const active = {
worktreeId: state.activeWorktreeId,
hostId: state.activeWorkspaceExecutionHostId ?? null
}
const keys = new Set<string>()
for (const row of rows) {
if (row.type === 'item' && isActiveSidebarWorkspace(row.worktree, active)) {
keys.add(row.rowKey)
} else if (
row.type === 'folder-workspace' &&
isActiveSidebarWorkspace(folderWorkspaceToWorktree(row.folderWorkspace), active)
) {
keys.add(row.key)
}
}
return keys
}
function desktopList(state: Partial<AppState>): string[] {
useAppStore.setState(state)
const { result, unmount } = renderHook(() => useGoldenSidebarPipeline())
const { sectionRows } = result.current.rowModel
const lines = projectParityRows(
sectionRows,
desktopActiveRowKeys(sectionRows, useAppStore.getState())
)
unmount()
return lines
}
async function phoneList(state: Partial<AppState>): Promise<string[]> {
useAppStore.setState(state)
const full = useAppStore.getState()
const feed = new SidebarInputsFeed(
() => feedDomainsFromWindowState(full),
() => {}
)
const phone = new TestPhone(feed, 'phone-1')
phone.refresh()
await phone.settle()
phone.disconnect()
const installed = phone.state.installed
if (!installed) {
throw new Error('the feed installed nothing')
}
const { catalog, view } = phoneInputsFromWindowState(full)
const { sectionRows, activeRowKeys } = phoneAdapter.buildSidebarFeedRows(
phoneAdapter.readSidebarFeedDomains(installed),
catalog,
view
)
return projectParityRows(sectionRows, activeRowKeys)
}
function worldState(view: GoldenView, extra: Partial<AppState> = {}): Partial<AppState> {
const base = buildGoldenStoreState(view)
return { ...base, ...parityWorldOverrides(base), ...extra }
}
/** Both adapters over one store; the desktop's list is the expected side. */
async function expectAdaptersAgree(state: Partial<AppState>): Promise<string[]> {
const desktop = desktopList(state)
useAppStore.setState(initialState, true)
const phone = await phoneList(state)
useAppStore.setState(initialState, true)
expect(phone.join('\n')).toBe(desktop.join('\n'))
return desktop
}
/** Every other header collapsed, and the first lineage parent. */
function collapsedKeysOf(lines: readonly string[]): Set<string> {
const keys = lines.flatMap((line) => {
const header = /^HEADER (\S+) collapseKey=(\S+)/.exec(line)
if (header) {
return [header[2] === '-' ? header[1] : header[2]]
}
const host = /^HOST (\S+)/.exec(line)
return host ? [host[1]] : []
})
const collapsed = new Set(keys.filter((_, index) => index % 2 === 0))
const lineage = lines
.map((line) => / lineageKey=(\S+)/.exec(line)?.[1])
.find((key) => key && key !== '-')
if (lineage) {
collapsed.add(lineage)
}
return collapsed
}
describe('sidebar list parity: desktop window and feed reader', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(GOLDEN_NOW)
useAppStore.setState(initialState, true)
})
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
vi.useRealTimers()
})
for (const groupBy of GROUP_BYS) {
it(`agrees for groupBy=${groupBy} across sort, project order, pinned policy and collapse`, async () => {
for (const sortBy of SORT_BYS) {
for (const projectOrderBy of PROJECT_ORDER_BYS) {
for (const pinnedInGroups of [false, true]) {
const view: GoldenView = {
groupBy,
sortBy,
projectOrderBy,
pinnedInGroups,
collapsedGroups: new Set()
}
// Bare id: every occurrence highlights, a pinned duplicate included.
const active = parityActiveState(GOLDEN_WORKTREE_IDS.orcaFeatA, null)
const expanded = await expectAdaptersAgree(worldState(view, active))
expect(expanded.filter((line) => line.endsWith(' ACTIVE')).length).toBe(
pinnedInGroups ? 2 : 1
)
await expectAdaptersAgree(
worldState({ ...view, collapsedGroups: collapsedKeysOf(expanded) }, active)
)
}
}
}
})
}
// Each scenario with a line its expanded list must hold, so an empty list cannot pass.
const SCENARIOS: Record<string, [Partial<AppState>, RegExp]> = {
'host sections': [
{ visibleWorkspaceHostIds: ['local', 'ssh:gpu-vm', 'runtime:env-1'] },
/^HOST \S+ host=runtime:env-1 label=Build server /
],
'single host scope': [{ workspaceHostScope: 'ssh:gpu-vm' }, /^ITEM .*ssh:gpu-vm\|/],
'paired server scope': [{ workspaceHostScope: 'runtime:env-1' }, /^ITEM .*runtime:env-1\|/],
'repo filter': [{ filterRepoIds: ['orca', 'lab'] }, /^ITEM .*\|lab::/],
'hide sleeping': [
{ showSleepingWorkspaces: false, ...goldenLiveTerminalState() },
/^ITEM .*\|orca::\/w\/feat-a /
],
'hide default branch': [{ hideDefaultBranchWorkspace: true }, /^ITEM .*api-y /],
// The same id on two hosts: only the SSH copy is open.
'open on one of two hosts': [
parityActiveState('shared::/w/same', 'ssh:gpu-vm'),
/^ITEM \S*ssh:gpu-vm\|shared::\/w\/same .* ACTIVE$/
],
'open folder workspace': [
parityActiveState('folder:fw-remote', 'ssh:gpu-vm'),
/^FOLDER folder-workspace:fw-remote .* ACTIVE$/
],
'open server folder workspace': [
parityActiveState('folder:fw-server', 'runtime:env-1'),
/^FOLDER folder-workspace:fw-server group=pg-server-folders .* ACTIVE$/
],
'hide CLI-created': [
{ hideCliCreatedWorkspaces: true },
/^ITEM .*runtime:env-1\|api::\/w\/api-y /
]
}
for (const [name, [overrides, shows]] of Object.entries(SCENARIOS)) {
it(`agrees with ${name} across groupBy, pinned policy and collapse`, async () => {
for (const groupBy of GROUP_BYS) {
for (const pinnedInGroups of [false, true]) {
const view: GoldenView = {
groupBy,
sortBy: 'manual',
projectOrderBy: 'manual',
pinnedInGroups,
collapsedGroups: new Set()
}
const expanded = await expectAdaptersAgree(worldState(view, overrides))
expect(expanded.some((line) => shows.test(line))).toBe(true)
await expectAdaptersAgree(
worldState({ ...view, collapsedGroups: collapsedKeysOf(expanded) }, overrides)
)
}
}
})
}
})
@@ -7,10 +7,8 @@ import type {
WorktreeLineage
} from '../../../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import {
composeWorktreeHostIdentity,
getWorktreeHostIdentity
} from '../../../../../../shared/worktree/host-qualified-identity'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { isActiveSidebarWorkspace } from '../../../../../../shared/sidebar-list/active-workspace'
import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status'
import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
@@ -64,11 +62,10 @@ export function renderFolderWorkspaceVirtualRow(args: {
const { ctx, row, vItem } = args
const folderWorktree = folderWorkspaceToWorktree(row.folderWorkspace)
const folderWorktreeIdentity = getWorktreeHostIdentity(folderWorktree)
const isActiveWorktree =
ctx.activeWorktreeId === folderWorktree.id &&
(!ctx.activeWorkspaceExecutionHostId ||
folderWorktreeIdentity ===
composeWorktreeHostIdentity(ctx.activeWorkspaceExecutionHostId, folderWorktree.id))
const isActiveWorktree = isActiveSidebarWorkspace(folderWorktree, {
worktreeId: ctx.activeWorktreeId,
hostId: ctx.activeWorkspaceExecutionHostId ?? null
})
const pathStatus = ctx.getCachedFolderWorkspacePathStatus({
scope: 'folder-workspace',
folderWorkspaceId: row.folderWorkspace.id
@@ -3,10 +3,8 @@ import { cn } from '@/lib/utils'
import type { AppState } from '@/store/types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { Worktree } from '../../../../../../shared/worktree/types'
import {
composeWorktreeHostIdentity,
getWorktreeHostIdentity
} from '../../../../../../shared/worktree/host-qualified-identity'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { isActiveSidebarWorkspace } from '../../../../../../shared/sidebar-list/active-workspace'
import WorktreeCard, { type ActiveSurfaceVariant } from '../../WorktreeCard'
import { PINNED_GROUP_KEY } from '../../../../../../shared/sidebar-list/group-keys'
import type { WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types'
@@ -123,11 +121,10 @@ export function renderWorktreeItemRow(
ctx.worktreeDragState.draggingWorktreeId &&
(ctx.worktreeDragState.lineageDropTargetId === itemRow.worktree.id ||
ctx.nativeLineageDropTargetId === itemRow.worktree.id)
const isActiveWorktree =
ctx.activeWorktreeId === itemRow.worktree.id &&
(!ctx.activeWorkspaceExecutionHostId ||
worktreeIdentity ===
composeWorktreeHostIdentity(ctx.activeWorkspaceExecutionHostId, itemRow.worktree.id))
const isActiveWorktree = isActiveSidebarWorkspace(itemRow.worktree, {
worktreeId: ctx.activeWorktreeId,
hostId: ctx.activeWorkspaceExecutionHostId ?? null
})
return (
<div
key={itemRow.rowKey}
@@ -13,7 +13,9 @@ describe('readWindowPairedServerRows', () => {
makeWorktree('other', 'Unlisted server', { hostId: 'runtime:gone' })
]
},
folderWorkspaces: [makeFolderWorkspace({ executionHostId: 'runtime:box', name: 'Notes' })]
folderWorkspaces: [makeFolderWorkspace({ executionHostId: 'runtime:box', name: 'Notes' })],
repos: [],
worktreeLineageById: {}
},
new Set(['runtime:box'])
)
@@ -27,7 +29,14 @@ describe('readWindowPairedServerRows', () => {
branch: 'refs/heads/main',
displayName: 'On the server',
isArchived: false,
isPinned: true
isPinned: true,
isMainWorktree: false,
isUnread: false,
lineage: null,
workspaceStatus: 'in-progress',
sortOrder: 0,
defaultBranch: false,
recentAt: 0
},
'runtime:box|folder:folder-1': expect.objectContaining({
kind: 'folder',
@@ -1,5 +1,9 @@
import { useAppStore, type AppState } from '@/store'
import { getAllWorktreesFromState } from '@/store/selectors'
import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../../shared/workspace-statuses'
import { getWorkspaceRowKindFacts } from '../../../../shared/worktree/workspace-kinds'
import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage'
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import {
folderWorkspaceRepoId,
folderWorkspaceToWorktree
@@ -17,15 +21,35 @@ import { defineSidebarShadowDomain, type SidebarShadowMainRead } from './sidebar
/** The paired-server rows the window fetched itself, keyed by host-qualified id, for these hosts. */
export function readWindowPairedServerRows(
state: Pick<AppState, 'worktreesByRepo' | 'folderWorkspaces'>,
state: Pick<AppState, 'worktreesByRepo' | 'folderWorkspaces' | 'repos' | 'worktreeLineageById'>,
hostIds: ReadonlySet<string>
): Map<string, PairedServerWorkspaceRow> {
const rows = new Map<string, PairedServerWorkspaceRow>()
const repoById = getRepoMapFromState(state)
// The server sends only edges its own check keeps, so the window checks its copy the same way.
const checkedLineageByKey = new Map<string, WorktreeLineage | null>()
for (const hostId of hostIds) {
const hostWorktrees = getAllWorktreesFromState(state).filter(
(worktree) => worktree.hostId === hostId
)
for (const worktree of projectResolvedWorktreeLineage(
hostWorktrees,
state.worktreeLineageById
)) {
checkedLineageByKey.set(getWorktreeHostIdentity(worktree), worktree.lineage)
}
}
const add = (worktree: Worktree, kind: PairedServerWorkspaceRow['kind']): void => {
if (!worktree.hostId || !hostIds.has(worktree.hostId)) {
return
}
const key = getWorktreeHostIdentity(worktree)
const lineage = checkedLineageByKey.get(key)
// The server's reply carries no automation name, so the row leaves it out too.
const { automationName: _automationName, ...kindFacts } = getWorkspaceRowKindFacts(
worktree,
repoById.get(worktree.repoId)
)
rows.set(key, {
key,
worktreeId: worktree.id,
@@ -35,7 +59,26 @@ export function readWindowPairedServerRows(
branch: worktree.branch,
displayName: worktree.displayName,
isArchived: worktree.isArchived,
isPinned: worktree.isPinned
isPinned: worktree.isPinned,
isMainWorktree: worktree.isMainWorktree,
isUnread: worktree.isUnread,
instanceId: worktree.instanceId,
lineage: lineage
? {
parentWorktreeId: lineage.parentWorktreeId,
worktreeInstanceId: lineage.worktreeInstanceId,
parentWorktreeInstanceId: lineage.parentWorktreeInstanceId
}
: null,
workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
sortOrder: worktree.sortOrder,
manualOrder: worktree.manualOrder,
createdAt: worktree.createdAt,
creatorProvenance: worktree.creatorProvenance,
...kindFacts,
recentAt: worktree.lastActivityAt,
smartTier: worktree.smartTier,
smartAt: worktree.smartAt
})
}
for (const worktree of getAllWorktreesFromState(state)) {
@@ -87,9 +130,27 @@ export const pairedServerRowsShadow = defineSidebarShadowDomain<
}
},
readWindow: (main) => readWindowPairedServerRows(useAppStore.getState(), main.liveHostIds),
equals: (window, main) => JSON.stringify(window ?? null) === JSON.stringify(main ?? null)
// Stamps and unread move with every server event, so a fetch either side made a moment apart differs.
equals: (window, main) =>
JSON.stringify(withoutStamps(window)) === JSON.stringify(withoutStamps(main))
})
function withoutStamps(
row: PairedServerWorkspaceRow | undefined
): Partial<PairedServerWorkspaceRow> | null {
if (!row) {
return null
}
const {
recentAt: _recentAt,
smartTier: _smartTier,
smartAt: _smartAt,
isUnread: _isUnread,
...rest
} = row
return rest
}
/** The window's header facts for these servers, from its own status, environment and label state. */
export function readWindowPairedServerHosts(
state: Pick<
@@ -0,0 +1,24 @@
import type { ExecutionHostId } from '../execution-host'
import {
composeWorktreeHostIdentity,
getWorktreeHostIdentity
} from '../worktree/host-qualified-identity'
import type { Worktree } from '../worktree/types'
/** The desktop's open workspace, as its session saves it; both null when nothing is open. */
export type ActiveSidebarWorkspace = {
worktreeId: string | null
hostId: ExecutionHostId | null
}
/** Every occurrence of the open workspace highlights, so a pinned duplicate shows twice. */
export function isActiveSidebarWorkspace(
worktree: Pick<Worktree, 'id' | 'hostId'>,
active: ActiveSidebarWorkspace
): boolean {
return (
active.worktreeId === worktree.id &&
(!active.hostId ||
getWorktreeHostIdentity(worktree) === composeWorktreeHostIdentity(active.hostId, worktree.id))
)
}
+78 -14
View File
@@ -2,19 +2,45 @@ import { z } from 'zod'
import type { ExecutionHostId } from '../execution-host'
import { composeWorktreeHostIdentity } from '../worktree/host-qualified-identity'
import { openEnum } from '../zod-salvage'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../workspace-statuses'
import { SMART_TIERS } from './sort-stamps'
import { WorkspaceRowSchema } from './workspace-rows'
/** One paired-server workspace as the sidebar lists it, keyed by its host-qualified id. */
const PairedServerWorkspaceRowSchema = z.object({
key: z.string(),
worktreeId: z.string(),
repoId: z.string(),
kind: z.enum(['git', 'folder']),
path: z.string(),
branch: z.string(),
displayName: z.string(),
isArchived: z.boolean(),
isPinned: z.boolean()
})
const PairedServerWorkspaceRowSchema = z
.object({
key: z.string(),
worktreeId: z.string(),
repoId: z.string(),
kind: z.enum(['git', 'folder']),
path: z.string(),
branch: z.string(),
displayName: z.string(),
isArchived: z.boolean(),
isPinned: z.boolean()
})
.extend(
// What the list sorts, groups and filters by, as the server stamped it; older desktops omit them.
// `lineage` is the edge the server's own check kept, not its stored record.
WorkspaceRowSchema.pick({
isMainWorktree: true,
isUnread: true,
instanceId: true,
lineage: true,
workspaceStatus: true,
sortOrder: true,
manualOrder: true,
createdAt: true,
creatorProvenance: true,
defaultBranch: true,
automationCreated: true,
cliCreated: true,
detachedHead: true,
recentAt: true,
smartTier: true,
smartAt: true
}).partial().shape
)
/** See PairedServerHost; health and compatibility stay open strings, as a reader only compares them. */
const PairedServerHostFactsSchema = z.object({
@@ -52,7 +78,25 @@ const ServerWorktreePsRowSchema = z.object({
branch: z.string().catch(''),
displayName: z.string().catch(''),
isArchived: z.boolean().catch(false),
isPinned: z.boolean().catch(false)
isPinned: z.boolean().catch(false),
isMainWorktree: z.boolean().catch(false),
unread: z.boolean().optional().catch(undefined),
worktreeInstanceId: z.string().optional().catch(undefined),
parentWorktreeId: z.string().nullable().optional().catch(undefined),
lineageWorktreeInstanceId: z.string().optional().catch(undefined),
parentWorktreeInstanceId: z.string().optional().catch(undefined),
workspaceStatus: z.string().catch(DEFAULT_WORKSPACE_STATUS_ID),
sortOrder: z.number().optional().catch(undefined),
manualOrder: z.number().optional().catch(undefined),
createdAt: z.number().optional().catch(undefined),
creatorProvenance: WorkspaceRowSchema.shape.creatorProvenance.catch(undefined),
defaultBranch: z.boolean().optional().catch(undefined),
automationCreated: z.literal(true).optional().catch(undefined),
cliCreated: z.literal(true).optional().catch(undefined),
detachedHead: z.literal(true).optional().catch(undefined),
lastActivityAt: z.number().optional().catch(undefined),
smartTier: z.enum(SMART_TIERS).optional().catch(undefined),
smartAt: z.number().optional().catch(undefined)
})
/** A server's own `worktree.ps` row, with defaults for what older servers omit; null without an id. */
@@ -64,10 +108,30 @@ export function normalizePairedServerRow(
if (!parsed.success) {
return null
}
const { workspaceKind, ...fields } = parsed.data
const {
workspaceKind,
lastActivityAt,
unread,
worktreeInstanceId,
parentWorktreeId,
lineageWorktreeInstanceId,
parentWorktreeInstanceId,
...fields
} = parsed.data
return {
key: composeWorktreeHostIdentity(hostId, fields.worktreeId),
kind: workspaceKind === 'folder-workspace' ? 'folder' : 'git',
...fields
...fields,
...(unread !== undefined ? { isUnread: unread } : {}),
...(worktreeInstanceId !== undefined ? { instanceId: worktreeInstanceId } : {}),
lineage:
parentWorktreeId && lineageWorktreeInstanceId && parentWorktreeInstanceId
? {
parentWorktreeId,
worktreeInstanceId: lineageWorktreeInstanceId,
parentWorktreeInstanceId
}
: null,
recentAt: lastActivityAt
}
}
@@ -0,0 +1,83 @@
import type { z } from 'zod'
import { DesktopSidebarFactsSchema, type DesktopSidebarFacts } from './desktop-sidebar-facts'
import type { InstalledSidebarInputs } from './sidebar-inputs-feed-installer'
import {
PairedServerRowsSnapshotSchema,
type PairedServerHostFacts,
type PairedServerWorkspaceRow
} from './paired-server-rows'
import { SidebarTemporalSnapshotSchema, type SidebarRetention } from './sidebar-temporal-snapshot'
import {
WorkspaceActivityFactsSnapshotSchema,
type WorkspaceActivityFacts
} from './workspace-activity-facts'
import { WorkspaceCreationsSnapshotSchema, type WorkspaceCreation } from './workspace-creations'
import {
WorkspaceReviewFactsSnapshotSchema,
type WorkspaceReviewFact
} from './workspace-review-facts'
import { WorkspaceRowsSnapshotSchema, type WorkspaceRow } from './workspace-rows'
/** One installed feed, typed per domain; an entity this build cannot parse is left out. */
export type SidebarFeedDomains = {
workspaces: ReadonlyMap<string, WorkspaceRow>
activity: ReadonlyMap<string, WorkspaceActivityFacts>
review: ReadonlyMap<string, WorkspaceReviewFact>
creations: ReadonlyMap<string, WorkspaceCreation>
pairedServerHosts: ReadonlyMap<
string,
{ freshness: 'live' | 'unverifiable'; host: PairedServerHostFacts | null }
>
pairedServerRows: ReadonlyMap<string, PairedServerWorkspaceRow>
/** Null until the host's desktop facts arrive whole. */
desktop: DesktopSidebarFacts | null
retention: SidebarRetention | null
}
const entitySchemas = {
workspaces: WorkspaceRowsSnapshotSchema.shape.rows.valueType,
activity: WorkspaceActivityFactsSnapshotSchema.shape.facts.valueType,
review: WorkspaceReviewFactsSnapshotSchema.shape.facts.valueType,
creations: WorkspaceCreationsSnapshotSchema.shape.creations.valueType,
pairedServerHosts: PairedServerRowsSnapshotSchema.shape.servers.element.pick({
freshness: true,
host: true
}),
pairedServerRows: PairedServerRowsSnapshotSchema.shape.servers.element.shape.rows.element
}
function readDomain<Schema extends z.ZodType>(
installed: InstalledSidebarInputs,
name: string,
schema: Schema
): Map<string, z.infer<Schema>> {
const entities = new Map<string, z.infer<Schema>>()
for (const [key, value] of installed.domains.get(name) ?? []) {
const parsed = schema.safeParse(value)
if (parsed.success) {
entities.set(key, parsed.data)
}
}
return entities
}
/** The domains main publishes (sidebar-inputs-feed-domains.ts), read off an installed feed. */
export function readSidebarFeedDomains(installed: InstalledSidebarInputs): SidebarFeedDomains {
// The desktop and temporal domains publish each field as its own entity.
const desktop = DesktopSidebarFactsSchema.safeParse(
Object.fromEntries(installed.domains.get('desktop') ?? [])
)
const temporal = SidebarTemporalSnapshotSchema.pick({ retention: true }).safeParse(
Object.fromEntries(installed.domains.get('temporal') ?? [])
)
return {
workspaces: readDomain(installed, 'workspaces', entitySchemas.workspaces),
activity: readDomain(installed, 'activity', entitySchemas.activity),
review: readDomain(installed, 'review', entitySchemas.review),
creations: readDomain(installed, 'creations', entitySchemas.creations),
pairedServerHosts: readDomain(installed, 'pairedServerHosts', entitySchemas.pairedServerHosts),
pairedServerRows: readDomain(installed, 'pairedServerRows', entitySchemas.pairedServerRows),
desktop: desktop.success ? desktop.data : null,
retention: temporal.success ? temporal.data.retention : null
}
}
@@ -0,0 +1,308 @@
import {
ALL_EXECUTION_HOSTS_SCOPE,
getSshTargetIdForExecutionHost,
getWorktreeExecutionHostId,
normalizeExecutionHostOrder,
parseExecutionHostId,
type ExecutionHostId,
type ExecutionHostScope
} from '../execution-host'
import { buildExecutionHostRegistry } from '../execution-host-registry'
import type { ProjectGroup } from '../project-group-types'
import { getLogicalRepoOrderRankById } from '../project-section-order'
import type { ProjectGroupingModel } from '../project-section-grouping'
import type { Repo } from '../repo-types'
import type { ProjectOrderBy } from '../ui-chrome-types'
import {
filterFolderWorkspacesFromOtherDevices,
isWorkspaceOwnedBySomeoneElse
} from '../workspace-creator-visibility'
import type { WorkspaceRowFilters } from '../workspace-view/workspace-row-filters'
import { getWorktreeHostIdentity } from '../worktree/host-qualified-identity'
import { folderWorkspaceToWorktree } from '../folder-workspace-worktree'
import { isActiveSidebarWorkspace } from './active-workspace'
import type { WorkspaceStatusDefinition, Worktree } from '../worktree/types'
import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos'
import {
filterFolderWorkspacesForVisibleHosts,
filterProjectGroupsForVisibleHosts,
getVisibleSidebarHostIdSet
} from './host-filtering'
import { deferHostSectionCollapse, scopeHostSectionCollapse } from './host-section-collapse'
import { orderHostSectionOptions } from './host-section-order'
import {
addHostSectionRows,
type HostSectionOption,
type HostSectionRow
} from './host-section-rows'
import type { PinnedWorktreeDisplayPolicy, Row, WorktreeGroupBy } from './row-types'
import type { SidebarFeedDomains } from './sidebar-feed-domains'
import {
readFeedLineage,
readFeedPairedServerRow,
readFeedWorkspaceRow,
type SidebarFeedWorkspace
} from './sidebar-feed-worktrees'
import { planSidebarRows } from './sidebar-row-plan'
import { sortWorktreesByStamps, type SortBy } from './smart-sort'
import { selectVisibleSidebarWorktrees } from './visible-worktrees'
import { isIdleWorkspaceActivity } from './workspace-activity-facts'
import { prGroupOfReviewFact } from './workspace-review-facts'
/** The host's view settings a reader shows the list with; the host's own phone reads them off ui.get. */
export type SidebarFeedView = {
groupBy: WorktreeGroupBy
sortBy: SortBy
projectOrderBy: ProjectOrderBy
collapsedGroups: ReadonlySet<string>
filters: WorkspaceRowFilters
workspaceHostScope: ExecutionHostScope
visibleWorkspaceHostIds: readonly ExecutionHostId[] | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
/** The host the desktop has focused (its settings' active runtime), where unowned rows land. */
defaultHostId: ExecutionHostId
hostLabelOverrides: ReadonlyMap<ExecutionHostId, string>
}
/** The catalog the feed does not carry: every host's repos (repo.list), groups and projects. */
export type SidebarFeedCatalog = {
repos: readonly Repo[]
projectGroups: readonly ProjectGroup[]
projectGrouping: ProjectGroupingModel
}
export type SidebarFeedRows = {
visibleWorktrees: Worktree[]
rows: Row[]
sectionRows: HostSectionRow[]
/** Row keys of every occurrence of the desktop's open workspace; a pinned duplicate shows twice. */
activeRowKeys: ReadonlySet<string>
}
function readFeedWorkspaces(domains: SidebarFeedDomains): SidebarFeedWorkspace[] {
return [
...[...domains.workspaces].flatMap(
([identity, row]) => readFeedWorkspaceRow(identity, row) ?? []
),
...[...domains.pairedServerRows.values()].flatMap((row) => readFeedPairedServerRow(row) ?? [])
]
}
function buildFeedHostOptions(
domains: SidebarFeedDomains,
repos: readonly Repo[],
view: SidebarFeedView
): HostSectionOption[] {
const servers = [...domains.pairedServerHosts].flatMap(([hostId, { host }]) => {
const parsed = parseExecutionHostId(hostId)
return parsed?.kind === 'runtime' && host ? [{ parsed, host }] : []
})
const serverLabelByHostId = new Map<ExecutionHostId, string>(
servers.map(({ parsed, host }) => [parsed.id, host.label])
)
const focused = parseExecutionHostId(view.defaultHostId)
return buildExecutionHostRegistry({
repos,
settings: {
activeRuntimeEnvironmentId: focused?.kind === 'runtime' ? focused.environmentId : null
},
sshTargetLabels: new Map(
Object.entries(domains.desktop?.sshHosts ?? {}).flatMap(([hostId, host]) => {
const targetId = getSshTargetIdForExecutionHost(hostId)
return targetId ? [[targetId, host.label] as const] : []
})
),
runtimeEnvironments: servers.map(({ parsed, host }) => ({
id: parsed.environmentId,
name: host.label
})),
hostLabelOverrides: view.hostLabelOverrides
}).map((host) => {
const label = serverLabelByHostId.get(host.id)
return label ? { ...host, label } : host
})
}
/**
* The sidebar a feed reader shows: the host's published inputs through the same builder, filters
* and host sections the desktop window runs, with no clock. The window's transient overlays (drag
* previews, search, hover hold, agent-send reveal) and its desktop-only cards are not inputs here.
*/
export function buildSidebarFeedRows(
domains: SidebarFeedDomains,
catalog: SidebarFeedCatalog,
view: SidebarFeedView
): SidebarFeedRows {
const { repos, projectGroups } = catalog
const { defaultHostId, groupBy } = view
const workspaces = readFeedWorkspaces(domains)
const byIdentity = new Map(workspaces.map((entry) => [entry.identity, entry]))
const gitWorkspaces = workspaces.filter((entry) => entry.folderWorkspace === null)
const allWorktrees = gitWorkspaces.map((entry) => entry.worktree)
const folderWorkspaces = workspaces.flatMap((entry) => entry.folderWorkspace ?? [])
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
const worktreeMap = new Map<string, Worktree>()
const ancestorById = new Map<string, Worktree>()
const worktreesByRepo: Record<string, Worktree[]> = {}
for (const worktree of allWorktrees) {
if (!worktreeMap.has(worktree.id)) {
worktreeMap.set(worktree.id, worktree)
}
if (!worktree.isArchived) {
ancestorById.set(worktree.id, worktree)
}
const repoWorktrees = worktreesByRepo[worktree.repoId]
if (repoWorktrees) {
repoWorktrees.push(worktree)
} else {
worktreesByRepo[worktree.repoId] = [worktree]
}
}
const lineageById = readFeedLineage(workspaces)
const retained = new Set(domains.retention?.retainedIds ?? [])
const factsOf = (worktree: Worktree) => byIdentity.get(getWorktreeHostIdentity(worktree))?.facts
const pairedDeviceIdsByEnvironment = new Map(
[...domains.pairedServerHosts].flatMap(([hostId, { host }]) => {
const environmentId = parseExecutionHostId(hostId)
return environmentId?.kind === 'runtime' && host?.pairedDeviceId
? [[environmentId.environmentId, host.pairedDeviceId] as const]
: []
})
)
const sorted = sortWorktreesByStamps(
allWorktrees.filter((worktree) => !worktree.isArchived),
view.sortBy,
repoMap
)
const visibleWorktrees = selectVisibleSidebarWorktrees({
rows: allWorktrees,
ancestorById,
rankById: new Map(sorted.map((worktree, index) => [worktree.id, index])),
filters: view.filters,
facts: {
isArchived: (worktree) => worktree.isArchived,
isFolderWorkspace: () => false,
repoId: (worktree) => worktree.repoId,
hostId: (worktree) => {
const repo = repoMap.get(worktree.repoId)
return repo ? getWorktreeExecutionHostId(worktree, repo, defaultHostId) : null
},
isOtherClient: (worktree) =>
isWorkspaceOwnedBySomeoneElse(worktree, pairedDeviceIdsByEnvironment),
isDefaultBranch: (worktree) => factsOf(worktree)?.defaultBranch === true,
isAutomationCreated: (worktree) => factsOf(worktree)?.automationCreated === true,
isCliCreated: (worktree) => factsOf(worktree)?.cliCreated === true,
isDetachedHead: (worktree) => factsOf(worktree)?.detachedHead === true,
isMainWorktree: (worktree) => worktree.isMainWorktree,
isSleeping: (worktree) => {
const activity = domains.activity.get(worktree.id)
return !retained.has(worktree.id) && (!activity || isIdleWorkspaceActivity(activity))
}
},
worktreeLineageById: lineageById,
filterRepoIds: view.filters.filterRepoIds,
preserveLineageParentOrder: view.sortBy === 'manual'
})
const visibleHostIdSet = getVisibleSidebarHostIdSet(
view.visibleWorkspaceHostIds,
view.workspaceHostScope
)
const visibleRepos = visibleHostIdSet
? repos.filter((repo) =>
visibleHostIdSet.has(getWorktreeExecutionHostId({}, repo, defaultHostId))
)
: repos
const hostVisibleFolderWorkspaces = filterFolderWorkspacesForVisibleHosts(
folderWorkspaces,
projectGroups,
visibleHostIdSet,
defaultHostId
)
const visibleFolderWorkspaces = view.filters.hideWorkspacesFromOtherDevices
? filterFolderWorkspacesFromOtherDevices(
hostVisibleFolderWorkspaces,
pairedDeviceIdsByEnvironment
)
: hostVisibleFolderWorkspaces
const listed = new Set(workspaces.map((entry) => entry.worktree.id))
const pendingCreations = [...domains.creations].flatMap(([creationId, creation]) =>
creation.worktreeId !== undefined && listed.has(creation.worktreeId)
? []
: [{ creationId, repoId: creation.repoId }]
)
const hostOptions = buildFeedHostOptions(domains, repos, view)
// The catalog's order, not the feed's repoOrder: only it places a paired server's projects.
const repoOrder = getLogicalRepoOrderRankById(repos.map((repo) => repo.id))
const plan = planSidebarRows({
groupBy,
worktrees: visibleWorktrees,
repoMap,
prGroupOf: (worktree) => prGroupOfReviewFact(domains.review.get(worktree.id)),
repoOrder,
workspaceStatuses: view.workspaceStatuses,
projectOrderBy: view.projectOrderBy,
lineageById,
worktreeMap,
nestLineage: true,
projectGroups: filterProjectGroupsForVisibleHosts(
projectGroups,
visibleHostIdSet,
defaultHostId
),
placeholderRepoIds: getEmptyProjectPlaceholderRepoIds({
groupBy,
repos: visibleRepos,
worktreesByRepo,
visibleWorktrees,
filterRepoIds: view.filters.filterRepoIds
}),
importedWorktreesByRepo: new Map(),
newExternalWorktreesInboxByRepo: new Map(),
pendingCreations,
projectGrouping: catalog.projectGrouping,
folderWorkspaces: visibleFolderWorkspaces,
hostLabelById: new Map(hostOptions.map((host) => [host.id, host.label])),
defaultHostId,
pinnedDisplayPolicy: view.pinnedDisplayPolicy,
filterRepoIds: view.filters.filterRepoIds
})
const collapsedGroups = new Set(view.collapsedGroups)
const hostScoped =
view.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE || view.visibleWorkspaceHostIds !== null
const rows = plan.render(hostScoped ? deferHostSectionCollapse(collapsedGroups) : collapsedGroups)
const sectioned = addHostSectionRows({
rows,
hostOptions: orderHostSectionOptions(
hostOptions,
normalizeExecutionHostOrder(domains.desktop?.workspaceHostOrder)
),
workspaceHostScope: view.workspaceHostScope,
visibleWorkspaceHostIds: view.visibleWorkspaceHostIds,
defaultHostId,
collapsedHostKeys: collapsedGroups,
forceCollapseHosts: false,
preferProjectGrouping: true
})
const sectionRows = hostScoped
? scopeHostSectionCollapse({ rows: sectioned, collapsedGroups })
: sectioned
const active = {
worktreeId: domains.desktop?.desktopActive.worktreeId ?? null,
hostId: parseExecutionHostId(domains.desktop?.desktopActive.hostId)?.id ?? null
}
const activeRowKeys = new Set<string>()
for (const row of sectionRows) {
if (row.type === 'item' && isActiveSidebarWorkspace(row.worktree, active)) {
activeRowKeys.add(row.rowKey)
} else if (
row.type === 'folder-workspace' &&
isActiveSidebarWorkspace(folderWorkspaceToWorktree(row.folderWorkspace), active)
) {
activeRowKeys.add(row.key)
}
}
return { visibleWorktrees, rows, sectionRows, activeRowKeys }
}
@@ -0,0 +1,195 @@
import {
getSshTargetIdForExecutionHost,
parseExecutionHostId,
type ExecutionHostId
} from '../execution-host'
import type { FolderWorkspace } from '../folder-workspace-types'
import { projectGroupIdFromRepoId } from '../folder-workspace-worktree'
import { parseWorkspaceKey } from '../workspace-scope'
import { getExecutionHostIdFromWorktreeHostIdentity } from '../worktree/host-qualified-identity'
import type { WorktreeLineage } from '../worktree/lineage-types'
import type { Worktree } from '../worktree/types'
import type { PairedServerWorkspaceRow } from './paired-server-rows'
import type { WorkspaceRow } from './workspace-rows'
/** What the filters read off a row that the host decided, not the reader (workspace-kinds.ts). */
export type SidebarFeedRowFacts = Partial<
Pick<
WorkspaceRow,
'defaultBranch' | 'automationCreated' | 'cliCreated' | 'detachedHead' | 'creatorProvenance'
>
>
/** One feed workspace as the shared builder reads it, keyed by its identity. */
export type SidebarFeedWorkspace = {
identity: string
worktree: Worktree
facts: SidebarFeedRowFacts
/** Set on a folder row: the builder lists folder workspaces apart from worktrees. */
folderWorkspace: FolderWorkspace | null
lineage: WorkspaceRow['lineage']
}
/** The fields both a host's own rows and a paired server's rows carry for the list. */
type FeedRowFields = Pick<
WorkspaceRow,
'repoId' | 'kind' | 'path' | 'branch' | 'displayName' | 'isArchived' | 'isPinned'
> &
Partial<
Pick<
WorkspaceRow,
| 'isMainWorktree'
| 'isUnread'
| 'instanceId'
| 'lineage'
| 'workspaceStatus'
| 'sortOrder'
| 'manualOrder'
| 'createdAt'
| 'recentAt'
| 'smartTier'
| 'smartAt'
>
> &
SidebarFeedRowFacts
function readFeedRow(
identity: string,
id: string,
ownerHostId: ExecutionHostId,
row: FeedRowFields,
extra: Partial<
Pick<
Worktree,
| 'comment'
| 'linkedIssue'
| 'linkedPR'
| 'linkedLinearIssue'
| 'linkedGitLabMR'
| 'linkedGitLabIssue'
| 'linkedItems'
>
> = {}
): SidebarFeedWorkspace {
// The identity's host, not the owner: a local row whose metadata names no host keeps `|id`.
const hostId = getExecutionHostIdFromWorktreeHostIdentity(identity)
const owner = parseExecutionHostId(ownerHostId)
const { defaultBranch, automationCreated, cliCreated, detachedHead, creatorProvenance } = row
const worktree: Worktree = {
id,
...(hostId ? { hostId } : {}),
...(owner?.kind === 'runtime' ? { runtimeOwnerEnvironmentId: owner.environmentId } : {}),
...(row.instanceId !== undefined ? { instanceId: row.instanceId } : {}),
repoId: row.repoId,
path: row.path,
branch: row.branch,
head: '',
isBare: false,
displayName: row.displayName,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isMainWorktree: row.isMainWorktree ?? false,
isArchived: row.isArchived,
isPinned: row.isPinned,
isUnread: row.isUnread ?? false,
workspaceStatus: row.workspaceStatus,
sortOrder: row.sortOrder ?? 0,
...(row.manualOrder !== undefined ? { manualOrder: row.manualOrder } : {}),
...(row.createdAt !== undefined ? { createdAt: row.createdAt } : {}),
...(creatorProvenance ? { creatorProvenance } : {}),
lastActivityAt: row.recentAt ?? 0,
...(row.smartTier !== undefined ? { smartTier: row.smartTier, smartAt: row.smartAt } : {}),
...extra
}
return {
identity,
worktree,
facts: { defaultBranch, automationCreated, cliCreated, detachedHead, creatorProvenance },
folderWorkspace: row.kind === 'folder' ? folderWorkspaceOf(worktree, ownerHostId) : null,
lineage: row.lineage ?? null
}
}
function folderWorkspaceOf(
worktree: Worktree,
ownerHostId: ExecutionHostId
): FolderWorkspace | null {
const id = parseWorkspaceKey(worktree.id)
const projectGroupId = projectGroupIdFromRepoId(worktree.repoId)
if (id?.type !== 'folder' || !projectGroupId) {
return null
}
return {
id: id.folderWorkspaceId,
projectGroupId,
name: worktree.displayName,
folderPath: worktree.path,
executionHostId: ownerHostId,
connectionId: getSshTargetIdForExecutionHost(ownerHostId),
...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}),
linkedTask: null,
...(worktree.linkedItems ? { linkedItems: worktree.linkedItems } : {}),
comment: worktree.comment,
isArchived: worktree.isArchived,
isUnread: worktree.isUnread,
isPinned: worktree.isPinned,
sortOrder: worktree.sortOrder,
...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}),
workspaceStatus: worktree.workspaceStatus,
lastActivityAt: worktree.lastActivityAt,
...(worktree.smartTier !== undefined
? { smartTier: worktree.smartTier, smartAt: worktree.smartAt }
: {}),
createdAt: worktree.createdAt ?? 0,
updatedAt: 0
}
}
/** A row this host lists in its own catalog (workspace-rows.ts), back in the builder's shape. */
export function readFeedWorkspaceRow(
identity: string,
row: WorkspaceRow
): SidebarFeedWorkspace | null {
const owner = parseExecutionHostId(row.hostId)
return owner
? readFeedRow(identity, row.id, owner.id, row, {
comment: row.comment,
linkedIssue: row.linkedIssue,
linkedPR: row.linkedPR,
linkedLinearIssue: row.linkedLinearIssue,
linkedGitLabMR: row.linkedGitLabMR,
linkedGitLabIssue: row.linkedGitLabIssue,
linkedItems: row.linkedItems
})
: null
}
/** A paired server's row, as the server's own list reports it; older desktops send fewer fields. */
export function readFeedPairedServerRow(
row: PairedServerWorkspaceRow
): SidebarFeedWorkspace | null {
const owner = parseExecutionHostId(getExecutionHostIdFromWorktreeHostIdentity(row.key))
return owner?.kind === 'runtime' ? readFeedRow(row.key, row.worktreeId, owner.id, row) : null
}
/** The rows' stored lineage edges as the builder's lineage map, keyed by bare id as the window's. */
export function readFeedLineage(
workspaces: readonly SidebarFeedWorkspace[]
): Record<string, WorktreeLineage> {
const lineageById: Record<string, WorktreeLineage> = {}
for (const { worktree, lineage } of workspaces) {
if (lineage && !(worktree.id in lineageById)) {
// The builder validates only the edge; provenance stays the host's.
lineageById[worktree.id] = {
worktreeId: worktree.id,
...lineage,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 0
}
}
}
return lineageById
}
+38 -15
View File
@@ -79,6 +79,41 @@ export function compareWorktreeSortLabel(
)
}
function worktreeSortReads(
repoMap: Map<string, Pick<Repo, 'displayName'>>,
labels: WorktreeSortLabels | undefined
) {
return {
label: (worktree: Worktree): string => labels?.get(worktree) ?? getWorktreeSortLabel(worktree),
identity: getWorktreeHostIdentity,
repoLabel: (worktree: Worktree) => repoMap.get(worktree.repoId)?.displayName ?? '',
// Why fallback to sortOrder: existing users have a persisted smart-sort snapshot but no
// manualOrder yet, so Manual starts from that order instead of alphabetizing.
manualRank: (worktree: Worktree) => worktree.manualOrder ?? worktree.sortOrder
}
}
function buildStampComparator(
sortBy: SortBy,
repoMap: Map<string, Pick<Repo, 'displayName'>>,
labels: WorktreeSortLabels | undefined
): (a: Worktree, b: Worktree) => number {
return buildWorkspaceRowComparator<Worktree>(sortBy, {
...worktreeSortReads(repoMap, labels),
recentActivity: recentStamp,
compareSmart: compareSmartStamps
})
}
/** Sorts in place by the owning hosts' stamps, reading no clock: the order every stamp reader shares. */
export function sortWorktreesByStamps<W extends Worktree>(
worktrees: W[],
sortBy: SortBy,
repoMap: Map<string, Pick<Repo, 'displayName'>>
): W[] {
return worktrees.sort(buildStampComparator(sortBy, repoMap, buildWorktreeSortLabels(worktrees)))
}
/**
* Build a comparator for sorting worktrees based on the current sort mode.
*
@@ -95,24 +130,12 @@ export function buildWorktreeComparator(
attentionByWorktree: Map<string, WorktreeAttention>,
labels?: WorktreeSortLabels
): (a: Worktree, b: Worktree) => number {
const read = {
label: (worktree: Worktree): string => labels?.get(worktree) ?? getWorktreeSortLabel(worktree),
identity: getWorktreeHostIdentity,
repoLabel: (worktree: Worktree) => repoMap.get(worktree.repoId)?.displayName ?? '',
// Why fallback to sortOrder: existing users have a persisted smart-sort snapshot but no
// manualOrder yet, so Manual starts from that order instead of alphabetizing.
manualRank: (worktree: Worktree) => worktree.manualOrder ?? worktree.sortOrder
}
// Switch point: Recent and Smart read the owning host's stamps, with no clock.
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
return buildWorkspaceRowComparator<Worktree>(sortBy, {
...read,
recentActivity: recentStamp,
compareSmart: compareSmartStamps
})
return buildStampComparator(sortBy, repoMap, labels)
}
return buildWorkspaceRowComparator<Worktree>(sortBy, {
...read,
...worktreeSortReads(repoMap, labels),
recentActivity: (worktree) => effectiveRecentActivity(worktree, now),
compareSmart: (a, b) => {
const aw = attentionByWorktree.get(a.id) ?? IDLE
@@ -213,7 +236,7 @@ export function rankWorktreesForSort<W extends Worktree>(args: {
const labels = buildWorktreeSortLabels(worktrees)
// Switch point: stamps need no cold phase, latch or attention map.
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
worktrees.sort(buildWorktreeComparator(sortBy, args.repoMap, now, new Map(), labels))
worktrees.sort(buildStampComparator(sortBy, args.repoMap, labels))
return { sorted: worktrees, attentionByWorktree: null, detectedLiveSmartSignal: false }
}
let detectedLiveSmartSignal = false
@@ -4,6 +4,8 @@ import type { Worktree } from '../worktree/types'
import { getGitHubPRCacheKeyCandidates } from '../github/github-cache-key'
import { isGitHubPRSuppressed } from '../worktree/github-pr-suppression'
import { getPRGroupKeyForState, type PRGroupKey } from '../workspace-view/workspace-pr-groups'
import type { PairedServerWorkspaceRow } from './paired-server-rows'
import { parseExecutionHostId } from '../execution-host'
/** Grouping by PR reads GitHub PRs only, so only a row with a GitHub PR carries a fact. */
const WorkspaceReviewFactSchema = z.object({
@@ -74,3 +76,28 @@ export function buildWorkspaceReviewFacts(
}
return facts
}
/** A paired server's rows against this desktop's own cache, under the server's host-scoped keys. */
export function buildPairedServerReviewFacts(
servers: readonly { hostId: string; rows: readonly PairedServerWorkspaceRow[] }[],
prCache: PRCacheView
): Record<string, WorkspaceReviewFact> {
const facts: Record<string, WorkspaceReviewFact> = {}
for (const server of servers) {
const hostId = parseExecutionHostId(server.hostId)?.id
for (const row of hostId ? server.rows : []) {
const fact =
row.kind === 'git'
? readWorkspaceReviewFact(
{ branch: row.branch, linkedPR: null },
{ id: row.repoId, path: '', executionHostId: hostId },
prCache
)
: null
if (fact) {
facts[row.worktreeId] = fact
}
}
}
return facts
}
@@ -87,17 +87,21 @@ describe('buildWorkspaceRows', () => {
})
})
it('takes the host from the repo when the row has none, and leaves paired-server rows out', () => {
it('keys a row whose metadata names no host as the window does, its owner from the repo', () => {
const sshOnly = { ...sshRepo, id: 'repo-ssh' }
const rows = buildWorkspaceRows({
worktrees: [
worktree(),
// The window stamps a remote repo's host on every row it lists.
worktree({ id: 'repo-ssh::/remote/b', repoId: 'repo-ssh' }),
worktree({ id: 'repo-2::/x', repoId: 'repo-2', hostId: 'runtime:env-1' })
],
folderWorkspaces: [{ ...folder, executionHostId: 'runtime:env-1' }],
repos: [localRepo],
repos: [localRepo, sshOnly],
lineageById: {}
})
expect([...rows.keys()]).toEqual(['local|repo-1::/r/a'])
expect([...rows.keys()]).toEqual(['|repo-1::/r/a', 'ssh:box|repo-ssh::/remote/b'])
expect(rows.get('|repo-1::/r/a')?.hostId).toBe('local')
})
it('carries the stored lineage edge for the builder to validate', () => {
+11 -3
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'
import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
@@ -20,7 +21,7 @@ import { SMART_TIERS } from './sort-stamps'
* One workspace this host lists, as the sidebar reads it: catalog, metadata and the host's sort
* stamps. Activity and PR state are their own domains, so plain terminal output never changes a row.
*/
const WorkspaceRowSchema = z.object({
export const WorkspaceRowSchema = z.object({
id: z.string(),
hostId: z.string(),
kind: z.enum(['git', 'folder']),
@@ -44,6 +45,8 @@ const WorkspaceRowSchema = z.object({
workspaceStatus: z.string(),
comment: z.string(),
manualOrder: z.number().optional(),
/** The saved order Manual falls back to where a row has no manualOrder; older hosts omit it. */
sortOrder: z.number().optional(),
createdAt: z.number().optional(),
creatorProvenance: z
.union([
@@ -71,7 +74,8 @@ const WorkspaceRowSchema = z.object({
smartAt: z.number().optional()
})
/** Keyed by host-qualified id, the form the frozen sort order and paired-server rows use. */
/** Keyed by the row's identity as every client keys it: a local row whose metadata names no host
* keeps `|id`, while `hostId` still says which host owns it. */
export const WorkspaceRowsSnapshotSchema = z.object({
revision: z.number(),
rows: z.record(z.string(), WorkspaceRowSchema)
@@ -111,6 +115,7 @@ function toWorkspaceRow(
workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
comment: worktree.comment,
...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}),
sortOrder: worktree.sortOrder,
...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}),
...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}),
linkedIssue: worktree.linkedIssue,
@@ -151,8 +156,11 @@ export function buildWorkspaceRows(catalog: {
): void => {
const hostId = worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined)
if (hostId && parseExecutionHostId(hostId)?.kind !== 'runtime') {
// As the window stamps rows (worktree-host-ownership.ts): a remote repo's host always, a
// local row only the host its metadata names.
const identityHostId = hostId === LOCAL_EXECUTION_HOST_ID ? worktree.hostId : hostId
rows.set(
composeWorktreeHostIdentity(hostId, worktree.id),
composeWorktreeHostIdentity(identityHostId, worktree.id),
toWorkspaceRow(worktree, hostId, kind, repo, lineage)
)
}