Sidebar: land the switch-on fixes from the flip branch on main, switch still off (#31466)

* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip)

The fixes found while the sidebar ran from main's feed, without turning
that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's
stop-gaps come along.

- Main stops counting a closed tab's terminal once it was asked to stop
  and no pane shows it; a failed or refused stop takes the request back,
  and a host out of contact stays unverifiable.
- Main fetches a paired server's rows with the transport's normal bound,
  so a server slower than 5 s still lists.
- A deferred unread raise flips the folder row it was decided for, and a
  read while the owner check is out cancels it.
- Phone: no worktree.ps flash before the feed; a host's last installed
  feed stands in while a fresh read is out; it highlights its own
  last-opened workspace and a fold never scrolls; projects file under
  their project group with the desktop's indent; the collapse spinner
  shows only after 500 ms; "Reopen to update" keeps the rows on screen.
- e2e helpers and journeys for the switch-on suite.

* fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises

- A stop-and-wait that throws takes its stop request back, like one that fails.
- A final stop the legacy relay still owes stays marked, since the relay retries it.
- Pending unread raises are tracked only while one waits, so the map no longer grows.
- The paired-server fallback poll test gives its third fetch a reply and asserts live.
This commit is contained in:
Jinwoo Hong
2026-10-21 12:24:27 -04:00
committed by GitHub
parent 50b07c0e8f
commit 92bbb380a6
53 changed files with 2404 additions and 299 deletions
@@ -0,0 +1,23 @@
import type { AppState } from '@/store/types'
import { group, mainWt, repo, wt } from './sidebar-list-golden-factories.test-fixture'
// A group ahead of every other holding a git project and a folder project, a child group of it holding
// only a folder project, and an empty group: the phone must file each project under its group, in order.
const lake = repo('lake', 'Lake', { projectGroupId: 'pg-school', projectGroupOrder: 1 })
const capstone = repo('capstone', 'capstone', {
kind: 'folder',
projectGroupId: 'pg-school',
projectGroupOrder: 0
})
const notes = repo('notes', 'notes', { kind: 'folder', projectGroupId: 'pg-folder-only' })
export const GROUPED_REPOS = [lake, capstone, notes]
export const GROUPED_WORKTREES: AppState['worktreesByRepo'] = {
lake: [mainWt(lake), wt(lake, 'essay', { sortOrder: 5 })],
capstone: [mainWt(capstone, { branch: '' })],
notes: [mainWt(notes, { branch: '' })]
}
export const GROUPS_OF_GROUPED_REPOS = [
group('pg-school', 'school', { tabOrder: -1, color: '#22c55e' }),
group('pg-folder-only', 'Folder only', { tabOrder: 5, parentGroupId: 'pg-school' }),
group('pg-empty', 'Empty group', { tabOrder: 6 })
]
@@ -22,6 +22,11 @@ import type {
import type { SmartStampFields } from '../../../../shared/sidebar-list/sort-stamps'
import { buildPairedServerActivityFacts } from '../../../../shared/sidebar-list/paired-server-activity-facts'
import { GOLDEN_NOW, folder, lineage } from './sidebar-list-golden-factories.test-fixture'
import {
GROUPED_REPOS,
GROUPED_WORKTREES,
GROUPS_OF_GROUPED_REPOS
} from './sidebar-list-parity-catalog.test-fixture'
import { sidebarWorkspaceRowFilters } from './sidebar-filter-actions'
import {
readWindowWorkspaceActivityFacts,
@@ -76,9 +81,13 @@ const STAMPS: Record<string, SmartStampFields & { lastActivityAt?: number; isUnr
* this host runs.
*/
export function parityWorldOverrides(base: Partial<AppState>): Partial<AppState> {
const repoById = new Map((base.repos ?? []).map((repo) => [repo.id, repo]))
const repos = [...(base.repos ?? []), ...GROUPED_REPOS]
const repoById = new Map(repos.map((entry) => [entry.id, entry]))
const worktreesByRepo: AppState['worktreesByRepo'] = {}
for (const [repoId, worktrees] of Object.entries(base.worktreesByRepo ?? {})) {
for (const [repoId, worktrees] of Object.entries({
...base.worktreesByRepo,
...GROUPED_WORKTREES
})) {
const repo = repoById.get(repoId)
worktreesByRepo[repoId] = worktrees.map((worktree) => {
const branch = worktree.branch.replace('refs/heads/', '')
@@ -96,7 +105,8 @@ export function parityWorldOverrides(base: Partial<AppState>): Partial<AppState>
const pending = Object.values(base.pendingWorktreeCreations ?? {})[0]
const [serverMain, serverChild] = worktreesByRepo.api ?? []
// The repos, projects and server groups as the window's all-host fetch folds them.
const catalog = windowCatalogFromServerAnswers({ ...base, worktreesByRepo })
const projectGroups = [...(base.projectGroups ?? []), ...GROUPS_OF_GROUPED_REPOS]
const catalog = windowCatalogFromServerAnswers({ ...base, repos, worktreesByRepo, projectGroups })
return {
...catalog,
// A server workspace nested under the server's main, and a server folder workspace.
@@ -179,9 +189,12 @@ export function parityServerBrowserTabState(): Partial<AppState> {
* The feed domains main would publish for this store, read through the window-side projections
* the shadow comparison held equal to main's producers before the window read main's feed.
*/
export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDomain[] {
export function feedDomainsFromWindowState(
state: AppState,
serverHostIds: ReadonlySet<string> = new Set([SERVER_HOST_ID])
): SidebarInputsFeedDomain[] {
const worktrees = getAllWorktreesFromState(state)
const servers = readWindowPairedServerHosts(state, new Set([SERVER_HOST_ID]))
const servers = readWindowPairedServerHosts(state, serverHostIds)
const windowActivity = readWindowWorkspaceActivityFacts(state, GOLDEN_NOW)
const serverRows = readWindowPairedServerRows(state, new Set(servers.keys()))
// The server reports the terminals and agents the desktop runs in its workspaces.
@@ -189,6 +202,9 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo
const facts = windowActivity.get(row.worktreeId)
return { ...row, activity: { terminal: facts?.terminal ?? null, agent: facts?.agent ?? null } }
})
// Each server reports only its own rows, keyed `hostId|worktreeId`.
const rowsOf = <Row extends { key: string }>(rows: readonly Row[], hostId: string): Row[] =>
rows.filter((row) => row.key.startsWith(`${hostId}|`))
const domain = (
name: string,
read: SidebarInputsFeedDomain['read']
@@ -211,7 +227,11 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo
domain('activity', () => ({
// The window's browser and chat tabs, as its saved session for the server holds them.
...buildPairedServerActivityFacts(
[{ hostId: SERVER_HOST_ID, freshness: 'live', rows: reportedRows }],
[...servers.keys()].map((hostId) => ({
hostId,
freshness: 'live' as const,
rows: rowsOf(reportedRows, hostId)
})),
() => ({
browserTabsByWorktree: state.browserTabsByWorktree,
unifiedTabs: state.unifiedTabsByWorktree
@@ -221,7 +241,10 @@ export function feedDomainsFromWindowState(state: AppState): SidebarInputsFeedDo
})),
domain('review', () => ({
...buildPairedServerReviewFacts(
[{ hostId: SERVER_HOST_ID, rows: [...serverRows.values()] }],
[...servers.keys()].map((hostId) => ({
hostId,
rows: rowsOf([...serverRows.values()], hostId)
})),
state.prCache
),
...Object.fromEntries(
@@ -296,7 +319,7 @@ export function projectParityRows(
]
case 'header':
return [
`HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'}`
`HEADER ${row.key} collapseKey=${row.collapseKey ?? '-'} label=${row.label ?? '-'} icon=${JSON.stringify(row.icon)} count=${row.count} hostId=${row.hostId ?? '-'} groupDepth=${row.projectGroupDepth ?? '-'}`
]
case 'item':
return [
@@ -11,7 +11,8 @@ import { removeTabsAttention } from './subject-attention-transitions'
import {
markWorkspaceUnreadFlag,
onScreenHostId,
settleWorkspaceUnreadFlag
settleWorkspaceUnreadFlag,
noteWorkspaceRead
} from './workspace-unread-flag'
import { workspaceMarkerTabIds } from './workspace-live-markers'
import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets'
@@ -119,6 +120,7 @@ export const createWorkspaceAttentionSlice: StateCreator<
},
setWorkspaceUnreadByUser: (worktreeId, isUnread, executionHostId) => {
if (!isUnread) {
noteWorkspaceRead(worktreeId)
removeAndDismiss(workspaceMarkerTabIds(get(), worktreeId))
}
// Why: the toggle writes only the clicked host's row; the markers it removes are
@@ -138,6 +138,17 @@ describe('the window raises the unread flag only where its host does not', () =>
expect(isUnread(UNREACHABLE_SERVER_WT)).toBe(true)
})
it('drops a raise still waiting on a server’s first status once the user marks it read', async () => {
const { store, mark, isUnread } = createStore()
let answer: (capabilities: readonly string[]) => void = () => {}
hostCapabilities.set('slow', new Promise((resolve) => (answer = resolve)))
await mark(SLOW_SERVER_WT)
store.getState().setWorkspaceUnreadByUser(SLOW_SERVER_WT, false, 'runtime:slow')
answer([])
await new Promise((resolve) => setTimeout(resolve, 0))
expect(isUnread(SLOW_SERVER_WT)).toBe(false)
})
it('decides a folder workspace by the host of the row it raises', async () => {
const { store, mark, updateFolderWorkspace } = createStore()
const folder = folderWorkspaceKey(FOLDER_ID)
@@ -154,6 +165,25 @@ describe('the window raises the unread flag only where its host does not', () =>
)
})
it('raises the folder row it decided for, even if the screen moves to the other row meanwhile', async () => {
const { store, mark, updateFolderWorkspace } = createStore()
const folder = folderWorkspaceKey(FOLDER_ID)
let answer: (capabilities: readonly string[]) => void = () => {}
hostCapabilities.set('old', new Promise((resolve) => (answer = resolve)))
store.setState({ activeWorktreeId: folder, activeWorkspaceExecutionHostId: 'runtime:old' })
await mark(folder)
store.setState({ activeWorkspaceExecutionHostId: 'runtime:new' })
answer([])
await vi.waitFor(() =>
expect(updateFolderWorkspace).toHaveBeenCalledWith(
FOLDER_ID,
expect.objectContaining({ isUnread: true }),
{ executionHostId: 'runtime:old' }
)
)
expect(updateFolderWorkspace).toHaveBeenCalledTimes(1)
})
it('clears a flag on every owner, whichever side raised it', () => {
const { store, isUnread } = createStore()
for (const id of [LOCAL_WT, NEW_SERVER_WT, OLD_SERVER_WT]) {
@@ -12,12 +12,7 @@ import { persistPassiveWorktreeMetaForOwner } from '../worktrees/listing/worktre
import type { WorkspaceAttentionGet, WorkspaceAttentionSet } from './workspace-attention-contract'
import { workspaceHasLiveMarker } from './workspace-live-markers'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../shared/sidebar-inputs-switch'
import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../shared/workspace-unread-raise-capability'
import {
readCachedRuntimeEnvironmentCapability,
runtimeEnvironmentSupportsCapability
} from '@/runtime/runtime-rpc-client'
import { tryRuntimeTargetForWorktreeOwner } from '../worktrees/listing/worktree-owner-target'
import { ownerHostOwnsSidebarInputs } from '../worktrees/listing/owner-host-sidebar-inputs'
// The persisted workspace flag `isUnread`. Only the workspace-attention module writes it.
@@ -31,47 +26,47 @@ export function markWorkspaceUnreadFlag(
return
}
// Switch point: the owning host raises the flag from the same news, unless it is older.
void ownerRaisesUnreadFlag(get(), worktreeId).then((ownerRaises) => {
if (!ownerRaises) {
raiseWorkspaceUnreadFlag(set, get, worktreeId)
}
})
}
/**
* Whether the owning host raises the flag itself. A local owner runs this build (main, its SSH
* hosts, or the host that served the browser client). A paired server answers by capability; an
* unreadable status raises here, so no flag is lost.
*/
async function ownerRaisesUnreadFlag(state: AppState, worktreeId: string): Promise<boolean> {
// Why the row's host now: same-id folder rows differ by host, and the screen can move to the
// other row while the owner is asked.
const state = get()
const workspaceScope = parseWorkspaceKey(worktreeId)
const folderRow =
workspaceScope?.type === 'folder'
? folderRowToMark(state, worktreeId, workspaceScope.folderWorkspaceId)
: undefined
const target = tryRuntimeTargetForWorktreeOwner(
state,
worktreeId,
folderRow ? getFolderWorkspaceHostId(folderRow, state.projectGroups) : undefined
)
if (!target) {
return false
const folderHostId = folderRow
? getFolderWorkspaceHostId(folderRow, state.projectGroups)
: undefined
const pending = pendingRaises.get(worktreeId) ?? { raises: 0, reads: 0 }
pendingRaises.set(worktreeId, pending)
pending.raises += 1
const readsBefore = pending.reads
void ownerHostOwnsSidebarInputs(state, worktreeId, folderHostId)
.then((ownerRaises) => {
// A read while the owner was being asked already covers this news.
if (!ownerRaises && pending.reads === readsBefore) {
raiseWorkspaceUnreadFlag(set, get, worktreeId, folderHostId)
}
})
.finally(() => {
pending.raises -= 1
if (pending.raises === 0) {
pendingRaises.delete(worktreeId)
}
})
}
/** Raises still waiting on their owner check, per workspace, with the reads seen meanwhile. Why
* not per host: a read names its row's host differently from the row's own host lookup (a
* group-hosted folder reads as 'local'), so a per-host count could miss the read it must see. */
const pendingRaises = new Map<string, { raises: number; reads: number }>()
/** A read of the workspace by any path; a raise still waiting on its owner check drops. */
export function noteWorkspaceRead(worktreeId: string): void {
const pending = pendingRaises.get(worktreeId)
if (pending) {
pending.reads += 1
}
if (target.kind === 'local') {
return true
}
// Why the cached answer first: a per-raise probe would delay the flag past a screen arrival.
const cached = readCachedRuntimeEnvironmentCapability(
target.environmentId,
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
)
if (cached !== null) {
return cached
}
return runtimeEnvironmentSupportsCapability(
target.environmentId,
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
).catch(() => false)
}
/** Same-id folder rows: the on-screen host's, else the first. */
@@ -80,7 +75,15 @@ function folderRowToMark(
worktreeId: string,
folderWorkspaceId: string
): FolderWorkspace | undefined {
const host = onScreenHostId(state, worktreeId)
return folderRowOnHost(state, folderWorkspaceId, onScreenHostId(state, worktreeId))
}
/** The folder row on `host`, or the first same-id row when no host is named. */
function folderRowOnHost(
state: AppState,
folderWorkspaceId: string,
host: ExecutionHostId | null
): FolderWorkspace | undefined {
return state.folderWorkspaces.find(
(workspace) =>
workspace.id === folderWorkspaceId &&
@@ -91,14 +94,18 @@ function folderRowToMark(
function raiseWorkspaceUnreadFlag(
set: WorkspaceAttentionSet,
get: WorkspaceAttentionGet,
worktreeId: string
worktreeId: string,
/** The folder row's host when the news arrived; unset picks the on-screen row now. */
folderHostId?: ExecutionHostId
): void {
const stamp = { lastActivityAt: Date.now() }
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
const folderWorkspaceId = workspaceScope.folderWorkspaceId
const state = get()
const marked = folderRowToMark(state, worktreeId, folderWorkspaceId)
const marked = folderHostId
? folderRowOnHost(state, folderWorkspaceId, folderHostId)
: folderRowToMark(state, worktreeId, folderWorkspaceId)
if (!marked || marked.isUnread) {
return
}
@@ -112,7 +119,9 @@ function raiseWorkspaceUnreadFlag(
void get().updateFolderWorkspace(
folderWorkspaceId,
{ isUnread: true, ...stamp },
{ executionHostId: getFolderWorkspaceHostId(marked, state.projectGroups) }
{
executionHostId: getFolderWorkspaceHostId(marked, state.projectGroups)
}
)
return
}
@@ -164,6 +173,7 @@ export function clearWorkspaceUnreadFlag(
/** Folder rows share an id across hosts: the on-screen host's row, or null for every row. */
executionHostId?: ExecutionHostId | null
): void {
noteWorkspaceRead(worktreeId)
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
const folderWorkspaceId = workspaceScope.folderWorkspaceId
@@ -0,0 +1,40 @@
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../../shared/workspace-unread-raise-capability'
import {
readCachedRuntimeEnvironmentCapability,
runtimeEnvironmentSupportsCapability
} from '@/runtime/runtime-rpc-client'
import type { AppState } from '../../../types'
import { tryRuntimeTargetForWorktreeOwner } from './worktree-owner-target'
/**
* Whether the workspace's owning host runs main's sidebar inputs, so it raises the unread flag
* itself. A local owner runs this build (main, its SSH hosts, or the host that served the browser
* client). A paired server answers by capability; an unresolved owner or an unreadable status
* counts as older, so this window raises the flag.
*/
export async function ownerHostOwnsSidebarInputs(
state: AppState,
worktreeId: string,
executionHostId?: ExecutionHostId
): Promise<boolean> {
const target = tryRuntimeTargetForWorktreeOwner(state, worktreeId, executionHostId)
if (!target) {
return false
}
if (target.kind === 'local') {
return true
}
// Why the cached answer first: a per-write probe would delay the flag past a screen arrival.
const cached = readCachedRuntimeEnvironmentCapability(
target.environmentId,
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
)
if (cached !== null) {
return cached
}
return runtimeEnvironmentSupportsCapability(
target.environmentId,
WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY
).catch(() => false)
}