feat(sidebar): the phone reads its workspace list from the sidebar inputs (dark) (#30462)

* feat(mobile): the phone page lists workspaces from the host's sidebar feed (dark)

* fix(mobile): settle pending option changes by latest intent; feed carries the desktop's focused host and host names (dark)

* fix(sidebar): the feed carries the desktop's pinned placement; e2e compares the phone list to the desktop sidebar (dark)

* test(sidebar): the phone list e2e also expands and unpins

* fix(sidebar): phone feed list review fixes (dark)

A collapsed or worktree.ps-failing feed list no longer shows an empty or error state; a queued worktree read uses the latest client; a superseded option change does not report a refusal; the window shadow leaves the focused host to main's test (focus-read ratchet).

* fix(sidebar): phone feed tests typecheck; e2e helper no longer pulls the native runtime types

Mobile test fixtures fill the fields the test project's types require; the feed trigger type lives with the feed reader, so the e2e helper's imports stop dragging react-native globals into the e2e program; React Doctor's two blocking findings.
This commit is contained in:
Jinwoo Hong
2026-10-18 23:41:32 -04:00
committed by GitHub
parent 4fc1855bda
commit 88241bcd1e
58 changed files with 2659 additions and 172 deletions
@@ -34,6 +34,8 @@ const entryPoint = join(mobileDir, 'web-entry', 'index.tsx')
const { MOBILE_WEB_BUNDLE_DIR: defaultOutDir } = createRequire(import.meta.url)(
'./verify-packaged-mobile-web-bundle.cjs'
)
// The desktop serves this page, so a dark switch the desktop was built with reaches it too.
const { turnedOnDarkBuildDefines } = createRequire(import.meta.url)('../dark-build-switches.cjs')
/**
* Every shim the app bundle needs, each one a documented Metro/RN-Web gap. `appliesTo` reads the
@@ -347,7 +349,9 @@ export function mobileWebAppBuildOptions(routes) {
__DEV__: 'false',
'process.env.NODE_ENV': '"production"',
'process.env.EXPO_OS': '"web"',
'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"'
'process.env.EXPO_ROUTER_IMPORT_MODE': '"sync"',
// Only a switch asked for: an off build stays byte-identical, and its switch reads off.
...turnedOnDarkBuildDefines()
}
}
}
@@ -60,6 +60,12 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
color: colors.textMuted,
marginTop: 2
},
optionPending: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.xs
},
clearFiltersText: {
fontSize: 13,
color: colors.textSecondary
@@ -135,6 +135,7 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis
fetchRepoMetadata: async () => {},
hostCapabilities,
hostId: 'host-1',
sidebarFeed: null,
state,
syncViewSettingsFromDesktop: async () => {}
})
@@ -0,0 +1,90 @@
import {
ALL_EXECUTION_HOSTS_SCOPE,
LOCAL_EXECUTION_HOST_ID,
getLocalExecutionHostLabel,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import type { DesktopSidebarFacts } from '../../../src/shared/sidebar-list/desktop-sidebar-facts'
import type { ProjectGroup } from '../../../src/shared/project-group-types'
import type { Repo } from '../../../src/shared/repo-types'
import type {
SidebarFeedCatalog,
SidebarFeedView
} from '../../../src/shared/sidebar-list/sidebar-feed-rows'
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
import type { ServedProjectSections } from '../worktree/project-sections-operations'
import type { MobileViewState } from '../worktree/workspace-view-settings'
import type { SectionRepo } from '../worktree/workspace-list-types'
/**
* The view the desktop's sidebar shows, from the shared UI state and settings this phone mirrors.
* The host list is passed resolved with an "all" scope, which every builder step reads the same
* as the desktop's legacy scope.
*/
export function buildHostFeedView(args: {
view: MobileViewState
projectOrderBy: ProjectOrderBy
/** The feed's desktop facts: its focused host, its names for its hosts, pinned placement. */
desktop: DesktopSidebarFacts | null
/** The desktop's platform names its own host; the phone's must never. */
hostPlatform: NodeJS.Platform | null
}): SidebarFeedView {
const { view, desktop, hostPlatform } = args
const hostLabelOverrides = new Map<ExecutionHostId, string>(
Object.entries(desktop?.hostLabels ?? {}).flatMap(([hostId, label]) => {
const id = normalizeExecutionHostId(hostId)
return id ? [[id, label] as const] : []
})
)
if (hostPlatform && !hostLabelOverrides.has(LOCAL_EXECUTION_HOST_ID)) {
hostLabelOverrides.set(LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel(hostPlatform))
}
return {
groupBy: view.groupBy,
sortBy: view.sortBy,
projectOrderBy: args.projectOrderBy,
collapsedGroups: new Set(view.collapsedGroups),
filters: view.filters,
workspaceHostScope: ALL_EXECUTION_HOSTS_SCOPE,
visibleWorkspaceHostIds: view.filters.visibleHostIds,
workspaceStatuses: view.workspaceStatuses,
pinnedDisplayPolicy: desktop?.showPinnedInGroups ? 'duplicate-in-groups' : 'single-location',
defaultHostId: normalizeExecutionHostId(desktop?.focusedHostId) ?? LOCAL_EXECUTION_HOST_ID,
hostLabelOverrides
}
}
function catalogRepo(repo: SectionRepo): Repo {
return {
id: repo.id,
path: repo.path,
displayName: repo.displayName,
badgeColor: repo.badgeColor ?? '',
addedAt: repo.addedAt ?? 0,
...(repo.connectionId !== undefined ? { connectionId: repo.connectionId } : {}),
...(repo.executionHostId ? { executionHostId: repo.executionHostId } : {})
}
}
/**
* The catalog the feed does not carry: the one the desktop window publishes (every host, paired
* servers included), or on a host with no window its own repos and project groups.
*/
export function buildHostFeedCatalog(
served: ServedProjectSections | null,
hostRepos: readonly SectionRepo[],
hostProjectGroups: readonly ProjectGroup[]
): SidebarFeedCatalog {
return served
? {
repos: served.repos.map(catalogRepo),
projectGroups: served.projectGroups ?? hostProjectGroups,
projectGrouping: { projects: served.projects, projectHostSetups: served.setups }
}
: {
repos: hostRepos.map(catalogRepo),
projectGroups: hostProjectGroups,
projectGrouping: { projects: [], projectHostSetups: [] }
}
}
@@ -0,0 +1,69 @@
import { z } from 'zod'
import { SidebarInputsReadResultSchema } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable'
import type { RpcResponse } from '../transport/types'
// The host's sidebar feed (src/main/runtime/sidebar-inputs/), read only by a page built with
// ORCA_SIDEBAR_INPUTS=main. A host without it answers method_not_found, or forbidden from an
// older mobile allowlist; either means this page is newer than the host.
export const sidebarInputsRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'sidebarInputs.read-or-skip',
method: 'sidebarInputs.read',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant('sidebar-inputs', SidebarInputsReadResultSchema)
})
)
export const sidebarRepoIconsRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'sidebarInputs.repoIcons-or-skip',
method: 'sidebarInputs.repoIcons',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant(
'repo-icons',
z.object({ icons: z.array(z.object({ id: z.string(), dataUrl: z.string().nullable() })) })
)
})
)
export const setCollapsedWrite = bindDeferredRpcOperation(
defineRpcOperation({
name: 'ui.setCollapsed-or-skip',
method: 'ui.setCollapsed',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant(
'collapse-written',
z.object({ collapsed: z.boolean(), applied: z.boolean() })
)
})
)
/** A reply either answers, says the host lacks the method, or failed (retry on the next trigger). */
export type HostMethodReply<Value> =
| { kind: 'answered'; value: Value }
| { kind: 'unavailable' }
| { kind: 'failed' }
export function readHostMethodReply<Value>(
reply: RpcResponse,
interpret: (reply: RpcResponse) => { accepted: true; value: Value } | { accepted: false }
): HostMethodReply<Value> {
if (!reply.ok) {
return isMobileMethodUnavailableError(reply.error.code, reply.error.message)
? { kind: 'unavailable' }
: { kind: 'failed' }
}
try {
const verdict = interpret(reply)
return verdict.accepted ? { kind: 'answered', value: verdict.value } : { kind: 'failed' }
} catch {
return { kind: 'failed' }
}
}
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import { SidebarInputsFeed } from '../../../src/main/runtime/sidebar-inputs/sidebar-inputs-feed'
import { testDomain } from '../../../src/main/runtime/sidebar-inputs/sidebar-inputs-feed.test-fixture'
import type { SidebarInputsChangedEvent } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
import type { RpcResponse } from '../transport/types'
import type { UnvalidatedRpcRequestPort } from '../transport/unvalidated-rpc-request-port'
import { HostSidebarFeedReader, type HostSidebarFeedRead } from './host-sidebar-feed-reader'
import { readHostSidebarFeed } from './use-host-sidebar-feed'
function ok(result: unknown): RpcResponse {
return { id: 'r', ok: true, result, _meta: { runtimeId: 'runtime' } }
}
function refused(code: string, message = 'nope'): RpcResponse {
return { id: 'r', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime' } }
}
/** The host's real feed behind a phone client that answers every read from it. */
function hostWithFeed() {
const notices: SidebarInputsChangedEvent[] = []
const workspaces = testDomain('workspaces', { a: { id: 'a' } })
const feed = new SidebarInputsFeed(
() => [workspaces.domain],
(event) => notices.push(event)
)
feed.acquire('phone')
const sendRequest = vi.fn(async (_method: string, params: unknown) =>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader sends only sidebarInputs.read params.
ok(await feed.read(params as Parameters<typeof feed.read>[0]))
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader calls only sendRequest.
const client = { sendRequest } as unknown as UnvalidatedRpcRequestPort
const reads: HostSidebarFeedRead[] = []
const reader = new HostSidebarFeedReader(client, (read) => reads.push(read))
return { feed, workspaces, notices, sendRequest, reader, reads }
}
async function settle(): Promise<void> {
for (let tick = 0; tick < 20; tick += 1) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
}
describe('HostSidebarFeedReader', () => {
it('installs the host’s snapshot, then follows each notice to the newer revision', async () => {
const host = hostWithFeed()
host.reader.refresh()
await settle()
const first = host.reads.at(-1)!.installed!
expect(first.domains.get('workspaces')?.get('a')).toEqual({ id: 'a' })
host.workspaces.set('b', { id: 'b' })
await vi.waitFor(() => expect(host.notices.at(-1)?.revision).toBeGreaterThan(first.revision))
const notice = host.notices.at(-1)!
host.reader.changed(notice)
await settle()
const next = host.reads.at(-1)!.installed!
expect(next.revision).toBeGreaterThan(first.revision)
expect(next.domains.get('workspaces')?.get('b')).toEqual({ id: 'b' })
// A duplicate or late notice is already covered: no read goes out.
const sent = host.sendRequest.mock.calls.length
host.reader.changed(notice)
await settle()
expect(host.sendRequest.mock.calls.length).toBe(sent)
})
it.each([
['method_not_found', 'unknown method'],
['forbidden', "Method 'sidebarInputs.read' is not available to mobile clients"]
])('reads a host without the feed (%s) as unavailable', async (code, message) => {
const reads: HostSidebarFeedRead[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader calls only sendRequest.
const client = {
sendRequest: async () => refused(code, message)
} as unknown as UnvalidatedRpcRequestPort
new HostSidebarFeedReader(client, (read) => reads.push(read)).refresh()
await settle()
expect(reads.at(-1)).toEqual({ installed: null, unavailable: true })
expect(readHostSidebarFeed(reads.at(-1)!)).toEqual({ kind: 'reopen' })
})
it('keeps what it installed through a dropped read, and reads again on the next refresh', async () => {
const host = hostWithFeed()
host.reader.refresh()
await settle()
host.sendRequest.mockRejectedValueOnce(new Error('client_disconnected'))
host.reader.refresh()
await settle()
expect(host.reads.at(-1)!.installed).not.toBeNull()
host.workspaces.set('c', { id: 'c' })
host.reader.refresh()
await settle()
expect(host.reads.at(-1)!.installed!.domains.get('workspaces')?.get('c')).toEqual({ id: 'c' })
})
it('drops a reply that lands after the screen let go of its host', async () => {
const host = hostWithFeed()
host.reader.refresh()
host.reader.dispose()
await settle()
expect(host.reads).toEqual([])
})
})
describe('readHostSidebarFeed', () => {
const installed = {
generation: 'g',
revision: 3,
schemaVersion: 1,
semanticsVersion: 1,
domains: new Map()
}
it('is loading until a batch installs, then ready at that revision', () => {
expect(readHostSidebarFeed({ installed: null, unavailable: false })).toEqual({
kind: 'loading'
})
expect(readHostSidebarFeed({ installed, unavailable: false })).toMatchObject({ kind: 'ready' })
})
it('asks to reopen when the host means other things by its inputs', () => {
expect(
readHostSidebarFeed({ installed: { ...installed, semanticsVersion: 2 }, unavailable: false })
).toEqual({ kind: 'reopen' })
})
})
@@ -0,0 +1,88 @@
import {
INITIAL_SIDEBAR_INPUTS_FEED_STATE,
refreshSidebarInputs,
sidebarInputsChanged,
sidebarInputsReadFailed,
sidebarInputsReplied,
type InstalledSidebarInputs,
type SidebarInputsFeedRequest,
type SidebarInputsFeedStep
} from '../../../src/shared/sidebar-list/sidebar-inputs-feed-installer'
import type { SidebarInputsChangedEvent } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
import type { UnvalidatedRpcRequestPort } from '../transport/unvalidated-rpc-request-port'
import { readHostMethodReply, sidebarInputsRead } from './host-sidebar-feed-operations'
/** What the worktree refresh drives the feed with. */
export type HostSidebarFeedTriggers = {
refresh: () => void
changed: (notice: Pick<SidebarInputsChangedEvent, 'generation' | 'revision'>) => void
/** Each new install; returns the unsubscribe. */
onInstalled: (listener: () => void) => () => void
}
export type HostSidebarFeedRead = {
installed: InstalledSidebarInputs | null
/** The host has no feed (method_not_found, or forbidden by an older mobile allowlist). */
unavailable: boolean
}
/** One host connection's sidebar feed, driven through the shared installer. */
export class HostSidebarFeedReader {
private state = INITIAL_SIDEBAR_INPUTS_FEED_STATE
private unavailable = false
private disposed = false
constructor(
private readonly client: UnvalidatedRpcRequestPort,
private readonly onRead: (read: HostSidebarFeedRead) => void
) {}
/** On every stream ready and return to the foreground; supersedes a read in flight. */
refresh(): void {
this.apply(refreshSidebarInputs(this.state))
}
changed(notice: Pick<SidebarInputsChangedEvent, 'generation' | 'revision'>): void {
this.apply(sidebarInputsChanged(this.state, notice))
}
dispose(): void {
this.disposed = true
}
private apply(step: SidebarInputsFeedStep): void {
const before = this.state.installed
this.state = step.state
if (step.state.installed !== before) {
this.onRead({ installed: step.state.installed, unavailable: this.unavailable })
}
if (step.request) {
this.send(step.request)
}
}
private send(request: SidebarInputsFeedRequest): void {
const failed = (): void => {
this.state = sidebarInputsReadFailed(this.state, request.id)
}
sidebarInputsRead.request(this.client, request.params).then(
(reply) => {
if (this.disposed) {
return
}
const read = readHostMethodReply(reply, sidebarInputsRead.interpret)
if (read.kind === 'answered') {
this.apply(sidebarInputsReplied(this.state, request.id, read.value))
return
}
failed()
if (read.kind === 'unavailable' && !this.unavailable) {
this.unavailable = true
this.onRead({ installed: this.state.installed, unavailable: true })
}
},
// A dropped connection: the next ready reads again.
failed
)
}
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import {
buildSidebarFeedRows,
type SidebarFeedView
} from '../../../src/shared/sidebar-list/sidebar-feed-rows'
import type { ExecutionHostScope } from '../../../src/shared/execution-host'
import { resolveVisibleWorkspaceHostIds } from '../../../src/shared/workspace-view/workspace-host-visibility'
import { buildHostFeedCatalog, buildHostFeedView } from './host-sidebar-feed-inputs'
import { buildHostFeedSections } from './host-sidebar-feed-sections'
import { SERVED_SECTIONS, feedDomains, liveRow, phoneView } from './host-sidebar-feed.test-fixture'
import type { MobileViewState } from '../worktree/workspace-view-settings'
const catalog = buildHostFeedCatalog(SERVED_SECTIONS, [], [])
function phoneFeedView(view: MobileViewState, showPinnedInGroups = false): SidebarFeedView {
return buildHostFeedView({
view,
projectOrderBy: 'manual',
desktop: { ...feedDomains().desktop!, showPinnedInGroups },
hostPlatform: 'darwin'
})
}
function sectionsFor(
view: MobileViewState,
options: {
showPinnedInGroups?: boolean
live?: ReturnType<typeof liveRow>[]
search?: string
} = {}
) {
const built = buildSidebarFeedRows(
feedDomains(),
catalog,
phoneFeedView(view, options.showPinnedInGroups)
)
return buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
liveRows: options.live ?? [],
repoHostIdByRepoId: new Map([['lab', 'ssh:gpu']]),
search: options.search ?? ''
})
}
function outline(sections: ReturnType<typeof sectionsFor>): string[] {
return sections.flatMap((section) => [
`${section.key} "${section.title}" ${String(section.count)}${section.icon ? ' pin' : ''}`,
...section.data.map(
(row) =>
` ${row.sectionListKey} depth=${String(row.lineageDepth)}${row.isActive ? ' ACTIVE' : ''}`
)
])
}
describe('the phone list from the sidebar feed', () => {
it('draws the shared builder’s headers, order, nesting and single highlight', () => {
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)
})
// The phone sends its host list resolved with an "all" scope; the desktop keeps its legacy scope.
const scopes: [ExecutionHostScope, string[] | null][] = [
['ssh:gpu', null],
['all', ['local', 'ssh:gpu']],
['all', ['ssh:gpu']],
['local', ['local', 'ssh:gpu']]
]
it.each(scopes)('builds the desktop’s rows for scope %s and hosts %j', (scope, hostIds) => {
const visibleHostIds = resolveVisibleWorkspaceHostIds(hostIds, scope)
const view = phoneView({ filters: { ...phoneView().filters, visibleHostIds } })
const desktopView: SidebarFeedView = {
...phoneFeedView(view),
workspaceHostScope: scope,
visibleWorkspaceHostIds: hostIds && hostIds.length > 0 ? visibleHostIds : null
}
for (const groupBy of ['repo', 'none', 'workspace-status', 'pr-status'] as const) {
const phone = buildSidebarFeedRows(
feedDomains(),
catalog,
phoneFeedView({ ...view, groupBy })
)
const desktop = buildSidebarFeedRows(feedDomains(), catalog, { ...desktopView, groupBy })
expect(JSON.stringify(phone.sectionRows)).toBe(JSON.stringify(desktop.sectionRows))
}
})
it('names the desktop’s own host by its platform, not the phone’s', () => {
const view = phoneView({
filters: { ...phoneView().filters, visibleHostIds: ['local', 'ssh:gpu'] }
})
const built = buildSidebarFeedRows(feedDomains(), catalog, phoneFeedView(view))
const sections = buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
liveRows: [],
repoHostIdByRepoId: new Map(),
search: ''
})
expect(sections.map((section) => section.title)).toEqual(
expect.arrayContaining(['Local Mac', 'GPU box'])
)
})
it('draws each row with the live content worktree.ps served, matched by host', () => {
const sections = sectionsFor(phoneView(), {
live: [
liveRow({ worktreeId: 'orca::/w/child', liveTerminalCount: 2, status: 'working' }),
liveRow({ worktreeId: 'lab::/w/gpu', hostId: 'ssh:gpu', repoId: 'lab', unread: true })
]
})
const rows = sections.flatMap((section) => section.data)
expect(rows.find((row) => row.worktreeId === 'orca::/w/child')).toMatchObject({
liveTerminalCount: 2,
status: 'working',
lineageDepth: 1
})
expect(rows.find((row) => row.worktreeId === 'lab::/w/gpu')).toMatchObject({ unread: true })
// Not listed by worktree.ps yet: drawn from the feed alone.
expect(rows.find((row) => row.worktreeId === 'orca::/w/parent')).toMatchObject({
displayName: 'orca::/w/parent',
liveTerminalCount: 0,
isPinned: true
})
})
it('never draws one host’s live row under another host’s workspace of the same id', () => {
const sections = sectionsFor(phoneView(), {
live: [liveRow({ worktreeId: 'lab::/w/gpu', hostId: 'local', liveTerminalCount: 5 })]
})
const remote = sections
.flatMap((section) => section.data)
.find((row) => row.worktreeId === 'lab::/w/gpu')
expect(remote).toMatchObject({ hostId: 'ssh:gpu', liveTerminalCount: 0 })
})
it('keeps a collapsed section’s header and count with no rows under it', () => {
const expanded = sectionsFor(phoneView())
const lab = expanded.find((section) => section.title === 'lab')!
const collapsed = sectionsFor(phoneView({ collapsedGroups: [lab.key] }))
expect(collapsed.find((section) => section.key === lab.key)).toMatchObject({
count: 1,
data: []
})
})
it('narrows rows by search and drops sections left with none', () => {
const sections = sectionsFor(phoneView(), { search: 'gpu' })
expect(sections.flatMap((section) => section.data.map((row) => row.worktreeId))).toEqual([
'lab::/w/gpu'
])
expect(sections.map((section) => section.title)).toEqual(['lab'])
})
})
@@ -0,0 +1,192 @@
import {
LOCAL_EXECUTION_HOST_ID,
getWorktreeExecutionHostId,
type ExecutionHostId
} from '../../../src/shared/execution-host'
import { folderWorkspaceToWorktree } from '../../../src/shared/folder-workspace-worktree'
import { getFolderWorkspaceHostId } from '../../../src/shared/sidebar-list/folder-workspace-host-id'
import type { HostSectionRow } from '../../../src/shared/sidebar-list/host-section-rows'
import type { GroupHeaderRow } from '../../../src/shared/sidebar-list/row-types'
import { PR_GROUP_LABELS } from '../../../src/shared/workspace-view/workspace-pr-groups'
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 { resolveWorktreeHostId } from '../worktree/worktree-host-context-labels'
/** 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 rows worktree.ps and the desktop's server lists served, for what each row draws. */
liveRows: readonly Worktree[]
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
search: string
}
/** The builder names data headers; Pinned, All and the PR lanes each client names itself. */
function headerTitle(row: GroupHeaderRow): string {
switch (row.icon.kind) {
case 'pinned':
return row.label ?? 'Pinned'
case 'all':
return row.label ?? 'All'
case 'pr-status':
return row.label ?? PR_GROUP_LABELS[row.icon.prGroup]
case 'project':
case 'workspace-status':
return row.label ?? ''
}
}
function indexLiveRows(
rows: readonly Worktree[],
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
): (id: string, hostId: ExecutionHostId) => Worktree | undefined {
const byIdentity = new Map<string, Worktree>()
for (const row of rows) {
const identity = composeWorktreeHostIdentity(
resolveWorktreeHostId(row, repoHostIdByRepoId),
row.worktreeId
)
if (!byIdentity.has(identity)) {
byIdentity.set(identity, row)
}
}
// The same id can live on two hosts; only the row's own host's copy draws it.
return (id, hostId) => byIdentity.get(composeWorktreeHostIdentity(hostId, id))
}
/** A row worktree.ps has not listed yet: drawn from the feed alone, with no live content. */
function feedOnlyRow(
worktree: HostWorktree,
hostId: ExecutionHostId,
repoName: string,
workspaceKind: Worktree['workspaceKind']
): Worktree {
return {
worktreeId: worktree.id,
repoId: worktree.repoId,
hostId,
workspaceKind,
repo: repoName,
branch: worktree.branch,
displayName: worktree.displayName,
path: worktree.path,
isArchived: worktree.isArchived,
isMainWorktree: worktree.isMainWorktree,
liveTerminalCount: 0,
hasAttachedPty: false,
unread: worktree.isUnread,
isPinned: worktree.isPinned,
linkedPR: null
}
}
function matchesSearch(row: Worktree, query: string): boolean {
return (
row.displayName.toLowerCase().includes(query) ||
row.branch.toLowerCase().includes(query) ||
row.repo.toLowerCase().includes(query)
)
}
/**
* 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.
* Search narrows the rows here, as the desktop's own search never reaches the feed.
*/
export function buildHostFeedSections(sources: HostFeedListSources): Section[] {
const liveRowOf = indexLiveRows(sources.liveRows, sources.repoHostIdByRepoId)
const query = sources.search.trim().toLowerCase()
const sections: Section[] = []
const rowOf = (
worktree: HostWorktree,
hostId: ExecutionHostId,
repoName: string,
workspaceKind: Worktree['workspaceKind']
): Worktree =>
liveRowOf(worktree.id, hostId) ?? feedOnlyRow(worktree, hostId, repoName, workspaceKind)
const push = (section: Section) => sections.push(section)
const current = (): Section => {
const last = sections.at(-1)
if (last) {
return last
}
const first: Section = { key: 'all', title: '', data: [] }
push(first)
return first
}
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':
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 } : {}),
data: []
})
break
case 'item': {
// Local as the fallback, as the phone resolves its own rows: the desktop's focus is not an owner.
const hostId = getWorktreeExecutionHostId(row.worktree, row.repo, LOCAL_EXECUTION_HOST_ID)
const live = rowOf(row.worktree, hostId, row.repo?.displayName ?? '', 'git')
current().data.push({
...live,
isPinned: row.worktree.isPinned,
isActive: sources.activeRowKeys.has(row.rowKey),
sectionListKey: row.rowKey,
lineageDepth: row.depth,
lineageChildCount: row.lineageChildCount,
lineageCollapsed: row.lineageCollapsed === true,
isLastLineageChild: row.isLastLineageChild,
...(row.lineageGroupKey ? { lineageGroupKey: row.lineageGroupKey } : {}),
showProjectName: row.showProjectName === true,
// The desktop's mixed-host rule names the host; a health word alone still shows.
hostContextLabel:
row.hostContextLabel ??
(live.hostContextHealthLabel ? live.hostContextLabel : undefined)
})
break
}
case 'folder-workspace': {
const worktree = folderWorkspaceToWorktree(row.folderWorkspace)
const hostId = getFolderWorkspaceHostId(
row.folderWorkspace,
row.projectGroup,
LOCAL_EXECUTION_HOST_ID
)
current().data.push({
...rowOf(worktree, hostId, row.projectGroup.name, 'folder-workspace'),
isPinned: row.folderWorkspace.isPinned,
isActive: sources.activeRowKeys.has(row.key),
sectionListKey: row.key,
lineageDepth: 0
})
break
}
// A creation in progress and the desktop-only cards draw nothing on the phone.
case 'pending-creation':
case 'imported-worktrees-card':
case 'new-external-worktrees-inbox':
break
}
}
return query ? narrowSections(sections, query) : sections
}
/** A section whose title matches keeps every row; otherwise only matching rows, counted as shown. */
function narrowSections(sections: readonly Section[], query: string): Section[] {
return sections.flatMap((section) => {
if (section.title.toLowerCase().includes(query)) {
return [section]
}
const data = section.data.filter((row) => matchesSearch(row, query))
return data.length > 0 ? [{ ...section, data, count: data.length }] : []
})
}
@@ -0,0 +1,126 @@
import type { SidebarFeedDomains } from '../../../src/shared/sidebar-list/sidebar-feed-domains'
import type { WorkspaceRow } from '../../../src/shared/sidebar-list/workspace-rows'
import type { ServedProjectSections } from '../worktree/project-sections-operations'
import { INITIAL_MOBILE_VIEW, type MobileViewState } from '../worktree/workspace-view-settings'
import type { Worktree } from '../worktree/workspace-list-types'
export function feedRow(overrides: Partial<WorkspaceRow> & Pick<WorkspaceRow, 'id'>): WorkspaceRow {
return {
hostId: 'local',
kind: 'git',
repoId: 'orca',
path: `/w/${overrides.id}`,
branch: overrides.id,
displayName: overrides.id,
isMainWorktree: false,
lineage: null,
isPinned: false,
isArchived: false,
isUnread: false,
workspaceStatus: 'in-progress',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
linkedItems: [],
defaultBranch: false,
...overrides
}
}
/** Two local workspaces (one pinned, one its nested child), one on an SSH host. */
export const FEED_ROWS = {
parent: feedRow({
id: 'orca::/w/parent',
isPinned: true,
instanceId: 'i-parent',
manualOrder: 1
}),
child: feedRow({
id: 'orca::/w/child',
instanceId: 'i-child',
manualOrder: 2,
lineage: {
parentWorktreeId: 'orca::/w/parent',
worktreeInstanceId: 'i-child',
parentWorktreeInstanceId: 'i-parent'
}
}),
remote: feedRow({ id: 'lab::/w/gpu', hostId: 'ssh:gpu', repoId: 'lab', manualOrder: 3 })
}
export function feedDomains(overrides: Partial<SidebarFeedDomains> = {}): SidebarFeedDomains {
return {
workspaces: new Map([
['|orca::/w/parent', FEED_ROWS.parent],
['|orca::/w/child', FEED_ROWS.child],
['ssh:gpu|lab::/w/gpu', FEED_ROWS.remote]
]),
activity: new Map(),
review: new Map(),
creations: new Map(),
pairedServerHosts: new Map(),
pairedServerRows: new Map(),
repoIcons: new Map(),
desktop: {
desktopActive: { hostId: null, worktreeId: 'orca::/w/parent' },
pinnedWorkspaceIds: ['orca::/w/parent'],
repoOrder: [
{ hostId: 'local', repoId: 'orca' },
{ hostId: 'ssh:gpu', repoId: 'lab' }
],
workspaceHostOrder: [],
sshHosts: { 'ssh:gpu': { label: 'GPU box', status: 'connected' } },
wslHosts: {}
},
retention: null,
...overrides
}
}
type ServedRepo = ServedProjectSections['repos'][number]
function servedRepo(fields: Pick<ServedRepo, 'id' | 'path'> & Partial<ServedRepo>): ServedRepo {
return {
displayName: fields.id,
badgeColor: undefined,
addedAt: undefined,
connectionId: undefined,
executionHostId: undefined,
...fields
}
}
export const SERVED_SECTIONS: ServedProjectSections = {
repos: [
servedRepo({ id: 'orca', path: '/r/orca', badgeColor: '#f00' }),
servedRepo({ id: 'lab', path: '/r/lab', connectionId: 'gpu' })
],
projects: [],
setups: [],
projectOrderBy: 'manual',
projectGroups: undefined
}
export function phoneView(overrides: Partial<MobileViewState> = {}): MobileViewState {
return { ...INITIAL_MOBILE_VIEW, groupBy: 'repo', sortBy: 'manual', ...overrides }
}
/** A row worktree.ps served, with the live content the list draws. */
export function liveRow(overrides: Partial<Worktree> & Pick<Worktree, 'worktreeId'>): Worktree {
return {
repoId: 'orca',
repo: 'orca',
branch: overrides.worktreeId,
displayName: overrides.worktreeId,
path: `/w/${overrides.worktreeId}`,
liveTerminalCount: 0,
hasAttachedPty: false,
unread: false,
isPinned: false,
linkedPR: null,
...overrides
}
}
@@ -0,0 +1,92 @@
import type {
MobileViewPatch,
MobileViewState,
WorkspaceViewSettings
} from '../worktree/workspace-view-settings'
/**
* A Workspace options change this phone sent that the desktop has not answered yet. The list shows
* its desired value meanwhile; a slow answer is never a refusal.
*/
export type PendingViewChange = { opId: number; sentAt: number } & (
| { kind: 'view'; patch: MobileViewPatch; payload: WorkspaceViewSettings }
| { kind: 'collapse'; key: string; collapsed: boolean }
)
/** After this long without an answer the control says it is still waiting, and keeps the value. */
export const PENDING_VIEW_CHANGE_STALE_MS = 10_000
export function applyViewPatch(view: MobileViewState, patch: MobileViewPatch): MobileViewState {
return { ...view, ...patch, filters: { ...view.filters, ...patch.filters } }
}
export function applyCollapse(
view: MobileViewState,
key: string,
collapsed: boolean
): MobileViewState {
const rest = view.collapsedGroups.filter((candidate) => candidate !== key)
return { ...view, collapsedGroups: collapsed ? [...rest, key] : rest }
}
/** The view with every unanswered change laid over it, oldest first. */
export function withPendingViewChanges(
view: MobileViewState,
pending: readonly PendingViewChange[]
): MobileViewState {
return pending.reduce(
(current, change) =>
change.kind === 'view'
? applyViewPatch(current, change.patch)
: applyCollapse(current, change.key, change.collapsed),
view
)
}
/** Each stored value the change sets, keyed by what it writes; a collapse writes its one key. */
function writesOf(change: PendingViewChange): Map<string, (ui: WorkspaceViewSettings) => boolean> {
if (change.kind === 'collapse') {
return new Map([
[
`collapse:${change.key}`,
(ui) => (ui.collapsedGroups?.includes(change.key) ?? false) === change.collapsed
]
])
}
return new Map(
Object.entries(change.payload).map(([field, value]) => [
field,
(ui) => JSON.stringify(new Map(Object.entries(ui)).get(field)) === JSON.stringify(value)
])
)
}
/** Adds a change; an earlier one whose every write it repeats is no longer the user's intent. */
export function addPendingViewChange(
pending: readonly PendingViewChange[],
change: PendingViewChange
): PendingViewChange[] {
const writes = writesOf(change)
return [
...pending.filter((earlier) => [...writesOf(earlier).keys()].some((key) => !writes.has(key))),
change
]
}
/**
* The changes the desktop's values do not hold yet. Only changes sent before the read are judged
* by it, and a value a later change also writes is that change's to settle.
*/
export function reconcilePendingViewChanges(
pending: readonly PendingViewChange[],
ui: WorkspaceViewSettings,
sentByOpId: number
): PendingViewChange[] {
return pending.filter((change, index) => {
if (change.opId > sentByOpId) {
return true
}
const later = new Set(pending.slice(index + 1).flatMap((next) => [...writesOf(next).keys()]))
return [...writesOf(change)].some(([key, holds]) => !later.has(key) && !holds(ui))
})
}
@@ -63,7 +63,7 @@ function listWith(fields: { actionError: string; setActionError: (value: string)
router: { push: () => {} },
sectionsResult: { rawSections: [], sections: [], repoById: new Map() },
setDismissedNotice: () => {},
settings: { activeFilterCount: 0 },
settings: { activeFilterCount: 0, pendingViewChanges: { pending: [], stale: false } },
state: {
actionError: fields.actionError,
setActionError: fields.setActionError,
@@ -88,7 +88,11 @@ function listProps(worktrees: Worktree[]) {
reconnectAttempts: 0,
relayRecovery: { pairingRejected: false },
sectionsResult: { rawSections: sections, sections, repoById: stable.repoById },
settings: { activeFilterCount: 0, toggleWorktreeLineage: noop },
settings: {
activeFilterCount: 0,
toggleWorktreeLineage: noop,
pendingViewChanges: { pending: [], stale: false }
},
state: {
actionError: '',
setActionTarget: noop,
+47 -14
View File
@@ -1,5 +1,13 @@
import { useMemo } from 'react'
import { Platform, Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
import {
ActivityIndicator,
Platform,
Pressable,
RefreshControl,
SectionList,
Text,
View
} from 'react-native'
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
import { AuthFailedBanner } from '../components/AuthFailedBanner'
import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink'
@@ -10,7 +18,10 @@ import { NewWorkspaceFab, FAB_SIZE } from '../components/NewWorkspaceFab'
import { WorktreeListRow } from '../components/WorktreeListRow'
import { colors, spacing } from '../theme/mobile-theme'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
import {
HostWorkspaceListReopen,
HostWorkspaceListStates
} from '../worktree/host-workspace-list-states'
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
import { repoColor } from '../worktree/repo-color'
import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties'
@@ -44,6 +55,12 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
state
} = 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]
)
const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById])
const { worktreeCardProperties, compactWorktreeCards, newCardStyle } = state.view
const cardDetails = useMemo(
@@ -113,17 +130,26 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
</View>
)}
<HostWorkspaceListStates
connState={connState}
worktreesLoaded={state.worktreesLoaded}
displayCount={displayWorktrees.length}
sectionCount={sections.length}
catalogError={state.catalogError}
search={state.search}
activeFilterCount={controller.activeFilterCount}
/>
{/* A feed this page cannot read: the host is older or newer than the page. */}
{controller.feed?.kind === 'reopen' ? (
<HostWorkspaceListReopen />
) : (
<HostWorkspaceListStates
connState={connState}
// A feed-built list is loaded once the feed installs; rows draw before worktree.ps answers.
worktreesLoaded={
controller.feed ? controller.feed.kind === 'ready' : state.worktreesLoaded
}
// A feed-built list counts its sections, so collapsed ones and a failing worktree.ps never blank it.
displayCount={controller.feed ? sections.length : displayWorktrees.length}
sectionCount={sections.length}
catalogError={state.catalogError}
search={state.search}
activeFilterCount={controller.activeFilterCount}
/>
)}
{sections.length > 0 && (
{sections.length > 0 && controller.feed?.kind !== 'reopen' && (
<SectionList
ref={activeWorktreeScroll.sectionListRef}
sections={sections}
@@ -147,8 +173,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
return null
}
const isCollapsed = controller.collapsedGroups.has(section.key)
const rawSection = rawSections.find((s) => s.key === section.key)
const count = rawSection?.data.length ?? 0
const count =
section.count ?? rawSections.find((s) => s.key === section.key)?.data.length ?? 0
// Why: the desktop's header draws its anchor repo's colour and icon.
const repoSectionLook =
state.view.groupBy === 'repo' && section.repo
@@ -180,6 +206,13 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
) : null}
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionCount}>{count}</Text>
{pendingCollapseKeys.has(section.key) && (
<ActivityIndicator
size="small"
color={colors.textMuted}
style={styles.sectionIcon}
/>
)}
</Pressable>
)
}}
@@ -0,0 +1,171 @@
import { useMemo } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
import { buildSidebarFeedRows } from '../../../src/shared/sidebar-list/sidebar-feed-rows'
import { repoIconFeedKey } from '../../../src/shared/sidebar-list/repo-icon-data-urls-contract'
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
import type { RpcClient } from '../transport/rpc-client'
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 { rowFiltersForView } from '../worktree/workspace-view-settings'
import { buildHostFeedCatalog, buildHostFeedView } from './host-sidebar-feed-inputs'
import { buildHostFeedSections } from './host-sidebar-feed-sections'
import { useHostRepoIconDataUrls } from './use-host-repo-icon-data-urls'
import type { HostSidebarFeed } from './use-host-sidebar-feed'
import type { HostScreenState } from './use-host-screen-state'
export type HostListSectionsInputs = {
client: RpcClient | null
/** 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. */
displayWorktrees: Worktree[]
state: HostScreenState
}
export type HostListSections = {
sections: Section[]
/** Each section before collapse; a feed-built section carries its own count instead. */
rawSections: Section[]
uniqueRepos: WorkspaceSectionRepo[]
/** Each row's repo by id, as the desktop's cards look theirs up for colour and icon. */
repoById: ReadonlyMap<string, SectionRepo>
collapsedGroups: Set<string>
/** The desktop's value once read; the served catalog's (or the default) before. */
projectOrderBy: ProjectOrderBy
}
function useCollapsedGroups(state: HostScreenState): Set<string> {
return useMemo(() => new Set(state.view.collapsedGroups), [state.view.collapsedGroups])
}
function useFilterRepos(
state: HostScreenState,
displayWorktrees: Worktree[]
): WorkspaceSectionRepo[] {
const catalogRepos = state.servedProjectSections?.repos ?? state.desktopRepos
return useMemo(
() => buildFilterRepos(catalogRepos, displayWorktrees),
[catalogRepos, displayWorktrees]
)
}
/** The phone's own list rules, for a page built without the feed. */
function useLegacyHostListSections({
displayWorktrees,
state
}: HostListSectionsInputs): HostListSections {
const projectCatalog = useMemo(
() =>
buildProjectSectionCatalog(
state.servedProjectSections,
state.desktopRepos,
state.view.filters.visibleHostIds,
state.view.projectOrderBy
),
[
state.servedProjectSections,
state.desktopRepos,
state.view.filters.visibleHostIds,
state.view.projectOrderBy
]
)
const collapsedGroups = useCollapsedGroups(state)
const rowFilters = useMemo(() => rowFiltersForView(state.view), [state.view])
const sectionsResult = useWorkspaceSections({
displayWorktrees,
sortMode: state.view.sortBy,
filters: rowFilters,
search: state.search,
groupMode: state.view.groupBy,
pinnedIds: state.pinnedIds,
showPinnedInGroups: state.showPinnedInGroups,
projectCatalog,
filterCatalogRepos: state.servedProjectSections?.repos ?? state.desktopRepos,
collapsedGroups,
workspaceStatuses: state.view.workspaceStatuses,
repoHostIdByRepoId: state.repoHostIdByRepoId
})
return { ...sectionsResult, collapsedGroups, projectOrderBy: projectCatalog.projectOrderBy }
}
const NO_SECTIONS: Section[] = []
/** The desktop sidebar's own list, built by the shared builder from the host's feed. */
function useFeedHostListSections({
client,
feed,
displayWorktrees,
state
}: HostListSectionsInputs): HostListSections {
const collapsedGroups = useCollapsedGroups(state)
const uniqueRepos = useFilterRepos(state, displayWorktrees)
const projectOrderBy =
state.view.projectOrderBy ?? state.servedProjectSections?.projectOrderBy ?? 'manual'
const domains = feed?.kind === 'ready' ? feed.domains : null
const catalog = useMemo(
() =>
buildHostFeedCatalog(
state.servedProjectSections,
state.desktopRepos,
state.hostProjectGroups
),
[state.servedProjectSections, state.desktopRepos, state.hostProjectGroups]
)
const view = useMemo(
() =>
buildHostFeedView({
view: state.view,
projectOrderBy,
desktop: domains?.desktop ?? null,
hostPlatform: state.hostPlatform
}),
[state.view, projectOrderBy, domains, state.hostPlatform]
)
const built = useMemo(
() => (domains ? buildSidebarFeedRows(domains, catalog, view) : null),
[domains, catalog, view]
)
const sections = useMemo(
() =>
built
? buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
liveRows: displayWorktrees,
repoHostIdByRepoId: state.repoHostIdByRepoId,
search: state.search
})
: NO_SECTIONS,
[built, displayWorktrees, state.repoHostIdByRepoId, state.search]
)
const iconIds = useMemo(() => [...new Set(domains?.repoIcons.values() ?? [])], [domains])
const iconDataUrls = useHostRepoIconDataUrls(client, iconIds)
const repoById = useMemo(() => {
// The desktop's repo.list still carries uploads, which the served catalog withholds.
const hostIcons = new Map(
state.desktopRepos.map((repo) => [repoIconFeedKey(repo), repo.repoIcon])
)
const repos = state.servedProjectSections?.repos ?? state.desktopRepos
return new Map(
repos.map((repo) => {
const key = repoIconFeedKey(repo)
const iconId = domains?.repoIcons.get(key)
const dataUrl = iconId ? iconDataUrls.get(iconId) : undefined
const repoIcon = dataUrl
? { type: 'image', src: dataUrl }
: (repo.repoIcon ?? hostIcons.get(key))
return [repo.id, { ...repo, repoIcon }]
})
)
}, [state.servedProjectSections, state.desktopRepos, domains, iconDataUrls])
return { sections, rawSections: sections, uniqueRepos, repoById, collapsedGroups, projectOrderBy }
}
/** Switch point: the list comes from the host's sidebar feed, or the phone builds it itself. */
export const useHostListSections = ORCA_SIDEBAR_INPUTS_IS_MAIN
? useFeedHostListSections
: useLegacyHostListSections
@@ -0,0 +1,222 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../../src/shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
import {
SetCollapsedParams,
type SetCollapsedRequest
} from '../../../src/shared/rpc-contract/workspace-view-collapse-params'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
import {
PENDING_VIEW_CHANGE_STALE_MS,
addPendingViewChange,
reconcilePendingViewChanges,
withPendingViewChanges,
type PendingViewChange
} from './host-view-pending-changes'
import {
useSwitchedHostPendingViewChanges,
type HostPendingViewChanges,
type PendingViewOutcome
} from './use-host-pending-view-changes'
function ok(): RpcResponse {
return {
id: 'r',
ok: true,
result: { collapsed: true, applied: true },
_meta: { runtimeId: 'x' }
}
}
function refused(code: string): RpcResponse {
return { id: 'r', ok: false, error: { code, message: code }, _meta: { runtimeId: 'x' } }
}
function mount(sendRequest: (method: string, params: unknown) => Promise<RpcResponse>) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest.
const client = { sendRequest: vi.fn(sendRequest) } as unknown as RpcClient
let changes: HostPendingViewChanges | null = null
function Probe(): null {
changes = useSwitchedHostPendingViewChanges(client)
return null
}
act(() => {
create(createElement(Probe))
})
return { client, current: () => changes! }
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('the phone’s own option changes', () => {
it('show as pending until the desktop acknowledges them', async () => {
let answer: (reply: RpcResponse) => void = () => {}
const screen = mount(() => new Promise((resolve) => (answer = resolve)))
let outcome: Promise<PendingViewOutcome> | null = null
act(() => {
outcome = screen.current().controls.sendView({ groupBy: 'none' }, { groupBy: 'none' })
})
expect(screen.current().status.pending).toHaveLength(1)
answer(ok())
await flush()
expect(await outcome).toBe('settled')
expect(screen.current().status.pending).toEqual([])
})
it('drop a refused change and say it was refused', async () => {
const screen = mount(async () => refused('invalid_params'))
let outcome: Promise<PendingViewOutcome> | null = null
act(() => {
outcome = screen.current().controls.sendView({ groupBy: 'none' }, { groupBy: 'none' })
})
await flush()
expect(await outcome).toBe('refused')
expect(screen.current().status.pending).toEqual([])
})
it('stay quiet about a refusal of a change a later one replaced', async () => {
const answers: ((reply: RpcResponse) => void)[] = []
const screen = mount(() => new Promise((resolve) => answers.push(resolve)))
let first: Promise<PendingViewOutcome> | null = null
act(() => {
first = screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' })
})
act(() => void screen.current().controls.sendView({ sortBy: 'recent' }, { sortBy: 'recent' }))
answers[0](refused('invalid_params'))
await flush()
expect(await first).toBe('waiting')
expect(screen.current().status.pending).toHaveLength(1)
})
it('keep a change the desktop has not answered, saying so after ten seconds', () => {
vi.useFakeTimers()
const screen = mount(() => new Promise(() => {}))
act(() => void screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' }))
expect(screen.current().status.stale).toBe(false)
act(() => {
vi.advanceTimersByTime(PENDING_VIEW_CHANGE_STALE_MS)
})
expect(screen.current().status.stale).toBe(true)
expect(screen.current().status.pending).toHaveLength(1)
})
it('keep a change whose request dropped, and resend it after a reconnect', async () => {
const screen = mount(async () => {
throw new Error('client_disconnected')
})
act(() => void screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' }))
await flush()
expect(screen.current().status.pending).toHaveLength(1)
const sentByOpId = screen.current().controls.lastOpId()
act(() => {
screen.current().controls.reconcile({ sortBy: 'recent' }, { reconnected: true, sentByOpId })
})
expect(screen.client.sendRequest).toHaveBeenCalledTimes(2)
act(() => {
screen.current().controls.reconcile({ sortBy: 'name' }, { reconnected: false, sentByOpId })
})
expect(screen.current().status.pending).toEqual([])
})
it('report a host without ui.setCollapsed, which then takes the whole list', async () => {
const screen = mount(async () => refused('method_not_found'))
let outcome: Promise<PendingViewOutcome> | null = null
act(() => {
outcome = screen.current().controls.sendCollapse('repo:orca', true)
})
await flush()
expect(await outcome).toBe('unavailable')
expect(screen.current().status.pending).toEqual([])
})
it('send collapses under one writer with a growing seq', async () => {
const sent: SetCollapsedRequest[] = []
const screen = mount(async (_method, params) => {
sent.push(SetCollapsedParams.parse(params))
return ok()
})
act(() => void screen.current().controls.sendCollapse('a', true))
act(() => void screen.current().controls.sendCollapse('a', false))
await flush()
const [first, second] = sent
expect(second.opId.writer).toBe(first.opId.writer)
expect(second.opId.seq).toBeGreaterThan(first.opId.seq)
})
it('keep the same controls while changes come and go', async () => {
const screen = mount(async () => ok())
const before = screen.current().controls
act(() => void screen.current().controls.sendView({ sortBy: 'name' }, { sortBy: 'name' }))
await flush()
expect(screen.current().controls).toBe(before)
})
})
describe('pending changes over the desktop’s values', () => {
const collapse = (opId: number, collapsed: boolean): PendingViewChange => ({
opId,
sentAt: 0,
kind: 'collapse',
key: 'k',
collapsed
})
it('lay the desired values over the view, oldest first', () => {
const view = withPendingViewChanges(INITIAL_MOBILE_VIEW, [
{ opId: 1, sentAt: 0, kind: 'view', patch: { groupBy: 'none' }, payload: {} },
collapse(2, true),
{ opId: 3, sentAt: 0, kind: 'view', patch: { groupBy: 'pr-status' }, payload: {} }
])
expect(view.groupBy).toBe('pr-status')
expect(view.collapsedGroups).toEqual(['k'])
})
it('keep only the latest intent for a section toggled twice', () => {
const pending = addPendingViewChange([collapse(1, true)], collapse(2, false))
expect(pending.map((change) => change.opId)).toEqual([2])
// The desktop already shows it expanded, the latest intent, so nothing waits.
expect(reconcilePendingViewChanges(pending, { collapsedGroups: [] }, 2)).toEqual([])
})
it('judge only changes sent before the read', () => {
const pending = [collapse(3, true)]
expect(reconcilePendingViewChanges(pending, { collapsedGroups: ['k'] }, 2)).toHaveLength(1)
expect(reconcilePendingViewChanges(pending, { collapsedGroups: ['k'] }, 3)).toEqual([])
})
it('leave a value a later change also writes to that change', () => {
const narrowed: PendingViewChange = {
opId: 1,
sentAt: 0,
kind: 'view',
patch: {},
payload: { visibleWorkspaceHostIds: ['ssh:a'], workspaceHostScope: 'ssh:a' }
}
const widened: PendingViewChange = {
opId: 2,
sentAt: 0,
kind: 'view',
patch: {},
payload: { visibleWorkspaceHostIds: ['ssh:a', 'local'] }
}
const pending = addPendingViewChange([narrowed], widened)
expect(pending).toHaveLength(2)
// The scope the first wrote holds; its host list is the second's to settle.
const waiting = reconcilePendingViewChanges(pending, { workspaceHostScope: 'ssh:a' }, 2)
expect(waiting.map((change) => change.opId)).toEqual([2])
})
})
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileViewPatch, WorkspaceViewSettings } from '../worktree/workspace-view-settings'
import { hostViewSettingsWrite } from './host-screen-operations'
import { readHostMethodReply, setCollapsedWrite } from './host-sidebar-feed-operations'
import {
PENDING_VIEW_CHANGE_STALE_MS,
addPendingViewChange,
reconcilePendingViewChanges,
type PendingViewChange
} from './host-view-pending-changes'
/** How the desktop answered: `waiting` keeps the change (no answer is not a refusal). */
export type PendingViewOutcome = 'settled' | 'refused' | 'unavailable' | 'waiting'
export type PendingViewStatus = {
pending: readonly PendingViewChange[]
/** Some change has waited past PENDING_VIEW_CHANGE_STALE_MS. */
stale: boolean
}
/** Stable for one client, so callers' callbacks never change with what is pending. */
export type PendingViewControls = {
sendView: (patch: MobileViewPatch, payload: WorkspaceViewSettings) => Promise<PendingViewOutcome>
/** `unavailable`: the host has no ui.setCollapsed. */
sendCollapse: (key: string, collapsed: boolean) => Promise<PendingViewOutcome>
/** The last change sent; a read sent after it judges every change up to it. */
lastOpId: () => number
/** The changes still waiting once the desktop's values are read; after a reconnect each is resent. */
reconcile: (
ui: WorkspaceViewSettings,
options: { reconnected: boolean; sentByOpId: number }
) => PendingViewChange[]
}
export type HostPendingViewChanges = { status: PendingViewStatus; controls: PendingViewControls }
type OwnedChanges = { client: RpcClient | null; pending: readonly PendingViewChange[] }
const NO_CHANGES: readonly PendingViewChange[] = []
/** The phone's own option changes until the desktop answers each, for the client that sent them. */
function useHostPendingViewChanges(client: RpcClient | null): HostPendingViewChanges {
const [owned, setOwned] = useState<OwnedChanges>({ client, pending: NO_CHANGES })
const ownedRef = useRef(owned)
const seqRef = useRef(0)
// A fresh writer per screen: its seq restarts at zero.
const [writer] = useState(
() => `phone-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
)
// Another host's changes never show or settle here.
const pending = owned.client === client ? owned.pending : NO_CHANGES
const [stale, setStale] = useState(false)
const current = useCallback(
() => (ownedRef.current.client === client ? ownedRef.current.pending : NO_CHANGES),
[client]
)
const write = useCallback(
(next: readonly PendingViewChange[]) => {
ownedRef.current = { client, pending: next }
setOwned(ownedRef.current)
},
[client]
)
const send = useCallback(
async (change: PendingViewChange): Promise<PendingViewOutcome> => {
if (!client) {
return 'waiting'
}
let outcome: PendingViewOutcome
try {
if (change.kind === 'view') {
const reply = await hostViewSettingsWrite.request(client, change.payload)
outcome = reply.ok ? 'settled' : 'refused'
} else {
const reply = await setCollapsedWrite.request(client, {
key: change.key,
collapsed: change.collapsed,
opId: { writer, seq: change.opId }
})
const unavailable =
readHostMethodReply(reply, setCollapsedWrite.interpret).kind === 'unavailable'
outcome = unavailable ? 'unavailable' : reply.ok ? 'settled' : 'refused'
}
} catch {
return 'waiting'
}
// A change a later one superseded is no longer the user's to hear about.
if (!current().some((candidate) => candidate.opId === change.opId)) {
return 'waiting'
}
write(current().filter((candidate) => candidate.opId !== change.opId))
return outcome
},
[client, writer, current, write]
)
const add = useCallback(
(change: PendingViewChange) => {
write(addPendingViewChange(current(), change))
return send(change)
},
[current, write, send]
)
const nextOp = (): { opId: number; sentAt: number } => {
seqRef.current += 1
return { opId: seqRef.current, sentAt: Date.now() }
}
const sendView = useCallback(
(patch: MobileViewPatch, payload: WorkspaceViewSettings) =>
add({ ...nextOp(), kind: 'view', patch, payload }),
[add]
)
const sendCollapse = useCallback(
(key: string, collapsed: boolean) => add({ ...nextOp(), kind: 'collapse', key, collapsed }),
[add]
)
const lastOpId = useCallback(() => seqRef.current, [])
const reconcile = useCallback(
(ui: WorkspaceViewSettings, options: { reconnected: boolean; sentByOpId: number }) => {
const waiting = reconcilePendingViewChanges(current(), ui, options.sentByOpId)
if (waiting.length !== current().length) {
write(waiting)
}
if (options.reconnected) {
// Each sets a desired value, so sending one again is safe.
waiting.forEach((change) => void send(change))
}
return waiting
},
[current, write, send]
)
useEffect(() => {
const oldest = pending[0]
if (!oldest) {
setStale(false)
return
}
const wait = oldest.sentAt + PENDING_VIEW_CHANGE_STALE_MS - Date.now()
setStale(wait <= 0)
if (wait <= 0) {
return
}
const timer = setTimeout(() => setStale(true), wait)
return () => clearTimeout(timer)
}, [pending])
const controls = useMemo(
() => ({ sendView, sendCollapse, lastOpId, reconcile }),
[sendView, sendCollapse, lastOpId, reconcile]
)
const status = useMemo(() => ({ pending, stale }), [pending, stale])
return { status, controls }
}
/** Switch point: a page built without the feed sends its changes best-effort, with no pending state. */
export const useSwitchedHostPendingViewChanges: (
client: RpcClient | null
) => HostPendingViewChanges | null = ORCA_SIDEBAR_INPUTS_IS_MAIN
? useHostPendingViewChanges
: () => null
@@ -27,7 +27,7 @@ describe('useHostProjectSections', () => {
client,
connState: 'connected',
hostId,
state: { clientRef, setServedProjectSections: () => {} }
state: { clientRef, setServedProjectSections: () => {}, setHostProjectGroups: () => {} }
})
return null
}
@@ -1,10 +1,13 @@
import { useCallback, useRef } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
import {
hostProjectGroupsRead,
projectSectionsRead,
readServedProjectSections
} from '../worktree/project-sections-operations'
import { readHostMethodReply } from './host-sidebar-feed-operations'
import type { HostScreenState } from './use-host-screen-state'
const PROJECT_SECTIONS_REFRESH_MS = 60_000
@@ -17,10 +20,10 @@ export function useHostProjectSections(args: {
client: RpcClient | null
connState: ConnectionState
hostId: string | undefined
state: Pick<HostScreenState, 'clientRef' | 'setServedProjectSections'>
state: Pick<HostScreenState, 'clientRef' | 'setServedProjectSections' | 'setHostProjectGroups'>
}): (options?: { force?: boolean }) => Promise<void> {
const { client, connState, hostId, state } = args
const { clientRef, setServedProjectSections } = state
const { clientRef, setServedProjectSections, setHostProjectGroups } = state
const inFlight = useRef(new WeakSet<RpcClient>())
// Per client, so a reused screen for another host fetches its catalog at once.
const fetchedAt = useRef(new WeakMap<RpcClient, number>())
@@ -46,11 +49,19 @@ export function useHostProjectSections(args: {
if (sections.kind === 'answered') {
fetchedAt.current.set(client, Date.now())
setServedProjectSections(sections.value)
// Switch point: with no window catalog's groups, a feed-built list reads the host's own.
if (ORCA_SIDEBAR_INPUTS_IS_MAIN && !sections.value?.projectGroups) {
const reply = await hostProjectGroupsRead.request(client).catch(() => null)
const groups = reply && readHostMethodReply(reply, hostProjectGroupsRead.interpret)
if (groups?.kind === 'answered' && clientRef.current === client) {
setHostProjectGroups(groups.value)
}
}
}
} finally {
inFlight.current.delete(client)
}
},
[client, clientRef, connState, hostId, setServedProjectSections]
[client, clientRef, connState, hostId, setServedProjectSections, setHostProjectGroups]
)
}
@@ -0,0 +1,61 @@
import { useEffect, useRef, useState } from 'react'
import { MAX_REPO_ICON_IDS_PER_READ } from '../../../src/shared/sidebar-list/repo-icon-data-urls-contract'
import type { RpcClient } from '../transport/rpc-client'
import { readHostMethodReply, sidebarRepoIconsRead } from './host-sidebar-feed-operations'
const NO_DATA_URLS: ReadonlyMap<string, string | null> = new Map()
/**
* The data URL behind each icon id the feed names, read once per id and client. Null: the host has
* no image within its caps, and the phone draws its own badge. Ids are content hashes, so a changed
* icon arrives as a new id.
*/
export function useHostRepoIconDataUrls(
client: RpcClient | null,
ids: readonly string[]
): ReadonlyMap<string, string | null> {
// Keyed by client: another host's icons never show here.
const [read, setRead] = useState<{
client: RpcClient | null
dataUrls: ReadonlyMap<string, string | null>
}>({ client, dataUrls: NO_DATA_URLS })
const askedRef = useRef<{ client: RpcClient | null; ids: Set<string> }>({
client,
ids: new Set()
})
useEffect(() => {
if (!client) {
return
}
if (askedRef.current.client !== client) {
askedRef.current = { client, ids: new Set() }
}
const asked = askedRef.current.ids
const wanted = ids.filter((id) => !asked.has(id))
for (let start = 0; start < wanted.length; start += MAX_REPO_ICON_IDS_PER_READ) {
const batch = wanted.slice(start, start + MAX_REPO_ICON_IDS_PER_READ)
batch.forEach((id) => asked.add(id))
void sidebarRepoIconsRead
.request(client, { ids: batch })
.then((reply) => readHostMethodReply(reply, sidebarRepoIconsRead.interpret))
.catch(() => ({ kind: 'failed' as const }))
.then((reply) => {
if (reply.kind === 'failed') {
// Asked again on the next feed install; a host without the method is not.
batch.forEach((id) => asked.delete(id))
}
if (reply.kind !== 'answered' || askedRef.current.client !== client) {
return
}
setRead((current) => {
const next = new Map(current.client === client ? current.dataUrls : NO_DATA_URLS)
reply.value.icons.forEach(({ id, dataUrl }) => next.set(id, dataUrl))
return { client, dataUrls: next }
})
})
}
}, [client, ids])
return read.client === client ? read.dataUrls : NO_DATA_URLS
}
@@ -18,15 +18,14 @@ import {
useReconnectAttempt,
useRelayRecoveryStatus
} from '../transport/client-context-connection-metrics'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
import { buildProjectSectionCatalog } from '../worktree/project-section-catalog'
import { useHostListSections } from './use-host-list-sections'
import { useSwitchedHostSidebarFeed } from './use-host-sidebar-feed'
import { useHostProjectSections } from './use-host-project-sections'
import { useHostRepoMetadata, type FetchHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenIdentity } from './use-host-screen-identity'
import { useHostScreenState } from './use-host-screen-state'
import { useHostViewSettings } from './use-host-view-settings'
import { rowFiltersForView } from '../worktree/workspace-view-settings'
import { countActiveWorkspaceFilters } from '../../../src/shared/workspace-view/workspace-row-filters'
import { useHostWorktreeActions } from './use-host-worktree-actions'
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
@@ -89,6 +88,7 @@ export function useHostScreenController({
},
[fetchProjectSections, fetchRepoMetadataOnly]
)
const sidebarFeed = useSwitchedHostSidebarFeed(client)
const catalog = useHostWorktreeCatalog({
client,
connState,
@@ -96,6 +96,7 @@ export function useHostScreenController({
fetchRepoMetadata,
hostCapabilities,
hostId,
sidebarFeed: sidebarFeed?.triggers ?? null,
state,
syncViewSettingsFromDesktop: settings.syncViewSettingsFromDesktop
})
@@ -120,40 +121,9 @@ export function useHostScreenController({
const showNewWorktree = resolvedRouteActionState.showNewWorktree
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
const projectCatalog = useMemo(
() =>
buildProjectSectionCatalog(
state.servedProjectSections,
state.desktopRepos,
state.view.filters.visibleHostIds,
state.view.projectOrderBy
),
[
state.servedProjectSections,
state.desktopRepos,
state.view.filters.visibleHostIds,
state.view.projectOrderBy
]
)
const collapsedGroups = useMemo(
() => new Set(state.view.collapsedGroups),
[state.view.collapsedGroups]
)
const rowFilters = useMemo(() => rowFiltersForView(state.view), [state.view])
const sectionsResult = useWorkspaceSections({
displayWorktrees,
sortMode: state.view.sortBy,
filters: rowFilters,
search: state.search,
groupMode: state.view.groupBy,
pinnedIds: state.pinnedIds,
showPinnedInGroups: state.showPinnedInGroups,
projectCatalog,
filterCatalogRepos: state.servedProjectSections?.repos ?? state.desktopRepos,
collapsedGroups,
workspaceStatuses: state.view.workspaceStatuses,
repoHostIdByRepoId: state.repoHostIdByRepoId
})
const feed = sidebarFeed?.feed ?? null
const sectionsResult = useHostListSections({ client, feed, displayWorktrees, state })
const { collapsedGroups, projectOrderBy } = sectionsResult
const { uniqueRepos } = sectionsResult
const activeFilterCount = useMemo(
() =>
@@ -172,8 +142,9 @@ export function useHostScreenController({
activeWorktreeScroll,
catalog,
collapsedGroups,
feed,
// Resolved: the desktop's value once read; the served catalog's (or the default) before.
projectOrderBy: projectCatalog.projectOrderBy,
projectOrderBy,
client,
connState,
contentMaxWidth,
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
import { getCachedWorktrees } from '../cache/worktree-cache'
import { loadPinnedIds } from '../storage/preferences'
import { loadHosts, updateLastConnected } from '../transport/host-store'
@@ -37,7 +38,8 @@ export function useHostScreenIdentity(args: {
// Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
useEffect(() => {
if (!hostId) {
// Switch point: a feed-built list takes pins from the host alone.
if (!hostId || ORCA_SIDEBAR_INPUTS_IS_MAIN) {
return
}
let stale = false
@@ -10,6 +10,7 @@ import type { Worktree } from '../worktree/workspace-list-sections'
import type { ServedProjectSections } from '../worktree/project-sections-operations'
import type { SectionRepo } from '../worktree/workspace-list-types'
import { NO_SERVER_WORKSPACES, type ServerWorkspaces } from '../worktree/server-workspaces'
import type { ProjectGroup } from '../../../src/shared/project-group-types'
export function useHostScreenState(hostId: string | undefined, action: string | undefined) {
const [initialCache] = useState(() =>
@@ -81,6 +82,8 @@ export function useHostScreenState(hostId: string | undefined, action: string |
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
// Desktop's "Also show pinned worktrees in their original lists"; off by default.
const [showPinnedInGroups, setShowPinnedInGroups] = useState(false)
// A feed-built list's project groups on a host with no window to publish them.
const [hostProjectGroups, setHostProjectGroups] = useState<readonly ProjectGroup[]>([])
return {
actionTarget,
@@ -88,6 +91,7 @@ export function useHostScreenState(hostId: string | undefined, action: string |
clientRef,
confirmDelete,
confirmRemoveHost,
hostProjectGroups,
actionError,
error,
fetchRepoMetadataInFlightRef,
@@ -115,6 +119,7 @@ export function useHostScreenState(hostId: string | undefined, action: string |
setCatalogError,
setConfirmDelete,
setConfirmRemoveHost,
setHostProjectGroups,
setActionError,
setError,
setHostLabelById,
@@ -0,0 +1,98 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
import {
SIDEBAR_INPUTS_SEMANTICS_VERSION,
type SidebarInputsChangedEvent
} from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
import {
readSidebarFeedDomains,
type SidebarFeedDomains
} from '../../../src/shared/sidebar-list/sidebar-feed-domains'
import type { RpcClient } from '../transport/rpc-client'
import {
HostSidebarFeedReader,
type HostSidebarFeedRead,
type HostSidebarFeedTriggers
} from './host-sidebar-feed-reader'
/** What this page's list rules mean; a host publishing another meaning would build another list. */
const SUPPORTED_SIDEBAR_SEMANTICS_VERSIONS: readonly number[] = [SIDEBAR_INPUTS_SEMANTICS_VERSION]
export type HostSidebarFeed =
| { kind: 'loading' }
| { kind: 'ready'; domains: SidebarFeedDomains }
/** The host lacks the feed or means other things by it: only reopening the page can match it. */
| { kind: 'reopen' }
export type HostSidebarFeedControls = {
feed: HostSidebarFeed
/** Stable for the screen's life, so the refresh lifecycle never restarts for them. */
triggers: HostSidebarFeedTriggers
}
const NOTHING_READ: HostSidebarFeedRead = { installed: null, unavailable: false }
export function readHostSidebarFeed({
installed,
unavailable
}: HostSidebarFeedRead): HostSidebarFeed {
if (unavailable) {
return { kind: 'reopen' }
}
if (!installed) {
return { kind: 'loading' }
}
if (!SUPPORTED_SIDEBAR_SEMANTICS_VERSIONS.includes(installed.semanticsVersion)) {
return { kind: 'reopen' }
}
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 {
const readerRef = useRef<HostSidebarFeedReader | null>(null)
const installListenersRef = useRef(new Set<() => void>())
const [read, setRead] = useState(NOTHING_READ)
useEffect(() => {
setRead(NOTHING_READ)
if (!client) {
return
}
let lastInstalled: HostSidebarFeedRead['installed'] = null
const reader = new HostSidebarFeedReader(client, (next) => {
setRead(next)
if (next.installed && next.installed !== lastInstalled) {
installListenersRef.current.forEach((listener) => listener())
}
lastInstalled = next.installed
})
readerRef.current = reader
return () => {
reader.dispose()
if (readerRef.current === reader) {
readerRef.current = null
}
}
}, [client])
const triggers = useMemo(
(): HostSidebarFeedTriggers => ({
refresh: () => readerRef.current?.refresh(),
changed: (notice: Pick<SidebarInputsChangedEvent, 'generation' | 'revision'>) =>
readerRef.current?.changed(notice),
onInstalled: (listener: () => void) => {
installListenersRef.current.add(listener)
return () => installListenersRef.current.delete(listener)
}
}),
[]
)
const feed = useMemo(() => readHostSidebarFeed(read), [read])
return { feed, triggers }
}
/** Switch point: a page built without the feed reads none. */
export const useSwitchedHostSidebarFeed: (
client: RpcClient | null
) => HostSidebarFeedControls | null = ORCA_SIDEBAR_INPUTS_IS_MAIN ? useHostSidebarFeed : () => null
+100 -48
View File
@@ -27,17 +27,28 @@ import type {
} from '../../../src/shared/ui-chrome-types'
import { getWorktreeCardModeUpdates } from '../../../src/shared/worktree/card-properties'
import type { HostScreenState } from './use-host-screen-state'
import { applyCollapse, withPendingViewChanges } from './host-view-pending-changes'
import {
useSwitchedHostPendingViewChanges,
type PendingViewStatus
} from './use-host-pending-view-changes'
import type { FilterState } from '../worktree/workspace-list-types'
const REFUSED_NOTE = 'The desktop did not take that change.'
const NOTHING_PENDING: PendingViewStatus = { pending: [], stale: false }
export function useHostViewSettings(args: {
client: RpcClient | null
connState: ConnectionState
state: HostScreenState
}) {
const { client, connState, state } = args
const { clientRef, setShowPinnedInGroups, setView, viewRef } = state
const { clientRef, setActionError, setShowPinnedInGroups, setView, viewRef } = state
const localEditsRef = useRef(0)
// Null unless the page lists from the feed: the changes the desktop has not answered.
const pendingViewChanges = useSwitchedHostPendingViewChanges(client)
const pendingChanges = pendingViewChanges?.controls ?? null
// The one writer of the view: the ref is its synchronous mirror for edits made before a render.
const applyViewState = useCallback((next: MobileViewState) => {
@@ -45,6 +56,69 @@ export function useHostViewSettings(args: {
setView(next)
}, [])
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
const syncViewSettingsFromDesktop = useCallback(
async (options: { reconnected?: boolean } = {}) => {
if (!client || connState !== 'connected') {
return
}
const requestClient = client
const editsAtRequest = localEditsRef.current
const opsAtRequest = pendingChanges?.lastOpId() ?? 0
try {
// Each half applies on its own, so one failing read does not hold back the other.
const [uiReply, settingsReply] = await Promise.all([
hostViewSettingsRead.request(requestClient).catch(() => null),
hostListSettingsRead.request(requestClient).catch(() => null)
])
// Why the client, not the host id: a new host always arrives with a new client.
if (clientRef.current !== requestClient) {
return
}
const hostSettings = settingsReply
? hostListSettingsRead.interpret(settingsReply)
: { accepted: false as const }
if (hostSettings.accepted) {
setShowPinnedInGroups(hostSettings.value.showPinnedWorktreesInGroups)
}
const uiRead = uiReply ? hostViewSettingsRead.interpret(uiReply) : null
const read = uiRead?.accepted ? uiRead.value : undefined
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
const ui = read as WorkspaceViewSettings | undefined
// What the desktop had not answered when the read went out still shows as asked.
const waiting =
pendingChanges && ui
? pendingChanges.reconcile(ui, {
reconnected: options.reconnected === true,
sentByOpId: opsAtRequest
})
: []
// Why skip after a local edit: this reply may predate it; the edit's own change event re-reads.
if (localEditsRef.current !== editsAtRequest) {
return
}
let next = ui
? withPendingViewChanges(applyDesktopViewSettings(viewRef.current, ui), waiting)
: viewRef.current
if (hostSettings.accepted) {
const { compactWorktreeCards, newCardStyle } = hostSettings.value
next = { ...next, compactWorktreeCards, newCardStyle }
}
if (next !== viewRef.current) {
applyViewState(next)
}
} catch {
// Transient transport failure; retry on the next focus/connect.
}
},
[client, connState, applyViewState, pendingChanges]
)
// A refusal undoes the change by re-reading what the desktop holds.
const refused = useCallback(() => {
setActionError(REFUSED_NOTE)
void syncViewSettingsFromDesktop()
}, [setActionError, syncViewSettingsFromDesktop])
// Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync.
const persistViewSettings = useCallback(
(patch: MobileViewPatch, options: { cardLayoutPreset?: boolean } = {}) => {
@@ -66,57 +140,19 @@ export function useHostViewSettings(args: {
if (Object.keys(payload).length === 0) {
return
}
if (pendingChanges) {
void pendingChanges
.sendView(patch, payload)
.then((outcome) => outcome === 'refused' && refused())
return
}
void hostViewSettingsWrite.request(client, payload).catch(() => {
// Best-effort: view settings are a convenience preference.
})
},
[client, applyViewState]
[client, applyViewState, pendingChanges, refused]
)
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
const syncViewSettingsFromDesktop = useCallback(async () => {
if (!client || connState !== 'connected') {
return
}
const requestClient = client
const editsAtRequest = localEditsRef.current
try {
// Each half applies on its own, so one failing read does not hold back the other.
const [uiReply, settingsReply] = await Promise.all([
hostViewSettingsRead.request(requestClient).catch(() => null),
hostListSettingsRead.request(requestClient).catch(() => null)
])
// Why the client, not the host id: a new host always arrives with a new client.
if (clientRef.current !== requestClient) {
return
}
const hostSettings = settingsReply
? hostListSettingsRead.interpret(settingsReply)
: { accepted: false as const }
if (hostSettings.accepted) {
setShowPinnedInGroups(hostSettings.value.showPinnedWorktreesInGroups)
}
// Why skip after a local edit: this reply may predate it; the edit's own change event re-reads.
if (localEditsRef.current !== editsAtRequest) {
return
}
const uiRead = uiReply ? hostViewSettingsRead.interpret(uiReply) : null
const read = uiRead?.accepted ? uiRead.value : undefined
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
const ui = read as WorkspaceViewSettings | undefined
let next = ui ? applyDesktopViewSettings(viewRef.current, ui) : viewRef.current
if (hostSettings.accepted) {
const { compactWorktreeCards, newCardStyle } = hostSettings.value
next = { ...next, compactWorktreeCards, newCardStyle }
}
if (next !== viewRef.current) {
applyViewState(next)
}
} catch {
// Transient transport failure; retry on the next focus/connect.
}
}, [client, connState, applyViewState])
const handleSortChange = useCallback(
(value: MobileViewState['sortBy']) => {
persistViewSettings({ sortBy: value })
@@ -210,21 +246,37 @@ export function useHostViewSettings(args: {
const toggleCollapsed = useCallback(
(key: string) => {
// One section's change, so another device's edit to a sibling section is kept.
if (pendingChanges && client) {
const collapsed = !viewRef.current.collapsedGroups.includes(key)
localEditsRef.current += 1
applyViewState(applyCollapse(viewRef.current, key, collapsed))
void pendingChanges.sendCollapse(key, collapsed).then((outcome) => {
if (outcome === 'unavailable') {
persistViewSettings({ collapsedGroups: [...viewRef.current.collapsedGroups] })
} else if (outcome === 'refused') {
refused()
}
})
return
}
const next = new Set(viewRef.current.collapsedGroups)
if (!next.delete(key)) {
next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
[persistViewSettings]
[client, applyViewState, persistViewSettings, pendingChanges, refused]
)
const toggleWorktreeLineage = useCallback(
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)),
(item: Worktree) =>
toggleCollapsed(item.lineageGroupKey ?? getMobileWorkspaceLineageGroupKey(item)),
[toggleCollapsed]
)
return {
clearFilters,
pendingViewChanges: pendingViewChanges?.status ?? NOTHING_PENDING,
setAgentActivityDisplayMode,
setCardLayout,
setProjectOrderBy,
@@ -1,4 +1,5 @@
import { useCallback } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
import type { useRouter } from 'expo-router'
import { floatingWorkspaceSessionPath } from '../session/floating-workspace'
import { savePinnedIds } from '../storage/preferences'
@@ -95,6 +96,10 @@ export function useHostWorktreeActions(args: {
const updateLocalPins = useCallback(
(worktreeId: string, pinned: boolean) => {
// Switch point: a feed-built list keeps no pin cache of its own.
if (ORCA_SIDEBAR_INPUTS_IS_MAIN) {
return
}
setPinnedIds((prev) => {
const next = new Set(prev)
if (pinned) {
@@ -252,7 +257,10 @@ export function useHostWorktreeActions(args: {
if (routeHost === null) {
return
}
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
// Switch point: a feed-built list highlights only what the desktop has open.
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
}
const rowClient = clientForRow(item)
if (rowClient && connState === 'connected') {
void worktreeActivate
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef } from 'react'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../src/shared/sidebar-inputs-switch'
import { useFocusEffect } from 'expo-router'
import { setCachedWorktrees } from '../cache/worktree-cache'
import type { RpcClient } from '../transport/rpc-client'
@@ -6,6 +7,7 @@ import type { ConnectionState } from '../transport/types'
import { RpcIncompatibleReplyError } from '../transport/rpc-incompatible-reply-error'
import { useWorktreeResync } from '../transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh'
import type { HostSidebarFeedTriggers } from './host-sidebar-feed-reader'
import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot'
import {
clearConfirmedActiveWorktreeIdentity,
@@ -28,8 +30,10 @@ export function useHostWorktreeCatalog(args: {
fetchRepoMetadata: FetchHostRepoMetadata
hostCapabilities: readonly string[]
hostId: string | undefined
/** Null in a build that lists without the feed. */
sidebarFeed: HostSidebarFeedTriggers | null
state: HostScreenState
syncViewSettingsFromDesktop: () => Promise<void>
syncViewSettingsFromDesktop: (options?: { reconnected?: boolean }) => Promise<void>
}) {
const {
client,
@@ -38,6 +42,7 @@ export function useHostWorktreeCatalog(args: {
fetchRepoMetadata,
hostCapabilities,
hostId,
sidebarFeed,
state,
syncViewSettingsFromDesktop
} = args
@@ -87,8 +92,10 @@ export function useHostWorktreeCatalog(args: {
[hostCapabilities]
)
// A re-read asked for during a flight: that flight may predate the change that asked.
const fetchWorktreesQueuedRef = useRef(false)
const fetchWorktrees = useCallback(
async (options: { allowDuringModal?: boolean } = {}) => {
async (options: { allowDuringModal?: boolean; queueIfInFlight?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId) {
return
}
@@ -97,6 +104,7 @@ export function useHostWorktreeCatalog(args: {
}
// Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
if (fetchWorktreesInFlightRef.current) {
fetchWorktreesQueuedRef.current ||= options.queueIfInFlight === true
return
}
fetchWorktreesInFlightRef.current = true
@@ -150,16 +158,22 @@ export function useHostWorktreeCatalog(args: {
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
// Switch point: a feed-built list keeps no pin cache of its own.
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
setPinnedIds((prev) => {
if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) {
return prev
}
if (hostId) {
void savePinnedIds(hostId, serverPinned)
}
return serverPinned
})
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
setPinnedIds((prev) => {
if (
serverPinned.size === prev.size &&
[...serverPinned].every((id) => prev.has(id))
) {
return prev
}
if (hostId) {
void savePinnedIds(hostId, serverPinned)
}
return serverPinned
})
}
}
} catch (error) {
// Will retry on reconnect
@@ -172,10 +186,19 @@ export function useHostWorktreeCatalog(args: {
}
} finally {
fetchWorktreesInFlightRef.current = false
if (fetchWorktreesQueuedRef.current) {
fetchWorktreesQueuedRef.current = false
// The latest render's fetch, without this flight's modal override: the client may have changed.
void fetchWorktreesRef.current({ queueIfInFlight: true })
}
}
},
[client, connState, hostId, fetchServerRows]
)
const fetchWorktreesRef = useRef(fetchWorktrees)
useEffect(() => {
fetchWorktreesRef.current = fetchWorktrees
}, [fetchWorktrees])
useFocusEffect(
useCallback(() => {
@@ -194,9 +217,17 @@ export function useHostWorktreeCatalog(args: {
client,
fetchWorktrees,
fetchRepoMetadata,
syncViewSettings: syncViewSettingsFromDesktop
syncViewSettings: syncViewSettingsFromDesktop,
sidebarFeed
})
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
}, [
client,
connState,
fetchWorktrees,
fetchRepoMetadata,
syncViewSettingsFromDesktop,
sidebarFeed
])
useFocusEffect(
useCallback(() => {
@@ -57,7 +57,8 @@ function buildController(options: SheetOptions) {
setFilter: vi.fn(),
setProjectOrderBy: vi.fn(),
toggleCardProperty: vi.fn(),
toggleRepoFilter: vi.fn()
toggleRepoFilter: vi.fn(),
pendingViewChanges: { pending: [], stale: false }
}
const controller = {
activeFilterCount: 0,
@@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from 'react'
import { Pressable, Text, View } from 'react-native'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { useBackClaim } from '../navigation/use-back-claim'
@@ -75,6 +75,22 @@ export function WorkspaceOptionsSheet({ controller }: { controller: HostScreenCo
)
}
/** The phone's own changes the desktop has not answered; a slow answer is not a refusal. */
function WorkspaceOptionsPending({ settings }: Pick<HostScreenController, 'settings'>) {
const { pending, stale } = settings.pendingViewChanges
if (!pending.some((change) => change.kind === 'view')) {
return null
}
return (
<View style={styles.optionPending} accessibilityLiveRegion="polite">
<ActivityIndicator size="small" color={colors.textMuted} />
<Text style={styles.filterModalSubtitle}>
{stale ? 'Still waiting for the desktop' : 'Saving to the desktop'}
</Text>
</View>
)
}
// Its own component so the host list is built only while the sheet is open.
function WorkspaceOptionsContent({
controller,
@@ -134,6 +150,7 @@ function WorkspaceOptionsContent({
<View style={styles.filterModalHeading}>
<Text style={styles.filterModalTitle}>{WORKSPACE_OPTIONS_TITLE}</Text>
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
<WorkspaceOptionsPending settings={settings} />
</View>
{controller.activeFilterCount > 0 && (
<Pressable onPress={settings.clearFilters}>
@@ -45,6 +45,18 @@ 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() {
return (
<View style={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.
</Text>
</View>
)
}
const styles = StyleSheet.create({
centered: {
flex: 1,
@@ -41,7 +41,9 @@ describe('startHostWorktreeRefresh', () => {
stop = null
})
function start(): void {
function start(
sidebarFeed: Parameters<typeof startHostWorktreeRefresh>[0]['sidebarFeed'] = null
): void {
const client = {
subscribe: vi.fn(
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
@@ -50,7 +52,13 @@ describe('startHostWorktreeRefresh', () => {
}
)
} as unknown as RpcClient
stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata, syncViewSettings })
stop = startHostWorktreeRefresh({
client,
fetchWorktrees,
fetchRepoMetadata,
syncViewSettings,
sidebarFeed
})
}
afterEach(() => {
@@ -155,4 +163,52 @@ describe('startHostWorktreeRefresh', () => {
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
})
describe('with the host’s sidebar feed', () => {
it('reads it at start, on every ready and on foreground, and never polls', async () => {
const sidebarFeed = { refresh: vi.fn(), changed: vi.fn(), onInstalled: vi.fn(() => () => {}) }
start(sidebarFeed)
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(1)
eventListener?.({ type: 'ready', subscriptionId: 'events-1' })
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(2)
eventListener?.({ type: 'ready', subscriptionId: 'events-2' })
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(3)
expect(syncViewSettings).toHaveBeenCalledWith({ reconnected: true })
appState.listener?.('active')
expect(sidebarFeed.refresh).toHaveBeenCalledTimes(4)
fetchWorktrees.mockClear()
await vi.advanceTimersByTimeAsync(30_000)
expect(fetchWorktrees).not.toHaveBeenCalled()
})
it('re-reads each row’s live content on every install, queued behind a read in flight', () => {
let installed = (): void => {}
const stopFollowing = vi.fn()
const sidebarFeed = {
refresh: vi.fn(),
changed: vi.fn(),
onInstalled: vi.fn((listener: () => void) => {
installed = listener
return stopFollowing
})
}
start(sidebarFeed)
fetchWorktrees.mockClear()
installed()
expect(fetchWorktrees).toHaveBeenCalledWith({ queueIfInFlight: true })
stop?.()
stop = null
expect(stopFollowing).toHaveBeenCalledOnce()
})
it('hands each change notice to the feed', () => {
const sidebarFeed = { refresh: vi.fn(), changed: vi.fn(), onInstalled: vi.fn(() => () => {}) }
start(sidebarFeed)
eventListener?.({ type: 'sidebarInputsChanged', generation: 'g', revision: 4 })
expect(sidebarFeed.changed).toHaveBeenCalledWith(
expect.objectContaining({ generation: 'g', revision: 4 })
)
})
})
})
+37 -18
View File
@@ -1,10 +1,11 @@
import { AppState } from 'react-native'
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
import type { RpcClient } from '../transport/rpc-client'
import type { HostSidebarFeedTriggers } from '../host-screen/host-sidebar-feed-reader'
const WORKTREE_REFRESH_MS = 3000
type WorktreeRefreshOptions = { allowDuringModal?: boolean }
type WorktreeRefreshOptions = { allowDuringModal?: boolean; queueIfInFlight?: boolean }
type RepoRefreshOptions = { force?: boolean; queueIfInFlight?: boolean }
type HostWorktreeRefreshArgs = {
@@ -12,14 +13,17 @@ type HostWorktreeRefreshArgs = {
fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise<void>
fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise<void>
/** Re-reads the Workspace options the phone shares with the desktop. */
syncViewSettings: () => Promise<void>
syncViewSettings: (options?: { reconnected?: boolean }) => Promise<void>
/** The host's sidebar feed, which replaces the poll: its notices say when anything changed. */
sidebarFeed?: HostSidebarFeedTriggers | null
}
export function startHostWorktreeRefresh({
client,
fetchWorktrees,
fetchRepoMetadata,
syncViewSettings
syncViewSettings,
sidebarFeed
}: HostWorktreeRefreshArgs): () => void {
let stale = false
let eventStreamReady = false
@@ -28,6 +32,7 @@ export function startHostWorktreeRefresh({
if (AppState.currentState !== 'active') {
return
}
sidebarFeed?.refresh()
void fetchWorktrees({ allowDuringModal: true })
void fetchRepoMetadata({ queueIfInFlight: true })
}
@@ -37,18 +42,20 @@ export function startHostWorktreeRefresh({
refreshOnForeground()
}
})
const interval = setInterval(() => {
if (AppState.currentState !== 'active') {
return
}
void fetchWorktrees()
// Why: desktop Settings repo edits (icon/color/name, repo removal) now emit `reposChanged`
// (#11994), but a host on an older build does not; keep this periodic repo.list as the
// convergence safety-net. fetchRepoMetadata self-throttles to REPO_METADATA_REFRESH_MS
// (60s), so this is ~1 request/min while foregrounded — the AppState gate is what removes
// the waste (both stop while backgrounded).
void fetchRepoMetadata()
}, WORKTREE_REFRESH_MS)
const interval = sidebarFeed
? null
: setInterval(() => {
if (AppState.currentState !== 'active') {
return
}
void fetchWorktrees()
// Why: desktop Settings repo edits (icon/color/name, repo removal) now emit `reposChanged`
// (#11994), but a host on an older build does not; keep this periodic repo.list as the
// convergence safety-net. fetchRepoMetadata self-throttles to REPO_METADATA_REFRESH_MS
// (60s), so this is ~1 request/min while foregrounded — the AppState gate is what removes
// the waste (both stop while backgrounded).
void fetchRepoMetadata()
}, WORKTREE_REFRESH_MS)
const unsubscribe = client.subscribe(
'runtime.clientEvents.subscribe',
null,
@@ -58,13 +65,15 @@ export function startHostWorktreeRefresh({
}
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
if (event.type === 'ready') {
// Every ready: a change between the last read and this subscription sent no notice.
sidebarFeed?.refresh()
const replayedAfterReconnect = eventStreamReady
eventStreamReady = true
if (replayedAfterReconnect) {
// Why: client events are not queued while disconnected, so re-read both snapshots after replay.
void fetchWorktrees()
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
void syncViewSettings()
void syncViewSettings({ reconnected: true })
}
return
}
@@ -72,7 +81,9 @@ export function startHostWorktreeRefresh({
eventStreamReady = false
return
}
if (event.type === 'reposChanged') {
if (event.type === 'sidebarInputsChanged') {
sidebarFeed?.changed(event)
} else if (event.type === 'reposChanged') {
// Why: folder workspace mutations publish reposChanged, and worktree.ps owns this catalog.
void fetchWorktrees()
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
@@ -87,12 +98,20 @@ export function startHostWorktreeRefresh({
}
)
// A row's live content (agents, terminals) may follow any install; worktree.ps carries it.
const stopFollowingFeed = sidebarFeed?.onInstalled(
() => void fetchWorktrees({ queueIfInFlight: true })
)
sidebarFeed?.refresh()
void fetchWorktrees()
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
return () => {
stale = true
clearInterval(interval)
if (interval) {
clearInterval(interval)
}
stopFollowingFeed?.()
appStateSubscription.remove()
unsubscribe()
}
@@ -29,7 +29,8 @@ describe('buildProjectSectionCatalog', () => {
repos: [repo('r1'), repo('r1', 'runtime:m4air')],
projects: [],
setups: [],
projectOrderBy: 'manual'
projectOrderBy: 'manual',
projectGroups: undefined
}
const catalog = buildProjectSectionCatalog(
served,
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { MOBILE_PROJECT_SECTIONS_METHOD } from '../../../src/shared/mobile-project-sections-contract'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import type { ProjectHostSetupMethod } from '../../../src/shared/project-types'
import type { ProjectGroup, ProjectGroupCreatedFrom } from '../../../src/shared/project-group-types'
import type { RepoKind } from '../../../src/shared/repo-types'
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
@@ -56,12 +57,35 @@ const setupSchema = z.object({
executionHostId: salvagedOptional('executionHostId', hostIdSchema)
})
const PROJECT_GROUP_CREATED_FROM_ARMS = hostUnionArms<ProjectGroupCreatedFrom>({
manual: true,
'folder-scan': true,
migration: true
})
const projectGroupSchema = z.object({
id: z.string(),
name: z.string(),
parentPath: z.string().nullable(),
connectionId: salvagedOptional('connectionId', z.string().nullable()),
executionHostId: salvagedOptional('executionHostId', z.string().nullable()),
parentGroupId: z.string().nullable(),
createdFrom: z.enum(PROJECT_GROUP_CREATED_FROM_ARMS).catch('manual'),
tabOrder: z.number(),
isCollapsed: z.boolean(),
color: z.string().nullable(),
createdAt: z.number(),
updatedAt: z.number()
}) satisfies z.ZodType<ProjectGroup, unknown>
const catalogSchema = z.object({
repos: salvagingArray(repoSchema),
projects: salvagingArray(z.object({ id: z.string(), displayName: z.string() })),
setups: salvagingArray(setupSchema),
// Until the phone has read the shared UI value (projectOrderBy, ui.get), it orders by this.
projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual')
projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual'),
// Every host's project groups, for a list built from the sidebar feed; older desktops omit it.
projectGroups: salvagedOptional('projectGroups', salvagingArray(projectGroupSchema))
})
/** The desktop sidebar's Project grouping inputs, as this phone reads them. */
@@ -81,6 +105,20 @@ export const projectSectionsRead = bindDeferredRpcOperation(
})
)
/** A host's own project groups, which a feed-built list files folder workspaces under. */
export const hostProjectGroupsRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'projectGroup.host-groups-or-skip',
method: 'projectGroup.list',
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant(
'project-groups',
z.object({ groups: salvagingArray(projectGroupSchema) }).transform(({ groups }) => groups)
)
})
)
/**
* The desktop's sidebar catalog: absent (null) on a desktop that predates the read, and left
* as it was after a transient failure, so a dropped reply never regroups the list.
@@ -57,6 +57,8 @@ export type Worktree = {
lineageDepth?: number
lineageChildCount?: number
lineageCollapsed?: boolean
/** The collapse key the shared builder gave this row's children; set only on a feed-built list. */
lineageGroupKey?: string
isLastLineageChild?: boolean
liveTerminalCount: number
hasAttachedPty: boolean
@@ -90,5 +92,7 @@ export type Section = {
icon?: 'pin'
/** Under Project grouping, the repo whose colour and icon the header shows, as on the desktop. */
repo?: SectionRepo
/** The header's own count from a feed-built list, which a collapsed section still shows. */
count?: number
data: Worktree[]
}
+35 -2
View File
@@ -1,6 +1,6 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { ALL_RPC_METHODS } from './rpc/methods'
import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist'
@@ -46,6 +46,13 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
'agentSession.release'
]
// Why: only a page built with ORCA_SIDEBAR_INPUTS=main calls these, and only such a host serves them.
const MOBILE_SIDEBAR_INPUTS_RPC_METHODS = [
'sidebarInputs.read',
'sidebarInputs.repoIcons',
'ui.setCollapsed'
]
const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [
// Why: shared-control unsubscribe methods are sent from generated cleanup
// paths, so literal mobile source scanning cannot discover every one.
@@ -98,7 +105,9 @@ function mobileLiteralRpcMethods(): string[] {
}
function mobileRpcMethods(): string[] {
return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort()
return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])]
.filter((method) => !MOBILE_SIDEBAR_INPUTS_RPC_METHODS.includes(method))
.sort()
}
function mobileRpcAllowlist(): ReadonlySet<string> {
@@ -151,6 +160,30 @@ describe('mobile RPC allowlist', () => {
expect(missing).toEqual([])
})
it('allows and registers the sidebar feed the phone reads only in a switched-on build', async () => {
expect(mobileLiteralRpcMethods()).toEqual(
expect.arrayContaining(MOBILE_SIDEBAR_INPUTS_RPC_METHODS)
)
for (const method of MOBILE_SIDEBAR_INPUTS_RPC_METHODS) {
expect(mobileRpcAllowlist().has(method)).toBe(false)
}
vi.resetModules()
vi.doMock('../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
try {
const { MOBILE_RPC_METHOD_ALLOWLIST: switchedOn } =
await import('./runtime-rpc/runtime-rpc-mobile-method-allowlist')
const { ALL_RPC_METHODS: switchedOnMethods } = await import('./rpc/methods')
const registered = new Set<string>(switchedOnMethods.map((method) => method.name))
for (const method of MOBILE_SIDEBAR_INPUTS_RPC_METHODS) {
expect(switchedOn.has(method)).toBe(true)
expect(registered.has(method)).toBe(true)
}
} finally {
vi.doUnmock('../../shared/sidebar-inputs-switch')
vi.resetModules()
}
})
it('allows every cleanup RPC for mobile streaming subscriptions', () => {
const allowed = mobileRpcAllowlist()
const missing = MOBILE_STREAMING_CLEANUP_RPC_METHODS.filter((method) => !allowed.has(method))
+1
View File
@@ -106,6 +106,7 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase {
pairedServerRows: requireSwitchedOn(this.pairedServerRows),
workspaceCreations: requireSwitchedOn(this.workspaceCreations),
workspaceRows: requireSwitchedOn(this.workspaceRows),
repoIcons: requireSwitchedOn(this.repoIconDataUrls),
store: () => this.store,
onClientEvent: (listener, options) => this.onClientEvent(listener, options)
}),
@@ -108,6 +108,8 @@ export const PAIRED_DESKTOP_METHODS = [
'settings.updateTerminalQuickCommands',
'terminal.getAutoRestoreFit',
'terminal.setAutoRestoreFit',
// Switch point: the per-section collapse write and the sidebar feed exist only with ORCA_SIDEBAR_INPUTS=main.
...(ORCA_SIDEBAR_INPUTS_IS_MAIN ? (['ui.setCollapsed', 'sidebarInputs.read'] as const) : [])
// Switch point: the per-section collapse write, the sidebar feed and its icons exist only with ORCA_SIDEBAR_INPUTS=main.
...(ORCA_SIDEBAR_INPUTS_IS_MAIN
? (['ui.setCollapsed', 'sidebarInputs.read', 'sidebarInputs.repoIcons'] as const)
: [])
] as const
@@ -161,6 +161,8 @@ export type RuntimeStore = {
terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority']
worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
hostSettingOverrides?: GlobalSettings['hostSettingOverrides']
activeRuntimeEnvironmentId?: GlobalSettings['activeRuntimeEnvironmentId']
showPinnedWorktreesInGroups?: GlobalSettings['showPinnedWorktreesInGroups']
agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled']
nativeChatSessionOptions?: GlobalSettings['nativeChatSessionOptions']
nativeChatPermissionMode?: GlobalSettings['nativeChatPermissionMode']
@@ -89,6 +89,25 @@ function sources(overrides: {
}
describe('readDesktopSidebarFacts', () => {
it('publishes the focused host and the user’s host names from the settings', () => {
const base = sources({})
const facts = readDesktopSidebarFacts({
...base,
store: {
...base.store,
getSettings: () => ({
activeRuntimeEnvironmentId: 'env-1',
showPinnedWorktreesInGroups: true,
hostSettingOverrides: { 'ssh:box': { displayLabel: ' GPU ' }, local: {} }
})
}
})
expect(facts.focusedHostId).toBe('runtime:env-1')
expect(facts.hostLabels).toEqual({ 'ssh:box': 'GPU' })
expect(facts.showPinnedInGroups).toBe(true)
expect(readDesktopSidebarFacts(sources({})).focusedHostId).toBe('local')
})
it('publishes the desktop focus the window saved to the local session', () => {
const facts = readDesktopSidebarFacts(
sources({
@@ -1,6 +1,7 @@
import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
getSettingsFocusedExecutionHostId,
normalizeExecutionHostOrder,
toRuntimeExecutionHostId,
toSshExecutionHostId,
@@ -8,6 +9,8 @@ import {
} from '../../../shared/execution-host'
import { applyManualRepoOrder } from '../../../shared/manual-repo-order'
import type { DesktopSidebarFacts } from '../../../shared/sidebar-list/desktop-sidebar-facts'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types'
import type { WslHostStatus } from '../../../shared/wsl-host-types'
import type { PersistedUIState } from '../../../shared/persisted-ui-state-types'
@@ -31,6 +34,10 @@ export type DesktopSidebarFactSources = {
hostId: ExecutionHostId
) => Pick<WorkspaceSessionState, 'activeWorktreeId' | 'activeWorkspaceExecutionHostId'>
getUI?: () => Pick<PersistedUIState, 'manualRepoOrder' | 'workspaceHostOrder'>
getSettings?: () => Pick<
GlobalSettings,
'activeRuntimeEnvironmentId' | 'hostSettingOverrides' | 'showPinnedWorktreesInGroups'
>
}
/** The registry's list, which leaves out targets a paired server owns; the window lists the same. */
sshTargets: readonly Pick<SshTarget, 'id' | 'label'>[]
@@ -45,6 +52,7 @@ export function readDesktopSidebarFacts(sources: DesktopSidebarFactSources): Des
// Global session fields live on the local slice alone (workspace-session-host-field-ownership.ts).
const session = store.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)
const ui = store.getUI?.()
const settings = store.getSettings?.()
const repos = runtimeVisibleRepos(store)
const repoHostIds = new Set<ExecutionHostId>(repos.map((repo) => getRepoExecutionHostId(repo)))
@@ -84,6 +92,9 @@ export function readDesktopSidebarFacts(sources: DesktopSidebarFactSources): Des
toRuntimeExecutionHostId(status.environmentId),
{ distro: status.distro, state: status.state }
])
)
),
focusedHostId: getSettingsFocusedExecutionHostId(settings),
hostLabels: Object.fromEntries(getHostDisplayLabelOverrides(settings)),
showPinnedInGroups: settings?.showPinnedWorktreesInGroups === true
}
}
@@ -87,6 +87,22 @@ describe('RepoIconDataUrls without an encoder (orcad)', () => {
})
})
describe('RepoIconDataUrls.idsByRepo', () => {
it('names each repo’s image by host and repo, the ids a read answers', async () => {
const remote = { ...repo('a', AVATAR), connectionId: 'box' }
const { icons } = setup([
repo('a', AVATAR),
remote,
repo('glyph', { type: 'emoji', emoji: 'x' }),
repo('none', null)
])
const id = repoIconId(AVATAR)!
expect(icons.idsByRepo()).toEqual({ 'local|a': id, 'ssh:box|a': id })
expect((await icons.read([id])).icons[0].dataUrl).not.toBeNull()
})
})
describe('RepoIconDataUrls with an encoder (desktop)', () => {
it('re-encodes a fetched avatar at 64 px', async () => {
const encoder = vi.fn<RepoIconPngEncoder>(() => ENCODED)
@@ -4,6 +4,7 @@ import type { Repo } from '../../../shared/repo-types'
import { readRasterImageDimensions } from '../../../shared/raster-image-dimensions'
import {
MAX_REPO_ICON_DATA_URL_BYTES,
repoIconFeedKey,
type RepoIconDataUrlsReply
} from '../../../shared/sidebar-list/repo-icon-data-urls-contract'
import {
@@ -77,10 +78,22 @@ export class RepoIconDataUrls {
return { icons: ids.map((id, index) => ({ id, dataUrl: dataUrls[index] })) }
}
/** Each repo's icon id by `hostId|repoId`, which the feed publishes; repos without an image are absent. */
idsByRepo(): Record<string, string> {
return Object.fromEntries(
this.identifiedIcons().map(({ repo, id }) => [repoIconFeedKey(repo), id])
)
}
private currentIcons(): Map<string, ImageRepoIcon> {
const icons = new Map<string, ImageRepoIcon>()
return new Map(this.identifiedIcons().map(({ icon, id }) => [id, icon]))
}
private identifiedIcons(): { repo: Repo; icon: ImageRepoIcon; id: string }[] {
const identified: { repo: Repo; icon: ImageRepoIcon; id: string }[] = []
const idsBySrc = new Map<string, { source: string; id: string }>()
for (const { repoIcon } of this.options.repos()) {
for (const repo of this.options.repos()) {
const { repoIcon } = repo
if (repoIcon?.type !== 'image') {
continue
}
@@ -90,11 +103,11 @@ export class RepoIconDataUrls {
const id = known?.source === repoIcon.source ? known.id : repoIconId(repoIcon)
if (id) {
idsBySrc.set(repoIcon.src, { source: repoIcon.source, id })
icons.set(id, repoIcon)
identified.push({ repo, icon: repoIcon, id })
}
}
this.idsBySrc = idsBySrc
return icons
return identified
}
private dataUrlFor(id: string, icon: ImageRepoIcon | undefined): Promise<string | null> {
@@ -10,6 +10,7 @@ import type { OrcaRuntimeWithSidebarTemporal } from './orca-runtime-sidebar-temp
import type { PairedServerRowsProducer } from './paired-server-rows-producer'
import type { WorkspaceCreationRegistry } from './workspace-creation-registry'
import type { WorkspaceRowsProducer } from './workspace-rows-producer'
import type { RepoIconDataUrls } from './repo-icon-data-urls'
import type { SidebarInputsFeedDomain } from './sidebar-inputs-feed'
import { readDesktopSidebarFacts } from './desktop-sidebar-facts'
import { buildPairedServerReviewFacts } from '../../../shared/sidebar-list/workspace-review-facts'
@@ -37,6 +38,7 @@ export type SidebarInputsFeedSources = {
pairedServerRows: PairedServerRowsProducer
workspaceCreations: WorkspaceCreationRegistry
workspaceRows: WorkspaceRowsProducer
repoIcons: Pick<RepoIconDataUrls, 'idsByRepo'>
store: () => RuntimeStore | null
onClientEvent: (
listener: (event: RuntimeClientEvent) => void,
@@ -118,12 +120,19 @@ export function sidebarInputsFeedDomains(
const detaches = [
watchCatalog(changed),
store?.onUIChanged?.(changed),
store?.onWorkspaceSessionWritten?.(changed)
store?.onWorkspaceSessionWritten?.(changed),
store?.onSettingsChanged?.(changed)
]
return () => detaches.forEach((detach) => detach?.())
},
rereadOnRead: true
},
{
// Ids only: a reader fetches the images through sidebarInputs.repoIcons.
name: 'repoIcons',
read: () => sources.repoIcons.idsByRepo(),
watch: watchCatalog
},
{
name: 'review',
read: async () => ({
@@ -46,6 +46,7 @@ const NO_FEED: SidebarFeedDomains = {
creations: new Map(),
pairedServerHosts: new Map(),
pairedServerRows: new Map(),
repoIcons: new Map(),
desktop: null,
retention: null
}
@@ -33,6 +33,7 @@ function domains(
creations: new Map(),
pairedServerHosts: new Map(),
pairedServerRows: new Map(serverRows.map((row) => [row.key, row])),
repoIcons: new Map(),
desktop: null,
retention: null
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProjectGroup } from '../../../../shared/project-group-types'
const group: ProjectGroup = {
id: 'g1',
name: 'Notes',
parentPath: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0
}
async function catalogWith(switchedOn: boolean) {
vi.resetModules()
vi.doMock('../../../../shared/sidebar-inputs-switch', () => ({
ORCA_SIDEBAR_INPUTS_IS_MAIN: switchedOn
}))
const { getProjectSectionCatalogFromState } = await import('./project-section-catalog')
return getProjectSectionCatalogFromState({
repos: [],
projects: [],
projectHostSetups: [],
projectOrderBy: 'manual',
projectGroups: [group]
})
}
afterEach(() => {
vi.doUnmock('../../../../shared/sidebar-inputs-switch')
vi.resetModules()
})
describe('the sidebar catalog the window publishes for the phone', () => {
it('carries project groups only for a page reading the sidebar feed', async () => {
expect(await catalogWith(false)).not.toHaveProperty('projectGroups')
expect((await catalogWith(true))?.projectGroups).toEqual([group])
})
})
@@ -5,9 +5,11 @@ import {
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector'
import type { AppState } from '@/store/types'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-switch'
type CatalogSource = {
repos: AppState['repos']
projectGroups: AppState['projectGroups'] | undefined
projection: ProjectHostSetupProjection
projectOrderBy: AppState['projectOrderBy']
catalog: MobileProjectSectionCatalog
@@ -17,7 +19,8 @@ let cachedCatalogSource: CatalogSource | null = null
/** What the sidebar groups Project headers by: state.repos in its order and the selector's projection. */
export function getProjectSectionCatalogFromState(
state: Pick<AppState, 'repos' | 'projects' | 'projectHostSetups' | 'projectOrderBy'>
state: Pick<AppState, 'repos' | 'projects' | 'projectHostSetups' | 'projectOrderBy'> &
Partial<Pick<AppState, 'projectGroups'>>
): MobileProjectSectionCatalog | null {
// Why: graph publication must not fail on a store slice that holds no repo catalog.
if (!Array.isArray(state.repos)) {
@@ -28,14 +31,22 @@ export function getProjectSectionCatalogFromState(
if (
cached &&
cached.repos === state.repos &&
cached.projectGroups === state.projectGroups &&
cached.projection === projection &&
cached.projectOrderBy === state.projectOrderBy
) {
return cached.catalog
}
const catalog = buildMobileProjectSectionCatalog(state.repos, projection, state.projectOrderBy)
const catalog = buildMobileProjectSectionCatalog(
state.repos,
projection,
state.projectOrderBy,
// Switch point: a phone reading the feed lists folder workspaces under these.
ORCA_SIDEBAR_INPUTS_IS_MAIN ? state.projectGroups : undefined
)
cachedCatalogSource = {
repos: state.repos,
projectGroups: state.projectGroups,
projection,
projectOrderBy: state.projectOrderBy,
catalog
@@ -1,3 +1,4 @@
import type { ProjectGroup } from './project-group-types'
import type { ProjectHostSetupProjection } from './project-host-setup-projection'
import type { ProjectSectionProject, ProjectSectionSetup } from './project-section-grouping'
import type { RepoIcon } from './repo-icon'
@@ -17,6 +18,8 @@ export type MobileProjectSectionCatalog = {
projects: ProjectSectionProject[]
setups: ProjectSectionSetup[]
projectOrderBy: ProjectOrderBy
/** Every host's project groups, which a feed reader lists folder workspaces under; older desktops omit it. */
projectGroups?: ProjectGroup[]
}
/** Null until a desktop window has published its sidebar catalog. */
@@ -42,7 +45,8 @@ function sectionRepo(repo: Repo): MobileProjectSectionRepo {
export function buildMobileProjectSectionCatalog(
repos: readonly Repo[],
projection: ProjectHostSetupProjection,
projectOrderBy: ProjectOrderBy
projectOrderBy: ProjectOrderBy,
projectGroups?: readonly ProjectGroup[]
): MobileProjectSectionCatalog {
return {
repos: repos.map(sectionRepo),
@@ -59,6 +63,7 @@ export function buildMobileProjectSectionCatalog(
...(setup.connectionId !== undefined ? { connectionId: setup.connectionId } : {}),
...(setup.executionHostId ? { executionHostId: setup.executionHostId } : {})
})),
projectOrderBy
projectOrderBy,
...(projectGroups ? { projectGroups: [...projectGroups] } : {})
}
}
@@ -20,7 +20,13 @@ export const DesktopSidebarFactsSchema = z.object({
/** Configured SSH targets by host id; status is null until a connection is attempted. */
sshHosts: z.record(z.string(), z.object({ label: z.string(), status: z.string().nullable() })),
/** WSL hosts by host id, where this host can run them. */
wslHosts: z.record(z.string(), z.object({ distro: z.string(), state: z.string() }))
wslHosts: z.record(z.string(), z.object({ distro: z.string(), state: z.string() })),
/** The host the desktop has focused, where its sidebar files rows that name no owner. */
focusedHostId: z.string().optional(),
/** The user's own names for hosts, by host id. */
hostLabels: z.record(z.string(), z.string()).optional(),
/** "Also show pinned workspaces in their groups"; absent reads as off. */
showPinnedInGroups: z.boolean().optional()
})
export type DesktopSidebarFacts = z.infer<typeof DesktopSidebarFactsSchema>
@@ -1,4 +1,6 @@
import { z } from 'zod'
import { getRepoExecutionHostId } from '../execution-host'
import type { Repo } from '../repo-types'
/** Longest data URL one icon may reply with. */
export const MAX_REPO_ICON_DATA_URL_BYTES = 16 * 1024
@@ -18,3 +20,10 @@ export const RepoIconDataUrlsParams = /* @__PURE__ */ (() =>
export type RepoIconDataUrlsReply = {
icons: { id: string; dataUrl: string | null }[]
}
/** A repo's key in the feed's `repoIcons` domain: one repo id can be registered on two hosts. */
export function repoIconFeedKey(
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>
): string {
return `${getRepoExecutionHostId(repo)}|${repo.id}`
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Repo } from '../repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../worktree/types'
import type { ProjectGroupingModel } from '../project-section-grouping'
import type { ProjectSectionModel } from '../project-section-grouping'
import { ALL_GROUP_KEY } from './group-keys'
import type { SidebarPRGroupReader, WorktreeGroupBy } from './row-types'
import { getGroupKeyForWorktree } from './worktree-group-keys'
@@ -20,7 +20,7 @@ export function getRenderedNaturalAnchorRepoIds({
prGroupOf: SidebarPRGroupReader
collapsedGroups: ReadonlySet<string>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
projectGrouping?: ProjectGroupingModel
projectGrouping?: ProjectSectionModel
}): Set<string> {
const renderedRepoIds = new Set<string>()
if (groupBy === 'none') {
@@ -1,4 +1,4 @@
import type { z } from 'zod'
import { z } from 'zod'
import { DesktopSidebarFactsSchema, type DesktopSidebarFacts } from './desktop-sidebar-facts'
import type { InstalledSidebarInputs } from './sidebar-inputs-feed-installer'
import {
@@ -29,6 +29,8 @@ export type SidebarFeedDomains = {
{ freshness: 'live' | 'unverifiable'; host: PairedServerHostFacts | null }
>
pairedServerRows: ReadonlyMap<string, PairedServerWorkspaceRow>
/** Icon ids by `hostId|repoId` (repoIconFeedKey) for this host's repos; a paired server's have none. */
repoIcons: ReadonlyMap<string, string>
/** Null until the host's desktop facts arrive whole. */
desktop: DesktopSidebarFacts | null
retention: SidebarRetention | null
@@ -43,7 +45,8 @@ const entitySchemas = {
freshness: true,
host: true
}),
pairedServerRows: PairedServerRowsSnapshotSchema.shape.servers.element.shape.rows.element
pairedServerRows: PairedServerRowsSnapshotSchema.shape.servers.element.shape.rows.element,
repoIcons: z.string()
}
function readDomain<Schema extends z.ZodType>(
@@ -97,6 +100,9 @@ export function readSidebarFeedDomains(
pairedServerRows:
unchanged('pairedServerRows')?.pairedServerRows ??
readDomain(installed, 'pairedServerRows', entitySchemas.pairedServerRows),
repoIcons:
unchanged('repoIcons')?.repoIcons ??
readDomain(installed, 'repoIcons', entitySchemas.repoIcons),
desktop: desktop.success ? desktop.data : null,
retention: temporal.success ? temporal.data.retention : null
}
+2 -2
View File
@@ -8,7 +8,7 @@ import {
} from '../execution-host'
import type { ProjectGroup } from '../project-group-types'
import { getLogicalRepoOrderRankById } from '../project-section-order'
import type { ProjectGroupingModel } from '../project-section-grouping'
import type { ProjectSectionModel } from '../project-section-grouping'
import type { Repo } from '../repo-types'
import type { ProjectOrderBy } from '../ui-chrome-types'
import {
@@ -78,7 +78,7 @@ export type SidebarFeedView = {
export type SidebarFeedCatalog = {
repos: readonly Repo[]
projectGroups: readonly ProjectGroup[]
projectGrouping: ProjectGroupingModel
projectGrouping: ProjectSectionModel
}
export type SidebarFeedRows = {
+2 -2
View File
@@ -21,7 +21,7 @@ import {
} from '../worktree-occurrence-plan'
import { getWorktreeHostIdentity } from '../worktree/host-qualified-identity'
import type { OrderedGroupEntry, WorktreeGroupEntry } from './worktree-group-entry'
import type { ProjectGroupingModel } from '../project-section-grouping'
import type { ProjectSectionModel } from '../project-section-grouping'
import {
appendProjectGroupSections,
getProjectGroupOccurrencePaths
@@ -70,7 +70,7 @@ export type SidebarRowPlanInputs = {
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
pendingCreations: readonly PendingCreationRef[]
projectGrouping?: ProjectGroupingModel
projectGrouping?: ProjectSectionModel
folderWorkspaces: readonly FolderWorkspace[]
hostLabelById?: ReadonlyMap<string, string>
defaultHostId: ExecutionHostId
@@ -8,7 +8,7 @@ import {
} from '../workspace-statuses'
import { ALL_GROUP_KEY, getPRLaneKey, getProjectGroupHeaderKey } from './group-keys'
import { buildProjectGroupingIndex, getProjectGroupingForRepo } from '../project-section-grouping'
import type { ProjectGroupingModel } from '../project-section-grouping'
import type { ProjectSectionModel } from '../project-section-grouping'
import type { SidebarPRGroupReader, WorktreeGroupBy } from './row-types'
export function getGroupKeyForWorktree(
@@ -18,7 +18,7 @@ export function getGroupKeyForWorktree(
/** Null reads every worktree as having no PR, which is the in-progress lane. */
prGroupOf: SidebarPRGroupReader | null,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
projectGrouping?: ProjectGroupingModel
projectGrouping?: ProjectSectionModel
): string | null {
if (groupBy === 'none') {
return ALL_GROUP_KEY
@@ -43,7 +43,7 @@ export function getGroupKeysForWorktree(
prGroupOf: SidebarPRGroupReader | null,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
projectGroups: readonly ProjectGroup[] = [],
projectGrouping?: ProjectGroupingModel
projectGrouping?: ProjectSectionModel
): string[] {
const groupKey = getGroupKeyForWorktree(
groupBy,
+167
View File
@@ -0,0 +1,167 @@
import { expect } from '@stablyai/playwright-test'
import type { Page } from '@stablyai/playwright-test'
import { buildSidebarFeedRows } from '../../../src/shared/sidebar-list/sidebar-feed-rows'
import {
HostSidebarFeedReader,
type HostSidebarFeedRead
} from '../../../mobile/src/host-screen/host-sidebar-feed-reader'
import {
buildHostFeedCatalog,
buildHostFeedView
} from '../../../mobile/src/host-screen/host-sidebar-feed-inputs'
import { buildHostFeedSections } from '../../../mobile/src/host-screen/host-sidebar-feed-sections'
import { readHostSidebarFeed } from '../../../mobile/src/host-screen/use-host-sidebar-feed'
import { readHostMethodReply } from '../../../mobile/src/host-screen/host-sidebar-feed-operations'
import {
hostPlatformRead,
hostViewSettingsRead
} from '../../../mobile/src/host-screen/host-screen-operations'
import { readServedProjectSections } from '../../../mobile/src/worktree/project-sections-operations'
import {
INITIAL_MOBILE_VIEW,
applyDesktopViewSettings,
type WorkspaceViewSettings
} from '../../../mobile/src/worktree/workspace-view-settings'
import type { RpcResponse } from '../../../mobile/src/transport/types'
import type { Section } from '../../../mobile/src/worktree/workspace-list-types'
import type { PairedMobileClient, PairedMobileSocket } from './paired-mobile-client'
/** One line per row the list shows, in order: what the phone and the desktop sidebar must share. */
export type ListOutline = string[]
function isChangeNotice(
result: unknown
): result is { type: 'sidebarInputsChanged'; generation: string; revision: number } {
return (
typeof result === 'object' &&
result !== null &&
'type' in result &&
result.type === 'sidebarInputsChanged' &&
'generation' in result &&
typeof result.generation === 'string' &&
'revision' in result &&
typeof result.revision === 'number'
)
}
/** The phone's one socket as the page's request port: each reply as the bridge hands it over. */
function socketPort(socket: PairedMobileSocket) {
let sequence = 0
return {
sendRequest: async (method: string, params?: unknown): Promise<RpcResponse> => {
sequence += 1
const id = `phone-list-${sequence}`
socket.send(id, method, params ?? null)
await expect
.poll(() => socket.frames.some((frame) => frame.id === id), { timeout: 30_000 })
.toBe(true)
const frame = socket.frames.find((candidate) => candidate.id === id)
return frame?.ok
? { id, ok: true, result: frame.result }
: { id, ok: false, error: { code: frame?.error?.code ?? 'unknown', message: '' } }
}
}
}
/**
* The phone page's workspace list over a real paired socket, built by the page's own modules: its
* feed reader, the shared builder and its section mapping, from the reads the page makes.
*/
export async function openPhoneFeedList(phone: PairedMobileClient) {
const socket = await phone.openSocket()
socket.send('phone-list-events', 'runtime.clientEvents.subscribe', null)
const port = socketPort(socket)
let read: HostSidebarFeedRead = { installed: null, unavailable: false }
const reader = new HostSidebarFeedReader(port, (next) => (read = next))
let framesSeen = 0
reader.refresh()
const forwardNotices = (): void => {
for (const frame of socket.frames.slice(framesSeen)) {
if (isChangeNotice(frame.result)) {
reader.changed(frame.result)
}
}
framesSeen = socket.frames.length
}
/** The sections the page draws; null until the feed installs. */
const sections = async (): Promise<Section[] | null> => {
forwardNotices()
const feed = readHostSidebarFeed(read)
if (feed.kind !== 'ready') {
return null
}
const [ui, served, platform] = await Promise.all(
['ui.get', 'mobileRelay.projectSections', 'host.platform'].map((method) =>
port.sendRequest(method)
)
)
const uiRead = readHostMethodReply(ui, hostViewSettingsRead.interpret)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the page reads ui.get the same way (use-host-view-settings.ts).
const uiValue = (uiRead.kind === 'answered' ? uiRead.value : {}) as WorkspaceViewSettings
const view = applyDesktopViewSettings(INITIAL_MOBILE_VIEW, uiValue)
const sections = readServedProjectSections(served)
const platformRead = readHostMethodReply(platform, hostPlatformRead.interpret)
const servedValue = sections.kind === 'answered' ? sections.value : null
const built = buildSidebarFeedRows(
feed.domains,
buildHostFeedCatalog(servedValue, [], []),
buildHostFeedView({
view,
projectOrderBy: view.projectOrderBy ?? servedValue?.projectOrderBy ?? 'manual',
desktop: feed.domains.desktop,
hostPlatform: platformRead.kind === 'answered' ? platformRead.value : null
})
)
return buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
liveRows: [],
repoHostIdByRepoId: new Map(),
search: ''
})
}
const outline = async (): Promise<ListOutline> =>
(await sections())?.flatMap((section) => [
// Titles only: the desktop draws a header's count on hover, not in its text.
`HEADER ${section.title}`,
...section.data.map((row) => `ITEM ${row.sectionListKey}${row.isActive ? ' ACTIVE' : ''}`)
]) ?? [readHostSidebarFeed(read).kind]
return {
sections,
outline,
/** The page's feed reader and its replies, for asserting what the page installed. */
feed: () => readHostSidebarFeed(read),
request: port.sendRequest,
close: () => {
reader.dispose()
socket.close()
}
}
}
/** 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(() => {
const prefix = 'worktree-list-option-'
const rows = [...document.querySelectorAll<HTMLElement>('[data-worktree-virtual-row]')].sort(
(a, b) => Number(a.dataset.index ?? 0) - Number(b.dataset.index ?? 0)
)
return rows.flatMap((row) =>
// A row element can itself be the option (item rows) or hold it (headers).
[row, ...row.querySelectorAll<HTMLElement>('[id]')]
.filter((node) => node.id.startsWith(prefix))
.map((option) => {
if (option.getAttribute('role') === 'option') {
const key = decodeURIComponent(option.id.slice(prefix.length))
return `ITEM ${key}${option.getAttribute('aria-current') === 'page' ? ' ACTIVE' : ''}`
}
const text = (option.innerText || option.textContent || '').replace(/\s+/g, ' ').trim()
return `HEADER ${text}`
})
)
})
}
+192
View File
@@ -0,0 +1,192 @@
/**
* The phone page's workspace list, built from the host's sidebar feed by the page's own modules,
* 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 { execFileSync } from 'node:child_process'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { z } from 'zod'
import { test, expect } from './helpers/orca-app'
import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { createDesktopMobilePairingOffer } from './helpers/paired-electron-client'
import { pairMobileClient } from './helpers/paired-mobile-client'
import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology'
import {
desktopSidebarOutline,
openPhoneFeedList,
type ListOutline
} from './helpers/phone-feed-list'
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> {
let desktop: ListOutline = []
let shown: ListOutline = []
await expect
.poll(
async () => {
desktop = await desktopSidebarOutline(page)
shown = await phone.outline()
return shown.join('\n') === desktop.join('\n')
},
{ timeout: 30_000 }
)
.toBe(true)
.catch(() => expect(shown).toEqual(desktop))
expect(shown.some((line) => line.startsWith('ITEM '))).toBe(true)
return shown
}
async function runtimeCall(page: Page, method: string, params: unknown): Promise<unknown> {
return page.evaluate(
async ({ method, params }) => {
const response = await window.api.runtime.call({ method, params })
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result
},
{ method, params }
)
}
test.describe('switch on', () => {
test.skip(
!SIDEBAR_INPUTS_BUILD,
'the phone reads the feed only in an ORCA_SIDEBAR_INPUTS=main build'
)
test('the phone lists the desktop sidebar from the feed, through pins, the open workspace and collapse', async ({
orcaPage
}) => {
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const activeId = await waitForActiveWorktree(orcaPage)
const repoId = activeId.split('::')[0]
const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage))
for (const name of ['feed-a', 'feed-b']) {
await phone.call('worktree.create', {
repo: `id:${repoId}`,
name,
setupDecision: 'skip'
})
}
const list = await openPhoneFeedList(phone)
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)
// 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)
// 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)
expect(project).toBeDefined()
const before = await orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups])
const reply = await list.request('ui.setCollapsed', {
key: project!.key,
collapsed: true,
opId: { writer: 'e2e-phone', seq: 1 }
})
expect(reply).toMatchObject({ ok: true })
await expect
.poll(() => orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups]))
.toEqual([...before, project!.key])
const folded = await expectPhoneMatchesDesktop(orcaPage, list)
const foldedSection = (await list.sections())?.find((section) => section.key === project!.key)
expect(foldedSection?.data).toEqual([])
expect(foldedSection?.count).toBeGreaterThan(0)
expect(folded.length).toBeLessThan(duplicated.length)
// Expanded again by a later op from the same writer.
expect(
await list.request('ui.setCollapsed', {
key: project!.key,
collapsed: false,
opId: { writer: 'e2e-phone', seq: 2 }
})
).toMatchObject({ ok: true })
await expectPhoneMatchesDesktop(orcaPage, list)
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)
} finally {
list.close()
phone.dispose()
}
})
test('an old open page keeps its own reads on a host that serves the feed', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage))
try {
// What a page built before the feed reads, unchanged on this host.
for (const method of ['worktree.ps', 'mobileRelay.projectSections', 'ui.get', 'repo.list']) {
expect(await phone.request(method, {}), method).toMatchObject({ ok: true })
}
const served = await phone.call<{ catalog: { repos: unknown[] } | null }>(
'mobileRelay.projectSections',
{}
)
expect(served.catalog?.repos.length).toBeGreaterThan(0)
} finally {
phone.dispose()
}
})
test('the phone lists a paired server’s workspaces as the desktop sidebar does', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch.
{}, testInfo) => {
test.setTimeout(240_000)
const serverRepo = testInfo.outputPath('server-repo')
mkdirSync(serverRepo, { recursive: true })
writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n')
const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' })
git('init', '-b', 'main')
git('add', 'README.md')
git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init')
const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology(
{ phoneTo: 'desktop' },
testInfo
)
const list = await openPhoneFeedList(phone)
try {
const added = AddedRepoSchema.parse(
(await host.client.call('repo.add', { path: serverRepo, kind: 'git' })).result
)
for (const name of ['server-a', 'server-b']) {
await host.client.call('worktree.create', {
repo: `id:${added.repo.id}`,
name,
setupDecision: 'skip'
})
}
const shown = await expectPhoneMatchesDesktop(desktop.page, list)
expect(shown.some((line) => line.includes('server-a'))).toBe(true)
} finally {
list.close()
await dispose()
}
})
})