From bad74e1b7c6ec0d96dcf869c05c8bb5e9c067a47 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:01:45 -0400 Subject: [PATCH] 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) --- config/tsconfig.tc.web.json | 5 + .../paired-server-rows-producer.test.ts | 74 ++++- .../sidebar-inputs-feed-domains.ts | 10 +- ...sidebar-list-golden-suite.test-fixture.tsx | 2 +- .../sidebar-list-parity.test-fixture.ts | 260 +++++++++++++++ .../sidebar/sidebar-list-parity.test.tsx | 253 ++++++++++++++ .../sidebar/worktree-list/rows/folder-row.tsx | 15 +- .../sidebar/worktree-list/rows/item-row.tsx | 15 +- .../paired-server-rows-shadow.test.ts | 13 +- .../paired-server-rows-shadow.ts | 69 +++- src/shared/sidebar-list/active-workspace.ts | 24 ++ src/shared/sidebar-list/paired-server-rows.ts | 92 +++++- .../sidebar-list/sidebar-feed-domains.ts | 83 +++++ src/shared/sidebar-list/sidebar-feed-rows.ts | 308 ++++++++++++++++++ .../sidebar-list/sidebar-feed-worktrees.ts | 195 +++++++++++ src/shared/sidebar-list/smart-sort.ts | 53 ++- .../sidebar-list/workspace-review-facts.ts | 27 ++ .../sidebar-list/workspace-rows.test.ts | 10 +- src/shared/sidebar-list/workspace-rows.ts | 14 +- 19 files changed, 1459 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx create mode 100644 src/shared/sidebar-list/active-workspace.ts create mode 100644 src/shared/sidebar-list/sidebar-feed-domains.ts create mode 100644 src/shared/sidebar-list/sidebar-feed-rows.ts create mode 100644 src/shared/sidebar-list/sidebar-feed-worktrees.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 47c166c0b44..6e37215a117 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -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", diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts index 0485516e6be..c65cbfd8dbb 100644 --- a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.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([ diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts index 4ac4a433242..fb93ac6297e 100644 --- a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/sidebar-list-golden-suite.test-fixture.tsx b/src/renderer/src/components/sidebar/sidebar-list-golden-suite.test-fixture.tsx index 3c10c0d3a60..4d1cd8eb991 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-golden-suite.test-fixture.tsx +++ b/src/renderer/src/components/sidebar/sidebar-list-golden-suite.test-fixture.tsx @@ -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) diff --git a/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts new file mode 100644 index 00000000000..69f8c3c1299 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts @@ -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 = { + 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): Partial { + 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 { + 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): 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[] { + 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 [] + } + }) +} diff --git a/src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx b/src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx new file mode 100644 index 00000000000..59ad7ade7f3 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-list-parity.test.tsx @@ -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 { + const active = { + worktreeId: state.activeWorktreeId, + hostId: state.activeWorkspaceExecutionHostId ?? null + } + const keys = new Set() + 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): 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): Promise { + 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 = {}): Partial { + 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): Promise { + 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 { + 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, 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) + ) + } + } + }) + } +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx index 4cc32b9ee94..759e47131f9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index d00569e79f3..9222fa37ca7 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -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 (
{ 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', diff --git a/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts index 0b53e4ef5e0..bec53a50686 100644 --- a/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/paired-server-rows-shadow.ts @@ -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, + state: Pick, hostIds: ReadonlySet ): Map { const rows = new Map() + 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() + 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 | 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< diff --git a/src/shared/sidebar-list/active-workspace.ts b/src/shared/sidebar-list/active-workspace.ts new file mode 100644 index 00000000000..5a8bf6dba0e --- /dev/null +++ b/src/shared/sidebar-list/active-workspace.ts @@ -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, + active: ActiveSidebarWorkspace +): boolean { + return ( + active.worktreeId === worktree.id && + (!active.hostId || + getWorktreeHostIdentity(worktree) === composeWorktreeHostIdentity(active.hostId, worktree.id)) + ) +} diff --git a/src/shared/sidebar-list/paired-server-rows.ts b/src/shared/sidebar-list/paired-server-rows.ts index 5b30ce915b0..f3cbd6aea6f 100644 --- a/src/shared/sidebar-list/paired-server-rows.ts +++ b/src/shared/sidebar-list/paired-server-rows.ts @@ -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 } } diff --git a/src/shared/sidebar-list/sidebar-feed-domains.ts b/src/shared/sidebar-list/sidebar-feed-domains.ts new file mode 100644 index 00000000000..233ad67460c --- /dev/null +++ b/src/shared/sidebar-list/sidebar-feed-domains.ts @@ -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 + activity: ReadonlyMap + review: ReadonlyMap + creations: ReadonlyMap + pairedServerHosts: ReadonlyMap< + string, + { freshness: 'live' | 'unverifiable'; host: PairedServerHostFacts | null } + > + pairedServerRows: ReadonlyMap + /** 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( + installed: InstalledSidebarInputs, + name: string, + schema: Schema +): Map> { + const entities = new Map>() + 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 + } +} diff --git a/src/shared/sidebar-list/sidebar-feed-rows.ts b/src/shared/sidebar-list/sidebar-feed-rows.ts new file mode 100644 index 00000000000..fbbd5cf7071 --- /dev/null +++ b/src/shared/sidebar-list/sidebar-feed-rows.ts @@ -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 + 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 +} + +/** 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 +} + +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( + 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() + const ancestorById = new Map() + const worktreesByRepo: Record = {} + 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() + 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 } +} diff --git a/src/shared/sidebar-list/sidebar-feed-worktrees.ts b/src/shared/sidebar-list/sidebar-feed-worktrees.ts new file mode 100644 index 00000000000..d9fc5366aa4 --- /dev/null +++ b/src/shared/sidebar-list/sidebar-feed-worktrees.ts @@ -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 { + const lineageById: Record = {} + 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 +} diff --git a/src/shared/sidebar-list/smart-sort.ts b/src/shared/sidebar-list/smart-sort.ts index 560d65a18a3..8bbc678512d 100644 --- a/src/shared/sidebar-list/smart-sort.ts +++ b/src/shared/sidebar-list/smart-sort.ts @@ -79,6 +79,41 @@ export function compareWorktreeSortLabel( ) } +function worktreeSortReads( + repoMap: Map>, + 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>, + labels: WorktreeSortLabels | undefined +): (a: Worktree, b: Worktree) => number { + return buildWorkspaceRowComparator(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( + worktrees: W[], + sortBy: SortBy, + repoMap: Map> +): 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, 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(sortBy, { - ...read, - recentActivity: recentStamp, - compareSmart: compareSmartStamps - }) + return buildStampComparator(sortBy, repoMap, labels) } return buildWorkspaceRowComparator(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(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 diff --git a/src/shared/sidebar-list/workspace-review-facts.ts b/src/shared/sidebar-list/workspace-review-facts.ts index 56806f0e4f3..09c6e2a9b75 100644 --- a/src/shared/sidebar-list/workspace-review-facts.ts +++ b/src/shared/sidebar-list/workspace-review-facts.ts @@ -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 { + const facts: Record = {} + 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 +} diff --git a/src/shared/sidebar-list/workspace-rows.test.ts b/src/shared/sidebar-list/workspace-rows.test.ts index 116451869e8..4ba2a2edbca 100644 --- a/src/shared/sidebar-list/workspace-rows.test.ts +++ b/src/shared/sidebar-list/workspace-rows.test.ts @@ -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', () => { diff --git a/src/shared/sidebar-list/workspace-rows.ts b/src/shared/sidebar-list/workspace-rows.ts index cbe8c9a511f..f299752e6fe 100644 --- a/src/shared/sidebar-list/workspace-rows.ts +++ b/src/shared/sidebar-list/workspace-rows.ts @@ -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) ) }