fix(sidebar): stabilize cross-host project grouping

This commit is contained in:
Brennan Benson
2026-07-31 12:36:42 -07:00
parent f3f5a928f6
commit cdeb993db3
5 changed files with 359 additions and 32 deletions
@@ -765,7 +765,7 @@ describe('buildRows with pinned worktrees', () => {
)
})
it('splits all project setups when one host has duplicate checkouts', () => {
it('splits only the surface with duplicate checkouts', () => {
const localRepoB: Repo = {
...repo,
id: 'repo-local-b',
@@ -822,10 +822,143 @@ describe('buildRows with pinned worktrees', () => {
expect(headers.map((row) => row.key)).toEqual([
'project:github:stablyai/orca::setup:repo-1',
'project:github:stablyai/orca::setup:repo-local-b',
'project:github:stablyai/orca::setup:repo-remote'
'project:github:stablyai/orca'
])
})
it('counts projection twins for one directory as one checkout', () => {
const runtimeHostId = 'runtime:m2-air'
const runtimeRepo: Repo = {
...remoteRepo,
id: 'repo-runtime',
path: '/home/alice/orca-runtime',
connectionId: undefined,
executionHostId: runtimeHostId
}
const runtimeWorktree: Worktree = {
...remoteWorktree,
id: 'wt-runtime',
repoId: runtimeRepo.id,
path: '/home/alice/orca-runtime-feature'
}
const runtimeSetup: ProjectHostSetup = {
...projectHostSetups[1]!,
id: runtimeRepo.id,
repoId: runtimeRepo.id,
path: runtimeRepo.path,
hostId: runtimeHostId,
executionHostId: runtimeHostId
}
const derivedSetup: ProjectHostSetup = {
...projectHostSetups[1]!,
hostId: runtimeHostId,
connectionId: 'intel mac',
executionHostId: runtimeHostId
}
const authoritativeSetup: ProjectHostSetup = {
...derivedSetup,
id: 'setup-authoritative',
path: `${derivedSetup.path}/`,
connectionId: null,
executionHostId: 'ssh:intel%20mac'
}
const grouping = {
projects: [{ ...project, sourceRepoIds: [remoteRepo.id, runtimeRepo.id] }],
projectHostSetups: [runtimeSetup, derivedSetup, authoritativeSetup]
}
expect([
getGroupKeyForWorktree(
'repo',
remoteWorktree,
new Map([[remoteRepo.id, remoteRepo]]),
null,
undefined,
undefined,
grouping
),
getGroupKeyForWorktree(
'repo',
runtimeWorktree,
new Map([[runtimeRepo.id, runtimeRepo]]),
null,
undefined,
undefined,
grouping
)
]).toEqual(['project:github:stablyai/orca', 'project:github:stablyai/orca'])
})
it('keeps Git hosts grouped when folder setups share the project identity', () => {
const windowsHostId = 'runtime:windows-server'
const windowsRepo: Repo = {
...repo,
id: 'repo-windows',
path: 'C:\\Users\\neil\\orca\\orca',
executionHostId: windowsHostId
}
const folderRepoA: Repo = {
...repo,
id: 'folder-qa-a',
path: '/tmp/pr11751-folder-qa',
displayName: 'pr11751-folder-qa',
kind: 'folder'
}
const folderRepoB: Repo = {
...folderRepoA,
id: 'folder-qa-b',
path: '/tmp/pr11767-runtime-folder',
displayName: 'pr11767-runtime-folder'
}
const setups: ProjectHostSetup[] = [
projectHostSetups[0]!,
{ ...projectHostSetups[0]!, id: 'projection-twin' },
projectHostSetups[1]!,
{
...projectHostSetups[0]!,
id: windowsRepo.id,
repoId: windowsRepo.id,
path: windowsRepo.path,
hostId: windowsHostId,
executionHostId: windowsHostId
},
{
...projectHostSetups[0]!,
id: folderRepoA.id,
repoId: folderRepoA.id,
path: folderRepoA.path,
displayName: folderRepoA.displayName,
kind: 'folder'
},
{
...projectHostSetups[0]!,
id: folderRepoB.id,
repoId: folderRepoB.id,
path: folderRepoB.path,
displayName: folderRepoB.displayName,
kind: 'folder'
}
]
const repos = [repo, remoteRepo, windowsRepo, folderRepoA, folderRepoB]
const grouping = {
projects: [{ ...project, sourceRepoIds: repos.map((entry) => entry.id) }],
projectHostSetups: setups
}
const groupKeys = repos.map((entry) =>
getGroupKeyForWorktree(
'repo',
{ ...worktree, id: `wt-${entry.id}`, repoId: entry.id, path: entry.path },
new Map([[entry.id, entry]]),
null,
undefined,
undefined,
grouping
)
)
expect(new Set(groupKeys)).toEqual(new Set(['project:github:stablyai/orca']))
})
it('keeps a provisioned runtime copy under the project header alongside a same-host checkout', () => {
const runtimeRepoB: Repo = {
...repo,
@@ -39,10 +39,14 @@ import {
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
getWorktreeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import {
isWindowsAbsolutePathLike,
normalizeRuntimePathForComparison
} from '../../../../shared/cross-platform-path'
import {
getCyclicProjectedWorktreeLineageIds,
getLineageRenderInfo
@@ -177,28 +181,43 @@ type WorktreeGroupEntry = {
type ProjectGroupingIndex = {
projectById: Map<string, Project>
setupByRepoId: Map<string, ProjectHostSetup>
projectIdsRequiringSetupGroups: Set<string>
surfaceKeysRequiringSetupGroups: Set<string>
}
const projectGroupingIndexCache = new WeakMap<ProjectGroupingModel, ProjectGroupingIndex | null>()
// Why: `provisioned` setups are ephemeral recipe-created runtime copies that
// nest under the project header; every other method is a real user checkout. See #5374.
// Why: provisioned and folder setups are not independent Git checkouts.
function isDistinctUserCheckout(setup: ProjectHostSetup): boolean {
return setup.setupMethod !== 'provisioned'
return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder'
}
// Why: execution target and filesystem namespace independently identify a surface.
function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string {
return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}`
}
function getExecutionSurface(setup: ProjectHostSetup): string {
const connectionId = setup.connectionId?.trim()
if (connectionId) {
return toSshExecutionHostId(connectionId)
}
return setup.executionHostId?.trim() || setup.hostId
}
// Why: projection twins differ by row identity, not checkout directory.
function getCheckoutIdentity(setup: ProjectHostSetup): string {
return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id
}
function getPathSurface(setup: ProjectHostSetup): string {
const wslPath = parseWslUncPath(setup.path)
if (wslPath) {
// Why: Windows host and WSL on one machine are separate execution surfaces;
// only duplicate checkouts within one surface make project grouping ambiguous.
return `${setup.projectId}::${setup.hostId}::wsl:${wslPath.distro.toLowerCase()}`
return `wsl:${wslPath.distro.toLowerCase()}`
}
if (isWindowsAbsolutePathLike(setup.path)) {
return `${setup.projectId}::${setup.hostId}::windows-host`
return 'windows-host'
}
return `${setup.projectId}::${setup.hostId}::default`
return 'default'
}
function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null {
@@ -215,9 +234,7 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin
projectGroupingIndexCache.set(model, null)
return null
}
// Count real user checkouts per host surface (provisioned copies excluded so
// they keep nesting); more than one on a surface makes that project ambiguous.
const checkoutsByProjectSurface = new Map<string, { projectId: string; count: number }>()
const checkoutsByProjectSurface = new Map<string, Set<string>>()
for (const setup of projectHostSetups) {
if (!isDistinctUserCheckout(setup)) {
continue
@@ -225,21 +242,21 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin
const key = getProjectSetupSurfaceKey(setup)
const existing = checkoutsByProjectSurface.get(key)
if (existing) {
existing.count += 1
existing.add(getCheckoutIdentity(setup))
} else {
checkoutsByProjectSurface.set(key, { projectId: setup.projectId, count: 1 })
checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)]))
}
}
const projectIdsRequiringSetupGroups = new Set<string>()
for (const { projectId, count } of checkoutsByProjectSurface.values()) {
if (count > 1) {
projectIdsRequiringSetupGroups.add(projectId)
const surfaceKeysRequiringSetupGroups = new Set<string>()
for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) {
if (checkouts.size > 1) {
surfaceKeysRequiringSetupGroups.add(surfaceKey)
}
}
const index = {
projectById: new Map(projects.map((project) => [project.id, project])),
setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])),
projectIdsRequiringSetupGroups
surfaceKeysRequiringSetupGroups
}
projectGroupingIndexCache.set(model, index)
return index
@@ -268,11 +285,10 @@ function getProjectGroupingForRepo(
}
}
if (
projectIndex?.projectIdsRequiringSetupGroups.has(setup.projectId) &&
projectIndex?.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) &&
isDistinctUserCheckout(setup)
) {
// Why: independent user checkouts of one project on the same host surface
// can't be safely merged, so each keeps its own sidebar entry. See #5374.
// Why: only the ambiguous surface needs checkout-specific headers.
return {
key: `project:${project.id}::setup:${repoId}`,
label: repo?.displayName ?? setup.displayName,
+5
View File
@@ -322,6 +322,11 @@
"f518e89aa5": "Project already exists in that profile",
"f03ae7f27b": "Failed to transfer project"
}
},
"runtime": {
"status": {
"runtimeHostDisconnectedDescription": "Workspaces and terminals on this server are unavailable."
}
}
}
},
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { toast } from 'sonner'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { createCompatibleRuntimeStatusResponse } from '../../runtime/runtime-compatibility-test-fixture'
import {
callRuntimeRpc,
@@ -12,6 +14,10 @@ import {
getRuntimeEnvironmentConnectionGeneration
} from './runtime-status'
vi.mock('sonner', () => ({
toast: { warning: vi.fn(), dismiss: vi.fn() }
}))
function createSliceStore() {
return create<RuntimeStatusSlice>()((...a) => ({
...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>))
@@ -32,6 +38,22 @@ function makeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
} as RuntimeStatus
}
function makeEnvironment(
overrides: Partial<PublicKnownRuntimeEnvironment> = {}
): PublicKnownRuntimeEnvironment {
return {
id: 'env-a',
name: 'Dev Box',
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [{ id: 'ws-a', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }],
preferredEndpointId: 'ws-a',
...overrides
}
}
function stubRuntimeEnvironmentApi({
getStatus = vi.fn(),
list = vi.fn()
@@ -50,6 +72,11 @@ function stubRuntimeEnvironmentApi({
return { getStatus, list }
}
beforeEach(() => {
vi.mocked(toast.warning).mockReset()
vi.mocked(toast.dismiss).mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
@@ -142,6 +169,77 @@ describe('runtime-status slice', () => {
expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5, connectionGeneration: 1 })
})
it('does not toast when the first probe finds a saved server offline', () => {
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 1 })
expect(toast.warning).not.toHaveBeenCalled()
})
it('toasts once when a connected server becomes unavailable', () => {
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 3 })
expect(toast.warning).toHaveBeenCalledTimes(1)
expect(toast.warning).toHaveBeenCalledWith(
'Dev Box disconnected',
expect.objectContaining({
id: 'runtime-environment-disconnected:env-a',
description: 'Workspaces and terminals on this server are unavailable.',
action: expect.objectContaining({ label: 'Retry' })
})
)
})
it('dismisses the disconnect toast when the server recovers', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 3 })
expect(toast.dismiss).toHaveBeenCalledWith('runtime-environment-disconnected:env-a')
})
it('retries from the toast and re-surfaces it when the server is still offline', async () => {
const getStatus = vi.fn().mockRejectedValue(new Error('closed'))
stubRuntimeEnvironmentApi({ getStatus })
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 })
const options = vi.mocked(toast.warning).mock.calls[0]?.[1] as unknown as {
action: { onClick: () => void }
}
options.action.onClick()
await vi.waitFor(() => {
expect(getStatus).toHaveBeenCalledWith({ selector: 'env-a', timeoutMs: 10_000 })
expect(toast.warning).toHaveBeenCalledTimes(2)
})
expect(vi.mocked(toast.warning).mock.calls[1]?.[1]?.id).toBe(
'runtime-environment-disconnected:env-a'
)
})
it('does not report removal or explicit status clearing as a disconnect', () => {
const store = createSliceStore()
store.setState({ runtimeEnvironments: [makeEnvironment()] })
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
store.getState().clearRuntimeEnvironmentStatus('env-a')
store.getState().setRuntimeEnvironments([])
expect(toast.warning).not.toHaveBeenCalled()
})
it('clears a single environment entry', () => {
const store = createSliceStore()
store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 })
@@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
@@ -8,6 +9,7 @@ import {
unwrapRuntimeRpcResult
} from '@/runtime/runtime-rpc-client'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import { translate } from '@/i18n/i18n'
/** Live status for one saved runtime environment, as last observed by the
* renderer. `status === null` records a probe that failed or timed out so the
@@ -57,6 +59,65 @@ export type RuntimeStatusSlice = {
}
const connectionGenerationByEnvironment = new Map<string, number>()
const activeRuntimeDisconnectedToasts = new Map<string, symbol>()
function getRuntimeDisconnectedToastId(environmentId: string): string {
return `runtime-environment-disconnected:${environmentId}`
}
function showRuntimeDisconnectedToast(environmentId: string, getState: () => AppState): void {
const environment = getState().runtimeEnvironments.find((entry) => entry.id === environmentId)
const toastId = getRuntimeDisconnectedToastId(environmentId)
const activation = Symbol(toastId)
const title = environment?.name
? translate(
'auto.components.sidebar.WorktreeCard.runtimeHostDisconnectedNamed',
'{{hostName}} disconnected',
{ hostName: environment.name }
)
: translate(
'auto.components.sidebar.WorktreeCard.runtimeHostDisconnected',
'Server disconnected'
)
activeRuntimeDisconnectedToasts.set(toastId, activation)
const clearActiveToast = (): void => {
if (activeRuntimeDisconnectedToasts.get(toastId) === activation) {
activeRuntimeDisconnectedToasts.delete(toastId)
}
}
toast.warning(title, {
id: toastId,
description: translate(
'auto.store.slices.runtime.status.runtimeHostDisconnectedDescription',
'Workspaces and terminals on this server are unavailable.'
),
action: {
label: translate('browser.loadFailure.retry', 'Retry'),
onClick: () => {
void getState()
.refreshRuntimeEnvironmentStatus(environmentId)
.then((reachable) => {
const stillSaved = getState().runtimeEnvironments.some(
(entry) => entry.id === environmentId
)
if (!reachable && stillSaved) {
showRuntimeDisconnectedToast(environmentId, getState)
}
})
}
},
onDismiss: clearActiveToast,
onAutoClose: clearActiveToast
})
}
function dismissRuntimeDisconnectedToast(environmentId: string): void {
const toastId = getRuntimeDisconnectedToastId(environmentId)
if (!activeRuntimeDisconnectedToasts.delete(toastId)) {
return
}
toast.dismiss?.(toastId)
}
export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number {
return connectionGenerationByEnvironment.get(environmentId) ?? 0
@@ -158,10 +219,12 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
const retiredEnvironmentIds = [...new Set([...removedIds, ...replacedEnvironmentIds])]
if (retiredEnvironmentIds.length > 0) {
get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds)
retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast)
}
},
setRuntimeEnvironmentStatus: (environmentId, status) => {
const previous = get().runtimeStatusByEnvironmentId.get(environmentId)
// Why: a non-null status proves the runtime just answered, so drop any stale
// "offline" compat failure before this online transition fires the
// reuse-flagged background refetches — a recovered host must re-probe.
@@ -170,7 +233,6 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
}
set((s) => {
const next = new Map(s.runtimeStatusByEnvironmentId)
const previous = next.get(environmentId)
const connectionChanged =
status.status !== null &&
(previous?.status == null || previous.status.runtimeId !== status.status.runtimeId)
@@ -185,9 +247,15 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
})
return { runtimeStatusByEnvironmentId: next }
})
if (previous?.status === null && status.status !== null) {
dismissRuntimeDisconnectedToast(environmentId)
} else if (previous && previous.status !== null && status.status === null) {
showRuntimeDisconnectedToast(environmentId, get)
}
},
clearRuntimeEnvironmentStatus: (environmentId) =>
clearRuntimeEnvironmentStatus: (environmentId) => {
dismissRuntimeDisconnectedToast(environmentId)
set((s) => {
advanceRuntimeEnvironmentConnectionGeneration(environmentId)
if (!s.runtimeStatusByEnvironmentId.has(environmentId)) {
@@ -196,11 +264,17 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
const next = new Map(s.runtimeStatusByEnvironmentId)
next.delete(environmentId)
return { runtimeStatusByEnvironmentId: next }
}),
})
},
retainRuntimeEnvironmentStatuses: (environmentIds) =>
retainRuntimeEnvironmentStatuses: (environmentIds) => {
const keep = new Set(environmentIds)
for (const id of get().runtimeStatusByEnvironmentId.keys()) {
if (!keep.has(id)) {
dismissRuntimeDisconnectedToast(id)
}
}
set((s) => {
const keep = new Set(environmentIds)
let changed = false
const next = new Map(s.runtimeStatusByEnvironmentId)
for (const id of next.keys()) {
@@ -210,7 +284,8 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
}
}
return changed ? { runtimeStatusByEnvironmentId: next } : s
}),
})
},
refreshRuntimeEnvironmentStatus: async (environmentId, timeoutMs = 10_000) => {
try {