From 88241bcd1e460eccf7c09cba7f6b182811df0de5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:41:32 -0400 Subject: [PATCH] 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. --- .../scripts/build-mobile-web-app-bundle.mjs | 6 +- .../host-screen-secondary-styles.ts | 6 + .../host-server-workspaces-wiring.test.ts | 1 + .../host-screen/host-sidebar-feed-inputs.ts | 90 +++++++ .../host-sidebar-feed-operations.ts | 69 ++++++ .../host-sidebar-feed-reader.test.ts | 127 ++++++++++ .../host-screen/host-sidebar-feed-reader.ts | 88 +++++++ .../host-sidebar-feed-sections.test.ts | 163 +++++++++++++ .../host-screen/host-sidebar-feed-sections.ts | 192 +++++++++++++++ .../host-sidebar-feed.test-fixture.ts | 126 ++++++++++ .../host-screen/host-view-pending-changes.ts | 92 ++++++++ .../host-workspace-list-action-error.test.tsx | 2 +- .../host-workspace-list-row-memo.test.tsx | 6 +- .../src/host-screen/host-workspace-list.tsx | 61 +++-- .../src/host-screen/use-host-list-sections.ts | 171 ++++++++++++++ .../use-host-pending-view-changes.test.ts | 222 ++++++++++++++++++ .../use-host-pending-view-changes.ts | 166 +++++++++++++ .../use-host-project-sections.test.tsx | 2 +- .../host-screen/use-host-project-sections.ts | 17 +- .../use-host-repo-icon-data-urls.ts | 61 +++++ .../host-screen/use-host-screen-controller.ts | 47 +--- .../host-screen/use-host-screen-identity.ts | 4 +- .../src/host-screen/use-host-screen-state.ts | 5 + .../src/host-screen/use-host-sidebar-feed.ts | 98 ++++++++ .../src/host-screen/use-host-view-settings.ts | 148 ++++++++---- .../host-screen/use-host-worktree-actions.ts | 10 +- .../host-screen/use-host-worktree-catalog.ts | 57 ++++- .../workspace-options-sheet.test.tsx | 3 +- .../host-screen/workspace-options-sheet.tsx | 19 +- .../worktree/host-workspace-list-states.tsx | 12 + .../worktree/host-worktree-refresh.test.ts | 60 ++++- mobile/src/worktree/host-worktree-refresh.ts | 55 +++-- .../worktree/project-section-catalog.test.ts | 3 +- .../worktree/project-sections-operations.ts | 40 +++- mobile/src/worktree/workspace-list-types.ts | 4 + src/main/runtime/mobile-rpc-allowlist.test.ts | 37 ++- src/main/runtime/orca-runtime.ts | 1 + ...ntime-rpc-mobile-paired-desktop-methods.ts | 6 +- src/main/runtime/runtime-store-contract.ts | 2 + .../desktop-sidebar-facts.test.ts | 19 ++ .../sidebar-inputs/desktop-sidebar-facts.ts | 13 +- .../repo-icon-data-urls.test.ts | 16 ++ .../sidebar-inputs/repo-icon-data-urls.ts | 21 +- .../sidebar-inputs-feed-domains.ts | 11 +- .../listing/use-main-list-model.ts | 1 + .../src/lib/sidebar-pending-layer.test.ts | 1 + .../project-section-catalog.test.ts | 42 ++++ .../project-section-catalog.ts | 15 +- .../mobile-project-sections-contract.ts | 9 +- .../sidebar-list/desktop-sidebar-facts.ts | 8 +- .../repo-icon-data-urls-contract.ts | 9 + src/shared/sidebar-list/section-order.ts | 4 +- .../sidebar-list/sidebar-feed-domains.ts | 10 +- src/shared/sidebar-list/sidebar-feed-rows.ts | 4 +- src/shared/sidebar-list/sidebar-row-plan.ts | 4 +- .../sidebar-list/worktree-group-keys.ts | 6 +- tests/e2e/helpers/phone-feed-list.ts | 167 +++++++++++++ tests/e2e/sidebar-phone-feed-list.spec.ts | 192 +++++++++++++++ 58 files changed, 2659 insertions(+), 172 deletions(-) create mode 100644 mobile/src/host-screen/host-sidebar-feed-inputs.ts create mode 100644 mobile/src/host-screen/host-sidebar-feed-operations.ts create mode 100644 mobile/src/host-screen/host-sidebar-feed-reader.test.ts create mode 100644 mobile/src/host-screen/host-sidebar-feed-reader.ts create mode 100644 mobile/src/host-screen/host-sidebar-feed-sections.test.ts create mode 100644 mobile/src/host-screen/host-sidebar-feed-sections.ts create mode 100644 mobile/src/host-screen/host-sidebar-feed.test-fixture.ts create mode 100644 mobile/src/host-screen/host-view-pending-changes.ts create mode 100644 mobile/src/host-screen/use-host-list-sections.ts create mode 100644 mobile/src/host-screen/use-host-pending-view-changes.test.ts create mode 100644 mobile/src/host-screen/use-host-pending-view-changes.ts create mode 100644 mobile/src/host-screen/use-host-repo-icon-data-urls.ts create mode 100644 mobile/src/host-screen/use-host-sidebar-feed.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.test.ts create mode 100644 tests/e2e/helpers/phone-feed-list.ts create mode 100644 tests/e2e/sidebar-phone-feed-list.spec.ts diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 1f6ce2e19df..ce882911c77 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -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() } } } diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts index 57a5a17de7d..54080d07ca2 100644 --- a/mobile/src/host-screen/host-screen-secondary-styles.ts +++ b/mobile/src/host-screen/host-screen-secondary-styles.ts @@ -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 diff --git a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index 5256ac4d1f6..62b39abbe0e 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -135,6 +135,7 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis fetchRepoMetadata: async () => {}, hostCapabilities, hostId: 'host-1', + sidebarFeed: null, state, syncViewSettingsFromDesktop: async () => {} }) diff --git a/mobile/src/host-screen/host-sidebar-feed-inputs.ts b/mobile/src/host-screen/host-sidebar-feed-inputs.ts new file mode 100644 index 00000000000..d55d402b8d2 --- /dev/null +++ b/mobile/src/host-screen/host-sidebar-feed-inputs.ts @@ -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( + 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: [] } + } +} diff --git a/mobile/src/host-screen/host-sidebar-feed-operations.ts b/mobile/src/host-screen/host-sidebar-feed-operations.ts new file mode 100644 index 00000000000..18040d14343 --- /dev/null +++ b/mobile/src/host-screen/host-sidebar-feed-operations.ts @@ -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 = + | { kind: 'answered'; value: Value } + | { kind: 'unavailable' } + | { kind: 'failed' } + +export function readHostMethodReply( + reply: RpcResponse, + interpret: (reply: RpcResponse) => { accepted: true; value: Value } | { accepted: false } +): HostMethodReply { + 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' } + } +} diff --git a/mobile/src/host-screen/host-sidebar-feed-reader.test.ts b/mobile/src/host-screen/host-sidebar-feed-reader.test.ts new file mode 100644 index 00000000000..828f6096690 --- /dev/null +++ b/mobile/src/host-screen/host-sidebar-feed-reader.test.ts @@ -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[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 { + 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' }) + }) +}) diff --git a/mobile/src/host-screen/host-sidebar-feed-reader.ts b/mobile/src/host-screen/host-sidebar-feed-reader.ts new file mode 100644 index 00000000000..2f7fe544345 --- /dev/null +++ b/mobile/src/host-screen/host-sidebar-feed-reader.ts @@ -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) => 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): 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 + ) + } +} diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts new file mode 100644 index 00000000000..522896bf88a --- /dev/null +++ b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts @@ -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[] + 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): 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']) + }) +}) diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.ts b/mobile/src/host-screen/host-sidebar-feed-sections.ts new file mode 100644 index 00000000000..afe5148063d --- /dev/null +++ b/mobile/src/host-screen/host-sidebar-feed-sections.ts @@ -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 + /** The rows worktree.ps and the desktop's server lists served, for what each row draws. */ + liveRows: readonly Worktree[] + repoHostIdByRepoId: ReadonlyMap + 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 +): (id: string, hostId: ExecutionHostId) => Worktree | undefined { + const byIdentity = new Map() + 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 }] : [] + }) +} diff --git a/mobile/src/host-screen/host-sidebar-feed.test-fixture.ts b/mobile/src/host-screen/host-sidebar-feed.test-fixture.ts new file mode 100644 index 00000000000..3bdee3cc396 --- /dev/null +++ b/mobile/src/host-screen/host-sidebar-feed.test-fixture.ts @@ -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 & Pick): 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 { + 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 & Partial): 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 { + 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 & Pick): 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 + } +} diff --git a/mobile/src/host-screen/host-view-pending-changes.ts b/mobile/src/host-screen/host-view-pending-changes.ts new file mode 100644 index 00000000000..133cf3166e2 --- /dev/null +++ b/mobile/src/host-screen/host-view-pending-changes.ts @@ -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 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)) + }) +} diff --git a/mobile/src/host-screen/host-workspace-list-action-error.test.tsx b/mobile/src/host-screen/host-workspace-list-action-error.test.tsx index c9b55835453..e36cd9756d7 100644 --- a/mobile/src/host-screen/host-workspace-list-action-error.test.tsx +++ b/mobile/src/host-screen/host-workspace-list-action-error.test.tsx @@ -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, diff --git a/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx b/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx index 540df466597..490bf88c26b 100644 --- a/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx +++ b/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx @@ -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, diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index d0931597086..7760f3d4674 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -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 )} - + {/* A feed this page cannot read: the host is older or newer than the page. */} + {controller.feed?.kind === 'reopen' ? ( + + ) : ( + + )} - {sections.length > 0 && ( + {sections.length > 0 && controller.feed?.kind !== 'reopen' && ( 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} {section.title} {count} + {pendingCollapseKeys.has(section.key) && ( + + )} ) }} diff --git a/mobile/src/host-screen/use-host-list-sections.ts b/mobile/src/host-screen/use-host-list-sections.ts new file mode 100644 index 00000000000..e3c40b500f3 --- /dev/null +++ b/mobile/src/host-screen/use-host-list-sections.ts @@ -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 + collapsedGroups: Set + /** The desktop's value once read; the served catalog's (or the default) before. */ + projectOrderBy: ProjectOrderBy +} + +function useCollapsedGroups(state: HostScreenState): Set { + 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 diff --git a/mobile/src/host-screen/use-host-pending-view-changes.test.ts b/mobile/src/host-screen/use-host-pending-view-changes.test.ts new file mode 100644 index 00000000000..8e63e3438cf --- /dev/null +++ b/mobile/src/host-screen/use-host-pending-view-changes.test.ts @@ -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) { + // 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 { + 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 | 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 | 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 | 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 | 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]) + }) +}) diff --git a/mobile/src/host-screen/use-host-pending-view-changes.ts b/mobile/src/host-screen/use-host-pending-view-changes.ts new file mode 100644 index 00000000000..7187e1c5d05 --- /dev/null +++ b/mobile/src/host-screen/use-host-pending-view-changes.ts @@ -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 + /** `unavailable`: the host has no ui.setCollapsed. */ + sendCollapse: (key: string, collapsed: boolean) => Promise + /** 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({ 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 => { + 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 diff --git a/mobile/src/host-screen/use-host-project-sections.test.tsx b/mobile/src/host-screen/use-host-project-sections.test.tsx index 9b8df9b7bca..aa063101181 100644 --- a/mobile/src/host-screen/use-host-project-sections.test.tsx +++ b/mobile/src/host-screen/use-host-project-sections.test.tsx @@ -27,7 +27,7 @@ describe('useHostProjectSections', () => { client, connState: 'connected', hostId, - state: { clientRef, setServedProjectSections: () => {} } + state: { clientRef, setServedProjectSections: () => {}, setHostProjectGroups: () => {} } }) return null } diff --git a/mobile/src/host-screen/use-host-project-sections.ts b/mobile/src/host-screen/use-host-project-sections.ts index c775009186a..ac142fdc015 100644 --- a/mobile/src/host-screen/use-host-project-sections.ts +++ b/mobile/src/host-screen/use-host-project-sections.ts @@ -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 + state: Pick }): (options?: { force?: boolean }) => Promise { const { client, connState, hostId, state } = args - const { clientRef, setServedProjectSections } = state + const { clientRef, setServedProjectSections, setHostProjectGroups } = state const inFlight = useRef(new WeakSet()) // Per client, so a reused screen for another host fetches its catalog at once. const fetchedAt = useRef(new WeakMap()) @@ -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] ) } diff --git a/mobile/src/host-screen/use-host-repo-icon-data-urls.ts b/mobile/src/host-screen/use-host-repo-icon-data-urls.ts new file mode 100644 index 00000000000..7b99b1cf0fa --- /dev/null +++ b/mobile/src/host-screen/use-host-repo-icon-data-urls.ts @@ -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 = 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 { + // Keyed by client: another host's icons never show here. + const [read, setRead] = useState<{ + client: RpcClient | null + dataUrls: ReadonlyMap + }>({ client, dataUrls: NO_DATA_URLS }) + const askedRef = useRef<{ client: RpcClient | null; ids: Set }>({ + 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 +} diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index e79cf96c148..9507e41069c 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -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, diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/use-host-screen-identity.ts index ac65777d030..82df5bdacd3 100644 --- a/mobile/src/host-screen/use-host-screen-identity.ts +++ b/mobile/src/host-screen/use-host-screen-identity.ts @@ -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 diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index 1c28e87184c..aed82bbfd21 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -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>(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([]) 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, diff --git a/mobile/src/host-screen/use-host-sidebar-feed.ts b/mobile/src/host-screen/use-host-sidebar-feed.ts new file mode 100644 index 00000000000..9ae36eb5c5a --- /dev/null +++ b/mobile/src/host-screen/use-host-sidebar-feed.ts @@ -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(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) => + 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 diff --git a/mobile/src/host-screen/use-host-view-settings.ts b/mobile/src/host-screen/use-host-view-settings.ts index 215058803a1..bcb2c46c6b4 100644 --- a/mobile/src/host-screen/use-host-view-settings.ts +++ b/mobile/src/host-screen/use-host-view-settings.ts @@ -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, diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 66b4b2ce6c2..565d1d4214e 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -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 diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts index 6ad0fcec835..f71a255c74a 100644 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ b/mobile/src/host-screen/use-host-worktree-catalog.ts @@ -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 + syncViewSettingsFromDesktop: (options?: { reconnected?: boolean }) => Promise }) { 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(() => { diff --git a/mobile/src/host-screen/workspace-options-sheet.test.tsx b/mobile/src/host-screen/workspace-options-sheet.test.tsx index b7e928e184e..0fa018e8281 100644 --- a/mobile/src/host-screen/workspace-options-sheet.test.tsx +++ b/mobile/src/host-screen/workspace-options-sheet.test.tsx @@ -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, diff --git a/mobile/src/host-screen/workspace-options-sheet.tsx b/mobile/src/host-screen/workspace-options-sheet.tsx index 5a66efff80e..64a0a625326 100644 --- a/mobile/src/host-screen/workspace-options-sheet.tsx +++ b/mobile/src/host-screen/workspace-options-sheet.tsx @@ -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) { + const { pending, stale } = settings.pendingViewChanges + if (!pending.some((change) => change.kind === 'view')) { + return null + } + return ( + + + + {stale ? 'Still waiting for the desktop' : 'Saving to the desktop'} + + + ) +} + // Its own component so the host list is built only while the sheet is open. function WorkspaceOptionsContent({ controller, @@ -134,6 +150,7 @@ function WorkspaceOptionsContent({ {WORKSPACE_OPTIONS_TITLE} {WORKSPACE_VIEW_SHARED_NOTE} + {controller.activeFilterCount > 0 && ( diff --git a/mobile/src/worktree/host-workspace-list-states.tsx b/mobile/src/worktree/host-workspace-list-states.tsx index 8e5d517fcf9..cb4e5edfe38 100644 --- a/mobile/src/worktree/host-workspace-list-states.tsx +++ b/mobile/src/worktree/host-workspace-list-states.tsx @@ -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 ( + + Reopen to update + + This host lists workspaces in a way this page does not know yet. + + + ) +} + const styles = StyleSheet.create({ centered: { flex: 1, diff --git a/mobile/src/worktree/host-worktree-refresh.test.ts b/mobile/src/worktree/host-worktree-refresh.test.ts index e15051d2b91..cfb0aa5ed66 100644 --- a/mobile/src/worktree/host-worktree-refresh.test.ts +++ b/mobile/src/worktree/host-worktree-refresh.test.ts @@ -41,7 +41,9 @@ describe('startHostWorktreeRefresh', () => { stop = null }) - function start(): void { + function start( + sidebarFeed: Parameters[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 }) + ) + }) + }) }) diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts index 494cc321e0c..d13ee94a488 100644 --- a/mobile/src/worktree/host-worktree-refresh.ts +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -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 fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise /** Re-reads the Workspace options the phone shares with the desktop. */ - syncViewSettings: () => Promise + syncViewSettings: (options?: { reconnected?: boolean }) => Promise + /** 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() } diff --git a/mobile/src/worktree/project-section-catalog.test.ts b/mobile/src/worktree/project-section-catalog.test.ts index b252a77f6d1..10a478c3d72 100644 --- a/mobile/src/worktree/project-section-catalog.test.ts +++ b/mobile/src/worktree/project-section-catalog.test.ts @@ -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, diff --git a/mobile/src/worktree/project-sections-operations.ts b/mobile/src/worktree/project-sections-operations.ts index cf54067a665..dada1e983f7 100644 --- a/mobile/src/worktree/project-sections-operations.ts +++ b/mobile/src/worktree/project-sections-operations.ts @@ -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({ + 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 + 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. diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index d9d829711e5..e69767b6ed7 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -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[] } diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index cb91f06bc1d..d8cb30b2937 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -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 { @@ -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(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)) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6203ade9af3..5c752f0f3b1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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) }), diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index 1b2062024c2..a65118af730 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -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 diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 8cdeaee3d60..2b40d8d386a 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -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'] diff --git a/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.test.ts b/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.test.ts index c25ee7e138e..ce86232db2c 100644 --- a/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.test.ts +++ b/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.test.ts @@ -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({ diff --git a/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.ts b/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.ts index a1025a9a40b..e277877a37a 100644 --- a/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.ts +++ b/src/main/runtime/sidebar-inputs/desktop-sidebar-facts.ts @@ -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 getUI?: () => Pick + 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[] @@ -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(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 } } diff --git a/src/main/runtime/sidebar-inputs/repo-icon-data-urls.test.ts b/src/main/runtime/sidebar-inputs/repo-icon-data-urls.test.ts index 4972c3dcbf1..33ca6e58ace 100644 --- a/src/main/runtime/sidebar-inputs/repo-icon-data-urls.test.ts +++ b/src/main/runtime/sidebar-inputs/repo-icon-data-urls.test.ts @@ -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(() => ENCODED) diff --git a/src/main/runtime/sidebar-inputs/repo-icon-data-urls.ts b/src/main/runtime/sidebar-inputs/repo-icon-data-urls.ts index 83e663cbf7e..2bab25be3ed 100644 --- a/src/main/runtime/sidebar-inputs/repo-icon-data-urls.ts +++ b/src/main/runtime/sidebar-inputs/repo-icon-data-urls.ts @@ -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 { + return Object.fromEntries( + this.identifiedIcons().map(({ repo, id }) => [repoIconFeedKey(repo), id]) + ) + } + private currentIcons(): Map { - const icons = new Map() + 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() - 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 { diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts index c2831e70ab6..c98820cda9d 100644 --- a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts @@ -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 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 () => ({ diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts index 261fc4e0491..67ad8a666cd 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts @@ -46,6 +46,7 @@ const NO_FEED: SidebarFeedDomains = { creations: new Map(), pairedServerHosts: new Map(), pairedServerRows: new Map(), + repoIcons: new Map(), desktop: null, retention: null } diff --git a/src/renderer/src/lib/sidebar-pending-layer.test.ts b/src/renderer/src/lib/sidebar-pending-layer.test.ts index 0cdaaf9814e..ef252ba9344 100644 --- a/src/renderer/src/lib/sidebar-pending-layer.test.ts +++ b/src/renderer/src/lib/sidebar-pending-layer.test.ts @@ -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 } diff --git a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.test.ts b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.test.ts new file mode 100644 index 00000000000..8350bc78e38 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.test.ts @@ -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]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts index e375e04d9ea..1c1f025630d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts @@ -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 + state: Pick & + Partial> ): 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 diff --git a/src/shared/mobile-project-sections-contract.ts b/src/shared/mobile-project-sections-contract.ts index b24bb6a7304..5539b0bf812 100644 --- a/src/shared/mobile-project-sections-contract.ts +++ b/src/shared/mobile-project-sections-contract.ts @@ -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] } : {}) } } diff --git a/src/shared/sidebar-list/desktop-sidebar-facts.ts b/src/shared/sidebar-list/desktop-sidebar-facts.ts index 5e5a915ec89..141e654fdce 100644 --- a/src/shared/sidebar-list/desktop-sidebar-facts.ts +++ b/src/shared/sidebar-list/desktop-sidebar-facts.ts @@ -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 diff --git a/src/shared/sidebar-list/repo-icon-data-urls-contract.ts b/src/shared/sidebar-list/repo-icon-data-urls-contract.ts index 38ba6cfbe83..63adf5a3461 100644 --- a/src/shared/sidebar-list/repo-icon-data-urls-contract.ts +++ b/src/shared/sidebar-list/repo-icon-data-urls-contract.ts @@ -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 +): string { + return `${getRepoExecutionHostId(repo)}|${repo.id}` +} diff --git a/src/shared/sidebar-list/section-order.ts b/src/shared/sidebar-list/section-order.ts index e7dfd888ee6..89e7868e19c 100644 --- a/src/shared/sidebar-list/section-order.ts +++ b/src/shared/sidebar-list/section-order.ts @@ -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 workspaceStatuses: readonly WorkspaceStatusDefinition[] - projectGrouping?: ProjectGroupingModel + projectGrouping?: ProjectSectionModel }): Set { const renderedRepoIds = new Set() if (groupBy === 'none') { diff --git a/src/shared/sidebar-list/sidebar-feed-domains.ts b/src/shared/sidebar-list/sidebar-feed-domains.ts index 6e5c552eb4a..f34c3ed4cb2 100644 --- a/src/shared/sidebar-list/sidebar-feed-domains.ts +++ b/src/shared/sidebar-list/sidebar-feed-domains.ts @@ -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 + /** Icon ids by `hostId|repoId` (repoIconFeedKey) for this host's repos; a paired server's have none. */ + repoIcons: ReadonlyMap /** 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( @@ -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 } diff --git a/src/shared/sidebar-list/sidebar-feed-rows.ts b/src/shared/sidebar-list/sidebar-feed-rows.ts index 2227bf4f598..a887154e74d 100644 --- a/src/shared/sidebar-list/sidebar-feed-rows.ts +++ b/src/shared/sidebar-list/sidebar-feed-rows.ts @@ -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 = { diff --git a/src/shared/sidebar-list/sidebar-row-plan.ts b/src/shared/sidebar-list/sidebar-row-plan.ts index ba9d791642c..537161811a2 100644 --- a/src/shared/sidebar-list/sidebar-row-plan.ts +++ b/src/shared/sidebar-list/sidebar-row-plan.ts @@ -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 newExternalWorktreesInboxByRepo: ReadonlyMap pendingCreations: readonly PendingCreationRef[] - projectGrouping?: ProjectGroupingModel + projectGrouping?: ProjectSectionModel folderWorkspaces: readonly FolderWorkspace[] hostLabelById?: ReadonlyMap defaultHostId: ExecutionHostId diff --git a/src/shared/sidebar-list/worktree-group-keys.ts b/src/shared/sidebar-list/worktree-group-keys.ts index 48360455059..809f175467c 100644 --- a/src/shared/sidebar-list/worktree-group-keys.ts +++ b/src/shared/sidebar-list/worktree-group-keys.ts @@ -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, diff --git a/tests/e2e/helpers/phone-feed-list.ts b/tests/e2e/helpers/phone-feed-list.ts new file mode 100644 index 00000000000..d18b9421b0d --- /dev/null +++ b/tests/e2e/helpers/phone-feed-list.ts @@ -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 => { + 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 => { + 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 => + (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 { + return page.evaluate(() => { + const prefix = 'worktree-list-option-' + const rows = [...document.querySelectorAll('[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('[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}` + }) + ) + }) +} diff --git a/tests/e2e/sidebar-phone-feed-list.spec.ts b/tests/e2e/sidebar-phone-feed-list.spec.ts new file mode 100644 index 00000000000..c86332af2bb --- /dev/null +++ b/tests/e2e/sidebar-phone-feed-list.spec.ts @@ -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> + +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 { + 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 { + 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() + } + }) +})