From 92bbb380a69d74a0cfcc3d0de970787cbcfd1dfc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:24:27 -0400 Subject: [PATCH] Sidebar: land the switch-on fixes from the flip branch on main, switch still off (#31466) * fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip) The fixes found while the sidebar ran from main's feed, without turning that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's stop-gaps come along. - Main stops counting a closed tab's terminal once it was asked to stop and no pane shows it; a failed or refused stop takes the request back, and a host out of contact stays unverifiable. - Main fetches a paired server's rows with the transport's normal bound, so a server slower than 5 s still lists. - A deferred unread raise flips the folder row it was decided for, and a read while the owner check is out cancels it. - Phone: no worktree.ps flash before the feed; a host's last installed feed stands in while a fresh read is out; it highlights its own last-opened workspace and a fold never scrolls; projects file under their project group with the desktop's indent; the collapse spinner shows only after 500 ms; "Reopen to update" keeps the rows on screen. - e2e helpers and journeys for the switch-on suite. * fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises - A stop-and-wait that throws takes its stop request back, like one that fails. - A final stop the legacy relay still owes stays marked, since the relay retries it. - Pending unread raises are tracked only while one waits, so the map no longer grows. - The paired-server fallback poll test gives its third fetch a reply and asserts live. --- mobile/src/components/WorktreeListRow.tsx | 4 +- .../hooks/use-active-worktree-scroll.test.tsx | 72 ++++++ .../src/hooks/use-active-worktree-scroll.ts | 28 ++- .../host-list-first-frame.test.tsx | 153 +++++++++++++ .../host-screen-secondary-styles.ts | 3 + .../host-sidebar-feed-sections.test.ts | 191 ++++++++++++++-- .../host-screen/host-sidebar-feed-sections.ts | 51 ++++- .../host-workspace-list-reopen.test.tsx | 110 +++++++++ .../src/host-screen/host-workspace-list.tsx | 34 ++- .../src/host-screen/use-host-list-sections.ts | 26 ++- .../host-screen/use-host-screen-controller.ts | 5 +- .../src/host-screen/use-host-sidebar-feed.ts | 55 ++++- .../host-screen/use-host-worktree-actions.ts | 6 +- .../use-slow-pending-keys.test.tsx | 87 ++++++++ .../src/host-screen/use-slow-pending-keys.ts | 48 ++++ .../use-page-host-snapshot.test.tsx | 10 +- ...use-mobile-session-keyboard-state.test.tsx | 4 +- .../use-mobile-session-keyboard-state.ts | 8 +- mobile/src/storage/mirrored-storage-keys.ts | 16 ++ mobile/src/worktree/host-list-highlight.ts | 34 +++ .../worktree/host-workspace-list-states.tsx | 10 +- .../last-visited-worktree-by-host.test.ts | 136 ++++++++++++ .../worktree/last-visited-worktree-by-host.ts | 148 ++++++++++++ .../last-visited-worktree-repo.test.ts | 59 +---- .../worktree/last-visited-worktree-repo.ts | 16 -- .../src/worktree/project-group-indent.test.ts | 27 +++ mobile/src/worktree/project-group-indent.ts | 32 +++ ...use-last-visited-worktree-on-host.test.tsx | 136 ++++++++++++ .../use-last-visited-worktree-on-host.ts | 63 ++++++ mobile/src/worktree/workspace-list-types.ts | 8 + src/main/ipc/pty/ipc/renderer-kill.ts | 4 + .../runtime/kill-legacy-relay-bridged.test.ts | 87 +++++++- src/main/ipc/pty/runtime/kill.ts | 20 ++ .../mobile-relay-host-catalog.test.ts | 4 +- .../orca-runtime-closed-tab-retention.test.ts | 179 +++++++++++++++ .../orca-runtime-workspace-activity-facts.ts | 45 +++- .../paired-server-rows-producer.test.ts | 89 +++++++- .../paired-server-rows-producer.ts | 5 +- .../terminal/layout/terminal-input-floor.ts | 5 + ...idebar-list-parity-catalog.test-fixture.ts | 23 ++ .../sidebar-list-parity.test-fixture.ts | 39 +++- .../workspace-attention-slice.ts | 4 +- .../workspace-unread-flag-host-raises.test.ts | 30 +++ .../workspace-unread-flag.ts | 100 +++++---- .../listing/owner-host-sidebar-inputs.ts | 40 ++++ tests/e2e/helpers/phone-feed-list.ts | 20 +- tests/e2e/helpers/sidebar-setup-prompt.ts | 29 +++ tests/e2e/helpers/slept-workspace-probe.ts | 3 + tests/e2e/sidebar-desktop-reads-main.spec.ts | 210 ++++++++++++------ tests/e2e/sidebar-phone-feed-list.spec.ts | 155 +++++++++++-- tests/e2e/sidebar-sort-stamps.spec.ts | 16 +- tests/e2e/ssh-orcad-phone-feed-list.spec.ts | 14 +- .../worktree-lineage-toggle-shortcut.spec.ts | 2 + 53 files changed, 2404 insertions(+), 299 deletions(-) create mode 100644 mobile/src/hooks/use-active-worktree-scroll.test.tsx create mode 100644 mobile/src/host-screen/host-list-first-frame.test.tsx create mode 100644 mobile/src/host-screen/host-workspace-list-reopen.test.tsx create mode 100644 mobile/src/host-screen/use-slow-pending-keys.test.tsx create mode 100644 mobile/src/host-screen/use-slow-pending-keys.ts create mode 100644 mobile/src/worktree/host-list-highlight.ts create mode 100644 mobile/src/worktree/last-visited-worktree-by-host.test.ts create mode 100644 mobile/src/worktree/last-visited-worktree-by-host.ts create mode 100644 mobile/src/worktree/project-group-indent.test.ts create mode 100644 mobile/src/worktree/project-group-indent.ts create mode 100644 mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx create mode 100644 mobile/src/worktree/use-last-visited-worktree-on-host.ts create mode 100644 src/main/runtime/sidebar-inputs/orca-runtime-closed-tab-retention.test.ts create mode 100644 src/renderer/src/components/sidebar/sidebar-list-parity-catalog.test-fixture.ts create mode 100644 src/renderer/src/store/slices/worktrees/listing/owner-host-sidebar-inputs.ts create mode 100644 tests/e2e/helpers/sidebar-setup-prompt.ts diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 3ceb5d7780c..6de4432b367 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -9,6 +9,7 @@ import type { WorktreeCardDetails } from '../../../src/shared/worktree/card-prop import { isRemoteExecutionHostId } from '../../../src/shared/execution-host' import type { AgentActivityDisplayMode } from '../../../src/shared/ui-chrome-types' import { areStructurallyEqual } from '../worktree/structural-equality' +import { projectGroupRowIndent } from '../worktree/project-group-indent' import { getWorktreeMetaGlyphPresence } from '../worktree/worktree-meta-glyph-presence' import { AgentSpinner } from './AgentSpinner' import { HostKindIcon } from './HostKindIcon' @@ -64,6 +65,7 @@ function WorktreeListRowComponent({ const folderMeta = item.comment?.trim() || item.path || 'Folder' const metaText = isFolderWorkspace ? folderMeta : displayBranch(item.branch) const lineageDepth = Math.max(0, item.lineageDepth ?? 0) + const indent = projectGroupRowIndent(item) + lineageDepth * 18 const lineageChildCount = item.lineageChildCount ?? 0 const shouldHideRepo = hideRepo && !item.showProjectName const showRepoChip = cardDetails.repoInTitle && !shouldHideRepo && !isFolderWorkspace @@ -79,7 +81,7 @@ function WorktreeListRowComponent({ [ styles.worktreeRow, - lineageDepth > 0 && { paddingLeft: spacing.lg + lineageDepth * 18 }, + indent > 0 && { paddingLeft: spacing.lg + indent }, item.isActive && styles.worktreeRowActive, pressed && styles.worktreeRowPressed ]} diff --git a/mobile/src/hooks/use-active-worktree-scroll.test.tsx b/mobile/src/hooks/use-active-worktree-scroll.test.tsx new file mode 100644 index 00000000000..1554a857f73 --- /dev/null +++ b/mobile/src/hooks/use-active-worktree-scroll.test.tsx @@ -0,0 +1,72 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { useActiveWorktreeScroll } from './use-active-worktree-scroll' + +type Row = { worktreeId: string; isActive?: boolean } +type Sections = { key: string; data: Row[] }[] + +/** Rows a, b, c with `active` highlighted; a folded section shows none of them. */ +const listOf = (active: string | null, folded = false): Sections => [ + { + key: 'all', + data: folded + ? [] + : ['a', 'b', 'c'].map((worktreeId) => ({ worktreeId, isActive: worktreeId === active })) + } +] + +const NONE = new Set() +const FOLDED = new Set(['all']) + +function mount(sections: Sections, collapsedGroups: ReadonlySet = NONE) { + const scrollToLocation = vi.fn() + function Probe(props: { sections: Sections; collapsedGroups: ReadonlySet }): null { + const { sectionListRef } = useActiveWorktreeScroll(props.sections, props.collapsedGroups) + // The hook calls only scrollToLocation on its list. + Object.assign(sectionListRef, { current: { scrollToLocation } }) + return null + } + let renderer: ReactTestRenderer | null = null + act(() => { + renderer = create(createElement(Probe, { sections, collapsedGroups })) + }) + const update = (next: Sections, nextCollapsed: ReadonlySet = NONE) => + act(() => { + renderer?.update(createElement(Probe, { sections: next, collapsedGroups: nextCollapsed })) + }) + return { scrollToLocation, update } +} + +describe('useActiveWorktreeScroll', () => { + it('scrolls to the highlighted row, and again when another workspace is highlighted', () => { + const list = mount(listOf('b')) + expect(list.scrollToLocation).toHaveBeenLastCalledWith( + expect.objectContaining({ sectionIndex: 0, itemIndex: 1 }) + ) + list.update(listOf('c')) + expect(list.scrollToLocation).toHaveBeenLastCalledWith( + expect.objectContaining({ itemIndex: 2 }) + ) + expect(list.scrollToLocation).toHaveBeenCalledTimes(2) + }) + + it('never scrolls when a fold or search hides the row and it comes back', () => { + const list = mount(listOf('b')) + list.update(listOf('b', true), FOLDED) + list.update(listOf('b'), NONE) + // A search hiding the row, then cleared. + list.update(listOf(null)) + list.update(listOf('b')) + expect(list.scrollToLocation).toHaveBeenCalledTimes(1) + }) + + it('never scrolls to a highlight an unfold first reveals', () => { + const list = mount(listOf('b', true), FOLDED) + list.update(listOf('b'), NONE) + expect(list.scrollToLocation).not.toHaveBeenCalled() + // A later move to another workspace still scrolls. + list.update(listOf('c'), NONE) + expect(list.scrollToLocation).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/hooks/use-active-worktree-scroll.ts b/mobile/src/hooks/use-active-worktree-scroll.ts index ff0441c85a8..c2d428bc7e3 100644 --- a/mobile/src/hooks/use-active-worktree-scroll.ts +++ b/mobile/src/hooks/use-active-worktree-scroll.ts @@ -3,20 +3,25 @@ import type { SectionList, SectionListData } from 'react-native' type WithId = { worktreeId: string; isActive?: boolean } -// Scrolls the desktop-focused worktree into view when the active selection -// changes, so the mobile list mirrors the desktop's current workspace. Fires -// only on a *change* of active id (not every re-render) so it never yanks the -// list while the user scrolls or searches. Returns the ref to attach to the -// SectionList and the onScrollToIndexFailed handler it needs for rows that -// aren't measured yet (variable heights from the inline agent list). +// Scrolls the highlighted (isActive) worktree into view when the highlight +// moves to a different workspace. A highlight a search or fold hides and then +// shows again is the same workspace, so it never yanks the list. Returns the +// ref to attach to the SectionList and the onScrollToIndexFailed handler it +// needs for rows that aren't measured yet (variable heights from the inline +// agent list). export function useActiveWorktreeScroll( - sections: ReadonlyArray & { data: readonly T[] }> + sections: readonly (SectionListData & { data: readonly T[] })[], + /** The folded sections: a highlight an unfold first reveals is not scrolled to either. */ + collapsedGroups?: ReadonlySet ): { sectionListRef: React.RefObject | null> onScrollToIndexFailed: (info: { averageItemLength: number }) => void } { const sectionListRef = useRef>(null) const lastScrolledActiveIdRef = useRef(null) + // The last workspace highlighted, scrolled to or not: a hidden row coming back is no change. + const seenActiveIdRef = useRef(null) + const collapsedGroupsRef = useRef(collapsedGroups) const activeWorktreeId = useMemo(() => { for (const section of sections) { @@ -53,13 +58,16 @@ export function useActiveWorktreeScroll( ) useEffect(() => { - if (!activeWorktreeId || activeWorktreeId === lastScrolledActiveIdRef.current) { + const folded = collapsedGroupsRef.current !== collapsedGroups + collapsedGroupsRef.current = collapsedGroups + if (!activeWorktreeId || activeWorktreeId === seenActiveIdRef.current) { return } - if (scrollToWorktree(activeWorktreeId)) { + seenActiveIdRef.current = activeWorktreeId + if (!folded && scrollToWorktree(activeWorktreeId)) { lastScrolledActiveIdRef.current = activeWorktreeId } - }, [activeWorktreeId, scrollToWorktree]) + }, [collapsedGroups, activeWorktreeId, scrollToWorktree]) const onScrollToIndexFailed = useCallback( (info: { averageItemLength: number }) => { diff --git a/mobile/src/host-screen/host-list-first-frame.test.tsx b/mobile/src/host-screen/host-list-first-frame.test.tsx new file mode 100644 index 00000000000..15b84621b27 --- /dev/null +++ b/mobile/src/host-screen/host-list-first-frame.test.tsx @@ -0,0 +1,153 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SIDEBAR_INPUTS_SEMANTICS_VERSION } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract' +import type { InstalledSidebarInputs } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-installer' +import type { RpcClient } from '../transport/rpc-client' +import type { Section } from '../worktree/workspace-list-types' +import type { HostSidebarFeedRead } from './host-sidebar-feed-reader' +import { FEED_ROWS, liveRow, phoneView } from './host-sidebar-feed.test-fixture' + +const readers = vi.hoisted((): { onRead: ((read: HostSidebarFeedRead) => void)[] } => ({ + onRead: [] +})) + +vi.mock('../../../src/shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) +vi.mock('./host-sidebar-feed-reader', () => ({ + HostSidebarFeedReader: class { + constructor(_client: unknown, onRead: (read: HostSidebarFeedRead) => void) { + readers.onRead.push(onRead) + } + refresh(): void {} + changed(): void {} + dispose(): void {} + } +})) +vi.mock('./use-host-repo-icon-data-urls', () => ({ useHostRepoIconDataUrls: () => new Map() })) +vi.mock('../worktree/use-last-visited-worktree-on-host', () => ({ + useLastVisitedWorktreeOnHost: () => ({ kind: 'none' }) +})) + +import { useHostListSections } from './use-host-list-sections' +import type { HostScreenState } from './use-host-screen-state' +import { forgetRememberedSidebarFeeds, useHostSidebarFeed } from './use-host-sidebar-feed' + +/** worktree.ps's rows: flat, unpinned, in its own order — not the shape the desktop draws. */ +const LISTED = [ + liveRow({ worktreeId: 'lab::/w/gpu', repoId: 'lab', repo: 'lab', hostId: 'ssh:gpu' }), + liveRow({ worktreeId: 'orca::/w/child' }), + liveRow({ worktreeId: 'orca::/w/parent' }) +] + +const INSTALLED: InstalledSidebarInputs = { + generation: 'g', + revision: 1, + schemaVersion: 1, + semanticsVersion: SIDEBAR_INPUTS_SEMANTICS_VERSION, + domains: new Map([ + [ + 'workspaces', + new Map([ + ['|orca::/w/parent', FEED_ROWS.parent], + ['|orca::/w/child', FEED_ROWS.child] + ]) + ] + ]) +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list builder reads only these fields. +const STATE = { + view: phoneView(), + search: '', + servedProjectSections: null, + desktopRepos: [], + hostProjectGroups: [], + repoHostIdByRepoId: new Map(), + pinnedIds: [], + showPinnedInGroups: false +} as unknown as HostScreenState + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked reader never sends. +const CLIENT = {} as RpcClient + +let drawn: Section[] = [] +let kind = '' + +function List({ hostId }: { hostId: string }) { + const { feed } = useHostSidebarFeed(CLIENT, hostId) + kind = feed.kind + drawn = useHostListSections({ + client: CLIENT, + hostId, + feed, + displayWorktrees: LISTED, + hostPlatform: 'darwin', + state: STATE + }).sections + return null +} + +const renderers: ReactTestRenderer[] = [] +async function open(hostId: string): Promise { + await act(async () => { + renderers.push(create(createElement(List, { hostId }))) + }) +} +async function read(next: HostSidebarFeedRead): Promise { + await act(async () => readers.onRead.at(-1)?.(next)) +} +const rowIds = () => drawn.flatMap((section) => section.data.map((row) => row.worktreeId)) + +describe("the host list's first frame under the feed", () => { + beforeEach(() => { + readers.onRead.length = 0 + forgetRememberedSidebarFeeds() + }) + afterEach(() => { + act(() => renderers.splice(0).forEach((renderer) => renderer.unmount())) + }) + + it('draws no rows from worktree.ps before the feed installs, then the desktop’s list', async () => { + await open('host-1') + expect(kind).toBe('loading') + expect(drawn).toEqual([]) + await read({ installed: INSTALLED, unavailable: false }) + expect(drawn[0]?.icon).toBe('pin') + expect(rowIds()).toContain('orca::/w/parent') + }) + + it("opens the list again on the host's last installed feed, not on worktree.ps", async () => { + await open('host-1') + await read({ installed: INSTALLED, unavailable: false }) + const desktopList = drawn + act(() => renderers.splice(0).forEach((renderer) => renderer.unmount())) + + await open('host-1') + expect(kind).toBe('ready') + expect(drawn).toEqual(desktopList) + }) + + it("never opens one host's list on another's feed", async () => { + await open('host-1') + await read({ installed: INSTALLED, unavailable: false }) + await open('host-2') + expect(kind).toBe('loading') + expect(drawn).toEqual([]) + }) + + it('still lists an older desktop that has no feed, from worktree.ps', async () => { + await open('host-1') + await read({ installed: null, unavailable: true }) + expect(kind).toBe('reopen') + expect(rowIds()).toEqual(LISTED.map((row) => row.worktreeId)) + }) + + it('forgets a feed the host stopped serving', async () => { + await open('host-1') + await read({ installed: INSTALLED, unavailable: false }) + await read({ installed: INSTALLED, unavailable: true }) + act(() => renderers.splice(0).forEach((renderer) => renderer.unmount())) + await open('host-1') + expect(kind).toBe('loading') + }) +}) diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts index 54080d07ca2..4d76642696d 100644 --- a/mobile/src/host-screen/host-screen-secondary-styles.ts +++ b/mobile/src/host-screen/host-screen-secondary-styles.ts @@ -34,6 +34,9 @@ export const hostScreenSecondaryStyles = StyleSheet.create({ color: colors.textMuted, marginLeft: spacing.xs }, + sectionPendingSpinner: { + marginLeft: spacing.sm + }, separator: { height: 1, backgroundColor: colors.borderSubtle, diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts index ae5fcbe0cdb..46ab448ded0 100644 --- a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts +++ b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts @@ -13,10 +13,14 @@ import { FEED_ROWS, SERVED_SECTIONS, feedDomains, + feedRow, liveRow, phoneView } from './host-sidebar-feed.test-fixture' import type { MobileViewState } from '../worktree/workspace-view-settings' +import type { ProjectGroup } from '../../../src/shared/project-group-types' +import { readServedProjectSections } from '../worktree/project-sections-operations' +import type { LastVisitedWorktreeOnHost } from '../worktree/host-list-highlight' const catalog = buildSidebarFeedCatalog(SERVED_SECTIONS, [], []) @@ -39,6 +43,7 @@ function sectionsFor( showPinnedInGroups?: boolean live?: ReturnType[] search?: string + lastVisited?: LastVisitedWorktreeOnHost | null } = {} ) { const built = buildSidebarFeedRows( @@ -48,18 +53,23 @@ function sectionsFor( ) return buildHostFeedSections({ rows: built.sectionRows, - activeRowKeys: built.activeRowKeys, + lastVisited: options.lastVisited ?? null, liveRows: options.live ?? [], repoHostIdByRepoId: new Map([['lab', 'ssh:gpu']]), search: options.search ?? '' }) } -function sectionsFromDomains(domains: SidebarFeedDomains, view: MobileViewState, search = '') { +function sectionsFromDomains( + domains: SidebarFeedDomains, + view: MobileViewState, + search = '', + lastVisited: LastVisitedWorktreeOnHost | null = null +) { const built = buildSidebarFeedRows(domains, catalog, phoneFeedView(view)) return buildHostFeedSections({ rows: built.sectionRows, - activeRowKeys: built.activeRowKeys, + lastVisited, liveRows: [], repoHostIdByRepoId: new Map(), search @@ -77,18 +87,145 @@ function outline(sections: ReturnType): string[] { } describe('the phone list from the sidebar feed', () => { - it('draws the shared builder’s headers, order, nesting and single highlight', () => { + it('draws the shared builder’s headers, order and nesting', () => { const lines = outline(sectionsFor(phoneView())) // The pinned parent carries its nested child. expect(lines[0]).toMatch(/ "Pinned" 2 pin$/) - expect(lines.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1) expect(lines.some((line) => line.includes('orca::/w/child depth=1'))).toBe(true) expect(lines.some((line) => line.includes('"lab"'))).toBe(true) }) - it('highlights every occurrence when pinned rows also show in their groups', () => { - const lines = outline(sectionsFor(phoneView(), { showPinnedInGroups: true })) - expect(lines.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(2) + it('files a grouped project under its group header, nested and in the group’s order', () => { + const group = (id: string, name: string, extra: Partial = {}): ProjectGroup => ({ + id, + name, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0, + ...extra + }) + // As the desktop serves it, read by the page's own reply parser. + const served = readServedProjectSections({ + id: 'sections', + ok: true, + result: { + catalog: { + ...SERVED_SECTIONS, + repos: [ + // Listed against their group order, so the order must come from projectGroupOrder. + { ...SERVED_SECTIONS.repos[0]!, projectGroupId: 'pg-inner', projectGroupOrder: 1 }, + { ...SERVED_SECTIONS.repos[1]!, projectGroupId: 'pg-inner', projectGroupOrder: 0 } + ], + projectGroups: [ + group('pg-school', 'school'), + group('pg-inner', 'inner', { parentGroupId: 'pg-school', createdFrom: 'folder-scan' }) + ] + } + } + }) + if (served.kind !== 'answered' || !served.value) { + throw new Error('expected a served catalog') + } + const grouped = buildSidebarFeedCatalog(served.value, [], []) + const base = feedDomains() + // An unpinned orca workspace, so orca draws its own header beside lab. + const domains = feedDomains({ + workspaces: new Map([ + ...base.workspaces, + ['|orca::/w/solo', feedRow({ id: 'orca::/w/solo', manualOrder: 4 })] + ]) + }) + const built = buildSidebarFeedRows(domains, grouped, phoneFeedView(phoneView())) + const sections = buildHostFeedSections({ + rows: built.sectionRows, + lastVisited: null, + liveRows: [], + repoHostIdByRepoId: new Map([['lab', 'ssh:gpu']]), + search: '' + }) + const headers = sections.map( + (section) => + `${section.title}${section.isProjectGroup ? ' group' : ''} depth=${String(section.projectGroupDepth ?? 0)}` + ) + expect(headers).toEqual([ + 'Pinned depth=0', + 'school group depth=0', + 'inner group depth=1', + 'lab depth=2', + 'orca depth=2' + ]) + // Rows carry their header's depth and the folder-scanned group, so they indent as the desktop's do. + expect( + sections + .slice(-2) + .map((section) => + section.data.map((row) => [row.projectGroupDepth, row.inFolderScanGroup === true]) + ) + ).toEqual([[[2, true]], [[2, true]]]) + expect(sections.at(-2)?.data.map((row) => row.worktreeId)).toEqual(['lab::/w/gpu']) + }) + + describe('the highlight: only the workspace this phone last opened, where shown', () => { + const active = (sections: ReturnType) => + outline(sections).filter((line) => line.endsWith(' ACTIVE')) + + it('highlights nothing while this phone has opened nothing on the host', () => { + // The fixture's desktop has the pinned parent selected; the phone never shows it. + expect(active(sectionsFor(phoneView(), { lastVisited: null }))).toEqual([]) + }) + + it('highlights the listed row this phone opened, and only it', () => { + const lines = active( + sectionsFor(phoneView(), { lastVisited: { worktreeId: 'orca::/w/child' } }) + ) + expect(lines).toHaveLength(1) + expect(lines[0]).toContain('orca::/w/child') + }) + + it('highlights an SSH row by its id, as the session route names it', () => { + const lines = active(sectionsFor(phoneView(), { lastVisited: { worktreeId: 'lab::/w/gpu' } })) + expect(lines).toHaveLength(1) + expect(lines[0]).toContain('lab::/w/gpu') + }) + + it('never matches a desktop row for a visit on a server of the same id', () => { + const lastVisited = { worktreeId: 'orca::/w/child', executionHost: 'runtime:env-1' as const } + expect(active(sectionsFor(phoneView(), { lastVisited }))).toEqual([]) + }) + + it('highlights nothing while the row is searched away or folded, and the row once it is back', () => { + const lastVisited = { worktreeId: 'lab::/w/gpu' } + expect(active(sectionsFor(phoneView(), { lastVisited, search: 'parent' }))).toEqual([]) + const lab = sectionsFor(phoneView()).find((section) => section.title === 'lab')! + const folded = sectionsFor(phoneView({ collapsedGroups: [lab.key] }), { lastVisited }) + expect(active(folded)).toEqual([]) + expect(active(sectionsFor(phoneView(), { lastVisited }))).toHaveLength(1) + }) + + it('highlights nothing once the row is deleted', () => { + const workspaces = new Map(feedDomains().workspaces) + workspaces.delete('|orca::/w/child') + const domains = feedDomains({ workspaces }) + const sections = sectionsFromDomains(domains, phoneView(), '', { + worktreeId: 'orca::/w/child' + }) + expect(active(sections)).toEqual([]) + }) + + it('highlights every occurrence when pinned rows also show in their groups', () => { + const lines = active( + sectionsFor(phoneView(), { + showPinnedInGroups: true, + lastVisited: { worktreeId: 'orca::/w/parent' } + }) + ) + expect(lines).toHaveLength(2) + }) }) // The phone sends its host list resolved with an "all" scope; the desktop keeps its legacy scope. @@ -135,7 +272,7 @@ describe('the phone list from the sidebar feed', () => { ) const sections = buildHostFeedSections({ rows: built.sectionRows, - activeRowKeys: built.activeRowKeys, + lastVisited: null, liveRows: [], repoHostIdByRepoId: new Map(), search: '' @@ -206,7 +343,7 @@ describe('the phone list from the sidebar feed', () => { }) const sections = buildHostFeedSections({ rows: built.sectionRows, - activeRowKeys: built.activeRowKeys, + lastVisited: null, liveRows: [stale], repoHostIdByRepoId: new Map(), search: 'new name' @@ -299,11 +436,39 @@ describe('the phone list before the first feed read', () => { liveRow({ worktreeId: 'orca::/w/a', displayName: 'alpha' }), liveRow({ worktreeId: 'orca::/w/old', displayName: 'old', isArchived: true }) ] - const preFeed = (search: string, rows = cached) => - buildPreFeedSections({ rows, view: phoneView(), search, repoHostIdByRepoId: new Map() }) + const preFeedVisiting = ( + search: string, + rows: typeof cached, + lastVisited: LastVisitedWorktreeOnHost | null + ) => + buildPreFeedSections({ + rows, + view: phoneView(), + search, + repoHostIdByRepoId: new Map(), + lastVisited + }) + const preFeed = (search: string, rows = cached) => preFeedVisiting(search, rows, null) it('shows the rows the page last listed, in their order under no header, filtered as the view is', () => { - expect(preFeed('')).toEqual([{ key: 'all', title: '', data: cached.slice(0, 2) }]) + expect(preFeed('')).toEqual([ + { + key: 'all', + title: '', + data: cached.slice(0, 2).map((row) => ({ ...row, isActive: false })) + } + ]) + }) + + it('highlights the row this phone opened, never the one the desktop marked active', () => { + const rows = [{ ...cached[0]!, isActive: true }, cached[1]!] + const highlighted = (lastVisited: LastVisitedWorktreeOnHost | null) => + preFeedVisiting('', rows, lastVisited)[0] + ?.data.filter((row) => row.isActive) + .map((row) => row.worktreeId) + expect(highlighted(null)).toEqual([]) + expect(highlighted({ worktreeId: 'orca::/w/a' })).toEqual(['orca::/w/a']) + expect(highlighted({ worktreeId: 'orca::/w/gone' })).toEqual([]) }) it('narrows them by search, and draws nothing with none', () => { diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.ts b/mobile/src/host-screen/host-sidebar-feed-sections.ts index f29116d6214..49d71ff65c3 100644 --- a/mobile/src/host-screen/host-sidebar-feed-sections.ts +++ b/mobile/src/host-screen/host-sidebar-feed-sections.ts @@ -11,6 +11,10 @@ import { PR_GROUP_LABELS } from '../../../src/shared/workspace-view/workspace-pr import type { Worktree as HostWorktree } from '../../../src/shared/worktree/types' import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity' import type { Section, Worktree } from '../worktree/workspace-list-types' +import { + isLastVisitedWorktreeRow, + type LastVisitedWorktreeOnHost +} from '../worktree/host-list-highlight' import { resolveWorktreeHostId } from '../worktree/worktree-host-context-labels' import { filterWorktrees } from '../worktree/workspace-list-sections' import { rowFiltersForView, type MobileViewState } from '../worktree/workspace-view-settings' @@ -18,7 +22,8 @@ import { rowFiltersForView, type MobileViewState } from '../worktree/workspace-v /** What the host screen draws from a feed-built list, besides its rows' own live content. */ export type HostFeedListSources = { rows: readonly HostSectionRow[] - activeRowKeys: ReadonlySet + /** The phone's own highlight; null while unread or never opened, when nothing is lit. */ + lastVisited: LastVisitedWorktreeOnHost | null /** The rows worktree.ps and the desktop's server lists served, for what each row draws. */ liveRows: readonly Worktree[] repoHostIdByRepoId: ReadonlyMap @@ -97,7 +102,8 @@ function matchesSearch(row: Worktree, query: string): boolean { /** * The phone's sections from the list the shared builder made from the feed: the desktop's headers, - * order, nesting and highlight, each row drawn with the live content the phone already reads. + * order and nesting, each row drawn with the live content the phone already reads, and highlighted + * by `isLastVisitedWorktreeRow`. * Search narrows the rows here, as the desktop's own search never reaches the feed. */ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { @@ -121,18 +127,29 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { return first } + // Why: the desktop steps folder-scanned groups' rows by the compact header step; a group's header precedes its rows. + const folderScanGroupIds = new Set() for (const row of sources.rows) { switch (row.type) { case 'host-header': push({ key: row.key, title: row.label, count: row.count, data: [] }) break case 'header': + if ( + row.projectGroup?.id && + 'createdFrom' in row.projectGroup && + row.projectGroup.createdFrom === 'folder-scan' + ) { + folderScanGroupIds.add(row.projectGroup.id) + } push({ key: row.collapseKey ?? row.key, title: headerTitle(row), count: row.count, ...(row.icon.kind === 'pinned' ? { icon: 'pin' as const } : {}), ...(row.repo ? { repo: row.repo } : {}), + ...(row.projectGroup && !row.repo ? { isProjectGroup: true } : {}), + ...(row.projectGroupDepth ? { projectGroupDepth: row.projectGroupDepth } : {}), data: [] }) break @@ -142,9 +159,16 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { const drawn = rowOf(row.worktree, hostId, row.repo?.displayName, 'git') current().data.push({ ...drawn, - isActive: sources.activeRowKeys.has(row.rowKey), + isActive: isLastVisitedWorktreeRow( + { worktreeId: row.worktree.id, hostId }, + sources.lastVisited + ), sectionListKey: row.rowKey, lineageDepth: row.depth, + ...(row.groupDepth ? { projectGroupDepth: row.groupDepth } : {}), + ...(row.repo?.projectGroupId && folderScanGroupIds.has(row.repo.projectGroupId) + ? { inFolderScanGroup: true } + : {}), lineageChildCount: row.lineageChildCount, lineageCollapsed: row.lineageCollapsed === true, isLastLineageChild: row.isLastLineageChild, @@ -166,9 +190,14 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { ) current().data.push({ ...rowOf(worktree, hostId, row.projectGroup.name, 'folder-workspace'), - isActive: sources.activeRowKeys.has(row.key), + isActive: isLastVisitedWorktreeRow( + { worktreeId: worktree.id, hostId }, + sources.lastVisited + ), sectionListKey: row.key, - lineageDepth: 0 + lineageDepth: 0, + ...(row.groupDepth ? { projectGroupDepth: row.groupDepth } : {}), + ...(row.projectGroup.createdFrom === 'folder-scan' ? { inFolderScanGroup: true } : {}) }) break } @@ -195,22 +224,26 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { } /** - * Before the first feed read: the rows this page last listed (worktree.ps, or the cache the screen - * opened with) under the view's filters, in their served order and no header, so an offline open - * is not blank. + * For a host whose feed this page cannot read: the rows worktree.ps listed under the view's + * filters, in their served order and no header, so an older desktop still gets a list. */ export function buildPreFeedSections(sources: { rows: Worktree[] view: MobileViewState search: string repoHostIdByRepoId: ReadonlyMap + lastVisited: LastVisitedWorktreeOnHost | null }): Section[] { const rows = filterWorktrees( sources.rows, rowFiltersForView(sources.view), sources.search, sources.repoHostIdByRepoId - ) + ).map((row) => ({ + ...row, + // Replaces worktree.ps's mark, which is the desktop's selection. + isActive: isLastVisitedWorktreeRow(row, sources.lastVisited) + })) return rows.length > 0 ? [{ key: 'all', title: '', data: rows }] : [] } diff --git a/mobile/src/host-screen/host-workspace-list-reopen.test.tsx b/mobile/src/host-screen/host-workspace-list-reopen.test.tsx new file mode 100644 index 00000000000..6bba9112537 --- /dev/null +++ b/mobile/src/host-screen/host-workspace-list-reopen.test.tsx @@ -0,0 +1,110 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + RefreshControl: 'RefreshControl', + SectionList: 'SectionList', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight', + Pin: 'Pin', + X: 'X' +})) +vi.mock('../components/AuthFailedBanner', () => ({ AuthFailedBanner: 'AuthFailedBanner' })) +vi.mock('../components/HostDiagnosticsLink', () => ({ HostDiagnosticsLink: 'HostDiagnosticsLink' })) +vi.mock('../components/MobileRepoIcon', () => ({ MobileRepoIcon: 'MobileRepoIcon' })) +vi.mock('../components/MobileSearchField', () => ({ MobileSearchField: 'MobileSearchField' })) +vi.mock('../components/NewWorkspaceFab', () => ({ + NewWorkspaceFab: 'NewWorkspaceFab', + FAB_SIZE: 56 +})) +vi.mock('../components/WorktreeListRow', () => ({ WorktreeListRow: 'WorktreeListRow' })) + +import { HostWorkspaceList } from './host-workspace-list' +import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings' + +/** A host whose feed this page cannot read: the notice, above the rows the page already holds. */ +function listWith(rows: number) { + const section = { + key: 'all', + title: '', + data: Array.from({ length: rows }, (_, i) => ({ id: `w${i}` })) + } + const sections = rows > 0 ? [section] : [] + const controller = { + actions: { openWorktreeSession: () => {}, openNewWorktreeModal: () => {} }, + activeWorktreeScroll: { sectionListRef: { current: null }, onScrollToIndexFailed: () => {} }, + catalog: { refreshing: false, onRefresh: () => {} }, + collapsedGroups: new Set(), + connState: 'connected', + contentMaxWidth: 600, + displayWorktrees: section.data, + embedded: true, + feed: { kind: 'reopen' }, + forceReconnectHost: () => {}, + hostId: 'host-1', + insets: { bottom: 0 }, + isReadOnly: false, + isWideLayout: false, + noticeParam: undefined, + now: 0, + reconnectAttempts: 0, + relayRecovery: { pairingRejected: false }, + routeNotice: '', + router: { push: () => {} }, + sectionsResult: { rawSections: sections, sections, repoById: new Map() }, + setDismissedNotice: () => {}, + settings: { activeFilterCount: 0, pendingViewChanges: { pending: [], stale: false } }, + state: { + actionError: '', + setActionError: () => {}, + catalogError: null, + view: { ...INITIAL_MOBILE_VIEW, groupBy: 'none' }, + search: '', + showSearch: false, + worktreesLoaded: true + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every row and header below the notice is mocked to a host string here, so the list reads only the fields named above. + return { controller } as unknown as Parameters[0] +} + +function render(rows: number): ReactTestRenderer { + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + act(() => { + rendered.tree = create(createElement(HostWorkspaceList, listWith(rows))) + }) + if (rendered.tree === null) { + throw new Error('the list did not render') + } + return rendered.tree +} + +function texts(tree: ReactTestRenderer): unknown[] { + return tree.root + .findAll((node) => String(node.type) === 'Text') + .map((node) => node.props.children) +} + +describe('a host whose sidebar feed this page cannot read', () => { + it('keeps the rows the page holds on screen under "Reopen to update"', () => { + const tree = render(2) + expect(texts(tree)).toContain('Reopen to update') + const [list] = tree.root.findAll((node) => String(node.type) === 'SectionList') + expect(list?.props.sections[0].data).toHaveLength(2) + }) + + it('says only "Reopen to update" when the page holds no rows yet', () => { + const tree = render(0) + expect(texts(tree)).toContain('Reopen to update') + expect(tree.root.findAll((node) => String(node.type) === 'SectionList')).toEqual([]) + }) +}) diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index 67117c98f90..00beb941fd6 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -8,7 +8,7 @@ import { Text, View } from 'react-native' -import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native' +import { ChevronDown, ChevronRight, FolderTree, Pin } from 'lucide-react-native' import { AuthFailedBanner } from '../components/AuthFailedBanner' import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink' import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner' @@ -28,6 +28,8 @@ import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-proper import { hostScreenStyles as styles } from './host-screen-styles' import { buildRepoLookById } from './host-repo-looks' import { HostPendingCreationRow } from './host-pending-creation-row' +import { useSlowPendingKeys } from './use-slow-pending-keys' +import { projectGroupHeaderIndent } from '../worktree/project-group-indent' import type { HostScreenController } from './use-host-screen-controller' export function HostWorkspaceList({ controller }: { controller: HostScreenController }) { @@ -57,10 +59,12 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro } = controller const { rawSections, sections, repoById } = sectionsResult const { pending } = settings.pendingViewChanges - // A collapse the desktop has not answered spins on its header. - const pendingCollapseKeys = useMemo( - () => new Set(pending.flatMap((change) => (change.kind === 'collapse' ? [change.key] : []))), - [pending] + // A collapse the desktop has not answered within SLOW_PENDING_MS spins on its header. + const pendingCollapseKeys = useSlowPendingKeys( + useMemo( + () => new Set(pending.flatMap((change) => (change.kind === 'collapse' ? [change.key] : []))), + [pending] + ) ) const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById]) const { worktreeCardProperties, compactWorktreeCards, newCardStyle } = state.view @@ -133,7 +137,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro {/* A feed this page cannot read: the host is older or newer than the page. */} {controller.feed?.kind === 'reopen' ? ( - + 0} /> ) : ( )} - {sections.length > 0 && controller.feed?.kind !== 'reopen' && ( + {sections.length > 0 && ( settings.toggleCollapsed(section.key)} > {isCollapsed ? ( @@ -200,7 +212,9 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro {section.icon === 'pin' && ( )} - {state.view.groupBy === 'repo' ? ( + {state.view.groupBy === 'repo' && section.isProjectGroup ? ( + + ) : state.view.groupBy === 'repo' ? ( )} diff --git a/mobile/src/host-screen/use-host-list-sections.ts b/mobile/src/host-screen/use-host-list-sections.ts index a9578993901..1f232b0895c 100644 --- a/mobile/src/host-screen/use-host-list-sections.ts +++ b/mobile/src/host-screen/use-host-list-sections.ts @@ -8,6 +8,7 @@ import { buildProjectSectionCatalog } from '../worktree/project-section-catalog' import { useWorkspaceSections } from '../worktree/use-workspace-sections' import { buildFilterRepos, type WorkspaceSectionRepo } from '../worktree/workspace-filter-repos' import type { Section, SectionRepo, Worktree } from '../worktree/workspace-list-types' +import { useLastVisitedWorktreeOnHost } from '../worktree/use-last-visited-worktree-on-host' import { rowFiltersForView } from '../worktree/workspace-view-settings' import { buildSidebarFeedCatalog } from '../../../src/shared/sidebar-list/sidebar-feed-catalog' import { buildHostFeedView } from './host-sidebar-feed-inputs' @@ -18,6 +19,8 @@ import type { HostScreenState } from './use-host-screen-state' export type HostListSectionsInputs = { client: RpcClient | null + /** The paired host whose list this is. */ + hostId: string | undefined /** Null in a build that lists without the feed. */ feed: HostSidebarFeed | null /** The rows worktree.ps and the desktop's server lists served, with display state applied. */ @@ -97,6 +100,7 @@ const NO_SECTIONS: Section[] = [] /** The desktop sidebar's own list, built by the shared builder from the host's feed. */ function useFeedHostListSections({ client, + hostId, feed, displayWorktrees, hostPlatform, @@ -126,26 +130,38 @@ function useFeedHostListSections({ () => (domains ? buildSidebarFeedRows(domains, catalog, view) : null), [domains, catalog, view] ) - const loading = feed?.kind === 'loading' + const lastVisited = useLastVisitedWorktreeOnHost(hostId) + // Only under a feed this page cannot read (an older desktop): before the first read the list + // waits for the feed, or the host's last installed one, so it never draws a shape the desktop doesn't. + const preFeed = feed?.kind === 'reopen' const sections = useMemo( () => built ? buildHostFeedSections({ rows: built.sectionRows, - activeRowKeys: built.activeRowKeys, + lastVisited, liveRows: displayWorktrees, repoHostIdByRepoId: state.repoHostIdByRepoId, search: state.search }) - : loading + : preFeed ? buildPreFeedSections({ rows: displayWorktrees, view: state.view, search: state.search, - repoHostIdByRepoId: state.repoHostIdByRepoId + repoHostIdByRepoId: state.repoHostIdByRepoId, + lastVisited }) : NO_SECTIONS, - [built, loading, displayWorktrees, state.view, state.repoHostIdByRepoId, state.search] + [ + built, + preFeed, + displayWorktrees, + state.view, + state.repoHostIdByRepoId, + state.search, + lastVisited + ] ) const iconIds = useMemo(() => [...new Set(domains?.repoIcons.values() ?? [])], [domains]) diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 38b524a5e66..20356316bde 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -80,7 +80,7 @@ export function useHostScreenController({ useHostScreenIdentity({ client, hostId, state }) const fetchRepoMetadataOnly = useHostRepoMetadata({ client, connState, hostId, state }) - const sidebarFeed = useSwitchedHostSidebarFeed(client) + const sidebarFeed = useSwitchedHostSidebarFeed(client, hostId) // Read through a ref so the metadata refresh keeps its identity across installs. const feedRef = useRef(sidebarFeed?.feed) useEffect(() => { @@ -140,6 +140,7 @@ export function useHostScreenController({ const feed = sidebarFeed?.feed ?? null const sectionsResult = useHostListSections({ client, + hostId, feed, displayWorktrees, hostPlatform, @@ -156,7 +157,7 @@ export function useHostScreenController({ [state.view.filters, uniqueRepos] ) const existingWorktreePaths = useMemo(() => state.worktrees.map((w) => w.path), [state.worktrees]) - const activeWorktreeScroll = useActiveWorktreeScroll(sectionsResult.sections) + const activeWorktreeScroll = useActiveWorktreeScroll(sectionsResult.sections, collapsedGroups) return { actions, diff --git a/mobile/src/host-screen/use-host-sidebar-feed.ts b/mobile/src/host-screen/use-host-sidebar-feed.ts index 9ae36eb5c5a..fe4a699ea01 100644 --- a/mobile/src/host-screen/use-host-sidebar-feed.ts +++ b/mobile/src/host-screen/use-host-sidebar-feed.ts @@ -4,6 +4,7 @@ import { SIDEBAR_INPUTS_SEMANTICS_VERSION, type SidebarInputsChangedEvent } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract' +import type { InstalledSidebarInputs } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-installer' import { readSidebarFeedDomains, type SidebarFeedDomains @@ -32,6 +33,38 @@ export type HostSidebarFeedControls = { const NOTHING_READ: HostSidebarFeedRead = { installed: null, unavailable: false } +const MAX_REMEMBERED_HOSTS = 20 +/** Each paired host's last installed feed, for this document's next list of it. */ +const rememberedFeeds = new Map() + +function rememberFeed(hostId: string, installed: InstalledSidebarInputs | null): void { + rememberedFeeds.delete(hostId) + if (!installed) { + return + } + rememberedFeeds.set(hostId, installed) + if (rememberedFeeds.size > MAX_REMEMBERED_HOSTS) { + const oldest = rememberedFeeds.keys().next().value + if (oldest !== undefined) { + rememberedFeeds.delete(oldest) + } + } +} + +/** The host's last installed feed while a fresh read is out: the desktop's list, if not its newest. */ +function rememberedRead(hostId: string | undefined): HostSidebarFeedRead { + const installed = hostId ? rememberedFeeds.get(hostId) : undefined + // One this page cannot read would flash "Reopen" for a host whose fresh read may be fine. + return installed && SUPPORTED_SIDEBAR_SEMANTICS_VERSIONS.includes(installed.semanticsVersion) + ? { installed, unavailable: false } + : NOTHING_READ +} + +/** Test seam: a fresh document remembers nothing. */ +export function forgetRememberedSidebarFeeds(): void { + rememberedFeeds.clear() +} + export function readHostSidebarFeed({ installed, unavailable @@ -48,20 +81,29 @@ export function readHostSidebarFeed({ return { kind: 'ready', domains: readSidebarFeedDomains(installed) } } -/** The connected host's installed sidebar feed; a new client is a new host, read from scratch. */ -export function useHostSidebarFeed(client: RpcClient | null): HostSidebarFeedControls { +/** + * The connected host's installed sidebar feed; a new client is a new host, read from scratch. + * Until that read installs, the host's last installed feed stands in for it. + */ +export function useHostSidebarFeed( + client: RpcClient | null, + hostId: string | undefined +): HostSidebarFeedControls { const readerRef = useRef(null) const installListenersRef = useRef(new Set<() => void>()) - const [read, setRead] = useState(NOTHING_READ) + const [read, setRead] = useState(() => rememberedRead(hostId)) useEffect(() => { - setRead(NOTHING_READ) + setRead(rememberedRead(hostId)) if (!client) { return } let lastInstalled: HostSidebarFeedRead['installed'] = null const reader = new HostSidebarFeedReader(client, (next) => { setRead(next) + if (hostId) { + rememberFeed(hostId, next.unavailable ? null : next.installed) + } if (next.installed && next.installed !== lastInstalled) { installListenersRef.current.forEach((listener) => listener()) } @@ -74,7 +116,7 @@ export function useHostSidebarFeed(client: RpcClient | null): HostSidebarFeedCon readerRef.current = null } } - }, [client]) + }, [client, hostId]) const triggers = useMemo( (): HostSidebarFeedTriggers => ({ @@ -94,5 +136,6 @@ export function useHostSidebarFeed(client: RpcClient | null): HostSidebarFeedCon /** Switch point: a page built without the feed reads none. */ export const useSwitchedHostSidebarFeed: ( - client: RpcClient | null + client: RpcClient | null, + hostId: string | undefined ) => HostSidebarFeedControls | null = ORCA_SIDEBAR_INPUTS_IS_MAIN ? useHostSidebarFeed : () => null diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 565d1d4214e..52bc2f65ef3 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -20,6 +20,7 @@ import { isSameWorktreeRow, removeWorktreeRow } from '../worktree/worktree-host-row-identity' +import { noteLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host' import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections' import { worktreeActivate, @@ -257,9 +258,12 @@ export function useHostWorktreeActions(args: { if (routeHost === null) { return } - // Switch point: a feed-built list highlights only what the desktop has open. + // Switch point: a feed-built list highlights this phone's last visit, noted here because the + // session may open as another page whose stored write this list never sees. if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) { setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item)) + } else if (hostId) { + noteLastVisitedWorktree({ hostId, worktreeId: item.worktreeId, executionHost: routeHost }) } const rowClient = clientForRow(item) if (rowClient && connState === 'connected') { diff --git a/mobile/src/host-screen/use-slow-pending-keys.test.tsx b/mobile/src/host-screen/use-slow-pending-keys.test.tsx new file mode 100644 index 00000000000..552824a9f30 --- /dev/null +++ b/mobile/src/host-screen/use-slow-pending-keys.test.tsx @@ -0,0 +1,87 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSlowPendingKeys } from './use-slow-pending-keys' + +let latest: ReadonlySet = new Set() +function Probe({ pending }: { pending: ReadonlySet }) { + latest = useSlowPendingKeys(pending) + return null +} + +function mount(pending: ReadonlySet): ReactTestRenderer { + let renderer: ReactTestRenderer | null = null + act(() => { + renderer = create(createElement(Probe, { pending })) + }) + if (!renderer) { + throw new Error('not mounted') + } + return renderer +} + +function update(renderer: ReactTestRenderer, pending: ReadonlySet): void { + act(() => renderer.update(createElement(Probe, { pending }))) +} + +function advance(ms: number): void { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +describe('useSlowPendingKeys', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('shows a key only once it has been pending for 500 ms', () => { + mount(new Set(['a'])) + expect([...latest]).toEqual([]) + advance(499) + expect([...latest]).toEqual([]) + advance(1) + expect([...latest]).toEqual(['a']) + }) + + it('never shows a key the desktop answers within 500 ms', () => { + const renderer = mount(new Set(['a'])) + advance(300) + update(renderer, new Set()) + advance(1_000) + expect([...latest]).toEqual([]) + }) + + it('hides a slow key on the render it settles', () => { + const renderer = mount(new Set(['a'])) + advance(500) + expect([...latest]).toEqual(['a']) + update(renderer, new Set()) + expect([...latest]).toEqual([]) + }) + + it('times each key from when it first went pending, across re-renders', () => { + const renderer = mount(new Set(['a'])) + advance(300) + update(renderer, new Set(['a', 'b'])) + advance(200) + expect([...latest]).toEqual(['a']) + advance(300) + expect([...latest].sort()).toEqual(['a', 'b']) + }) + + it('waits out the delay afresh for a key that settles and goes pending again', () => { + const renderer = mount(new Set(['a'])) + advance(600) + update(renderer, new Set()) + update(renderer, new Set(['a'])) + expect([...latest]).toEqual([]) + advance(499) + expect([...latest]).toEqual([]) + advance(1) + expect([...latest]).toEqual(['a']) + }) +}) diff --git a/mobile/src/host-screen/use-slow-pending-keys.ts b/mobile/src/host-screen/use-slow-pending-keys.ts new file mode 100644 index 00000000000..f62147a09e1 --- /dev/null +++ b/mobile/src/host-screen/use-slow-pending-keys.ts @@ -0,0 +1,48 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +/** How long the desktop has to answer before a pending change shows a spinner. */ +export const SLOW_PENDING_MS = 500 + +const NONE: ReadonlySet = new Set() + +/** The pending keys the desktop has not answered within `delayMs` of each first going pending. */ +export function useSlowPendingKeys( + pendingKeys: ReadonlySet, + delayMs: number = SLOW_PENDING_MS +): ReadonlySet { + const pendingSinceRef = useRef(new Map()) + const [slowKeys, setSlowKeys] = useState>(NONE) + + useEffect(() => { + const pendingSince = pendingSinceRef.current + const now = Date.now() + for (const key of pendingSince.keys()) { + if (!pendingKeys.has(key)) { + pendingSince.delete(key) + } + } + // Why prune: a settled key that goes pending again must wait out the delay afresh. + setSlowKeys((prev) => { + const kept = [...prev].filter((key) => pendingKeys.has(key)) + return kept.length === prev.size ? prev : new Set(kept) + }) + const timers: ReturnType[] = [] + for (const key of pendingKeys) { + const since = pendingSince.get(key) ?? now + pendingSince.set(key, since) + timers.push( + setTimeout( + () => setSlowKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key))), + Math.max(0, since + delayMs - now) + ) + ) + } + return () => timers.forEach(clearTimeout) + }, [pendingKeys, delayMs]) + + // Why intersect: a key that settled hides on this render, not after the effect's prune. + return useMemo(() => { + const visible = [...slowKeys].filter((key) => pendingKeys.has(key)) + return visible.length === slowKeys.size ? slowKeys : new Set(visible) + }, [slowKeys, pendingKeys]) +} diff --git a/mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx b/mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx index e74ad11181e..a6fd6ed32a4 100644 --- a/mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx +++ b/mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx @@ -64,7 +64,7 @@ vi.mock('../transport/host-store', () => ({ })) import { savePinnedIds } from '../storage/preferences' -import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-repo' +import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host' import { PAGE_STORAGE_MAX_VALUE_CHARS } from './page-storage-keys' import { usePageHostSnapshot, type PageHostSnapshotView } from './use-page-host-snapshot' @@ -138,10 +138,14 @@ describe('what the shell puts on every init', () => { // The device repro: the session screen writes while the page is open, the document reloads, // and the `init` answering its ready is built from this map with no read in between. A mirror // only the store read refreshed would hand the drawer the repo the user left, an `init` late. - writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' }) + await writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' }) await savePinnedIds('host-1', new Set(['one'])) expect(mounted.view().readStorage().storage).toEqual({ - [LAST_VISITED]: JSON.stringify({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' }), + [LAST_VISITED]: JSON.stringify({ + hostId: 'host-1', + worktreeId: 'host-1/repo/wt', + byHost: { 'host-1': { worktreeId: 'host-1/repo/wt' } } + }), [PINS]: '["one"]' }) }) diff --git a/mobile/src/session/use-mobile-session-keyboard-state.test.tsx b/mobile/src/session/use-mobile-session-keyboard-state.test.tsx index b7c414e2ab3..62cda6ae2fe 100644 --- a/mobile/src/session/use-mobile-session-keyboard-state.test.tsx +++ b/mobile/src/session/use-mobile-session-keyboard-state.test.tsx @@ -34,7 +34,9 @@ vi.mock('../terminal/terminal-viewport-refit', () => ({ }) })) vi.mock('../terminal/terminal-custom-keys', () => ({ removeCustomKey: harness.removeCustomKey })) -vi.mock('../worktree/last-visited-worktree-repo', () => ({ writeLastVisitedWorktree: () => {} })) +vi.mock('../worktree/last-visited-worktree-by-host', () => ({ + writeLastVisitedWorktree: async () => {} +})) import { useMobileSessionKeyboardState } from './use-mobile-session-keyboard-state' diff --git a/mobile/src/session/use-mobile-session-keyboard-state.ts b/mobile/src/session/use-mobile-session-keyboard-state.ts index 977bd03b135..72a04cf790b 100644 --- a/mobile/src/session/use-mobile-session-keyboard-state.ts +++ b/mobile/src/session/use-mobile-session-keyboard-state.ts @@ -2,7 +2,8 @@ import { useEffect, useCallback } from 'react' import { useSoftKeyboard } from '../platform/keyboard-occlusion' import { useTerminalViewportRefit } from '../terminal/terminal-viewport-refit' import { removeCustomKey, type CustomKey } from '../terminal/terminal-custom-keys' -import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-repo' +import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host' +import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host' import { resolveTabStripScrollOffset } from './tab-strip-scroll' import type { MobileSessionLifecycleModel } from './use-mobile-session-lifecycle' @@ -127,11 +128,12 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope) return () => cancelAnimationFrame(id) }, [activeSessionTabId, scrollActiveTabIntoView]) + const executionHost = useWorkspaceExecutionHost() useEffect(() => { if (hostId && worktreeId) { - writeLastVisitedWorktree({ hostId, worktreeId }) + void writeLastVisitedWorktree({ hostId, worktreeId, executionHost }) } - }, [hostId, worktreeId]) + }, [hostId, worktreeId, executionHost]) const handleDeleteCustomKey = useCallback(async (key: CustomKey) => { try { diff --git a/mobile/src/storage/mirrored-storage-keys.ts b/mobile/src/storage/mirrored-storage-keys.ts index b98760792ff..c1d0539acfe 100644 --- a/mobile/src/storage/mirrored-storage-keys.ts +++ b/mobile/src/storage/mirrored-storage-keys.ts @@ -62,9 +62,25 @@ export async function readMirroredKeyOrStore(key: string): Promise void +const keyListeners = new Map>() + +/** Each noted write of `key`, including a page's write the app takes on its behalf. */ +export function subscribeMirroredKey(key: string, listener: MirroredKeyListener): () => void { + const forKey = keyListeners.get(key) ?? new Set() + keyListeners.set(key, forKey) + forKey.add(listener) + return () => { + forKey.delete(listener) + } +} + function note(key: string, value: string | null): void { writeCount += 1 mirror.set(key, value) + for (const listener of keyListeners.get(key) ?? []) { + listener(value) + } } /** diff --git a/mobile/src/worktree/host-list-highlight.ts b/mobile/src/worktree/host-list-highlight.ts new file mode 100644 index 00000000000..6ff802bca19 --- /dev/null +++ b/mobile/src/worktree/host-list-highlight.ts @@ -0,0 +1,34 @@ +import { parseExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' +import type { Worktree } from './workspace-list-types' + +// Kept free of storage and React Native: the e2e phone-list helper builds sections through this. + +/** The workspace this phone last opened on one paired host: the session route's own params. */ +export type LastVisitedWorktreeOnHost = { + worktreeId: string + /** A server workspace's `runtime:` host; absent for the desktop's own (local and SSH) rows. */ + executionHost?: ExecutionHostId +} + +/** The route's server for a row, as the host list routes it: only a `runtime:` host is named. */ +export function routeExecutionHostOf( + hostId: string | null | undefined +): ExecutionHostId | undefined { + const parsed = parseExecutionHostId(hostId) + return parsed?.kind === 'runtime' ? parsed.id : undefined +} + +/** + * Whether `row` is the workspace this phone last opened on its host: the host list's only highlight. + * The desktop's selection never stands in, so a hidden or never-opened row leaves nothing lit. + */ +export function isLastVisitedWorktreeRow( + row: Pick, + visited: LastVisitedWorktreeOnHost | null +): boolean { + return ( + visited !== null && + row.worktreeId === visited.worktreeId && + routeExecutionHostOf(row.hostId) === visited.executionHost + ) +} diff --git a/mobile/src/worktree/host-workspace-list-states.tsx b/mobile/src/worktree/host-workspace-list-states.tsx index cb4e5edfe38..c2947258016 100644 --- a/mobile/src/worktree/host-workspace-list-states.tsx +++ b/mobile/src/worktree/host-workspace-list-states.tsx @@ -45,10 +45,10 @@ export function HostWorkspaceListStates( return null } -/** The host serves a list this page cannot build: a page from the host's own build can. */ -export function HostWorkspaceListReopen() { +/** A list this page cannot build; shown above the rows it already holds, so it never blanks. */ +export function HostWorkspaceListReopen({ aboveRows }: { aboveRows: boolean }) { return ( - + Reopen to update This host lists workspaces in a way this page does not know yet. @@ -63,6 +63,10 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center' }, + aboveRows: { + alignItems: 'center', + paddingVertical: spacing.md + }, emptyText: { color: colors.textSecondary, fontSize: typography.bodySize diff --git a/mobile/src/worktree/last-visited-worktree-by-host.test.ts b/mobile/src/worktree/last-visited-worktree-by-host.test.ts new file mode 100644 index 00000000000..a99ff87da6b --- /dev/null +++ b/mobile/src/worktree/last-visited-worktree-by-host.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest' + +type StorageDouble = { rejection: unknown; values: Map } + +const storage = vi.hoisted((): StorageDouble => ({ rejection: null, values: new Map() })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => storage.values.get(key) ?? null, + setItem: async (key: string, value: string) => { + if (storage.rejection !== null) { + throw storage.rejection + } + storage.values.set(key, value) + }, + removeItem: async (key: string) => { + storage.values.delete(key) + } + } +})) + +import { isLastVisitedWorktreeRow } from './host-list-highlight' +import { + readLastVisitedWorktreeOnHost, + subscribeLastVisitedWorktree, + withLastVisitedWorktree, + writeLastVisitedWorktree +} from './last-visited-worktree-by-host' +import { + LAST_VISITED_WORKTREE_STORAGE_KEY, + readLastVisitedWorktreeRecord +} from './last-visited-worktree-repo' + +const stored = () => storage.values.get(LAST_VISITED_WORKTREE_STORAGE_KEY) ?? null + +describe('readLastVisitedWorktreeOnHost', () => { + it('reads nothing for a host this phone never opened a workspace on', () => { + expect(readLastVisitedWorktreeOnHost(null, 'host-1')).toBeNull() + const raw = withLastVisitedWorktree(null, { hostId: 'host-2', worktreeId: 'repo::/w/a' }) + expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toBeNull() + }) + + it('remembers each host’s own visit, whichever host was visited last', () => { + let raw = withLastVisitedWorktree(null, { hostId: 'host-1', worktreeId: 'repo::/w/a' }) + raw = withLastVisitedWorktree(raw, { + hostId: 'host-2', + worktreeId: 'repo::/w/b', + executionHost: 'runtime:env-1' + }) + expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' }) + expect(readLastVisitedWorktreeOnHost(raw, 'host-2')).toEqual({ + worktreeId: 'repo::/w/b', + executionHost: 'runtime:env-1' + }) + // Home's Resume card still reads the latest visit off the same record. + expect(readLastVisitedWorktreeRecord(raw)).toEqual({ + hostId: 'host-2', + worktreeId: 'repo::/w/b' + }) + }) + + it('reads a record stored before per-host visits for its own host, and keeps it on the next write', () => { + const legacy = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo::/w/a' }) + expect(readLastVisitedWorktreeOnHost(legacy, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' }) + expect(readLastVisitedWorktreeOnHost(legacy, 'host-2')).toBeNull() + const raw = withLastVisitedWorktree(legacy, { hostId: 'host-2', worktreeId: 'repo::/w/b' }) + expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' }) + }) + + it('drops the oldest hosts past the cap rather than the store refusing the value', () => { + let raw: string | null = null + for (let index = 0; index < 20; index++) { + raw = withLastVisitedWorktree(raw, { hostId: `host-${index}`, worktreeId: 'repo::/w/a' }) + } + expect(readLastVisitedWorktreeOnHost(raw, 'host-0')).toBeNull() + expect(readLastVisitedWorktreeOnHost(raw, 'host-19')).toEqual({ worktreeId: 'repo::/w/a' }) + }) +}) + +describe('isLastVisitedWorktreeRow', () => { + it('matches a desktop row (local or SSH) by id, and a server row by its server too', () => { + const visit = { worktreeId: 'repo::/w/a' } + expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'local' }, visit)).toBe( + true + ) + expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'ssh:gpu' }, visit)).toBe( + true + ) + expect( + isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'runtime:env-1' }, visit) + ).toBe(false) + const serverVisit = { worktreeId: 'repo::/w/a', executionHost: 'runtime:env-1' as const } + expect( + isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'runtime:env-1' }, serverVisit) + ).toBe(true) + expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'local' }, null)).toBe( + false + ) + }) +}) + +describe('writeLastVisitedWorktree', () => { + it('tells listeners at once and stores the visit beside other hosts’ visits', async () => { + storage.values.clear() + const told: unknown[] = [] + const unsubscribe = subscribeLastVisitedWorktree((hostId, visit) => told.push([hostId, visit])) + void writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo::/w/a' }) + expect(told).toEqual([['host-1', { worktreeId: 'repo::/w/a' }]]) + await writeLastVisitedWorktree({ + hostId: 'host-2', + worktreeId: 'repo::/w/b', + executionHost: 'runtime:env-1' + }) + unsubscribe() + expect(readLastVisitedWorktreeOnHost(stored(), 'host-1')).toEqual({ worktreeId: 'repo::/w/a' }) + expect(readLastVisitedWorktreeOnHost(stored(), 'host-2')).toEqual({ + worktreeId: 'repo::/w/b', + executionHost: 'runtime:env-1' + }) + }) + + it('handles a store that refuses the write instead of leaving the rejection loose', async () => { + storage.rejection = new Error('quota exceeded') + const seen: unknown[] = [] + const listener = (reason: unknown) => seen.push(reason) + process.on('unhandledRejection', listener) + try { + await writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo::/w/c' }) + await new Promise((resolve) => setImmediate(resolve)) + } finally { + process.off('unhandledRejection', listener) + storage.rejection = null + } + expect(seen).toEqual([]) + }) +}) diff --git a/mobile/src/worktree/last-visited-worktree-by-host.ts b/mobile/src/worktree/last-visited-worktree-by-host.ts new file mode 100644 index 00000000000..285cbca75d1 --- /dev/null +++ b/mobile/src/worktree/last-visited-worktree-by-host.ts @@ -0,0 +1,148 @@ +import type { ExecutionHostId } from '../../../src/shared/execution-host' +import { PAGE_STORAGE_MAX_VALUE_CHARS } from '../mobile-web-shell/page-storage-keys' +import { persistMirrored, readMirroredKeyOrStore } from '../storage/mirrored-storage-keys' +import { + LAST_VISITED_WORKTREE_STORAGE_KEY, + readLastVisitedWorktreeRecord, + type LastVisitedWorktreeRecord +} from './last-visited-worktree-repo' +import { routeExecutionHostOf, type LastVisitedWorktreeOnHost } from './host-list-highlight' + +// Why a cap: the page's store refuses a value past its frame cap, and a forgotten host never writes again. +const MAX_REMEMBERED_HOSTS = 16 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function visitOf(worktreeId: string, executionHost: string | undefined): LastVisitedWorktreeOnHost { + const server = routeExecutionHostOf(executionHost) + return server ? { worktreeId, executionHost: server } : { worktreeId } +} + +function readEntry(value: unknown): LastVisitedWorktreeOnHost | null { + if (!isRecord(value) || typeof value.worktreeId !== 'string' || value.worktreeId === '') { + return null + } + return visitOf( + value.worktreeId, + typeof value.executionHost === 'string' ? value.executionHost : undefined + ) +} + +function readByHost(raw: string | null): Map { + const byHost = new Map() + if (!raw) { + return byHost + } + try { + const parsed: unknown = JSON.parse(raw) + if (isRecord(parsed) && isRecord(parsed.byHost)) { + for (const [hostId, value] of Object.entries(parsed.byHost)) { + const entry = readEntry(value) + if (entry) { + byHost.set(hostId, entry) + } + } + } + } catch { + // Unreadable history is no history. + } + return byHost +} + +/** This phone's last-opened workspace on `hostId`, or null when it has opened none there. */ +export function readLastVisitedWorktreeOnHost( + raw: string | null, + hostId: string +): LastVisitedWorktreeOnHost | null { + const entry = readByHost(raw).get(hostId) + if (entry) { + return entry + } + // A record written before `byHost` names only the last host, and only the desktop's own rows. + const record = readLastVisitedWorktreeRecord(raw) + return record?.hostId === hostId ? { worktreeId: record.worktreeId } : null +} + +/** The stored value with `record` as the latest visit and its host's entry. */ +export function withLastVisitedWorktree( + raw: string | null, + record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId } +): string { + const byHost = readByHost(raw) + const previous = readLastVisitedWorktreeRecord(raw) + if (previous && !byHost.has(previous.hostId)) { + byHost.set(previous.hostId, { worktreeId: previous.worktreeId }) + } + // Re-inserted so the map's order is oldest visit first, which is what the cap drops. + byHost.delete(record.hostId) + byHost.set(record.hostId, visitOf(record.worktreeId, record.executionHost)) + const serialize = () => + JSON.stringify({ + hostId: record.hostId, + worktreeId: record.worktreeId, + byHost: Object.fromEntries(byHost) + }) + let value = serialize() + while ( + byHost.size > 1 && + (byHost.size > MAX_REMEMBERED_HOSTS || value.length > PAGE_STORAGE_MAX_VALUE_CHARS) + ) { + const oldest = byHost.keys().next().value + if (oldest === undefined) { + break + } + byHost.delete(oldest) + value = serialize() + } + return value +} + +type Listener = (hostId: string, visited: LastVisitedWorktreeOnHost) => void +const listeners = new Set() + +/** Visits noted in this JS context, told at once so an open list moves without a re-read. */ +export function subscribeLastVisitedWorktree(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** + * Tells this context's open lists about a visit without storing it. The list's tap uses this: the + * shell's session page is another page that stores the visit itself, and a long-lived list page's + * load-time copy of the key could overwrite newer visits to other hosts. + */ +export function noteLastVisitedWorktree( + record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId } +): void { + const visited = visitOf(record.worktreeId, record.executionHost) + for (const listener of listeners) { + listener(record.hostId, visited) + } +} + +// Serialized: each write merges into what the last one stored. +let writeChain: Promise = Promise.resolve() + +/** + * The one writer of this key (the session route), so the hybrid shell's mirror sees it as it is + * written. The page is handed this key on every `init`, built from that mirror; a write that went + * straight to the store would reach the page one `init` later. + */ +export function writeLastVisitedWorktree( + record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId } +): Promise { + noteLastVisitedWorktree(record) + // Through the path that notes on an accepted write (ruling 35); the page's adapter may refuse, + // and nothing is owed a caller that cannot act on one, so a refusal is the adapter's log. + writeChain = writeChain + .then(() => readMirroredKeyOrStore(LAST_VISITED_WORKTREE_STORAGE_KEY).catch(() => null)) + .then((raw) => + persistMirrored(LAST_VISITED_WORKTREE_STORAGE_KEY, withLastVisitedWorktree(raw, record)) + ) + .catch(() => {}) + return writeChain +} diff --git a/mobile/src/worktree/last-visited-worktree-repo.test.ts b/mobile/src/worktree/last-visited-worktree-repo.test.ts index 138f579edf1..f54e63f70b5 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.test.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.test.ts @@ -1,65 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' - -type StorageDouble = { rejection: unknown; written: string[] } - -const storage = vi.hoisted((): StorageDouble => ({ rejection: null, written: [] })) - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - setItem: async (_key: string, value: string) => { - if (storage.rejection !== null) { - throw storage.rejection - } - storage.written.push(value) - }, - removeItem: async () => undefined - } -})) +import { describe, expect, it } from 'vitest' import { readLastVisitedWorktreeRecord, - readLastVisitedWorktreeRepoId, - writeLastVisitedWorktree + readLastVisitedWorktreeRepoId } from './last-visited-worktree-repo' -/** Node reports an unhandled rejection at the end of a microtask checkpoint, so one macrotask is - * long enough to see it, and a listener is the only way to observe one from inside a test. */ -async function unhandledRejectionsWhile(run: () => void): Promise { - const seen: unknown[] = [] - const listener = (reason: unknown) => seen.push(reason) - process.on('unhandledRejection', listener) - try { - run() - await new Promise((resolve) => setImmediate(resolve)) - await new Promise((resolve) => setImmediate(resolve)) - } finally { - process.off('unhandledRejection', listener) - } - return seen -} - -// Why: the mirror reports this key as written the moment it is noted, so a store write that -// rejects must be handled where it is made. Nothing above it is holding a catch. -describe('writeLastVisitedWorktree', () => { - it('handles a store that refuses the write instead of leaving the rejection loose', async () => { - storage.rejection = new Error('quota exceeded') - const loose = await unhandledRejectionsWhile(() => { - writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' }) - }) - storage.rejection = null - expect(loose).toEqual([]) - }) - - it('still persists the record when the store takes it', async () => { - storage.written.length = 0 - writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' }) - await new Promise((resolve) => setImmediate(resolve)) - expect(storage.written).toEqual([ - JSON.stringify({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' }) - ]) - }) -}) - describe('last visited worktree repo', () => { it('extracts the repo id for the current host', () => { const raw = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' }) diff --git a/mobile/src/worktree/last-visited-worktree-repo.ts b/mobile/src/worktree/last-visited-worktree-repo.ts index af587e443c0..59e1a024451 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.ts @@ -1,4 +1,3 @@ -import { persistMirrored } from '../storage/mirrored-storage-keys' import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-helpers' export const LAST_VISITED_WORKTREE_STORAGE_KEY = 'orca:last-visited-worktree' @@ -47,18 +46,3 @@ export function readLastVisitedWorktreeRepoId(raw: string | null, hostId: string const repoId = getRepoIdFromMobileWorktreeId(record.worktreeId).trim() return repoId || null } - -/** - * The one writer of this key, so the hybrid shell's mirror sees it as it is written. - * - * The page is handed this key on every `init`, built synchronously from that mirror; a write that - * went straight to the store would reach the page one `init` later, and the New Workspace drawer - * would open on the repo the user left rather than the one they just came from. - */ -export function writeLastVisitedWorktree(record: LastVisitedWorktreeRecord): void { - // Through the path that notes on an accepted write (ruling 35): this module is in the page's - // own closure, so the store behind it may be the bridge's adapter, which refuses a key this - // route was never given. Nothing is owed a caller that cannot act on one, so the refusal is the - // adapter's log rather than a rejection here. - void persistMirrored(LAST_VISITED_WORKTREE_STORAGE_KEY, JSON.stringify(record)).catch(() => {}) -} diff --git a/mobile/src/worktree/project-group-indent.test.ts b/mobile/src/worktree/project-group-indent.test.ts new file mode 100644 index 00000000000..4a7f4756851 --- /dev/null +++ b/mobile/src/worktree/project-group-indent.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { projectGroupHeaderIndent, projectGroupRowIndent } from './project-group-indent' + +const DEPTHS = [0, 1, 2, 6, 9, undefined] + +describe('project-group indent', () => { + it('steps headers 10 px a level, up to six levels', () => { + expect(DEPTHS.map(projectGroupHeaderIndent)).toEqual([0, 10, 20, 60, 60, 0]) + }) + + it('steps a hand-made group’s rows, folder workspaces too, a full tree step a level', () => { + for (const workspaceKind of ['git', 'folder-workspace', undefined] as const) { + expect( + DEPTHS.map((projectGroupDepth) => + projectGroupRowIndent({ workspaceKind, projectGroupDepth }) + ) + ).toEqual([0, 18, 36, 108, 108, 0]) + } + }) + + it('steps a folder-scanned group’s rows by the header step, its folder workspaces one level less', () => { + const indent = (workspaceKind: 'git' | 'folder-workspace', projectGroupDepth: number) => + projectGroupRowIndent({ workspaceKind, projectGroupDepth, inFolderScanGroup: true }) + expect([1, 2, 7].map((depth) => indent('git', depth))).toEqual([10, 20, 60]) + expect([1, 2, 7, 9].map((depth) => indent('folder-workspace', depth))).toEqual([0, 10, 60, 60]) + }) +}) diff --git a/mobile/src/worktree/project-group-indent.ts b/mobile/src/worktree/project-group-indent.ts new file mode 100644 index 00000000000..42a8199cec6 --- /dev/null +++ b/mobile/src/worktree/project-group-indent.ts @@ -0,0 +1,32 @@ +import type { Worktree } from './workspace-list-types' + +// The desktop's project-group tree steps (indentation.ts): headers 10 px a level, workspace cards a +// full 18 px tree step; capped at six levels, as the headers are. +const PROJECT_GROUP_HEADER_INDENT = 10 +const TREE_INDENT = 18 +const MAX_PROJECT_GROUP_DEPTH = 6 + +function cappedDepth(depth: number | undefined): number { + return Math.min(Math.max(0, Math.floor(depth ?? 0)), MAX_PROJECT_GROUP_DEPTH) +} + +/** A header's indent past the phone's base padding, for its depth in the group tree. */ +export function projectGroupHeaderIndent(depth: number | undefined): number { + return cappedDepth(depth) * PROJECT_GROUP_HEADER_INDENT +} + +/** + * A row's indent past the phone's base padding: what the desktop adds over an ungrouped card. A + * folder-scanned group's rows take the header step, its folder workspaces one level less. + */ +export function projectGroupRowIndent( + row: Pick +): number { + const depth = row.projectGroupDepth ?? 0 + if (!row.inFolderScanGroup) { + return cappedDepth(depth) * TREE_INDENT + } + return row.workspaceKind === 'folder-workspace' + ? cappedDepth(depth - 1) * PROJECT_GROUP_HEADER_INDENT + : cappedDepth(depth) * PROJECT_GROUP_HEADER_INDENT +} diff --git a/mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx b/mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx new file mode 100644 index 00000000000..562749eca31 --- /dev/null +++ b/mobile/src/worktree/use-last-visited-worktree-on-host.test.tsx @@ -0,0 +1,136 @@ +import type * as PageStorage from '../mobile-web-shell/bridge/page-async-storage' +import type * as Visits from './last-visited-worktree-by-host' +import { describe, expect, it, vi } from 'vitest' + +const KEY = 'orca:last-visited-worktree' +const HOST = 'host-1' + +/** The app's own store, which every page's writes reach and every page load copies. */ +const appStore = new Map() + +type PageModules = { + storage: typeof PageStorage + visits: typeof Visits +} + +/** One embedded page: its own module graph and its own copy of the store, taken at load. */ +async function loadPage(pathname: string): Promise { + vi.resetModules() + // Each page's store is its own page adapter, as in the shell: a load-time copy of the app's. + // doMock, not mock: the mock registry outlives resetModules, which would share one adapter. + vi.doMock('@react-native-async-storage/async-storage', async () => ({ + default: (await import('../mobile-web-shell/bridge/page-async-storage')).default + })) + const storage = await import('../mobile-web-shell/bridge/page-async-storage') + const visits = await import('./last-visited-worktree-by-host') + storage.publishPageStorage( + Object.fromEntries(appStore), + (key, value) => { + if (value === null) { + appStore.delete(key) + } else { + appStore.set(key, value) + } + return true + }, + HOST, + pathname + ) + return { storage, visits } +} + +/** The list page: a fresh graph rendering the hook, with its React and renderer from that graph. */ +async function mountListPage(): Promise<{ + page: PageModules + value: () => unknown + settle: () => Promise + unmount: () => void +}> { + const page = await loadPage(`/h/${HOST}`) + const { createElement } = await import('react') + const { act, create } = await import('react-test-renderer') + const { useLastVisitedWorktreeOnHost } = await import('./use-last-visited-worktree-on-host') + const held: { value: unknown } = { value: undefined } + function ListProbe(): null { + held.value = useLastVisitedWorktreeOnHost(HOST) + return null + } + let unmount = () => {} + await act(async () => { + const renderer = create(createElement(ListProbe)) + unmount = () => act(() => renderer.unmount()) + }) + // Lets this graph's renderer take state set from outside an act scope. + const settle = () => act(async () => {}) + return { page, value: () => held.value, settle, unmount } +} + +describe('the list page highlight across the shell’s separate pages', () => { + it('highlights the tapped row after the session page opens and Back returns to the list', async () => { + appStore.clear() + const list = await mountListPage() + expect(list.value()).toBeNull() + + // Another host's visit lands after the list page loaded, so the list's copy is stale. + appStore.set( + KEY, + JSON.stringify({ + hostId: 'host-2', + worktreeId: 'repo::/w/other', + byHost: { 'host-2': { worktreeId: 'repo::/w/other' } } + }) + ) + + // Tap w2: the list notes the visit in its own page. + list.page.visits.noteLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/2' }) + await list.settle() + expect(list.value()).toEqual({ worktreeId: 'repo::/w/2' }) + + // The shell pushes the session page: a fresh graph and a fresh copy, which stores the visit. + const session = await loadPage(`/h/${HOST}/session/${encodeURIComponent('repo::/w/2')}`) + await session.visits.writeLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/2' }) + + // Back: the list page was never reloaded and still highlights the tapped row. + expect(list.value()).toEqual({ worktreeId: 'repo::/w/2' }) + // Neither page's write overwrote the newer visit to the other host. + const stored = appStore.get(KEY) ?? null + expect(session.visits.readLastVisitedWorktreeOnHost(stored, 'host-2')).toEqual({ + worktreeId: 'repo::/w/other' + }) + expect(session.visits.readLastVisitedWorktreeOnHost(stored, HOST)).toEqual({ + worktreeId: 'repo::/w/2' + }) + list.unmount() + }) + + it('reads the stored visit when the list page loads again', async () => { + appStore.clear() + const session = await loadPage(`/h/${HOST}/session/w`) + await session.visits.writeLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/3' }) + const list = await mountListPage() + expect(list.value()).toEqual({ worktreeId: 'repo::/w/3' }) + list.unmount() + }) + + it('follows a session page’s write when the list is the app’s own screen beside it', async () => { + appStore.clear() + const list = await mountListPage() + const mirror = await import('../storage/mirrored-storage-keys') + // The app taking a session page's write, which is how a page's visit reaches an app screen. + mirror.writeMirroredStorage( + KEY, + JSON.stringify({ + hostId: HOST, + worktreeId: 'repo::/w/4', + byHost: { [HOST]: { worktreeId: 'repo::/w/4' } } + }) + ) + await list.settle() + expect(list.value()).toEqual({ worktreeId: 'repo::/w/4' }) + // An older page's write names another host only; this host's visit stands. + mirror.writeMirroredStorage(KEY, JSON.stringify({ hostId: 'host-2', worktreeId: 'repo::/w/x' })) + await list.settle() + expect(list.value()).toEqual({ worktreeId: 'repo::/w/4' }) + list.unmount() + }) +}) diff --git a/mobile/src/worktree/use-last-visited-worktree-on-host.ts b/mobile/src/worktree/use-last-visited-worktree-on-host.ts new file mode 100644 index 00000000000..0e35d7450a5 --- /dev/null +++ b/mobile/src/worktree/use-last-visited-worktree-on-host.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' +import { readMirroredKeyOrStore, subscribeMirroredKey } from '../storage/mirrored-storage-keys' +import { LAST_VISITED_WORKTREE_STORAGE_KEY } from './last-visited-worktree-repo' +import type { LastVisitedWorktreeOnHost } from './host-list-highlight' +import { + readLastVisitedWorktreeOnHost, + subscribeLastVisitedWorktree +} from './last-visited-worktree-by-host' + +/** + * The workspace this phone last opened on `hostId`: stored, then each visit noted in this context + * (a list tap) or written through this context's store (in the app, a session page's write). + * A web page's store is its load-time copy, so a visit another page makes reaches it on reload. + * Null until the stored visit is read, and when this phone has opened nothing there. + */ +export function useLastVisitedWorktreeOnHost( + hostId: string | undefined +): LastVisitedWorktreeOnHost | null { + const [visited, setVisited] = useState<{ + hostId: string + value: LastVisitedWorktreeOnHost | null + } | null>(null) + + useEffect(() => { + if (!hostId) { + return + } + let stale = false + let noted = false + const take = (value: LastVisitedWorktreeOnHost) => { + noted = true + setVisited({ hostId, value }) + } + const unsubscribeNotes = subscribeLastVisitedWorktree((visitedHostId, value) => { + if (visitedHostId === hostId) { + take(value) + } + }) + const unsubscribeStore = subscribeMirroredKey(LAST_VISITED_WORKTREE_STORAGE_KEY, (raw) => { + // A write naming no visit here (another host's, in an older shape) leaves this one standing. + const value = readLastVisitedWorktreeOnHost(raw, hostId) + if (value) { + take(value) + } + }) + void readMirroredKeyOrStore(LAST_VISITED_WORKTREE_STORAGE_KEY) + .then((raw) => { + // A visit noted while the read was open is newer than anything it returns. + if (!stale && !noted) { + setVisited({ hostId, value: readLastVisitedWorktreeOnHost(raw, hostId) }) + } + }) + .catch(() => {}) + return () => { + stale = true + unsubscribeNotes() + unsubscribeStore() + } + }, [hostId]) + + // Keyed by host so a switch to another host never shows the last one's visit. + return visited && visited.hostId === hostId ? visited.value : null +} diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index fbe884f350b..167af44a207 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -55,6 +55,10 @@ export type Worktree = { parentWorktreeId?: string | null childWorktreeIds?: string[] lineageDepth?: number + /** How deep the row sits in the desktop's project-group tree; indents it the same way. */ + projectGroupDepth?: number + /** Its project group came from a folder scan, whose rows the desktop steps more compactly. */ + inFolderScanGroup?: boolean lineageChildCount?: number lineageCollapsed?: boolean /** The collapse key the shared builder gave this row's children; set only on a feed-built list. */ @@ -100,6 +104,10 @@ export type Section = { icon?: 'pin' /** Under Project grouping, the repo whose colour and icon the header shows, as on the desktop. */ repo?: SectionRepo + /** A project group's own header (no repo), drawn with the group icon, as on the desktop. */ + isProjectGroup?: boolean + /** How deep the header sits in the desktop's project-group tree; indents it the same way. */ + projectGroupDepth?: number /** The header's own count from a feed-built list, which a collapsed section still shows. */ count?: number /** Drawn under the header, above its rows, as the desktop places them. */ diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index a69b92b2f00..fdc276ba3e7 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -75,6 +75,10 @@ async function stopRendererOwnedPtyAs( stopped = true } finally { settleStop?.(stopped) + if (!stopped) { + // The failed shutdown left the process running. + deps.runtime?.clearPtyStopRequest?.(args.id) + } } } diff --git a/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts index 545097d0d4d..db714522b9d 100644 --- a/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts +++ b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts @@ -132,7 +132,7 @@ describe('a tab close of a terminal an older relay still runs', () => { }) describe('a stop-and-wait of a terminal an older relay still runs', () => { - function install(recordStop = vi.fn()) { + function install(recordStop = vi.fn(), runtime: unknown = null) { installLegacyRelayBridge({ leases: () => [lease], retireLease: vi.fn(), @@ -146,7 +146,7 @@ describe('a stop-and-wait of a terminal an older relay still runs', () => { report: () => {} }) const deps = { - runtime: null, + runtime, store: null, getLocalPtyProviderStartupPromise: () => null, shutdownProviderAndDetectExit: vi.fn(), @@ -185,4 +185,87 @@ describe('a stop-and-wait of a terminal an older relay still runs', () => { expect(recordStop).not.toHaveBeenCalled() installLegacyRelayBridge(null) }) + + it('keeps the stop request of a final stop the bridge still owes, so the pane reads closing', async () => { + const runtime = { markPtyStopRequested: vi.fn(), clearPtyStopRequest: vi.fn() } + const recordStop = vi.fn() + const deps = install(recordStop, runtime) + const ptyId = toAppSshPtyId(TARGET, lease.ptyId) + await expect( + stopAndWaitPtyFromRuntimeController(deps, ptyId, { deadlineMs: Date.now() + 1_000 }) + ).resolves.toBe(false) + expect(recordStop).toHaveBeenCalled() + expect(runtime.clearPtyStopRequest).not.toHaveBeenCalled() + installLegacyRelayBridge(null) + }) + + it('takes back the stop request of a refused reversible stop, so the pane reads running', async () => { + const runtime = { markPtyStopRequested: vi.fn(), clearPtyStopRequest: vi.fn() } + const deps = install(vi.fn(), runtime) + const ptyId = toAppSshPtyId(TARGET, lease.ptyId) + await expect( + stopAndWaitPtyFromRuntimeController(deps, ptyId, { keepHistory: true }) + ).resolves.toBe(false) + expect(runtime.markPtyStopRequested).toHaveBeenCalledWith(ptyId) + expect(runtime.clearPtyStopRequest).toHaveBeenCalledWith(ptyId) + installLegacyRelayBridge(null) + }) +}) + +describe('a stop-and-wait whose provider startup fails', () => { + it('takes back its stop request, so the terminal still reads running', async () => { + const runtime = { markPtyStopRequested: vi.fn(), clearPtyStopRequest: vi.fn() } + const deps = { + runtime, + store: null, + getLocalPtyProviderStartupPromise: () => Promise.reject(new Error('daemon failed')), + shutdownProviderAndDetectExit: vi.fn(), + finishPtyShutdown: vi.fn() + } + await expect( + stopAndWaitPtyFromRuntimeController( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup await throws before any other dep is read. + deps as unknown as PtyRuntimeControllerDeps, + 'local-pty-1' + ) + ).rejects.toThrow('daemon failed') + expect(runtime.markPtyStopRequested).toHaveBeenCalledWith('local-pty-1') + expect(runtime.clearPtyStopRequest).toHaveBeenCalledWith('local-pty-1') + }) +}) + +describe('a failed tab-close stop', () => { + it('takes back its stop request, so the terminal still reads running', async () => { + installLegacyRelayBridge({ + leases: () => [lease], + retireLease: vi.fn(), + recordStop: vi.fn(), + connection: () => null, + onPanesChanged: () => {}, + onData: () => {}, + onReplay: () => {}, + onExit: () => {}, + report: () => {} + }) + const runtime = { + markPtyStopRequested: vi.fn(), + clearPtyStopRequest: vi.fn(), + intentionalPtyStops: { mark: () => () => {} } + } + const deps = { + runtime, + store: { markSshRemotePtyLease: vi.fn() }, + getLocalPtyProviderStartupPromise: () => undefined, + shutdownProviderAndDetectExit: vi.fn(), + rememberSyntheticKillExit: vi.fn(), + sendPtyExitToRenderer: vi.fn() + } + const ptyId = toAppSshPtyId(TARGET, lease.ptyId) + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch reads only these deps. + stopRendererOwnedPty(deps as unknown as PtyKillIpcDeps, { id: ptyId, keepHistory: true }) + ).rejects.toThrow('legacy_relay_bridged_pty_cannot_sleep') + expect(runtime.clearPtyStopRequest).toHaveBeenCalledWith(ptyId) + installLegacyRelayBridge(null) + }) }) diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index cc18dfa7002..6d0fb9199ad 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -196,6 +196,26 @@ export async function stopAndWaitPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } +): Promise { + // A final relay stop is owed until the relay proves it, so an unproven one still reads as closing. + const relayOwesStop = + !opts?.keepHistory && Boolean(getPtySshConnectionId(ptyId)) && isLegacyRelayBridgedPty(ptyId) + let stopped = false + try { + stopped = await stopAndWaitPty(deps, ptyId, opts) + return stopped + } finally { + if (!stopped && !relayOwesStop) { + // Refused, failed or thrown: the process may run on, so it must not read as closing. + deps.runtime?.clearPtyStopRequest?.(ptyId) + } + } +} + +async function stopAndWaitPty( + deps: PtyRuntimeControllerDeps, + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } ): Promise { const { runtime, diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts index 132a873e1cb..9c6bfe5dea8 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts @@ -239,7 +239,7 @@ describe('mobile relay host catalog', () => { 'worktree.ps', // The phone's full-catalog limit: the server's default page would silently truncate. { limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true }, - { timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: 'runtime-a' } } + { expected: { pairingRevision: 1, runtimeId: 'runtime-a' } } ) }) @@ -330,7 +330,6 @@ describe('mobile relay host catalog', () => { const next = hostCatalog.worktrees('runtime:env') await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(2)) expect(call).toHaveBeenLastCalledWith(expect.anything(), 'worktree.ps', expect.anything(), { - timeoutMs: 5_000, expected: { pairingRevision: 2, runtimeId: 'runtime-a' } }) await expect(next).resolves.toMatchObject({ worktrees: [{ worktreeId: 'new-server-row' }] }) @@ -395,7 +394,6 @@ describe('mobile relay host catalog', () => { worktrees: [{ worktreeId: 'w' }] }) expect(call).toHaveBeenCalledWith(expect.anything(), 'worktree.ps', expect.anything(), { - timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: null } }) }) diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-closed-tab-retention.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-closed-tab-retention.test.ts new file mode 100644 index 00000000000..6e1bea4859b --- /dev/null +++ b/src/main/runtime/sidebar-inputs/orca-runtime-closed-tab-retention.test.ts @@ -0,0 +1,179 @@ +// Runs on plain Node, as orcad does: main's "Hide sleeping" retention reads the runtime's own facts. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeStore } from '../runtime-store-contract' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { ResolvedWorktreeSnapshot } from '../runtime-resolved-worktree-cache' +import type { ResolvedWorktree } from '../runtime-worktree-path-identity' + +vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) + +const repo = { id: 'repo-1', path: '/r', displayName: 'r', badgeColor: 'blue', addedAt: 1 } +const WT = 'repo-1::/r/a' +const LEAF = '22222222-2222-4222-8222-222222222222' + +function worktree(id: string): ResolvedWorktree { + return { + id, + repoId: repo.id, + path: id, + branch: 'a', + head: '', + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { path: id, head: '', branch: 'a', isBare: false, isMainWorktree: false } + } +} + +class Probe extends OrcaRuntimeService { + protected override async listResolvedWorktreeSnapshot(): Promise { + return { worktrees: [worktree(WT)], platformByRepoId: new Map() } + } +} + +type Host = NonNullable + +/** The window's graph: one terminal tab in WT, running pty-1. */ +function publishTerminalTab(runtime: Host): void { + runtime.syncWindowGraph(1, { + tabs: [{ tabId: 'tab-1', worktreeId: WT, title: 'Terminal', activeLeafId: LEAF, layout: null }], + leaves: [{ tabId: 'tab-1', worktreeId: WT, leafId: LEAF, paneRuntimeId: 1, ptyId: 'pty-1' }], + mobileSessionTabs: [ + { + worktree: WT, + publicationEpoch: 'renderer:test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: `tab-1::${LEAF}`, + parentTabId: 'tab-1', + leafId: LEAF, + title: 'Terminal', + ptyId: 'pty-1', + isActive: true + } + ] + } + ] + }) +} + +/** A window with one terminal open in WT, its list mounted, "Hide sleeping" on. */ +async function createHost(): Promise { + const store: Pick = { + getRepos: () => [repo], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: retention reads only hideSleepingWorkspaces. + getUI: () => ({ hideSleepingWorkspaces: true }) as PersistedUIState + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these paths touch only repos and the UI flag. + const runtime = new Probe(store as RuntimeStore).sidebarFacts + if (!runtime) { + throw new Error('built without the sidebar facts layer') + } + publishTerminalTab(runtime) + runtime.beginSidebarListSession('session-1') + await vi.advanceTimersByTimeAsync(300) + expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live') + return runtime +} + +const retained = (runtime: Host): readonly string[] => + runtime.getSidebarTemporalSnapshot().retention?.retainedIds ?? [] + +/** The window's graph once its terminal tab in WT is closed. */ +function publishNoTabs(runtime: Host): void { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: WT, + publicationEpoch: 'renderer:test', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) +} + +// pty-1 is recorded as the spawn records it, so it reads live until its exit arrives. +async function createHostWithSpawnedPty(): Promise { + const runtime = await createHost() + runtime.registerPty('pty-1', WT) + return runtime +} + +describe('main sleeping retention and a closed last tab', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('drops the row as the window closes the tab, before the terminal exits', async () => { + const runtime = await createHostWithSpawnedPty() + // As closeTab does: pty:kill first, then the graph without the tab. + runtime.markPtyStopRequested('pty-1') + expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live') + publishNoTabs(runtime) + expect(runtime.getWorkspaceActivityFacts().facts[WT]).toBeUndefined() + expect(retained(runtime)).toEqual([]) + }) + + it('drops the row when the stop request lands after the graph', async () => { + const runtime = await createHostWithSpawnedPty() + publishNoTabs(runtime) + expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live') + runtime.markPtyStopRequested('pty-1') + expect(runtime.getWorkspaceActivityFacts().facts[WT]).toBeUndefined() + expect(retained(runtime)).toEqual([]) + }) + + it('keeps a terminal live while a pane still shows it, though asked to stop', async () => { + const runtime = await createHostWithSpawnedPty() + runtime.markPtyStopRequested('pty-1') + await vi.advanceTimersByTimeAsync(300) + expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live') + }) + + it('keeps a closed tab’s terminal unverifiable when its host stops answering', async () => { + const runtime = await createHostWithSpawnedPty() + runtime.markPtyStopRequested('pty-1') + publishNoTabs(runtime) + runtime.markPtyLivenessUnverifiable('pty-1', 'ssh contact lost') + expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('unverifiable') + }) + + it('counts the terminal live again when its stop fails', async () => { + const runtime = await createHostWithSpawnedPty() + runtime.markPtyStopRequested('pty-1') + publishNoTabs(runtime) + expect(runtime.getWorkspaceActivityFacts().facts[WT]).toBeUndefined() + runtime.clearPtyStopRequest('pty-1') + expect(runtime.getWorkspaceActivityFacts().facts[WT]?.terminal).toBe('live') + }) +}) diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.ts index edfebb987c3..69bfcbe26a7 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-activity-facts.ts @@ -33,6 +33,7 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW override syncWindowGraph(...args: Parameters) { const previousTabs = this.tabs + const previousLeaves = this.leaves const result = super.syncWindowGraph(...args) // Only a tab moving workspace can change where an agent row is attributed. const moved = [...this.tabs.values()].some( @@ -41,9 +42,22 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW if (moved || [...previousTabs.keys()].some((tabId) => !this.tabs.has(tabId))) { this.workspaceActivityFacts.resyncAgentPanes() } + // Now, not on the session publication: a closed pane's stopping PTY stops counting here. + // syncWindowGraph replaces this.leaves (session-window-graph.ts), so previousLeaves stays intact. + this.refreshWorkspaces(leafPtyChangedWorktrees(previousLeaves, this.leaves)) return result } + override markPtyStopRequested(ptyId: string): void { + super.markPtyStopRequested(ptyId) + this.refreshWorkspaces(this.ptyWorktreeIds(ptyId)) + } + + override clearPtyStopRequest(ptyId: string): void { + super.clearPtyStopRequest(ptyId) + this.refreshWorkspaces(this.ptyWorktreeIds(ptyId)) + } + protected override notifyMobileSessionTabsChangedNow( worktreeId: string, changeSequence: number @@ -102,6 +116,11 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW } } + // Asked to stop with no pane left: the window that closed it already shows it gone. + private isClosing(ptyId: string): boolean { + return this.isPtyStopRequested(ptyId) && this.getLeavesForPty(ptyId).length === 0 + } + private ptyWorktreeIds(ptyId: string): Set { const worktreeIds = new Set(this.getLeavesForPty(ptyId).map((leaf) => leaf.worktreeId)) const recorded = this.ptysById.get(ptyId)?.worktreeId @@ -123,8 +142,8 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW } } - // getLivePtyIdsForWorktree's identity rule in one pass. A host that only lost contact keeps its - // terminals unverifiable, never gone, even while the record still reads connected. + // Matches every host form of the worktree's id. A host that only lost contact keeps its terminals + // unverifiable, never gone, even while the record still reads connected or is being closed. private readWorkspaceTerminalLiveness(worktreeId: string): WorkspaceTerminalLiveness | null { const key = worktreeIdentityKey(worktreeId) const owns = (id: string): boolean => id === worktreeId || worktreeIdentityKey(id) === key @@ -136,7 +155,7 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW } if (this.getPtyLivenessVerdict(surface.ptyId)?.status === 'unverifiable') { unverifiable = true - } else if (surface.connected) { + } else if (surface.connected && !this.isClosing(surface.ptyId)) { return 'live' } } @@ -144,3 +163,23 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW return unverifiable ? 'unverifiable' : null } } + +function leafPtyChangedWorktrees( + before: ReadonlyMap, + after: ReadonlyMap +): Set { + const changed = new Set() + for (const [leafId, leaf] of before) { + const next = after.get(leafId) + if (next?.ptyId !== leaf.ptyId || next.worktreeId !== leaf.worktreeId) { + changed.add(leaf.worktreeId) + } + } + for (const [leafId, leaf] of after) { + const previous = before.get(leafId) + if (previous?.ptyId !== leaf.ptyId || previous.worktreeId !== leaf.worktreeId) { + changed.add(leaf.worktreeId) + } + } + return changed +} 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 2e49d3bb78d..0a75486f9f4 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 @@ -124,6 +124,9 @@ function fakeHosts(servers: { id: string; live: boolean }[]) { } } +// callRuntimeEnvironment's bound for a call that names none. +const DEFAULT_TRANSPORT_TIMEOUT_MS = 15_000 + const GROUP = { name: 'school', parentPath: null, @@ -172,7 +175,7 @@ describe('PairedServerRowsProducer', () => { { environmentId: 'box' }, 'worktree.ps', { limit: 10_000, supportsWorktreeVisibilitySourceDefaults: true }, - { timeoutMs: 5_000, expected: { pairingRevision: 1, runtimeId: 'runtime-a' } } + { expected: { pairingRevision: 1, runtimeId: 'runtime-a' } } ) expect(rowsOf(producer, 'runtime:box')).toEqual({ hostId: 'runtime:box', @@ -239,6 +242,90 @@ describe('PairedServerRowsProducer', () => { expect(rowsOf(producer, 'runtime:box')?.rows.map((entry) => entry.worktreeId)).toEqual(['new']) }) + // A server answering after `delayMs`, or failing as the transport would once its bound passes. + function slowServer(call: ReturnType['call'], delayMs: number, rows: string[]) { + call.mockImplementationOnce( + (_host, _method, _params, options) => + new Promise((resolve, reject) => { + const boundMs = options?.timeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS + if (delayMs > boundMs) { + setTimeout(() => reject(new Error('timed out')), boundMs) + } else { + setTimeout(() => resolve(psReply(rows.map((id) => row(id)))), delayMs) + } + }) + ) + } + + it('lists a server that takes 8 s to answer', async () => { + const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) + slowServer(call, 8_000, ['a']) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(7_999) + expect(rowsOf(producer, 'runtime:box')?.rows).toEqual([]) + await vi.advanceTimersByTimeAsync(1) + expect(rowsOf(producer, 'runtime:box')).toMatchObject({ + freshness: 'live', + rows: [{ worktreeId: 'a' }] + }) + }) + + it('holds one fetch per server, and notices during it coalesce into one follow-up', async () => { + const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) + slowServer(call, 8_000, ['old']) + slowServer(call, 8_000, ['new']) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(1_000) + // Change notices and a reader's refresh arrive while it runs. + watches.get('box')?.onChange() + watches.get('box')?.onChange() + void producer.refresh('box') + await vi.advanceTimersByTimeAsync(6_000) + expect(call).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1_000) + expect(call).toHaveBeenCalledTimes(2) + // The fallback poll answers too, so the rows stay live. + call.mockResolvedValueOnce(psReply([row('new')])) + await vi.advanceTimersByTimeAsync(30_000) + expect(call).toHaveBeenCalledTimes(3) + expect(rowsOf(producer, 'runtime:box')).toMatchObject({ freshness: 'live' }) + expect(rowsOf(producer, 'runtime:box')?.rows.map((entry) => entry.worktreeId)).toEqual(['new']) + }) + + it('keeps the last rows when a fetch times out', async () => { + const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) + call.mockResolvedValueOnce(psReply([row('a')])) + const producer = new PairedServerRowsProducer({ + hasDemand: () => true, + hostLabelOverrides: () => new Map() + }) + producer.attach(hosts) + watches.get('box')?.onChange() + await vi.advanceTimersByTimeAsync(0) + + slowServer(call, 60_000, ['never']) + watches.get('box')?.onChange() + // The transport's normal bound, not the phone's 5 s, ends the wait. + await vi.advanceTimersByTimeAsync(DEFAULT_TRANSPORT_TIMEOUT_MS - 1) + expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('live') + await vi.advanceTimersByTimeAsync(1) + expect(rowsOf(producer, 'runtime:box')).toMatchObject({ + freshness: 'unverifiable', + rows: [{ worktreeId: 'a' }] + }) + }) + it('keeps the last rows as unverifiable when the server loses contact or stops answering', async () => { const { hosts, call, watches, state, publishStatus } = fakeHosts([{ id: 'box', live: true }]) call.mockResolvedValueOnce(psReply([row('a')])) diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts index bc07f79e2dd..2bc0790759b 100644 --- a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts @@ -17,7 +17,6 @@ import { } from '../mobile-desktop-relay/mobile-relay-host-descriptions' import type { ServerWorktreeRows } from '../mobile-desktop-relay/mobile-relay-host-catalog' import { - SERVER_FETCH_TIMEOUT_MS, SERVER_WORKTREE_LIMIT, WorktreePsReplySchema, stampServerWorktreeRows @@ -244,8 +243,8 @@ export class PairedServerRowsProducer implements ServerWorktreeRows { const { contactGeneration } = entry const call = async (method: string, params?: unknown) => hosts.call({ environmentId: entry.environmentId }, method, params, { - // Why the transport bound too: it ends the hung call, so the next request fetches afresh. - timeoutMs: SERVER_FETCH_TIMEOUT_MS, + // Why the transport's normal bound: nothing waits on this fetch (readers take the latest + // rows), so a slow server's answer must still land; the bound still ends a hung call. expected: entry.identity }) // Only on contact, ready, a replay or reposChanged, which every catalog write announces. diff --git a/src/main/runtime/terminal/layout/terminal-input-floor.ts b/src/main/runtime/terminal/layout/terminal-input-floor.ts index 66dcf448579..ae10126a599 100644 --- a/src/main/runtime/terminal/layout/terminal-input-floor.ts +++ b/src/main/runtime/terminal/layout/terminal-input-floor.ts @@ -53,6 +53,11 @@ export abstract class OrcaRuntimeWithMarkPtyLivenessUnverifiable extends OrcaRun this.stopRequestedPtyIds.add(ptyId) } + /** A stop that failed or was refused: the PTY runs on, so nothing may read it as closing. */ + clearPtyStopRequest(ptyId: string): void { + this.stopRequestedPtyIds.delete(ptyId) + } + isPtyStopRequested(ptyId: string): boolean { return this.stopRequestedPtyIds.has(ptyId) } diff --git a/src/renderer/src/components/sidebar/sidebar-list-parity-catalog.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-parity-catalog.test-fixture.ts new file mode 100644 index 00000000000..abcfb8f71d3 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-list-parity-catalog.test-fixture.ts @@ -0,0 +1,23 @@ +import type { AppState } from '@/store/types' +import { group, mainWt, repo, wt } from './sidebar-list-golden-factories.test-fixture' + +// A group ahead of every other holding a git project and a folder project, a child group of it holding +// only a folder project, and an empty group: the phone must file each project under its group, in order. +const lake = repo('lake', 'Lake', { projectGroupId: 'pg-school', projectGroupOrder: 1 }) +const capstone = repo('capstone', 'capstone', { + kind: 'folder', + projectGroupId: 'pg-school', + projectGroupOrder: 0 +}) +const notes = repo('notes', 'notes', { kind: 'folder', projectGroupId: 'pg-folder-only' }) +export const GROUPED_REPOS = [lake, capstone, notes] +export const GROUPED_WORKTREES: AppState['worktreesByRepo'] = { + lake: [mainWt(lake), wt(lake, 'essay', { sortOrder: 5 })], + capstone: [mainWt(capstone, { branch: '' })], + notes: [mainWt(notes, { branch: '' })] +} +export const GROUPS_OF_GROUPED_REPOS = [ + group('pg-school', 'school', { tabOrder: -1, color: '#22c55e' }), + group('pg-folder-only', 'Folder only', { tabOrder: 5, parentGroupId: 'pg-school' }), + group('pg-empty', 'Empty group', { tabOrder: 6 }) +] 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 index 6664fc2ddea..836d5d697b3 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-parity.test-fixture.ts @@ -22,6 +22,11 @@ import type { import type { SmartStampFields } from '../../../../shared/sidebar-list/sort-stamps' import { buildPairedServerActivityFacts } from '../../../../shared/sidebar-list/paired-server-activity-facts' import { GOLDEN_NOW, folder, lineage } from './sidebar-list-golden-factories.test-fixture' +import { + GROUPED_REPOS, + GROUPED_WORKTREES, + GROUPS_OF_GROUPED_REPOS +} from './sidebar-list-parity-catalog.test-fixture' import { sidebarWorkspaceRowFilters } from './sidebar-filter-actions' import { readWindowWorkspaceActivityFacts, @@ -76,9 +81,13 @@ const STAMPS: Record): Partial { - const repoById = new Map((base.repos ?? []).map((repo) => [repo.id, repo])) + const repos = [...(base.repos ?? []), ...GROUPED_REPOS] + const repoById = new Map(repos.map((entry) => [entry.id, entry])) const worktreesByRepo: AppState['worktreesByRepo'] = {} - for (const [repoId, worktrees] of Object.entries(base.worktreesByRepo ?? {})) { + for (const [repoId, worktrees] of Object.entries({ + ...base.worktreesByRepo, + ...GROUPED_WORKTREES + })) { const repo = repoById.get(repoId) worktreesByRepo[repoId] = worktrees.map((worktree) => { const branch = worktree.branch.replace('refs/heads/', '') @@ -96,7 +105,8 @@ export function parityWorldOverrides(base: Partial): Partial const pending = Object.values(base.pendingWorktreeCreations ?? {})[0] const [serverMain, serverChild] = worktreesByRepo.api ?? [] // The repos, projects and server groups as the window's all-host fetch folds them. - const catalog = windowCatalogFromServerAnswers({ ...base, worktreesByRepo }) + const projectGroups = [...(base.projectGroups ?? []), ...GROUPS_OF_GROUPED_REPOS] + const catalog = windowCatalogFromServerAnswers({ ...base, repos, worktreesByRepo, projectGroups }) return { ...catalog, // A server workspace nested under the server's main, and a server folder workspace. @@ -179,9 +189,12 @@ export function parityServerBrowserTabState(): Partial { * The feed domains main would publish for this store, read through the window-side projections * the shadow comparison held equal to main's producers before the window read main's feed. */ -export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDomain[] { +export function feedDomainsFromWindowState( + state: AppState, + serverHostIds: ReadonlySet = new Set([SERVER_HOST_ID]) +): SidebarInputsFeedDomain[] { const worktrees = getAllWorktreesFromState(state) - const servers = readWindowPairedServerHosts(state, new Set([SERVER_HOST_ID])) + const servers = readWindowPairedServerHosts(state, serverHostIds) const windowActivity = readWindowWorkspaceActivityFacts(state, GOLDEN_NOW) const serverRows = readWindowPairedServerRows(state, new Set(servers.keys())) // The server reports the terminals and agents the desktop runs in its workspaces. @@ -189,6 +202,9 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo const facts = windowActivity.get(row.worktreeId) return { ...row, activity: { terminal: facts?.terminal ?? null, agent: facts?.agent ?? null } } }) + // Each server reports only its own rows, keyed `hostId|worktreeId`. + const rowsOf = (rows: readonly Row[], hostId: string): Row[] => + rows.filter((row) => row.key.startsWith(`${hostId}|`)) const domain = ( name: string, read: SidebarInputsFeedDomain['read'] @@ -211,7 +227,11 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo domain('activity', () => ({ // The window's browser and chat tabs, as its saved session for the server holds them. ...buildPairedServerActivityFacts( - [{ hostId: SERVER_HOST_ID, freshness: 'live', rows: reportedRows }], + [...servers.keys()].map((hostId) => ({ + hostId, + freshness: 'live' as const, + rows: rowsOf(reportedRows, hostId) + })), () => ({ browserTabsByWorktree: state.browserTabsByWorktree, unifiedTabs: state.unifiedTabsByWorktree @@ -221,7 +241,10 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo })), domain('review', () => ({ ...buildPairedServerReviewFacts( - [{ hostId: SERVER_HOST_ID, rows: [...serverRows.values()] }], + [...servers.keys()].map((hostId) => ({ + hostId, + rows: rowsOf([...serverRows.values()], hostId) + })), state.prCache ), ...Object.fromEntries( @@ -296,7 +319,7 @@ export function projectParityRows( ] case 'header': return [ - `HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'}` + `HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'} groupDepth=${row.projectGroupDepth ?? '-'}` ] case 'item': return [ diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts index 3ef999e41d1..51ab6f66d68 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts @@ -11,7 +11,8 @@ import { removeTabsAttention } from './subject-attention-transitions' import { markWorkspaceUnreadFlag, onScreenHostId, - settleWorkspaceUnreadFlag + settleWorkspaceUnreadFlag, + noteWorkspaceRead } from './workspace-unread-flag' import { workspaceMarkerTabIds } from './workspace-live-markers' import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets' @@ -119,6 +120,7 @@ export const createWorkspaceAttentionSlice: StateCreator< }, setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => { if (!isUnread) { + noteWorkspaceRead(worktreeId) removeAndDismiss(workspaceMarkerTabIds(get(), worktreeId)) } // Why: the toggle writes only the clicked host's row; the markers it removes are diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts index 688f0e137a8..96057d31d4e 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts @@ -138,6 +138,17 @@ describe('the window raises the unread flag only where its host does not', () => expect(isUnread(UNREACHABLE_SERVER_WT)).toBe(true) }) + it('drops a raise still waiting on a server’s first status once the user marks it read', async () => { + const { store, mark, isUnread } = createStore() + let answer: (capabilities: readonly string[]) => void = () => {} + hostCapabilities.set('slow', new Promise((resolve) => (answer = resolve))) + await mark(SLOW_SERVER_WT) + store.getState().setWorkspaceUnreadByUser(SLOW_SERVER_WT, false, 'runtime:slow') + answer([]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(isUnread(SLOW_SERVER_WT)).toBe(false) + }) + it('decides a folder workspace by the host of the row it raises', async () => { const { store, mark, updateFolderWorkspace } = createStore() const folder = folderWorkspaceKey(FOLDER_ID) @@ -154,6 +165,25 @@ describe('the window raises the unread flag only where its host does not', () => ) }) + it('raises the folder row it decided for, even if the screen moves to the other row meanwhile', async () => { + const { store, mark, updateFolderWorkspace } = createStore() + const folder = folderWorkspaceKey(FOLDER_ID) + let answer: (capabilities: readonly string[]) => void = () => {} + hostCapabilities.set('old', new Promise((resolve) => (answer = resolve))) + store.setState({ activeWorktreeId: folder, activeWorkspaceExecutionHostId: 'runtime:old' }) + await mark(folder) + store.setState({ activeWorkspaceExecutionHostId: 'runtime:new' }) + answer([]) + await vi.waitFor(() => + expect(updateFolderWorkspace).toHaveBeenCalledWith( + FOLDER_ID, + expect.objectContaining({ isUnread: true }), + { executionHostId: 'runtime:old' } + ) + ) + expect(updateFolderWorkspace).toHaveBeenCalledTimes(1) + }) + it('clears a flag on every owner, whichever side raised it', () => { const { store, isUnread } = createStore() for (const id of [LOCAL_WT, NEW_SERVER_WT, OLD_SERVER_WT]) { diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts index 7409a625491..d0e3cf46b06 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts @@ -12,12 +12,7 @@ import { persistPassiveWorktreeMetaForOwner } from '../worktrees/listing/worktre import type { WorkspaceAttentionGet, WorkspaceAttentionSet } from './workspace-attention-contract' import { workspaceHasLiveMarker } from './workspace-live-markers' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../shared/sidebar-inputs-switch' -import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../shared/workspace-unread-raise-capability' -import { - readCachedRuntimeEnvironmentCapability, - runtimeEnvironmentSupportsCapability -} from '@/runtime/runtime-rpc-client' -import { tryRuntimeTargetForWorktreeOwner } from '../worktrees/listing/worktree-owner-target' +import { ownerHostOwnsSidebarInputs } from '../worktrees/listing/owner-host-sidebar-inputs' // The persisted workspace flag `isUnread`. Only the workspace-attention module writes it. @@ -31,47 +26,47 @@ export function markWorkspaceUnreadFlag( return } // Switch point: the owning host raises the flag from the same news, unless it is older. - void ownerRaisesUnreadFlag(get(), worktreeId).then((ownerRaises) => { - if (!ownerRaises) { - raiseWorkspaceUnreadFlag(set, get, worktreeId) - } - }) -} - -/** - * Whether the owning host raises the flag itself. A local owner runs this build (main, its SSH - * hosts, or the host that served the browser client). A paired server answers by capability; an - * unreadable status raises here, so no flag is lost. - */ -async function ownerRaisesUnreadFlag(state: AppState, worktreeId: string): Promise { + // Why the row's host now: same-id folder rows differ by host, and the screen can move to the + // other row while the owner is asked. + const state = get() const workspaceScope = parseWorkspaceKey(worktreeId) const folderRow = workspaceScope?.type === 'folder' ? folderRowToMark(state, worktreeId, workspaceScope.folderWorkspaceId) : undefined - const target = tryRuntimeTargetForWorktreeOwner( - state, - worktreeId, - folderRow ? getFolderWorkspaceHostId(folderRow, state.projectGroups) : undefined - ) - if (!target) { - return false + const folderHostId = folderRow + ? getFolderWorkspaceHostId(folderRow, state.projectGroups) + : undefined + const pending = pendingRaises.get(worktreeId) ?? { raises: 0, reads: 0 } + pendingRaises.set(worktreeId, pending) + pending.raises += 1 + const readsBefore = pending.reads + void ownerHostOwnsSidebarInputs(state, worktreeId, folderHostId) + .then((ownerRaises) => { + // A read while the owner was being asked already covers this news. + if (!ownerRaises && pending.reads === readsBefore) { + raiseWorkspaceUnreadFlag(set, get, worktreeId, folderHostId) + } + }) + .finally(() => { + pending.raises -= 1 + if (pending.raises === 0) { + pendingRaises.delete(worktreeId) + } + }) +} + +/** Raises still waiting on their owner check, per workspace, with the reads seen meanwhile. Why + * not per host: a read names its row's host differently from the row's own host lookup (a + * group-hosted folder reads as 'local'), so a per-host count could miss the read it must see. */ +const pendingRaises = new Map() + +/** A read of the workspace by any path; a raise still waiting on its owner check drops. */ +export function noteWorkspaceRead(worktreeId: string): void { + const pending = pendingRaises.get(worktreeId) + if (pending) { + pending.reads += 1 } - if (target.kind === 'local') { - return true - } - // Why the cached answer first: a per-raise probe would delay the flag past a screen arrival. - const cached = readCachedRuntimeEnvironmentCapability( - target.environmentId, - WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY - ) - if (cached !== null) { - return cached - } - return runtimeEnvironmentSupportsCapability( - target.environmentId, - WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY - ).catch(() => false) } /** Same-id folder rows: the on-screen host's, else the first. */ @@ -80,7 +75,15 @@ function folderRowToMark( worktreeId: string, folderWorkspaceId: string ): FolderWorkspace | undefined { - const host = onScreenHostId(state, worktreeId) + return folderRowOnHost(state, folderWorkspaceId, onScreenHostId(state, worktreeId)) +} + +/** The folder row on `host`, or the first same-id row when no host is named. */ +function folderRowOnHost( + state: AppState, + folderWorkspaceId: string, + host: ExecutionHostId | null +): FolderWorkspace | undefined { return state.folderWorkspaces.find( (workspace) => workspace.id === folderWorkspaceId && @@ -91,14 +94,18 @@ function folderRowToMark( function raiseWorkspaceUnreadFlag( set: WorkspaceAttentionSet, get: WorkspaceAttentionGet, - worktreeId: string + worktreeId: string, + /** The folder row's host when the news arrived; unset picks the on-screen row now. */ + folderHostId?: ExecutionHostId ): void { const stamp = { lastActivityAt: Date.now() } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderWorkspaceId = workspaceScope.folderWorkspaceId const state = get() - const marked = folderRowToMark(state, worktreeId, folderWorkspaceId) + const marked = folderHostId + ? folderRowOnHost(state, folderWorkspaceId, folderHostId) + : folderRowToMark(state, worktreeId, folderWorkspaceId) if (!marked || marked.isUnread) { return } @@ -112,7 +119,9 @@ function raiseWorkspaceUnreadFlag( void get().updateFolderWorkspace( folderWorkspaceId, { isUnread: true, ...stamp }, - { executionHostId: getFolderWorkspaceHostId(marked, state.projectGroups) } + { + executionHostId: getFolderWorkspaceHostId(marked, state.projectGroups) + } ) return } @@ -164,6 +173,7 @@ export function clearWorkspaceUnreadFlag( /** Folder rows share an id across hosts: the on-screen host's row, or null for every row. */ executionHostId?: ExecutionHostId | null ): void { + noteWorkspaceRead(worktreeId) const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderWorkspaceId = workspaceScope.folderWorkspaceId diff --git a/src/renderer/src/store/slices/worktrees/listing/owner-host-sidebar-inputs.ts b/src/renderer/src/store/slices/worktrees/listing/owner-host-sidebar-inputs.ts new file mode 100644 index 00000000000..b9e550bea56 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/listing/owner-host-sidebar-inputs.ts @@ -0,0 +1,40 @@ +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../../shared/workspace-unread-raise-capability' +import { + readCachedRuntimeEnvironmentCapability, + runtimeEnvironmentSupportsCapability +} from '@/runtime/runtime-rpc-client' +import type { AppState } from '../../../types' +import { tryRuntimeTargetForWorktreeOwner } from './worktree-owner-target' + +/** + * Whether the workspace's owning host runs main's sidebar inputs, so it raises the unread flag + * itself. A local owner runs this build (main, its SSH hosts, or the host that served the browser + * client). A paired server answers by capability; an unresolved owner or an unreadable status + * counts as older, so this window raises the flag. + */ +export async function ownerHostOwnsSidebarInputs( + state: AppState, + worktreeId: string, + executionHostId?: ExecutionHostId +): Promise { + const target = tryRuntimeTargetForWorktreeOwner(state, worktreeId, executionHostId) + if (!target) { + return false + } + if (target.kind === 'local') { + return true + } + // Why the cached answer first: a per-write probe would delay the flag past a screen arrival. + const cached = readCachedRuntimeEnvironmentCapability( + target.environmentId, + WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY + ) + if (cached !== null) { + return cached + } + return runtimeEnvironmentSupportsCapability( + target.environmentId, + WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY + ).catch(() => false) +} diff --git a/tests/e2e/helpers/phone-feed-list.ts b/tests/e2e/helpers/phone-feed-list.ts index a058cf042c4..2bbcc030025 100644 --- a/tests/e2e/helpers/phone-feed-list.ts +++ b/tests/e2e/helpers/phone-feed-list.ts @@ -21,6 +21,8 @@ import { type WorkspaceViewSettings } from '../../../mobile/src/worktree/workspace-view-settings' import type { RpcResponse } from '../../../mobile/src/transport/types' +import { NODE_PLATFORM_NAMES } from '../../../mobile/src/transport/mobile-runtime-host-platform' +import type { LastVisitedWorktreeOnHost } from '../../../mobile/src/worktree/host-list-highlight' import type { Section } from '../../../mobile/src/worktree/workspace-list-types' import type { PairedMobileClient, PairedMobileSocket } from './paired-mobile-client' @@ -90,7 +92,11 @@ function socketEventClient(socket: PairedMobileSocket) { * refresh lifecycle, feed reader, the shared builder and its section mapping. Reads happen only * when that lifecycle makes them; an assertion reads what the page last installed. */ -export async function openPhoneFeedList(phone: PairedMobileClient) { +export async function openPhoneFeedList( + phone: PairedMobileClient, + /** The workspace this phone last opened on the host: its list's only highlight. */ + lastVisited: LastVisitedWorktreeOnHost | null = null +) { const socket = await phone.openSocket() const port = socketPort(socket) let read: HostSidebarFeedRead = { installed: null, unavailable: false } @@ -118,7 +124,10 @@ export async function openPhoneFeedList(phone: PairedMobileClient) { await port.sendRequest('host.platform'), hostPlatformRead.interpret ) - const hostPlatform = platformRead.kind === 'answered' ? platformRead.value : null + const answered = platformRead.kind === 'answered' ? platformRead.value : null + // Why matched, not read: this helper and the page may resolve different zod copies, so the + // reply's inferred type does not carry across. + const hostPlatform = NODE_PLATFORM_NAMES.find((name) => name === answered) ?? null await syncViewSettings() const stop = startHostWorktreeRefresh({ client: socketEventClient(socket), @@ -156,7 +165,7 @@ export async function openPhoneFeedList(phone: PairedMobileClient) { ) return buildHostFeedSections({ rows: built.sectionRows, - activeRowKeys: built.activeRowKeys, + lastVisited, liveRows: [], repoHostIdByRepoId: new Map(), search: '' @@ -186,6 +195,11 @@ export async function openPhoneFeedList(phone: PairedMobileClient) { } } +/** An outline without its highlight marks: the phone highlights its own last-opened workspace. */ +export function outlineRows(outline: ListOutline): ListOutline { + return outline.map((line) => line.replace(/ ACTIVE$/, '')) +} + /** The rows the desktop sidebar has rendered, in order, in the outline's terms. */ export async function desktopSidebarOutline(page: Page): Promise { return page.evaluate(() => { diff --git a/tests/e2e/helpers/sidebar-setup-prompt.ts b/tests/e2e/helpers/sidebar-setup-prompt.ts new file mode 100644 index 00000000000..239cc854e18 --- /dev/null +++ b/tests/e2e/helpers/sidebar-setup-prompt.ts @@ -0,0 +1,29 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { getRepoExecutionHostId } from '../../../src/shared/execution-host' +import { getRepoHostIdentityForParts } from '../../../src/shared/repo-host-identity' + +/** + * Dismisses the setup-script prompt for every loaded repo, as its Dismiss button does. The seeded + * repo's detected setup command shows the card over the sidebar's lower rows, and it re-inspects + * and reappears whenever the active workspace changes, so a visibility check can miss it. + */ +export async function dismissSetupScriptPrompts(page: Page): Promise { + const repos = await page.evaluate(() => + (window.__store?.getState().repos ?? []).map((repo) => ({ + id: repo.id, + connectionId: repo.connectionId ?? null, + executionHostId: repo.executionHostId ?? null + })) + ) + const identities = repos.map((repo) => + getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) + ) + await page.evaluate((keys) => { + const state = window.__store?.getState() + for (const key of keys) { + state?.dismissSetupScriptPrompt(key) + } + }, identities) + await expect(page.locator('[data-setup-script-prompt-layer]')).toHaveCount(0) +} diff --git a/tests/e2e/helpers/slept-workspace-probe.ts b/tests/e2e/helpers/slept-workspace-probe.ts index b2b5635b526..c2eabb119c5 100644 --- a/tests/e2e/helpers/slept-workspace-probe.ts +++ b/tests/e2e/helpers/slept-workspace-probe.ts @@ -7,6 +7,7 @@ import type { Locator, Page } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { ensureTerminalVisible } from './store' import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal' +import { dismissSetupScriptPrompts } from './sidebar-setup-prompt' export type WorkspaceSample = { livePtyCount: number @@ -111,6 +112,7 @@ export async function sleepWorkspaceViaSidebar(page: Page, worktreeId: string): const row = rowLocator(page, worktreeId) await expect(row).toBeVisible() await row.scrollIntoViewIfNeeded() + await dismissSetupScriptPrompts(page) const scope = row.locator('[data-worktree-context-menu-scope="worktree"]').first() const target = (await scope.count()) > 0 ? scope : row await target.click({ button: 'right' }) @@ -123,6 +125,7 @@ export async function activateWorkspaceByClick(page: Page, worktreeId: string): const row = rowLocator(page, worktreeId) await expect(row).toBeVisible() await row.scrollIntoViewIfNeeded() + await dismissSetupScriptPrompts(page) await row.click() await expect .poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null), { diff --git a/tests/e2e/sidebar-desktop-reads-main.spec.ts b/tests/e2e/sidebar-desktop-reads-main.spec.ts index dea4133cbba..8e3e82b4feb 100644 --- a/tests/e2e/sidebar-desktop-reads-main.spec.ts +++ b/tests/e2e/sidebar-desktop-reads-main.spec.ts @@ -1,6 +1,7 @@ /** * The desktop sidebar drawn from main's feed (ORCA_SIDEBAR_INPUTS=main): it follows main, not the - * window's own store, and a drag, create, open or collapse still lands in the frame it is made. + * window's own store, and a drag, create, open or collapse still shows at once from the window's + * own state. */ import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' @@ -25,45 +26,85 @@ function renderedOrder(page: Page): Promise { ) } -/** `sinceInput`: ms since the first pointerup or click after the log started. */ -type Frame = { ids: string[]; shows: boolean; sinceInput: number | null } +/** One committed sidebar DOM state. `afterInput`: committed once the first input began. + * `inInputTask`: committed within the task of an input, before any later task could run. */ +type DomState = { + ids: string[] + shows: boolean + current: string | null + afterInput: boolean + inInputTask: boolean +} -type FrameLog = { stop: () => Promise } +type DomLog = { stop: () => Promise<{ states: DomState[]; inputs: string[] }> } -/** Records, every animation frame, the rendered row ids and whether the sidebar shows `text`. */ -async function startFrameLog(page: Page, text = ''): Promise { +/** + * Records every committed state of the sidebar's DOM (rows top to bottom, whether it shows `text`, + * the current row) and whether it landed in the task of an input. Why that bound: the window + * applies its own state in the input's task, while main's answer needs an IPC reply, which is a + * later task, so it holds on any runner load. No paints: a hidden window may never paint. + */ +async function startDomLog(page: Page, text = ''): Promise { const handle = await page.evaluateHandle( ({ selector, wanted }) => { - const frames: Frame[] = [] - let running = true - let inputAt: number | null = null - const onInput = (): void => { - inputAt ??= performance.now() - } - window.addEventListener('pointerup', onInput, true) - window.addEventListener('mouseup', onInput, true) - window.addEventListener('click', onInput, true) - const sample = (): void => { - if (!running) { - return + const states: (Omit & { at: number })[] = [] + const inputs: { type: string; at: number; nextTaskAt: number }[] = [] + // Why the event's own time: the app's handlers can commit, and this log record the state, + // before this listener runs. A posted message runs in the next task, which ends the window. + const onInput = (event: Event): void => { + const input = { type: event.type, at: event.timeStamp, nextTaskAt: Infinity } + inputs.push(input) + const channel = new MessageChannel() + channel.port1.onmessage = () => { + input.nextTaskAt = performance.now() + channel.port1.close() } - const sidebar = document.querySelector('[data-worktree-sidebar]') - frames.push({ - ids: [...document.querySelectorAll(selector)] - .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top) - .map((row) => row.dataset.worktreeId ?? ''), - shows: wanted !== '' && (sidebar?.textContent ?? '').includes(wanted), - sinceInput: inputAt === null ? null : performance.now() - inputAt - }) - requestAnimationFrame(sample) + channel.port2.postMessage(null) } - requestAnimationFrame(sample) + // Why drop and dragend: a native drag ends in them, with no mouseup on Linux. + const events = ['pointerup', 'mouseup', 'click', 'drop', 'dragend'] + for (const type of events) { + window.addEventListener(type, onInput, true) + } + const sidebar = document.querySelector('[data-worktree-sidebar]') + const record = (): void => { + const rows = [...document.querySelectorAll(selector)].sort( + (a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top + ) + states.push({ + ids: rows.map((row) => row.dataset.worktreeId ?? ''), + shows: wanted !== '' && (sidebar?.textContent ?? '').includes(wanted), + current: + rows.find((row) => row.getAttribute('aria-current') === 'page')?.dataset.worktreeId ?? + null, + at: performance.now() + }) + } + // A mutation callback runs after each commit, so it sees every state React commits. + const observer = new MutationObserver(record) + if (sidebar) { + observer.observe(sidebar, { + subtree: true, + childList: true, + attributes: true, + characterData: true + }) + } + record() return () => { - running = false - window.removeEventListener('pointerup', onInput, true) - window.removeEventListener('mouseup', onInput, true) - window.removeEventListener('click', onInput, true) - return frames + observer.disconnect() + for (const type of events) { + window.removeEventListener(type, onInput, true) + } + const firstInputAt = inputs[0]?.at ?? Infinity + return { + states: states.map(({ at, ...state }) => ({ + ...state, + afterInput: at >= firstInputAt, + inInputTask: inputs.some((input) => input.at <= at && at <= input.nextTaskAt) + })), + inputs: inputs.map((input) => input.type) + } } }, { selector: ROWS, wanted: text } @@ -71,6 +112,11 @@ async function startFrameLog(page: Page, text = ''): Promise { return { stop: () => handle.evaluate((stop) => stop()) } } +/** The index of the first state after the input that satisfies `reached`, or -1. */ +function firstAfterInput(states: DomState[], reached: (state: DomState) => boolean): number { + return states.findIndex((state) => state.afterInput && reached(state)) +} + async function dragBefore(page: Page, sourceId: string, targetId: string): Promise { const source = page.locator(`${ROWS}[data-worktree-id=${JSON.stringify(sourceId)}]`).first() const target = page.locator(`${ROWS}[data-worktree-id=${JSON.stringify(targetId)}]`).first() @@ -109,42 +155,53 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps await expect.poll(async () => (await ours()).length, { timeout: 30_000 }).toBe(3) await orcaPage.screenshot({ path: testInfo.outputPath(`list.png`) }) - // Empty the window's own catalog for one frame: the rows still listed come from main. No - // refetch can land within the frame. - const listedInEmptiedFrame = await orcaPage.evaluate(async (selector) => { + // Empty the window's own catalog for one task: the rows still listed come from main. No + // refetch can answer before the next task. + const listedWhileEmptied = await orcaPage.evaluate(async (selector) => { window.__store!.setState({ worktreesByRepo: {} }) - await new Promise((resolve) => requestAnimationFrame(resolve)) + await new Promise((resolve) => setTimeout(resolve, 0)) return document.querySelectorAll(selector).length }, ROWS) - expect(listedInEmptiedFrame).toBeGreaterThan(0) + expect(listedWhileEmptied).toBeGreaterThan(0) await orcaPage.evaluate(() => window.__store!.getState().fetchAllWorktrees()) await expect.poll(async () => (await ours()).length).toBe(3) - // Drag: every frame after the drop shows the new order; none snaps back. + // Drag: the new order shows in the drop's own task, and no later state snaps back. const before = await ours() const source = before[2]! - const dragLog = await startFrameLog(orcaPage) + const dragLog = await startDomLog(orcaPage) await dragBefore(orcaPage, source, before[0]!) const expected = [source, before[0]!, before[1]!] await expect.poll(ours).toEqual(expected) await orcaPage.waitForTimeout(400) - // From the first frame painted after the drop, every frame shows the new order. - const afterDrop = (await dragLog.stop()) - .filter(({ sinceInput }) => sinceInput !== null) - .map(({ ids }) => ids.filter((id) => created.includes(id)).join()) - expect(afterDrop.length).toBeGreaterThan(0) - expect(new Set(afterDrop)).toEqual(new Set([expected.join()])) + const drag = await dragLog.stop() + const oursIn = (state: DomState): string => + state.ids.filter((id) => created.includes(id)).join() + const dropShown = firstAfterInput(drag.states, (state) => oursIn(state) === expected.join()) + expect(dropShown, `inputs seen: ${drag.inputs.join() || 'none'}`).toBeGreaterThanOrEqual(0) + expect(drag.states[dropShown]!.inInputTask).toBe(true) + expect( + drag.states + .slice(dropShown) + .map(oursIn) + .filter((order) => order !== expected.join()) + ).toEqual([]) await orcaPage.screenshot({ path: testInfo.outputPath(`after-drag.png`) }) - // Open: the clicked row is highlighted in the frame of the click. + // Open: the clicked row is highlighted in the click's own task. const openId = expected[1]! + const openLog = await startDomLog(orcaPage) await orcaPage .locator(`${ROWS}[data-worktree-id=${JSON.stringify(openId)}]`) .first() .click() await expect( orcaPage.locator(`${ROWS}[data-worktree-id=${JSON.stringify(openId)}][aria-current="page"]`) - ).toHaveCount(1, { timeout: 1_000 }) + ).toHaveCount(1) + const openStates = (await openLog.stop()).states + const opened = firstAfterInput(openStates, (state) => state.current === openId) + expect(opened).toBeGreaterThanOrEqual(0) + expect(openStates[opened]!.inInputTask).toBe(true) // Cmd/Ctrl+1–9 number the rendered rows. const rendered = await renderedOrder(orcaPage) @@ -154,29 +211,27 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps .poll(() => orcaPage.evaluate(() => window.__store!.getState().activeWorktreeId)) .toBe(rendered[1]) - // Collapse: the section's rows leave in the next frame, without waiting for main. + // Collapse: the section's rows leave in the click's own task, without waiting for main. await orcaPage.evaluate(() => window.__store!.getState().setGroupBy('repo')) await expect.poll(async () => (await ours()).length).toBe(3) const header = orcaPage.locator('[data-worktree-sidebar] [data-repo-header-id][aria-expanded]') - const collapseLog = await startFrameLog(orcaPage) + const collapseLog = await startDomLog(orcaPage) await header.first().click() await expect.poll(async () => (await ours()).length).toBe(0) - // A slow runner can settle the poll before any frame after the click is painted. - await orcaPage.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))) - const collapseFrames = await collapseLog.stop() - // The first frame painted after the click already has no rows. - expect( - collapseFrames - .find(({ sinceInput }) => sinceInput !== null) - ?.ids.some((id) => created.includes(id)) - ).toBe(false) + const collapseStates = (await collapseLog.stop()).states + const collapsed = firstAfterInput( + collapseStates, + (state) => !state.ids.some((id) => created.includes(id)) + ) + expect(collapsed).toBeGreaterThanOrEqual(0) + expect(collapseStates[collapsed]!.inInputTask).toBe(true) await header.first().click() await expect.poll(async () => (await ours()).length).toBe(3) - // Create: the pending row hands over to the new row with no frame showing neither. + // Create: the pending row hands over to the new row with no state showing neither. await orcaPage.evaluate(() => window.__store!.getState().setGroupBy('none')) const name = `reads-main-new-${Date.now()}` - const createLog = await startFrameLog(orcaPage, name) + const createLog = await startDomLog(orcaPage, name) await openSidebarWorkspaceComposer(orcaPage) const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) await dialog.getByPlaceholder(/Type a name/i).fill(name) @@ -195,10 +250,14 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps created.push(newId) await expect.poll(async () => (await renderedOrder(orcaPage)).includes(newId)).toBe(true) await orcaPage.waitForTimeout(300) - const createFrames = await createLog.stop() - const pendingShown = createFrames.findIndex(({ shows }) => shows) + const createStates = (await createLog.stop()).states + const pendingShown = createStates.findIndex(({ shows }) => shows) expect(pendingShown).toBeGreaterThanOrEqual(0) - const handover = createFrames.slice(pendingShown) + // The name shows as the pending row before the new row exists. + expect(createStates[pendingShown]!.ids).not.toContain(newId) + // The create click's own task lists the pending row, without waiting for main. + expect(createStates[pendingShown]!.inInputTask).toBe(true) + const handover = createStates.slice(pendingShown) expect(handover.filter(({ ids, shows }) => !shows && !ids.includes(newId))).toEqual([]) const appeared = handover.findIndex(({ ids }) => ids.includes(newId)) expect(handover.slice(appeared).every(({ ids }) => ids.includes(newId))).toBe(true) @@ -237,17 +296,24 @@ test('the sidebar lists main’s inputs and keeps drag, create, open and collaps ) .toBe(true) await scroller.evaluate((element) => element.scrollTo({ top: element.scrollHeight })) - const lastFullyShown = (): Promise => + // The list's last row, once painted at the bottom: rows render after the scroll, so until + // then the last mounted row sits mid-viewport, within overscan of the top. + const lastRowAtBottom = (): Promise => scroller.evaluate((element, selector) => { const box = element.getBoundingClientRect() - const shown = [...element.querySelectorAll(selector)].filter((row) => { - const rect = row.getBoundingClientRect() - return rect.top >= box.top && rect.bottom <= box.bottom + 1 - }) - return shown.at(-1)?.dataset.worktreeId ?? '' + const last = [...element.querySelectorAll(selector)] + .map((row) => ({ row, rect: row.getBoundingClientRect() })) + .sort((left, right) => left.rect.top - right.rect.top) + .at(-1) + if (!last || last.rect.top < box.top || last.rect.bottom > box.bottom + 1) { + return '' + } + return box.bottom - last.rect.bottom < last.rect.height + ? (last.row.dataset.worktreeId ?? '') + : '' }, ROWS) - await expect.poll(lastFullyShown).not.toBe('') - const bottomId = await lastFullyShown() + await expect.poll(lastRowAtBottom).not.toBe('') + const bottomId = await lastRowAtBottom() const bottomRow = orcaPage.locator(`${ROWS}[data-worktree-id=${JSON.stringify(bottomId)}]`) await expect(bottomRow).toBeInViewport() await scroller.evaluate((element) => element.scrollTo({ top: 0 })) diff --git a/tests/e2e/sidebar-phone-feed-list.spec.ts b/tests/e2e/sidebar-phone-feed-list.spec.ts index ccc8fab0d3b..427260bc625 100644 --- a/tests/e2e/sidebar-phone-feed-list.spec.ts +++ b/tests/e2e/sidebar-phone-feed-list.spec.ts @@ -3,7 +3,8 @@ * matches the desktop sidebar row for row: headers, order, nesting, the single highlight and * collapse. Desktop, then a desktop that is a paired server's client. */ -import { mkdirSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' import { execFileSync } from 'node:child_process' import path from 'node:path' import type { Page } from '@stablyai/playwright-test' @@ -16,6 +17,7 @@ import { pairMobileClient } from './helpers/paired-mobile-client' import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology' import { desktopSidebarOutline, + outlineRows, openPhoneFeedList, type ListOutline } from './helpers/phone-feed-list' @@ -25,7 +27,12 @@ type PhoneList = Awaited> const AddedRepoSchema = z.object({ repo: z.object({ id: z.string() }) }) /** Both sides inside one poll: either may still be catching up with the last change. */ -async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise { +/** Waits until both sides agree on a list that already shows the change (`showsChange`). */ +async function expectPhoneMatchesDesktop( + page: Page, + phone: PhoneList, + showsChange: (outline: ListOutline) => boolean = () => true +): Promise { let desktop: ListOutline = [] let shown: ListOutline = [] await expect @@ -33,12 +40,15 @@ async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise< async () => { desktop = await desktopSidebarOutline(page) shown = await phone.outline() - return shown.join('\n') === desktop.join('\n') + // Why the change too: the phone hears a change later, so both still agree on the old list. + return ( + outlineRows(shown).join('\n') === outlineRows(desktop).join('\n') && showsChange(shown) + ) }, { timeout: 30_000 } ) .toBe(true) - .catch(() => expect(shown).toEqual(desktop)) + .catch(() => expect(outlineRows(shown)).toEqual(outlineRows(desktop))) expect(shown.some((line) => line.startsWith('ITEM '))).toBe(true) return shown } @@ -77,22 +87,31 @@ test.describe('switch on', () => { setupDecision: 'skip' }) } - const list = await openPhoneFeedList(phone) + // The phone last opened the desktop's workspace, so it highlights that row. + const list = await openPhoneFeedList(phone, { worktreeId: activeId }) try { const first = await expectPhoneMatchesDesktop(orcaPage, list) expect(first.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1) // A pin on the desktop reaches the phone through a notice, with a Pinned section. await runtimeCall(orcaPage, 'worktree.set', { worktree: `id:${activeId}`, isPinned: true }) - const pinned = await expectPhoneMatchesDesktop(orcaPage, list) - expect(pinned.some((line) => line.startsWith('HEADER Pinned'))).toBe(true) + const hasPinned = (outline: ListOutline): boolean => + outline.some((line) => line.startsWith('HEADER Pinned')) + const pinned = await expectPhoneMatchesDesktop(orcaPage, list, hasPinned) + expect(hasPinned(pinned)).toBe(true) // Pinned rows also in their groups: the open workspace highlights at both. await orcaPage.evaluate(() => window.__store!.getState().updateSettings({ showPinnedWorktreesInGroups: true }) ) - const duplicated = await expectPhoneMatchesDesktop(orcaPage, list) - expect(duplicated.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(2) + const activeCount = (outline: ListOutline): number => + outline.filter((line) => line.endsWith(' ACTIVE')).length + const duplicated = await expectPhoneMatchesDesktop( + orcaPage, + list, + (outline) => activeCount(outline) === 2 + ) + expect(activeCount(duplicated)).toBe(2) // The phone collapses one section with ui.setCollapsed; the desktop and the phone both fold it. const project = (await list.sections())?.find((section) => section.repo) @@ -107,7 +126,11 @@ test.describe('switch on', () => { await expect .poll(() => orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups])) .toEqual([...before, project!.key]) - const folded = await expectPhoneMatchesDesktop(orcaPage, list) + const folded = await expectPhoneMatchesDesktop( + orcaPage, + list, + (outline) => outline.length < duplicated.length + ) const foldedSection = (await list.sections())?.find((section) => section.key === project!.key) expect(foldedSection?.data).toEqual([]) expect(foldedSection?.count).toBeGreaterThan(0) @@ -121,14 +144,120 @@ test.describe('switch on', () => { opId: { writer: 'e2e-phone', seq: 2 } }) ).toMatchObject({ ok: true }) - await expectPhoneMatchesDesktop(orcaPage, list) + await expectPhoneMatchesDesktop( + orcaPage, + list, + (outline) => outline.length === duplicated.length + ) const unfolded = (await list.sections())?.find((section) => section.key === project!.key) expect(unfolded?.data.length).toBeGreaterThan(0) // Unpinned on the desktop: the Pinned section goes and one highlight is left. await runtimeCall(orcaPage, 'worktree.set', { worktree: `id:${activeId}`, isPinned: false }) - const unpinned = await expectPhoneMatchesDesktop(orcaPage, list) - expect(unpinned.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1) + const unpinned = await expectPhoneMatchesDesktop( + orcaPage, + list, + (outline) => !hasPinned(outline) + ) + expect(activeCount(unpinned)).toBe(1) + } finally { + list.close() + phone.dispose() + } + }) + + test('the phone files grouped projects under the desktop’s project group, and folds it with the desktop', async ({ + orcaPage, + registerPostElectronShutdownCleanup + }) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const activeId = await waitForActiveWorktree(orcaPage) + const repoId = activeId.split('::')[0] + const folderPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-phone-group-'))) + registerPostElectronShutdownCleanup(async () => + rmSync(folderPath, { recursive: true, force: true }) + ) + const folder = AddedRepoSchema.parse( + await runtimeCall(orcaPage, 'repo.add', { path: folderPath, kind: 'folder' }) + ) + await expect + .poll(() => + orcaPage.evaluate( + (id) => window.__store!.getState().repos.some((entry) => entry.id === id), + folder.repo.id + ) + ) + .toBe(true) + // A group holding a repo project and a folder project, the folder project first. + const moved = await orcaPage.evaluate( + async ({ repoId, folderId }) => { + const state = () => window.__store!.getState() + const group = await state().createProjectGroup('school', 'local') + if (!group) { + throw new Error('Expected a project group') + } + const repoById = (id: string) => { + const found = state().repos.find((entry) => entry.id === id) + if (!found) { + throw new Error(`Expected repo ${id}`) + } + return found + } + return [ + await state().moveProjectToGroup(repoById(folderId), group.id, 0), + await state().moveProjectToGroup(repoById(repoId), group.id, 1) + ] + }, + { repoId, folderId: folder.repo.id } + ) + expect(moved).toEqual([true, true]) + const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage)) + const list = await openPhoneFeedList(phone) + try { + // The group, then the folder project, then the repo project's rows: the desktop's order. + const inGroup = (outline: ListOutline): boolean => { + const at = [ + outline.indexOf('HEADER school'), + outline.indexOf(`HEADER ${path.basename(folderPath)}`), + outline.findIndex((line) => line.startsWith('ITEM ') && line.includes(repoId)) + ] + return at[0] !== -1 && at[0] < at[1] && at[1] < at[2] + } + const shown = await expectPhoneMatchesDesktop(orcaPage, list, inGroup) + expect(inGroup(shown)).toBe(true) + const sections = (await list.sections()) ?? [] + const groupAt = sections.findIndex((section) => section.title === 'school') + expect(sections[groupAt]).toMatchObject({ isProjectGroup: true }) + // The folder project, then the repo project, both one level in. + expect( + sections + .slice(groupAt + 1, groupAt + 3) + .map((section) => [section.repo?.id, section.projectGroupDepth]) + ).toEqual([ + [folder.repo.id, 1], + [repoId, 1] + ]) + + // The phone folds the group; the desktop folds it too, and both hide its projects. + const key = sections[groupAt]!.key + expect( + await list.request('ui.setCollapsed', { + key, + collapsed: true, + opId: { writer: 'e2e-phone-group', seq: 1 } + }) + ).toMatchObject({ ok: true }) + // Every project sits in the group, so both lists fold down to its header alone. + await expect + .poll(async () => [await list.outline(), await desktopSidebarOutline(orcaPage)], { + timeout: 30_000 + }) + .toEqual([['HEADER school'], ['HEADER school']]) + + // The desktop unfolds it; the phone follows. + await orcaPage.evaluate((key) => window.__store!.getState().toggleCollapsedGroup(key), key) + await expectPhoneMatchesDesktop(orcaPage, list, (outline) => outline.length === shown.length) } finally { list.close() phone.dispose() diff --git a/tests/e2e/sidebar-sort-stamps.spec.ts b/tests/e2e/sidebar-sort-stamps.spec.ts index 322732c84e2..c9fef7c152a 100644 --- a/tests/e2e/sidebar-sort-stamps.spec.ts +++ b/tests/e2e/sidebar-sort-stamps.spec.ts @@ -3,7 +3,13 @@ import { test, expect } from './helpers/orca-app' import { postClaudeHook, readHookEndpoint } from './helpers/agent-hook-endpoint' import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import { + execInTerminal, + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' import { RECENT_STAMP_THROTTLE_MS } from '../../src/shared/sidebar-list/sort-stamps' import { WorkspaceRowsSnapshotSchema, @@ -36,6 +42,14 @@ test('the host stamps a workspace as its agent is prompted, finishes and asks fo await waitForActiveTerminalManager(orcaPage, 30_000) const endpoint = await readHookEndpoint(electronApp) const pane = await waitForActivePaneHookDescriptor(orcaPage) + // A terminal spawning in the pane clears what an earlier agent there reported, so hooks posted + // before it spawns are lost; wait for it, and hold its foreground as the agent would. + await execInTerminal( + orcaPage, + await waitForActivePanePtyId(orcaPage), + "printf 'agent-%s\\n' up; sleep 600" + ) + await waitForTerminalOutput(orcaPage, 'agent-up', 20_000) const rowKey = `local|${pane.worktreeId}` await expect.poll(async () => (await readMainRow(orcaPage, rowKey))?.kind).toBe('git') const before = (await readMainRow(orcaPage, rowKey))! diff --git a/tests/e2e/ssh-orcad-phone-feed-list.spec.ts b/tests/e2e/ssh-orcad-phone-feed-list.spec.ts index de98ac96c5e..07b1fed7231 100644 --- a/tests/e2e/ssh-orcad-phone-feed-list.spec.ts +++ b/tests/e2e/ssh-orcad-phone-feed-list.spec.ts @@ -8,6 +8,7 @@ */ import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' import { launchManagedWorkspace, skipUnlessManagedDockerHost, @@ -17,6 +18,7 @@ import { createDesktopMobilePairingOffer } from './helpers/paired-electron-clien import { pairMobileClient } from './helpers/paired-mobile-client' import { desktopSidebarOutline, + outlineRows, openPhoneFeedList, type ListOutline } from './helpers/phone-feed-list' @@ -39,12 +41,12 @@ async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise< async () => { desktop = await desktopSidebarOutline(page) shown = await phone.outline() - return shown.join('\n') === desktop.join('\n') + return outlineRows(shown).join('\n') === outlineRows(desktop).join('\n') }, { timeout: 60_000 } ) .toBe(true) - .catch(() => expect(shown).toEqual(desktop)) + .catch(() => expect(outlineRows(shown)).toEqual(outlineRows(desktop))) return shown } @@ -52,7 +54,7 @@ test("the phone lists the desktop's managed SSH workspace as the desktop sidebar {}, testInfo) => { test.setTimeout(12 * 60_000) await withManagedHost(testInfo, async (fixture) => { - const { page, seeded } = await launchManagedWorkspace(fixture) + const { page, seeded, environmentId } = await launchManagedWorkspace(fixture) const sshRow = (outline: ListOutline): string[] => outline.filter((line) => line.startsWith('ITEM ') && line.includes(seeded.repoPath)) // The desktop draws the SSH workspace before the phone is asked to match it. @@ -61,7 +63,11 @@ test("the phone lists the desktop's managed SSH workspace as the desktop sidebar .toHaveLength(1) const phone = pairMobileClient(await createDesktopMobilePairingOffer(page)) - const list = await openPhoneFeedList(phone) + // The phone last opened the seeded workspace, on its managed server, so it highlights that row. + const list = await openPhoneFeedList(phone, { + worktreeId: seeded.worktreeId, + executionHost: toRuntimeExecutionHostId(environmentId) + }) try { const shown = await expectPhoneMatchesDesktop(page, list) expect(sshRow(shown)).toEqual([expect.stringMatching(/ ACTIVE$/)]) diff --git a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts index 622255020b0..315e196aa2d 100644 --- a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts @@ -10,6 +10,7 @@ import { linkWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' +import { dismissSetupScriptPrompts } from './helpers/sidebar-setup-prompt' import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state' test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) @@ -286,6 +287,7 @@ test.describe('Toggle Child Workspaces shortcut', () => { const folder = sidebarWorktreeRow(orcaPage, folderKey) await expect(folder).toBeVisible() await expect(folder.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + await dismissSetupScriptPrompts(orcaPage) await sidebarWorktreeRow(orcaPage, parentId).click() await orcaPage.locator('[data-worktree-sidebar]').focus() await folder.hover()