From 824ca9545fcf03c2628003ba0b800d8a2be60c44 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:42:34 -0700 Subject: [PATCH] fix(ssh): a host converted to its managed server keeps its workspaces' visit recency (#30015) Converting an SSH host drops its relay-era rows, but recency stayed under ssh:| keys, which the next startup prune discards because no row matches them any more. Re-key that host's visits to runtime:| in the same update, keeping the newer visit on a collision. --- ...-managed-server-converted-catalogs.test.ts | 14 ++++++++ .../ssh-managed-server-state-effects.ts | 16 +++++++++- .../src/lib/worktree-visit-recency.test.ts | 32 +++++++++++++++++++ .../src/lib/worktree-visit-recency.ts | 24 ++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts index 86b00c67a67..710f924fd7b 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts @@ -143,6 +143,20 @@ describe('a host that just moved to its managed server', () => { expect(state.folderWorkspaces.map((entry) => entry.executionHostId)).toEqual(['runtime:env-1']) }) + it('carries the host’s visit recency to the server instead of leaving it for the next prune', async () => { + store.setState({ + lastVisitedAtByWorktreeId: { [`ssh:ssh-1|${worktreeId}`]: 500, [`local|${worktreeId}`]: 7 } + }) + applySshManagedServerTransition('ssh-1', undefined, { kind: 'managed', environmentId: 'env-7' }) + + await vi.waitFor(() => + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ + [`runtime:env-7|${worktreeId}`]: 500, + [`local|${worktreeId}`]: 7 + }) + ) + }) + it('moves the open workspace onto the server so its panes stop dialing the stopped relay', async () => { store.setState({ worktreesByRepo: { diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index d3f5b5b31bc..639a7ac27ee 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -9,6 +9,7 @@ import { import { translate } from '@/i18n/i18n' import { useAppStore } from '../../store' import { withoutConvertedSshHostRows } from '../../store/repos/converted-ssh-host-rows' +import { rekeyWorktreeVisitHost } from '@/lib/worktree-visit-recency' import { preserveConvertedSshBrowserPages } from '../../store/repos/converted-ssh-browser-pages' type ManagedServerStatus = SshConnectionState['managedServer'] @@ -154,7 +155,20 @@ async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): P if (!isCurrent()) { return } - useAppStore.setState((state) => withoutConvertedSshHostRows(state, targetId)) + useAppStore.setState((state) => { + // Why: the host's workspaces are the server's now; unmoved, the next prune drops their recency. + const lastVisitedAtByWorktreeId = rekeyWorktreeVisitHost( + state.lastVisitedAtByWorktreeId, + toSshExecutionHostId(targetId), + toRuntimeExecutionHostId(environmentId) + ) + return { + ...withoutConvertedSshHostRows(state, targetId), + ...(lastVisitedAtByWorktreeId === state.lastVisitedAtByWorktreeId + ? {} + : { lastVisitedAtByWorktreeId }) + } + }) rehomeActiveWorkspace(targetId, environmentId) if (!useAppStore.getState().startupWorktreeRefreshCompleted) { rehomeAfterStartupScan(targetId, environmentId, isCurrent) diff --git a/src/renderer/src/lib/worktree-visit-recency.test.ts b/src/renderer/src/lib/worktree-visit-recency.test.ts index 4b431886a1a..a0c022de4b1 100644 --- a/src/renderer/src/lib/worktree-visit-recency.test.ts +++ b/src/renderer/src/lib/worktree-visit-recency.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { getWorktreeVisitKey, getWorktreeVisitTimestamp, + rekeyWorktreeVisitHost, removeWorktreeVisitEntries } from './worktree-visit-recency' @@ -42,3 +43,34 @@ describe('worktree visit recency identities', () => { }) }) }) + +describe('rekeyWorktreeVisitHost', () => { + const id = 'repo::/srv/app' + + it('moves one host’s visits to the host that now runs its workspaces, newer visit winning', () => { + const moved = rekeyWorktreeVisitHost( + { + [getWorktreeVisitKey(id, 'ssh:box')]: 300, + [getWorktreeVisitKey(id, 'runtime:env')]: 100, + [getWorktreeVisitKey('repo::/srv/other', 'ssh:box')]: 50, + [getWorktreeVisitKey(id, 'ssh:box-2')]: 70, + [getWorktreeVisitKey(id, 'local')]: 20, + [id]: 10 + }, + 'ssh:box', + 'runtime:env' + ) + expect(moved).toEqual({ + [getWorktreeVisitKey(id, 'runtime:env')]: 300, + [getWorktreeVisitKey('repo::/srv/other', 'runtime:env')]: 50, + [getWorktreeVisitKey(id, 'ssh:box-2')]: 70, + [getWorktreeVisitKey(id, 'local')]: 20, + [id]: 10 + }) + }) + + it('returns the same map when the host has no visits', () => { + const timestamps = { [getWorktreeVisitKey(id, 'local')]: 1 } + expect(rekeyWorktreeVisitHost(timestamps, 'ssh:box', 'runtime:env')).toBe(timestamps) + }) +}) diff --git a/src/renderer/src/lib/worktree-visit-recency.ts b/src/renderer/src/lib/worktree-visit-recency.ts index 915c6e098c9..7a982b836e6 100644 --- a/src/renderer/src/lib/worktree-visit-recency.ts +++ b/src/renderer/src/lib/worktree-visit-recency.ts @@ -97,3 +97,27 @@ export function removeWorktreeVisitEntriesForTargets( } return changed ? next : (timestamps as Record) } + +/** + * Move one host's recency to another host when the same workspaces now live there (an SSH host + * converted to its managed server). Bare legacy keys and other hosts are untouched; a collision + * keeps the newer visit. + */ +export function rekeyWorktreeVisitHost( + timestamps: Readonly>, + fromHostId: ExecutionHostId, + toHostId: ExecutionHostId +): Readonly> { + const prefix = `${fromHostId}|` + if (!Object.keys(timestamps).some((key) => key.startsWith(prefix))) { + return timestamps + } + const next: Record = {} + for (const [key, timestamp] of Object.entries(timestamps)) { + const target = key.startsWith(prefix) + ? composeWorktreeHostIdentity(toHostId, getWorktreeIdFromHostIdentity(key)) + : key + next[target] = Math.max(next[target] ?? timestamp, timestamp) + } + return next +}