mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): phone list resends lost option changes, trusts the feed's row facts, and retries a failed feed read (#31088)
* fix(mobile): resend lost Workspace option changes on reconnect, draw feed-owned row fields from the feed, retry a failed feed read Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): end a feed retry streak only on install, draw the feed's comment Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: E2E Test <e2e@test.local> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
E2E Test
parent
e01a5392d4
commit
94e55a2f3d
@@ -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<RpcResponse>((_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()
|
||||
|
||||
@@ -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<typeof setTimeout> | 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>()
|
||||
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<void> => {}
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function mountScreen(client: FakeSession) {
|
||||
const held: { view?: ReturnType<typeof useHostViewSettings> } = {}
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -27,10 +27,10 @@ export type PendingViewControls = {
|
||||
sendCollapse: (key: string, collapsed: boolean) => Promise<PendingViewOutcome>
|
||||
/** The last change sent; a read sent after it judges every change up to it. */
|
||||
lastOpId: () => number
|
||||
/** The changes still waiting once the desktop's values are read; after a reconnect each is resent. */
|
||||
/** 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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -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<void>
|
||||
syncViewSettingsFromDesktop: (options?: { resend?: boolean }) => Promise<void>
|
||||
}) {
|
||||
const {
|
||||
client,
|
||||
@@ -212,7 +212,6 @@ export function useHostWorktreeCatalog(args: {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void syncViewSettingsFromDesktop()
|
||||
return startHostWorktreeRefresh({
|
||||
client,
|
||||
fetchWorktrees,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ type HostWorktreeRefreshArgs = {
|
||||
client: RpcClient
|
||||
fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise<void>
|
||||
fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise<void>
|
||||
/** Re-reads the Workspace options the phone shares with the desktop. */
|
||||
syncViewSettings: (options?: { reconnected?: boolean }) => Promise<void>
|
||||
/** Re-reads the Workspace options the phone shares with the desktop; `resend` retries unanswered changes. */
|
||||
syncViewSettings: (options?: { resend?: boolean }) => Promise<void>
|
||||
/** 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user