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.
This commit is contained in:
Jinwoo Hong
2026-10-21 12:24:27 -04:00
committed by GitHub
parent 50b07c0e8f
commit 92bbb380a6
53 changed files with 2404 additions and 299 deletions
+3 -1
View File
@@ -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({
<Pressable
style={({ pressed }) => [
styles.worktreeRow,
lineageDepth > 0 && { paddingLeft: spacing.lg + lineageDepth * 18 },
indent > 0 && { paddingLeft: spacing.lg + indent },
item.isActive && styles.worktreeRowActive,
pressed && styles.worktreeRowPressed
]}
@@ -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<string>()
const FOLDED = new Set(['all'])
function mount(sections: Sections, collapsedGroups: ReadonlySet<string> = NONE) {
const scrollToLocation = vi.fn()
function Probe(props: { sections: Sections; collapsedGroups: ReadonlySet<string> }): 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<string> = 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)
})
})
+18 -10
View File
@@ -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<T extends WithId, S>(
sections: ReadonlyArray<SectionListData<T, S> & { data: readonly T[] }>
sections: readonly (SectionListData<T, S> & { data: readonly T[] })[],
/** The folded sections: a highlight an unfold first reveals is not scrolled to either. */
collapsedGroups?: ReadonlySet<string>
): {
sectionListRef: React.RefObject<SectionList<T, S> | null>
onScrollToIndexFailed: (info: { averageItemLength: number }) => void
} {
const sectionListRef = useRef<SectionList<T, S>>(null)
const lastScrolledActiveIdRef = useRef<string | null>(null)
// The last workspace highlighted, scrolled to or not: a hidden row coming back is no change.
const seenActiveIdRef = useRef<string | null>(null)
const collapsedGroupsRef = useRef(collapsedGroups)
const activeWorktreeId = useMemo(() => {
for (const section of sections) {
@@ -53,13 +58,16 @@ export function useActiveWorktreeScroll<T extends WithId, S>(
)
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 }) => {
@@ -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<string, unknown>([
['|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<void> {
await act(async () => {
renderers.push(create(createElement(List, { hostId })))
})
}
async function read(next: HostSidebarFeedRead): Promise<void> {
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')
})
})
@@ -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,
@@ -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<typeof liveRow>[]
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<typeof sectionsFor>): 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> = {}): 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<typeof sectionsFor>) =>
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', () => {
@@ -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<string>
/** 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<string, ExecutionHostId>
@@ -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<string>()
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<string, ExecutionHostId>
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 }] : []
}
@@ -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<typeof HostWorkspaceList>[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([])
})
})
+24 -10
View File
@@ -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' ? (
<HostWorkspaceListReopen />
<HostWorkspaceListReopen aboveRows={sections.length > 0} />
) : (
<HostWorkspaceListStates
connState={connState}
@@ -150,7 +154,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
/>
)}
{sections.length > 0 && controller.feed?.kind !== 'reopen' && (
{sections.length > 0 && (
<SectionList
ref={activeWorktreeScroll.sectionListRef}
sections={sections}
@@ -189,7 +193,15 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
return (
<>
<Pressable
style={styles.sectionHeader}
style={[
styles.sectionHeader,
section.projectGroupDepth
? {
paddingLeft:
spacing.lg + projectGroupHeaderIndent(section.projectGroupDepth)
}
: null
]}
onPress={() => settings.toggleCollapsed(section.key)}
>
{isCollapsed ? (
@@ -200,7 +212,9 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
{section.icon === 'pin' && (
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{state.view.groupBy === 'repo' ? (
{state.view.groupBy === 'repo' && section.isProjectGroup ? (
<FolderTree size={12} color={colors.textPrimary} style={styles.sectionIcon} />
) : state.view.groupBy === 'repo' ? (
<View style={styles.sectionRepoIcon}>
<MobileRepoIcon
repoIcon={repoSectionIcon}
@@ -215,7 +229,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
<ActivityIndicator
size="small"
color={colors.textMuted}
style={styles.sectionIcon}
style={styles.sectionPendingSpinner}
/>
)}
</Pressable>
@@ -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])
@@ -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,
@@ -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<string, InstalledSidebarInputs>()
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<HostSidebarFeedReader | null>(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
@@ -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') {
@@ -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<string> = new Set()
function Probe({ pending }: { pending: ReadonlySet<string> }) {
latest = useSlowPendingKeys(pending)
return null
}
function mount(pending: ReadonlySet<string>): 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<string>): 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'])
})
})
@@ -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<string> = new Set()
/** The pending keys the desktop has not answered within `delayMs` of each first going pending. */
export function useSlowPendingKeys(
pendingKeys: ReadonlySet<string>,
delayMs: number = SLOW_PENDING_MS
): ReadonlySet<string> {
const pendingSinceRef = useRef(new Map<string, number>())
const [slowKeys, setSlowKeys] = useState<ReadonlySet<string>>(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<typeof setTimeout>[] = []
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])
}
@@ -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"]'
})
})
@@ -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'
@@ -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 {
@@ -62,9 +62,25 @@ export async function readMirroredKeyOrStore(key: string): Promise<string | null
return held === undefined ? AsyncStorage.getItem(key) : held
}
type MirroredKeyListener = (value: string | null) => void
const keyListeners = new Map<string, Set<MirroredKeyListener>>()
/** 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<MirroredKeyListener>()
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)
}
}
/**
@@ -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<Worktree, 'worktreeId' | 'hostId'>,
visited: LastVisitedWorktreeOnHost | null
): boolean {
return (
visited !== null &&
row.worktreeId === visited.worktreeId &&
routeExecutionHostOf(row.hostId) === visited.executionHost
)
}
@@ -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 (
<View style={styles.centered}>
<View style={aboveRows ? styles.aboveRows : styles.centered}>
<Text style={styles.emptyText}>Reopen to update</Text>
<Text style={styles.catalogErrorDetail}>
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
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
type StorageDouble = { rejection: unknown; values: Map<string, string> }
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<void>((resolve) => setImmediate(resolve))
} finally {
process.off('unhandledRejection', listener)
storage.rejection = null
}
expect(seen).toEqual([])
})
})
@@ -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<string, unknown> {
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<string, LastVisitedWorktreeOnHost> {
const byHost = new Map<string, LastVisitedWorktreeOnHost>()
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<Listener>()
/** 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<void> = 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<void> {
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
}
@@ -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<unknown[]> {
const seen: unknown[] = []
const listener = (reason: unknown) => seen.push(reason)
process.on('unhandledRejection', listener)
try {
run()
await new Promise<void>((resolve) => setImmediate(resolve))
await new Promise<void>((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<void>((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' })
@@ -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(() => {})
}
@@ -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])
})
})
@@ -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<Worktree, 'workspaceKind' | 'projectGroupDepth' | 'inFolderScanGroup'>
): 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
}
@@ -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<string, string>()
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<PageModules> {
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<void>
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()
})
})
@@ -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
}
@@ -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. */
+4
View File
@@ -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)
}
}
}
@@ -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)
})
})
+20
View File
@@ -196,6 +196,26 @@ export async function stopAndWaitPtyFromRuntimeController(
deps: PtyRuntimeControllerDeps,
ptyId: string,
opts?: { keepHistory?: boolean; deadlineMs?: number }
): Promise<boolean> {
// 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<boolean> {
const {
runtime,
@@ -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 }
})
})
@@ -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<ResolvedWorktreeSnapshot> {
return { worktrees: [worktree(WT)], platformByRepoId: new Map() }
}
}
type Host = NonNullable<OrcaRuntimeService['sidebarFacts']>
/** 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<Host> {
const store: Pick<RuntimeStore, 'getRepos' | 'getUI'> = {
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<Host> {
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')
})
})
@@ -33,6 +33,7 @@ export abstract class OrcaRuntimeWithWorkspaceActivityFacts extends OrcaRuntimeW
override syncWindowGraph(...args: Parameters<Runtime['syncWindowGraph']>) {
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<string> {
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<string, { worktreeId: string; ptyId: string | null }>,
after: ReadonlyMap<string, { worktreeId: string; ptyId: string | null }>
): Set<string> {
const changed = new Set<string>()
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
}
@@ -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<typeof fakeHosts>['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')]))
@@ -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.
@@ -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)
}
@@ -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 })
]
@@ -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<string, SmartStampFields & { lastActivityAt?: number; isUnr
* this host runs.
*/
export function parityWorldOverrides(base: Partial<AppState>): Partial<AppState> {
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<AppState>): Partial<AppState>
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<AppState> {
* 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<string> = 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 = <Row extends { key: string }>(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 [
@@ -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
@@ -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]) {
@@ -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<boolean> {
// 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<string, { raises: number; reads: number }>()
/** 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
@@ -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<boolean> {
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)
}
+17 -3
View File
@@ -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<ListOutline> {
return page.evaluate(() => {
+29
View File
@@ -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<void> {
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)
}
@@ -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), {
+138 -72
View File
@@ -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<string[]> {
)
}
/** `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<Frame[]> }
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<FrameLog> {
/**
* 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<DomLog> {
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<DomState, 'afterInput' | 'inInputTask'> & { 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<HTMLElement>(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<HTMLElement>(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<FrameLog> {
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<void> {
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<string> =>
// 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<string> =>
scroller.evaluate((element, selector) => {
const box = element.getBoundingClientRect()
const shown = [...element.querySelectorAll<HTMLElement>(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<HTMLElement>(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 }))
+142 -13
View File
@@ -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<ReturnType<typeof openPhoneFeedList>>
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<ListOutline> {
/** 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<ListOutline> {
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()
+15 -1
View File
@@ -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))!
+10 -4
View File
@@ -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$/)])
@@ -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()