mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
fix(sidebar): sweep host-retracted rows without hydrated tabs
This commit is contained in:
@@ -30,6 +30,25 @@ function status(tabId: string, worktreeId: string): AgentStatusEntry {
|
||||
}
|
||||
}
|
||||
|
||||
function hostSnapshot(tabId: string, worktreeId: string) {
|
||||
return makeSnapshot(
|
||||
[
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `${tabId}::${LEAF_ID}`,
|
||||
parentTabId: tabId,
|
||||
leafId: LEAF_ID,
|
||||
title: 'OMP',
|
||||
isActive: false,
|
||||
status: 'ready',
|
||||
terminal: `terminal-${tabId}`,
|
||||
agentStatus: status(tabId, worktreeId)
|
||||
}
|
||||
],
|
||||
{ worktree: worktreeId }
|
||||
)
|
||||
}
|
||||
|
||||
function rows(state: ReturnType<typeof makeState>, worktreeId: string) {
|
||||
return buildWorktreeAgentRows({
|
||||
tabs: state.tabsByWorktree[worktreeId] ?? [],
|
||||
@@ -77,9 +96,9 @@ describe('remote completed sidebar rows before tab hydration', () => {
|
||||
expect(rows(mirror, worktreeId)).toMatchObject([{ paneKey, state: 'done', agentType: 'omp' }])
|
||||
expect(rows(mirror, worktreeId)).toHaveLength(1)
|
||||
const removed = {
|
||||
...mirror,
|
||||
...beforeTabs,
|
||||
...applyWebSessionTabsSnapshot(
|
||||
mirror,
|
||||
beforeTabs,
|
||||
makeSnapshot([], { worktree: worktreeId, snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW + 1
|
||||
@@ -90,14 +109,73 @@ describe('remote completed sidebar rows before tab hydration', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('honors status removal before tab hydration', () => {
|
||||
it('retracts only the publishing host and workspace when client tabs are missing', () => {
|
||||
const worktreeId = 'folder:remote-workspace'
|
||||
const entry = status(toWebTerminalSurfaceTabId('host-tab'), worktreeId)
|
||||
const state = makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } })
|
||||
expect(rows(state, worktreeId)).toHaveLength(1)
|
||||
expect(rows({ ...state, agentStatusByPaneKey: {} }, worktreeId)).toEqual([])
|
||||
const initial = makeState()
|
||||
const owner = applyWebSessionTabsSnapshot(initial, hostSnapshot('owner', worktreeId), ENV, NOW)
|
||||
const sibling = applyWebSessionTabsSnapshot(
|
||||
initial,
|
||||
hostSnapshot('sibling', worktreeId),
|
||||
'other-host',
|
||||
NOW
|
||||
)
|
||||
const otherWorkspace = applyWebSessionTabsSnapshot(
|
||||
initial,
|
||||
hostSnapshot('other-workspace', 'folder:other'),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
const unknown = status(toWebTerminalSurfaceTabId('unknown-owner'), worktreeId)
|
||||
const beforeTabs = makeState({
|
||||
agentStatusByPaneKey: {
|
||||
...owner.agentStatusByPaneKey,
|
||||
...sibling.agentStatusByPaneKey,
|
||||
...otherWorkspace.agentStatusByPaneKey,
|
||||
[unknown.paneKey]: unknown
|
||||
}
|
||||
})
|
||||
const removed = {
|
||||
...beforeTabs,
|
||||
...applyWebSessionTabsSnapshot(
|
||||
beforeTabs,
|
||||
makeSnapshot([], { worktree: worktreeId, snapshotVersion: 2 }),
|
||||
ENV,
|
||||
NOW + 1
|
||||
)
|
||||
}
|
||||
expect(Object.keys(removed.agentStatusByPaneKey).sort()).toEqual(
|
||||
[
|
||||
makePaneKey(toWebTerminalSurfaceTabId('sibling'), LEAF_ID),
|
||||
makePaneKey(toWebTerminalSurfaceTabId('other-workspace'), LEAF_ID),
|
||||
unknown.paneKey
|
||||
].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['visibility-inventory-removal', 'removed:host-epoch'])(
|
||||
'does not treat worktree visibility removal %s as host tab retraction',
|
||||
(publicationEpoch) => {
|
||||
const worktreeId = 'folder:remote-workspace'
|
||||
const initial = makeState()
|
||||
const mirror = applyWebSessionTabsSnapshot(
|
||||
initial,
|
||||
hostSnapshot('host-tab', worktreeId),
|
||||
ENV,
|
||||
NOW
|
||||
)
|
||||
const beforeTabs = makeState({ agentStatusByPaneKey: mirror.agentStatusByPaneKey })
|
||||
const tombstone = {
|
||||
...makeSnapshot([], { worktree: worktreeId, publicationEpoch }),
|
||||
removed: true as const
|
||||
}
|
||||
const removed = {
|
||||
...beforeTabs,
|
||||
...applyWebSessionTabsSnapshot(beforeTabs, tombstone, ENV, NOW + 1)
|
||||
}
|
||||
expect(removed.agentStatusByPaneKey).toEqual(beforeTabs.agentStatusByPaneKey)
|
||||
}
|
||||
)
|
||||
|
||||
it('rebuilds same-key buckets when SSH attribution arrives or is removed', () => {
|
||||
const worktreeId = 'folder:remote-workspace'
|
||||
const entry = status('ssh-tab', worktreeId)
|
||||
|
||||
@@ -150,7 +150,6 @@ export function buildRemirroredClosedTabMarkerLiftPatch(
|
||||
*/
|
||||
export function buildRetractedMirroredTabSweepPatch(
|
||||
state: WebSessionTabsSyncState,
|
||||
worktreeId: string,
|
||||
nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'],
|
||||
agentStatusPatch: Pick<
|
||||
WebSessionTabsSyncState,
|
||||
@@ -179,13 +178,13 @@ export function buildRetractedMirroredTabSweepPatch(
|
||||
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {},
|
||||
retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {},
|
||||
sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch,
|
||||
// Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has",
|
||||
// so it must see the post-removal tab list, not the one the snapshot replaced.
|
||||
// Keep the sweep state consistent with the accepted host inventory.
|
||||
tabsByWorktree: nextTabsByWorktree
|
||||
}
|
||||
// Why: a retraction can be a reconnect re-key, not pane death (ssh-execution-boundary); keeping
|
||||
// cutoffs means a republished pane cannot replay activity the user cleared on this client.
|
||||
const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId, {
|
||||
// The host retracts exact tab ids; a worktree-wide orphan sweep could erase a sibling host.
|
||||
const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, undefined, {
|
||||
preserveActivityClearedState: true
|
||||
})
|
||||
if (!sweep?.agentStatusByPaneKey || !batchContext) {
|
||||
|
||||
@@ -16,7 +16,6 @@ export function buildWebSessionTabsFinalPatch(
|
||||
const {
|
||||
state,
|
||||
snapshot,
|
||||
worktreeId,
|
||||
now,
|
||||
batchContext,
|
||||
currentTerminalTabs,
|
||||
@@ -65,7 +64,6 @@ export function buildWebSessionTabsFinalPatch(
|
||||
? null
|
||||
: buildRetractedMirroredTabSweepPatch(
|
||||
state,
|
||||
worktreeId,
|
||||
nextTabsByWorktree,
|
||||
agentStatusPatch,
|
||||
removedTerminalResourceIds,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { collectUnhydratedMirroredTabRetractions } from './mirrored-status-tab-retractions'
|
||||
import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import type {
|
||||
WebSessionTabsBatchContext,
|
||||
@@ -188,6 +190,17 @@ export function prepareWebSessionTabsSnapshotBase(
|
||||
const removedTerminalIds = new Set(
|
||||
currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id)
|
||||
)
|
||||
if (reconcilesNonAgentTabs && !isWebSessionTabsWorktreeRemovalFrame(snapshot)) {
|
||||
for (const tabId of collectUnhydratedMirroredTabRetractions({
|
||||
state,
|
||||
environmentId,
|
||||
worktreeId,
|
||||
nextHostTerminalTabIds,
|
||||
currentTerminalIds: new Set(existingTerminalById.keys())
|
||||
})) {
|
||||
removedTerminalIds.add(tabId)
|
||||
}
|
||||
}
|
||||
const removedTerminalResourceIds = [...removedTerminalIds].filter(
|
||||
(tabId) => !mirroredTerminalIds.has(tabId)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { isWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
|
||||
import type { WebSessionTabsSyncState } from './state'
|
||||
import { resolveHostSessionTabIdForWebSessionTab } from './tracking-mappings'
|
||||
|
||||
/** Reuse the host mapping when a status row outlives this renderer's tab inventory. */
|
||||
export function collectUnhydratedMirroredTabRetractions(args: {
|
||||
state: WebSessionTabsSyncState
|
||||
environmentId: string
|
||||
worktreeId: string
|
||||
nextHostTerminalTabIds: ReadonlySet<string>
|
||||
currentTerminalIds: ReadonlySet<string>
|
||||
}): string[] {
|
||||
const retracted = new Set<string>()
|
||||
for (const [paneKey, entry] of Object.entries(args.state.agentStatusByPaneKey)) {
|
||||
if (entry.worktreeId !== args.worktreeId) {
|
||||
continue
|
||||
}
|
||||
const tabId = parsePaneKey(paneKey)?.tabId
|
||||
if (!tabId || !isWebTerminalSurfaceTabId(tabId) || args.currentTerminalIds.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
const hostTabId = resolveHostSessionTabIdForWebSessionTab(args.state, {
|
||||
environmentId: args.environmentId,
|
||||
worktreeId: args.worktreeId,
|
||||
tabId
|
||||
})
|
||||
// A missing mapping is unknown ownership; another host's snapshot cannot retract it.
|
||||
if (hostTabId !== null && !args.nextHostTerminalTabIds.has(hostTabId)) {
|
||||
retracted.add(tabId)
|
||||
}
|
||||
}
|
||||
return [...retracted]
|
||||
}
|
||||
Reference in New Issue
Block a user