diff --git a/mobile/src/host-screen/host-sidebar-feed-reader.test.ts b/mobile/src/host-screen/host-sidebar-feed-reader.test.ts index f0d8b77a81f..9017178c553 100644 --- a/mobile/src/host-screen/host-sidebar-feed-reader.test.ts +++ b/mobile/src/host-screen/host-sidebar-feed-reader.test.ts @@ -97,6 +97,81 @@ describe('HostSidebarFeedReader', () => { expect(host.reads.at(-1)!.installed!.domains.get('workspaces')?.get('c')).toEqual({ id: 'c' }) }) + it('catches up on its own when a read times out with the connection still up', async () => { + vi.useFakeTimers() + try { + const host = hostWithFeed() + host.reader.refresh() + await vi.advanceTimersByTimeAsync(0) + const first = host.reads.at(-1)!.installed! + + let timeOut: (error: Error) => void = () => {} + host.sendRequest.mockImplementationOnce( + () => new Promise((_resolve, reject) => (timeOut = reject)) + ) + host.workspaces.set('b', { id: 'b' }) + await vi.advanceTimersByTimeAsync(1_000) + host.reader.changed(host.notices.at(-1)!) + // A newer notice lands while that read waits; nothing else will repeat it. + host.workspaces.set('c', { id: 'c' }) + await vi.advanceTimersByTimeAsync(1_000) + host.reader.changed(host.notices.at(-1)!) + timeOut(new Error('Request timed out: sidebarInputs.read')) + + await vi.advanceTimersByTimeAsync(30_000) + const caughtUp = host.reads.at(-1)!.installed! + expect(caughtUp.revision).toBeGreaterThan(first.revision) + expect(caughtUp.domains.get('workspaces')?.get('c')).toEqual({ id: 'c' }) + } finally { + vi.useRealTimers() + } + }) + + it('stops retrying a host that keeps failing, and when the screen lets go', async () => { + vi.useFakeTimers() + try { + const host = hostWithFeed() + host.sendRequest.mockRejectedValue(new Error('Request timed out: sidebarInputs.read')) + host.reader.refresh() + await vi.advanceTimersByTimeAsync(10 * 60_000) + const attempts = host.sendRequest.mock.calls.length + expect(attempts).toBeGreaterThan(1) + expect(attempts).toBeLessThan(10) + + // A fresh trigger gets a fresh budget, which disposal cancels. + host.reader.refresh() + await vi.advanceTimersByTimeAsync(0) + host.reader.dispose() + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(host.sendRequest.mock.calls.length).toBe(attempts + 1) + } finally { + vi.useRealTimers() + } + }) + + it('stays bounded when a batch’s first page answers but a later page keeps failing', async () => { + vi.useFakeTimers() + try { + const host = hostWithFeed() + const answer = host.sendRequest.getMockImplementation()! + host.sendRequest.mockImplementation(async (method: string, params: unknown) => { + const reply = await answer(method, params) + const pageToken = new Map(Object.entries(Object(params))).get('pageToken') + if (pageToken !== undefined) { + throw new Error('Request timed out: sidebarInputs.read') + } + // Every first page says more follows, so no batch ever installs. + return { ...reply, result: { ...Object(reply.ok ? reply.result : {}), next: 'p#1' } } + }) + host.reader.refresh() + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(host.reads).toEqual([]) + expect(host.sendRequest.mock.calls.length).toBeLessThan(20) + } finally { + vi.useRealTimers() + } + }) + it('drops a reply that lands after the screen let go of its host', async () => { const host = hostWithFeed() host.reader.refresh() diff --git a/mobile/src/host-screen/host-sidebar-feed-reader.ts b/mobile/src/host-screen/host-sidebar-feed-reader.ts index 2f7fe544345..1a8b12611f5 100644 --- a/mobile/src/host-screen/host-sidebar-feed-reader.ts +++ b/mobile/src/host-screen/host-sidebar-feed-reader.ts @@ -26,11 +26,16 @@ export type HostSidebarFeedRead = { unavailable: boolean } +// A read that fails with the connection up gets no ready to repeat it; back off so a failing host is not hammered. +const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] + /** 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 + private retryTimer: ReturnType | null = null + private retries = 0 constructor( private readonly client: UnvalidatedRpcRequestPort, @@ -39,6 +44,7 @@ export class HostSidebarFeedReader { /** On every stream ready and return to the foreground; supersedes a read in flight. */ refresh(): void { + this.retries = 0 this.apply(refreshSidebarInputs(this.state)) } @@ -48,12 +54,18 @@ export class HostSidebarFeedReader { dispose(): void { this.disposed = true + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } } private apply(step: SidebarInputsFeedStep): void { const before = this.state.installed this.state = step.state if (step.state.installed !== before) { + // Only an install ends a failure streak: a page that answers mid-batch does not. + this.retries = 0 this.onRead({ installed: step.state.installed, unavailable: this.unavailable }) } if (step.request) { @@ -61,28 +73,45 @@ export class HostSidebarFeedReader { } } + /** Reads again from what is installed, which the host answers past every notice so far. */ + private retry(): void { + const delay = RETRY_DELAYS_MS[this.retries] + if (this.disposed || this.retryTimer || delay === undefined) { + return + } + this.retries += 1 + this.retryTimer = setTimeout(() => { + this.retryTimer = null + if (this.state.inFlight === null) { + this.apply(refreshSidebarInputs(this.state)) + } + }, delay) + } + private send(request: SidebarInputsFeedRequest): void { const failed = (): void => { + if (this.disposed || request.id !== this.state.inFlight) { + return + } this.state = sidebarInputsReadFailed(this.state, request.id) + if (!this.unavailable) { + this.retry() + } } - 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 - ) + 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 + } + if (read.kind === 'unavailable' && !this.unavailable) { + this.unavailable = true + this.onRead({ installed: this.state.installed, unavailable: true }) + } + failed() + }, 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 index 90951e88b09..a2ddf7e34df 100644 --- a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts +++ b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts @@ -8,7 +8,13 @@ import type { SidebarFeedDomains } from '../../../src/shared/sidebar-list/sideba import { resolveVisibleWorkspaceHostIds } from '../../../src/shared/workspace-view/workspace-host-visibility' import { buildHostFeedCatalog, buildHostFeedView } from './host-sidebar-feed-inputs' import { buildHostFeedSections, buildPreFeedSections } from './host-sidebar-feed-sections' -import { SERVED_SECTIONS, feedDomains, liveRow, phoneView } from './host-sidebar-feed.test-fixture' +import { + FEED_ROWS, + SERVED_SECTIONS, + feedDomains, + liveRow, + phoneView +} from './host-sidebar-feed.test-fixture' import type { MobileViewState } from '../worktree/workspace-view-settings' const catalog = buildHostFeedCatalog(SERVED_SECTIONS, [], []) @@ -143,7 +149,12 @@ describe('the phone list from the sidebar feed', () => { 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 }) + liveRow({ + worktreeId: 'lab::/w/gpu', + hostId: 'ssh:gpu', + repoId: 'lab', + hasAttachedPty: true + }) ] }) const rows = sections.flatMap((section) => section.data) @@ -152,7 +163,9 @@ describe('the phone list from the sidebar feed', () => { status: 'working', lineageDepth: 1 }) - expect(rows.find((row) => row.worktreeId === 'lab::/w/gpu')).toMatchObject({ unread: true }) + expect(rows.find((row) => row.worktreeId === 'lab::/w/gpu')).toMatchObject({ + hasAttachedPty: 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', @@ -161,6 +174,56 @@ describe('the phone list from the sidebar feed', () => { }) }) + it('takes what the feed carries from the feed, over an older worktree.ps row', () => { + const feedChild = { + ...FEED_ROWS.child, + displayName: 'new name', + branch: 'new-branch', + comment: 'new note', + isUnread: true, + isPinned: true + } + const built = buildSidebarFeedRows( + feedDomains({ + workspaces: new Map([ + ['|orca::/w/parent', FEED_ROWS.parent], + ['|orca::/w/child', feedChild] + ]) + }), + catalog, + phoneFeedView(phoneView()) + ) + const stale = liveRow({ + worktreeId: 'orca::/w/child', + displayName: 'old name', + branch: 'old-branch', + comment: 'old note', + unread: false, + isPinned: false, + isArchived: true, + liveTerminalCount: 3 + }) + const sections = buildHostFeedSections({ + rows: built.sectionRows, + activeRowKeys: built.activeRowKeys, + liveRows: [stale], + repoHostIdByRepoId: new Map(), + search: 'new name' + }) + expect(sections.flatMap((section) => section.data)).toEqual([ + expect.objectContaining({ + worktreeId: 'orca::/w/child', + displayName: 'new name', + branch: 'new-branch', + comment: 'new note', + unread: true, + isPinned: true, + isArchived: false, + liveTerminalCount: 3 + }) + ]) + }) + 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 })] diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.ts b/mobile/src/host-screen/host-sidebar-feed-sections.ts index 81548bfc0ab..f29116d6214 100644 --- a/mobile/src/host-screen/host-sidebar-feed-sections.ts +++ b/mobile/src/host-screen/host-sidebar-feed-sections.ts @@ -58,29 +58,32 @@ function indexLiveRows( 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( +/** Only worktree.ps carries these; a row it has not listed yet draws without them. */ +const NO_LIVE_CONTENT = { liveTerminalCount: 0, hasAttachedPty: false, linkedPR: null } + +/** What the feed carries comes from the feed: an older worktree.ps row adds only what it lacks. */ +function drawnRow( worktree: HostWorktree, hostId: ExecutionHostId, - repoName: string, - workspaceKind: Worktree['workspaceKind'] + repoName: string | undefined, + workspaceKind: Worktree['workspaceKind'], + live: Worktree | undefined ): Worktree { return { + ...(live ?? NO_LIVE_CONTENT), worktreeId: worktree.id, repoId: worktree.repoId, hostId, workspaceKind, - repo: repoName, + repo: repoName ?? live?.repo ?? '', branch: worktree.branch, displayName: worktree.displayName, + comment: worktree.comment, path: worktree.path, isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, - liveTerminalCount: 0, - hasAttachedPty: false, unread: worktree.isUnread, - isPinned: worktree.isPinned, - linkedPR: null + isPinned: worktree.isPinned } } @@ -104,10 +107,9 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { const rowOf = ( worktree: HostWorktree, hostId: ExecutionHostId, - repoName: string, + repoName: string | undefined, workspaceKind: Worktree['workspaceKind'] - ): Worktree => - liveRowOf(worktree.id, hostId) ?? feedOnlyRow(worktree, hostId, repoName, workspaceKind) + ): Worktree => drawnRow(worktree, hostId, repoName, workspaceKind, liveRowOf(worktree.id, hostId)) const push = (section: Section) => sections.push(section) const current = (): Section => { const last = sections.at(-1) @@ -137,10 +139,9 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { 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') + const drawn = rowOf(row.worktree, hostId, row.repo?.displayName, 'git') current().data.push({ - ...live, - isPinned: row.worktree.isPinned, + ...drawn, isActive: sources.activeRowKeys.has(row.rowKey), sectionListKey: row.rowKey, lineageDepth: row.depth, @@ -152,7 +153,7 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { // The desktop's mixed-host rule names the host; a health word alone still shows. hostContextLabel: row.hostContextLabel ?? - (live.hostContextHealthLabel ? live.hostContextLabel : undefined) + (drawn.hostContextHealthLabel ? drawn.hostContextLabel : undefined) }) break } @@ -165,7 +166,6 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { ) 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 diff --git a/mobile/src/host-screen/host-view-changes-reconnect.test.ts b/mobile/src/host-screen/host-view-changes-reconnect.test.ts new file mode 100644 index 00000000000..e9298790117 --- /dev/null +++ b/mobile/src/host-screen/host-view-changes-reconnect.test.ts @@ -0,0 +1,116 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } 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 })) +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) } +})) +vi.mock('expo-router', () => ({ useFocusEffect: () => {} })) +vi.mock('../cache/worktree-cache', () => ({ + getCachedWorktrees: () => null, + setCachedWorktrees: () => {} +})) +vi.mock('../storage/preferences', () => ({ savePinnedIds: async () => {} })) +vi.mock('../transport/use-worktree-resync', () => ({ + useWorktreeResync: () => ({ refreshing: false, onRefresh: async () => {} }) +})) + +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { useHostScreenState } from './use-host-screen-state' +import { useHostViewSettings } from './use-host-view-settings' +import { useHostWorktreeCatalog } from './use-host-worktree-catalog' + +function reply(result: unknown): RpcResponse { + return { id: 'reply', ok: true, result, _meta: { runtimeId: 'runtime' } } +} + +/** The desktop holds `recent` and nothing collapsed; the first of each write is lost in transit. */ +function desktop() { + const client = new FakeSession('connected') + const lost = new Set() + const writes: string[] = [] + client.sendRequest.mockImplementation(async (method: string) => { + if (method === 'ui.set' || method === 'ui.setCollapsed') { + writes.push(method) + if (!lost.has(method)) { + lost.add(method) + throw new Error('client_disconnected') + } + return reply(method === 'ui.set' ? { ui: {} } : { collapsed: true, applied: true }) + } + if (method === 'ui.get') { + return reply({ ui: { sortBy: 'recent', collapsedGroups: [] } }) + } + if (method === 'worktree.ps') { + return reply({ worktrees: [], snapshotId: 's1' }) + } + return reply({}) + }) + return { client, writes } +} + +// The feed's own reads are not under test; it only replaces the poll, as on a switched-on page. +const SIDEBAR_FEED = { refresh: () => {}, changed: () => {}, onInstalled: () => () => {} } +// Stable across renders, as the screen's own are: each is a dependency of the refresh lifecycle. +const NO_CAPABILITIES: string[] = [] +const fetchRepoMetadata = async (): Promise => {} + +let renderer: ReactTestRenderer | null = null + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +async function mountScreen(client: FakeSession) { + const held: { view?: ReturnType } = {} + function Probe({ connState }: { connState: ConnectionState }): null { + const state = useHostScreenState('host-1', undefined) + state.clientRef.current = client + const view = useHostViewSettings({ client, connState, state }) + held.view = view + useHostWorktreeCatalog({ + client, + connState, + embedded: true, + fetchRepoMetadata, + hostCapabilities: NO_CAPABILITIES, + hostId: 'host-1', + sidebarFeed: SIDEBAR_FEED, + state, + syncViewSettingsFromDesktop: view.syncViewSettingsFromDesktop + }) + return null + } + await act(async () => { + renderer = create(createElement(Probe, { connState: 'connected' })) + }) + return { + view: () => held.view!, + connect: async (connState: ConnectionState) => { + await act(async () => { + renderer!.update(createElement(Probe, { connState })) + }) + } + } +} + +describe('Workspace options changed while the connection drops', () => { + it('resends a sort and a collapse the desktop never got once the phone reconnects', async () => { + const { client, writes } = desktop() + const screen = await mountScreen(client) + await act(async () => { + screen.view().handleSortChange('name') + screen.view().toggleCollapsed('repo:orca') + }) + expect(screen.view().pendingViewChanges.pending).toHaveLength(2) + + await screen.connect('reconnecting') + await screen.connect('connected') + + expect(writes).toEqual(['ui.set', 'ui.setCollapsed', 'ui.set', 'ui.setCollapsed']) + expect(screen.view().pendingViewChanges.pending).toEqual([]) + }) +}) 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 index 8e63e3438cf..c9cdae83cfe 100644 --- a/mobile/src/host-screen/use-host-pending-view-changes.test.ts +++ b/mobile/src/host-screen/use-host-pending-view-changes.test.ts @@ -123,11 +123,11 @@ describe('the phone’s own option changes', () => { expect(screen.current().status.pending).toHaveLength(1) const sentByOpId = screen.current().controls.lastOpId() act(() => { - screen.current().controls.reconcile({ sortBy: 'recent' }, { reconnected: true, sentByOpId }) + screen.current().controls.reconcile({ sortBy: 'recent' }, { resend: true, sentByOpId }) }) expect(screen.client.sendRequest).toHaveBeenCalledTimes(2) act(() => { - screen.current().controls.reconcile({ sortBy: 'name' }, { reconnected: false, sentByOpId }) + screen.current().controls.reconcile({ sortBy: 'name' }, { resend: false, sentByOpId }) }) expect(screen.current().status.pending).toEqual([]) }) diff --git a/mobile/src/host-screen/use-host-pending-view-changes.ts b/mobile/src/host-screen/use-host-pending-view-changes.ts index 7187e1c5d05..bd69b75788f 100644 --- a/mobile/src/host-screen/use-host-pending-view-changes.ts +++ b/mobile/src/host-screen/use-host-pending-view-changes.ts @@ -27,10 +27,10 @@ export type PendingViewControls = { 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. */ + /** The changes still waiting once the desktop's values are read; `resend` sends each again. */ reconcile: ( ui: WorkspaceViewSettings, - options: { reconnected: boolean; sentByOpId: number } + options: { resend: boolean; sentByOpId: number } ) => PendingViewChange[] } @@ -121,12 +121,12 @@ function useHostPendingViewChanges(client: RpcClient | null): HostPendingViewCha ) const lastOpId = useCallback(() => seqRef.current, []) const reconcile = useCallback( - (ui: WorkspaceViewSettings, options: { reconnected: boolean; sentByOpId: number }) => { + (ui: WorkspaceViewSettings, options: { resend: boolean; sentByOpId: number }) => { const waiting = reconcilePendingViewChanges(current(), ui, options.sentByOpId) if (waiting.length !== current().length) { write(waiting) } - if (options.reconnected) { + if (options.resend) { // Each sets a desired value, so sending one again is safe. waiting.forEach((change) => void send(change)) } diff --git a/mobile/src/host-screen/use-host-view-settings.ts b/mobile/src/host-screen/use-host-view-settings.ts index bcb2c46c6b4..42f2601aa91 100644 --- a/mobile/src/host-screen/use-host-view-settings.ts +++ b/mobile/src/host-screen/use-host-view-settings.ts @@ -58,7 +58,7 @@ export function useHostViewSettings(args: { // Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here. const syncViewSettingsFromDesktop = useCallback( - async (options: { reconnected?: boolean } = {}) => { + async (options: { resend?: boolean } = {}) => { if (!client || connState !== 'connected') { return } @@ -89,7 +89,7 @@ export function useHostViewSettings(args: { const waiting = pendingChanges && ui ? pendingChanges.reconcile(ui, { - reconnected: options.reconnected === true, + resend: options.resend === true, sentByOpId: opsAtRequest }) : [] diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts index f71a255c74a..ee3ca8193a1 100644 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ b/mobile/src/host-screen/use-host-worktree-catalog.ts @@ -33,7 +33,7 @@ export function useHostWorktreeCatalog(args: { /** Null in a build that lists without the feed. */ sidebarFeed: HostSidebarFeedTriggers | null state: HostScreenState - syncViewSettingsFromDesktop: (options?: { reconnected?: boolean }) => Promise + syncViewSettingsFromDesktop: (options?: { resend?: boolean }) => Promise }) { const { client, @@ -212,7 +212,6 @@ export function useHostWorktreeCatalog(args: { if (!client || connState !== 'connected') { return } - void syncViewSettingsFromDesktop() return startHostWorktreeRefresh({ client, fetchWorktrees, diff --git a/mobile/src/worktree/host-worktree-refresh.test.ts b/mobile/src/worktree/host-worktree-refresh.test.ts index cfb0aa5ed66..0c6791bda7c 100644 --- a/mobile/src/worktree/host-worktree-refresh.test.ts +++ b/mobile/src/worktree/host-worktree-refresh.test.ts @@ -111,14 +111,20 @@ describe('startHostWorktreeRefresh', () => { expect(fetchRepoMetadata).not.toHaveBeenCalled() }) + it('reads the Workspace options at start, resending what the last connection left unanswered', () => { + start() + expect(syncViewSettings).toHaveBeenCalledExactlyOnceWith({ resend: true }) + }) + it('re-reads only the shared Workspace options on workspaceViewChanged', () => { start() fetchWorktrees.mockClear() fetchRepoMetadata.mockClear() + syncViewSettings.mockClear() eventListener?.({ type: 'workspaceViewChanged' }) - expect(syncViewSettings).toHaveBeenCalledOnce() + expect(syncViewSettings).toHaveBeenCalledExactlyOnceWith() expect(fetchWorktrees).not.toHaveBeenCalled() expect(fetchRepoMetadata).not.toHaveBeenCalled() }) @@ -173,7 +179,7 @@ describe('startHostWorktreeRefresh', () => { expect(sidebarFeed.refresh).toHaveBeenCalledTimes(2) eventListener?.({ type: 'ready', subscriptionId: 'events-2' }) expect(sidebarFeed.refresh).toHaveBeenCalledTimes(3) - expect(syncViewSettings).toHaveBeenCalledWith({ reconnected: true }) + expect(syncViewSettings).toHaveBeenCalledWith({ resend: true }) appState.listener?.('active') expect(sidebarFeed.refresh).toHaveBeenCalledTimes(4) diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts index d13ee94a488..bb84866a90d 100644 --- a/mobile/src/worktree/host-worktree-refresh.ts +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -12,8 +12,8 @@ type HostWorktreeRefreshArgs = { client: RpcClient fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise - /** Re-reads the Workspace options the phone shares with the desktop. */ - syncViewSettings: (options?: { reconnected?: boolean }) => Promise + /** Re-reads the Workspace options the phone shares with the desktop; `resend` retries unanswered changes. */ + syncViewSettings: (options?: { resend?: boolean }) => Promise /** The host's sidebar feed, which replaces the poll: its notices say when anything changed. */ sidebarFeed?: HostSidebarFeedTriggers | null } @@ -73,7 +73,7 @@ export function startHostWorktreeRefresh({ // 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({ reconnected: true }) + void syncViewSettings({ resend: true }) } return } @@ -105,6 +105,9 @@ export function startHostWorktreeRefresh({ sidebarFeed?.refresh() void fetchWorktrees() void fetchRepoMetadata({ force: true, queueIfInFlight: true }) + // Why resend: each connected edge starts a new lifecycle, so a write the last connection lost is + // never seen by a replayed ready above. + void syncViewSettings({ resend: true }) return () => { stale = true diff --git a/src/shared/sidebar-list/sidebar-inputs-feed-installer.ts b/src/shared/sidebar-list/sidebar-inputs-feed-installer.ts index 552f3baf5c6..a6846288944 100644 --- a/src/shared/sidebar-list/sidebar-inputs-feed-installer.ts +++ b/src/shared/sidebar-list/sidebar-inputs-feed-installer.ts @@ -95,7 +95,7 @@ export function sidebarInputsChanged( : { state: { ...state, noticed: notice }, request: null } } -/** The read failed; the next ready, foreground or notice reads again. */ +/** The read failed; its reader retries with a refresh, which reads past every notice so far. */ export function sidebarInputsReadFailed( state: SidebarInputsFeedState, requestId: number