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:
Jinwoo Hong
2026-10-20 06:47:40 -04:00
committed by GitHub
co-authored by Claude E2E Test
parent e01a5392d4
commit 94e55a2f3d
12 changed files with 346 additions and 55 deletions
@@ -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)
+6 -3
View File
@@ -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