Reduce idle remote runtime workspace work (#5895)
* Fix idle remote runtime session work * fix: stamp remote/SSH worktrees with their repo execution host Remote-runtime and SSH worktrees are fetched/created/reparented through the owning host, which reports them from its own perspective (hostId defaults to "local"). After the per-worktree hostId override in #2, that bogus "local" overrode the repo's runtime owner, so terminals/sessions for a remote worktree resolved to the local machine instead of the remote (clicking an omarchy-office worktree opened a shell on the local omarchy-thinkpad). Re-stamp every runtime worktree payload with the repo's execution host via a shared withRepoHostId helper, applied at all worktreesByRepo ingress points: toVisibleWorktrees (fetch), createWorktree, and applyWorktreeLineageUpdate. Local-owned repos are left untouched, so an explicit local worktree still overrides a runtime repo owner. Mirrors how repos already get repoWithFetchedOwner. * fix: auto-discover remote runtime projects on connect PR #2 gated the global session-tab sync to web clients only, removing the desktop path that eagerly populated remote projects. With no on-connect repo fetch left, remote projects only appeared after the user opened the Add-Project dropdown (which calls fetchRuntimeEnvironmentRepos directly). Seed an initial repo/worktree/lineage refresh for every runtime environment that is already connected when useIpcEvents mounts, and for each one that becomes connected afterward. Reuses the debounced/throttled refresh scheduler, and works regardless of whether the remote server emits client events. * fix: tighten idle runtime refresh behavior Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3cc610bcc3
commit
bbc5951958
|
|
@ -0,0 +1,105 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler'
|
||||
|
||||
describe('createRuntimeProjectRefreshScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('coalesces a burst of remote repo events into one refresh', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined)
|
||||
const scheduler = createRuntimeProjectRefreshScheduler({
|
||||
refresh,
|
||||
debounceMs: 100,
|
||||
minIntervalMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.request('env-1')
|
||||
scheduler.request('env-1')
|
||||
scheduler.request('env-1')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(99)
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(refresh).toHaveBeenCalledWith('env-1')
|
||||
|
||||
scheduler.stop()
|
||||
})
|
||||
|
||||
it('throttles repeated bursts after the first refresh', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined)
|
||||
const scheduler = createRuntimeProjectRefreshScheduler({
|
||||
refresh,
|
||||
debounceMs: 100,
|
||||
minIntervalMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.request('env-1')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
|
||||
scheduler.request('env-1')
|
||||
scheduler.request('env-1')
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
|
||||
scheduler.stop()
|
||||
})
|
||||
|
||||
it('waits for an in-flight refresh before running a pending follow-up', async () => {
|
||||
let finishRefresh = (): void => {
|
||||
throw new Error('Expected refresh promise resolver to be set')
|
||||
}
|
||||
const refresh = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishRefresh = resolve
|
||||
})
|
||||
)
|
||||
const scheduler = createRuntimeProjectRefreshScheduler({
|
||||
refresh,
|
||||
debounceMs: 100,
|
||||
minIntervalMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.request('env-1')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
|
||||
scheduler.request('env-1')
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
|
||||
finishRefresh()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
|
||||
scheduler.stop()
|
||||
})
|
||||
|
||||
it('clears pending timers on stop', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined)
|
||||
const scheduler = createRuntimeProjectRefreshScheduler({
|
||||
refresh,
|
||||
debounceMs: 100,
|
||||
minIntervalMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.request('env-1')
|
||||
scheduler.stop()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
export type RuntimeProjectRefreshSchedulerDeps = {
|
||||
refresh: (environmentId: string) => Promise<void>
|
||||
debounceMs?: number
|
||||
minIntervalMs?: number
|
||||
now?: () => number
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export type RuntimeProjectRefreshScheduler = {
|
||||
request: (environmentId: string) => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
type RefreshEntry = {
|
||||
inFlight: boolean
|
||||
lastStartedAt: number
|
||||
pending: boolean
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 250
|
||||
const DEFAULT_MIN_INTERVAL_MS = 5_000
|
||||
|
||||
export function createRuntimeProjectRefreshScheduler(
|
||||
deps: RuntimeProjectRefreshSchedulerDeps
|
||||
): RuntimeProjectRefreshScheduler {
|
||||
const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
||||
const minIntervalMs = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
||||
const now = deps.now ?? Date.now
|
||||
const entries = new Map<string, RefreshEntry>()
|
||||
let stopped = false
|
||||
|
||||
const getEntry = (environmentId: string): RefreshEntry => {
|
||||
let entry = entries.get(environmentId)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
inFlight: false,
|
||||
lastStartedAt: 0,
|
||||
pending: false,
|
||||
timer: null
|
||||
}
|
||||
entries.set(environmentId, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const schedule = (environmentId: string, entry: RefreshEntry): void => {
|
||||
if (stopped || entry.inFlight || entry.timer) {
|
||||
return
|
||||
}
|
||||
const elapsed = entry.lastStartedAt > 0 ? now() - entry.lastStartedAt : minIntervalMs
|
||||
const throttleDelay = Math.max(0, minIntervalMs - elapsed)
|
||||
const delay = Math.max(debounceMs, throttleDelay)
|
||||
entry.timer = setTimeout(() => {
|
||||
entry.timer = null
|
||||
void run(environmentId, entry)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const run = async (environmentId: string, entry: RefreshEntry): Promise<void> => {
|
||||
if (stopped || !entry.pending) {
|
||||
return
|
||||
}
|
||||
entry.pending = false
|
||||
entry.inFlight = true
|
||||
entry.lastStartedAt = now()
|
||||
try {
|
||||
await deps.refresh(environmentId)
|
||||
} catch (error) {
|
||||
deps.onError?.(error)
|
||||
} finally {
|
||||
entry.inFlight = false
|
||||
if (entry.pending) {
|
||||
// Why: runtime repo events can be noisy while a remote server is merely
|
||||
// connected; keep discovery live without letting it drive the renderer.
|
||||
schedule(environmentId, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const request = (environmentId: string): void => {
|
||||
const trimmedEnvironmentId = environmentId.trim()
|
||||
if (!trimmedEnvironmentId || stopped) {
|
||||
return
|
||||
}
|
||||
const entry = getEntry(trimmedEnvironmentId)
|
||||
entry.pending = true
|
||||
schedule(trimmedEnvironmentId, entry)
|
||||
}
|
||||
|
||||
const stop = (): void => {
|
||||
stopped = true
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.timer) {
|
||||
clearTimeout(entry.timer)
|
||||
}
|
||||
}
|
||||
entries.clear()
|
||||
}
|
||||
|
||||
return { request, stop }
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import {
|
||||
buildRuntimeClientEventEnvironmentKey,
|
||||
buildNewWorkspaceShortcutModalData,
|
||||
getNewlyConnectedRuntimeEnvironmentIds,
|
||||
getRuntimeProjectRefreshEnvironmentIds,
|
||||
openNewWorkspaceFromShortcut,
|
||||
resolveBrowserSessionTabTarget,
|
||||
resolveZoomTarget
|
||||
|
|
@ -37,6 +39,47 @@ describe('buildRuntimeClientEventEnvironmentKey', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('getNewlyConnectedRuntimeEnvironmentIds', () => {
|
||||
it('returns only environments that became connected', () => {
|
||||
expect(getNewlyConnectedRuntimeEnvironmentIds(['env-a'], ['env-a', 'env-b'])).toEqual(['env-b'])
|
||||
})
|
||||
|
||||
it('ignores environments that disconnected or stayed connected', () => {
|
||||
expect(getNewlyConnectedRuntimeEnvironmentIds(['env-a', 'env-b'], ['env-a'])).toEqual([])
|
||||
})
|
||||
|
||||
it('treats every environment as new when none were connected before', () => {
|
||||
expect(getNewlyConnectedRuntimeEnvironmentIds([], ['env-a', 'env-a', 'env-b'])).toEqual([
|
||||
'env-a',
|
||||
'env-b'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRuntimeProjectRefreshEnvironmentIds', () => {
|
||||
it('refreshes when an already-desired runtime becomes reachable', () => {
|
||||
expect(
|
||||
getRuntimeProjectRefreshEnvironmentIds({
|
||||
previousDesired: ['env-a'],
|
||||
nextDesired: ['env-a'],
|
||||
previousReachable: [],
|
||||
nextReachable: ['env-a']
|
||||
})
|
||||
).toEqual(['env-a'])
|
||||
})
|
||||
|
||||
it('deduplicates runtimes that are both newly desired and newly reachable', () => {
|
||||
expect(
|
||||
getRuntimeProjectRefreshEnvironmentIds({
|
||||
previousDesired: [],
|
||||
nextDesired: ['env-a'],
|
||||
previousReachable: [],
|
||||
nextReachable: ['env-a']
|
||||
})
|
||||
).toEqual(['env-a'])
|
||||
})
|
||||
})
|
||||
|
||||
function expectWorktreeRouting(worktreeId: string): unknown {
|
||||
return expect.objectContaining({ worktreeId })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
|
|||
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
|
||||
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
|
||||
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
|
||||
import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler'
|
||||
import { createRuntimeClientEventsSync } from './runtime-client-events-sync'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
|
|
@ -129,6 +130,7 @@ function getShortcutPlatform(): NodeJS.Platform {
|
|||
}
|
||||
|
||||
const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000
|
||||
const RUNTIME_PROJECT_REFRESH_CONCURRENCY = 5
|
||||
const browserAutomationBootstrapLeaseByPageId = new Map<string, { token: string; timer: number }>()
|
||||
|
||||
function isPinnedSessionTab(store: AppState, worktreeId: string, visibleId: string): boolean {
|
||||
|
|
@ -725,12 +727,74 @@ function getRuntimeClientEventEnvironmentIds(): string[] {
|
|||
return [...ids]
|
||||
}
|
||||
|
||||
function getReachableRuntimeEnvironmentIds(): string[] {
|
||||
const state = useAppStore.getState()
|
||||
const ids: string[] = []
|
||||
for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) {
|
||||
if (status?.status) {
|
||||
ids.push(environmentId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string {
|
||||
return [...new Set(environmentIds)].sort().join('\u0000')
|
||||
}
|
||||
|
||||
function getRuntimeClientEventEnvironmentKey(): string {
|
||||
return buildRuntimeClientEventEnvironmentKey(getRuntimeClientEventEnvironmentIds())
|
||||
/** Ids in `next` not in `previous` — runtime environments that just became
|
||||
* connected. Exported to unit-test the on-connect discovery trigger. */
|
||||
export function getNewlyConnectedRuntimeEnvironmentIds(
|
||||
previous: readonly string[],
|
||||
next: readonly string[]
|
||||
): string[] {
|
||||
const known = new Set(previous)
|
||||
return [...new Set(next)].filter((environmentId) => !known.has(environmentId))
|
||||
}
|
||||
|
||||
export function getRuntimeProjectRefreshEnvironmentIds(args: {
|
||||
previousDesired: readonly string[]
|
||||
nextDesired: readonly string[]
|
||||
previousReachable: readonly string[]
|
||||
nextReachable: readonly string[]
|
||||
}): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
...getNewlyConnectedRuntimeEnvironmentIds(args.previousDesired, args.nextDesired),
|
||||
...getNewlyConnectedRuntimeEnvironmentIds(args.previousReachable, args.nextReachable)
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
async function refreshRuntimeProjectWorktrees(repos: readonly { id: string }[]): Promise<void> {
|
||||
let nextIndex = 0
|
||||
const failures: { repoId: string; error: unknown }[] = []
|
||||
const workerCount = Math.min(RUNTIME_PROJECT_REFRESH_CONCURRENCY, repos.length)
|
||||
|
||||
// Why: one coalesced remote repo event can still represent many repos; keep the
|
||||
// expensive worktree probes bounded so idle refresh never floods the renderer.
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < repos.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
const repoId = repos[index].id
|
||||
try {
|
||||
await useAppStore.getState().fetchWorktrees(repoId)
|
||||
} catch (error) {
|
||||
failures.push({ repoId, error })
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(
|
||||
failures.map((failure) => failure.error),
|
||||
`Failed to refresh ${failures.length} runtime project worktree(s): ${failures
|
||||
.map((failure) => failure.repoId)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null {
|
||||
|
|
@ -871,13 +935,20 @@ export function useIpcEvents(): void {
|
|||
await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
|
||||
}
|
||||
|
||||
const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({
|
||||
refresh: async (environmentId) => {
|
||||
const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
|
||||
await refreshRuntimeProjectWorktrees(repos)
|
||||
await useAppStore.getState().fetchWorktreeLineage()
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to refresh runtime projects:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleRuntimeClientEvent = (environmentId: string, event: RuntimeClientEvent): void => {
|
||||
if (event.type === 'reposChanged') {
|
||||
const state = useAppStore.getState()
|
||||
void state.fetchRuntimeEnvironmentRepos(environmentId).then(async (repos) => {
|
||||
await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id)))
|
||||
await useAppStore.getState().fetchWorktreeLineage()
|
||||
})
|
||||
runtimeProjectRefreshScheduler.request(environmentId)
|
||||
return
|
||||
}
|
||||
if (event.type === 'worktreesChanged') {
|
||||
|
|
@ -909,18 +980,51 @@ export function useIpcEvents(): void {
|
|||
})
|
||||
|
||||
runtimeClientEventsSync.sync()
|
||||
let runtimeClientEventEnvironmentKey = getRuntimeClientEventEnvironmentKey()
|
||||
// Why: PR #2 removed desktop's eager session-sync discovery and there is no
|
||||
// on-connect repo fetch, so remote projects only appeared after the user
|
||||
// opened the Add-Project dropdown. Seed discovery for runtimes already
|
||||
// connected at mount, and for each newly-connected one below. The scheduler
|
||||
// debounces/throttles, so this stays cheap even with chatty status updates.
|
||||
let runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds()
|
||||
for (const environmentId of runtimeClientEventEnvironmentIds) {
|
||||
runtimeProjectRefreshScheduler.request(environmentId)
|
||||
}
|
||||
let runtimeClientEventEnvironmentKey = buildRuntimeClientEventEnvironmentKey(
|
||||
runtimeClientEventEnvironmentIds
|
||||
)
|
||||
let reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds()
|
||||
let reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey(
|
||||
reachableRuntimeEnvironmentIds
|
||||
)
|
||||
unsubs.push(
|
||||
useAppStore.subscribe(() => {
|
||||
const nextKey = getRuntimeClientEventEnvironmentKey()
|
||||
if (nextKey === runtimeClientEventEnvironmentKey) {
|
||||
const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds()
|
||||
const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds)
|
||||
const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds()
|
||||
const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds)
|
||||
if (
|
||||
nextKey === runtimeClientEventEnvironmentKey &&
|
||||
nextReachableKey === reachableRuntimeEnvironmentKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({
|
||||
previousDesired: runtimeClientEventEnvironmentIds,
|
||||
nextDesired: nextEnvironmentIds,
|
||||
previousReachable: reachableRuntimeEnvironmentIds,
|
||||
nextReachable: nextReachableEnvironmentIds
|
||||
})) {
|
||||
runtimeProjectRefreshScheduler.request(environmentId)
|
||||
}
|
||||
runtimeClientEventEnvironmentIds = nextEnvironmentIds
|
||||
runtimeClientEventEnvironmentKey = nextKey
|
||||
reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds
|
||||
reachableRuntimeEnvironmentKey = nextReachableKey
|
||||
runtimeClientEventsSync.sync()
|
||||
})
|
||||
)
|
||||
unsubs.push(runtimeClientEventsSync.stop)
|
||||
unsubs.push(runtimeProjectRefreshScheduler.stop)
|
||||
|
||||
unsubs.push(
|
||||
window.api.repos.onChanged(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getExplicitRuntimeEnvironmentIdForWorktree,
|
||||
getExecutionHostIdForWorktree,
|
||||
getRuntimeEnvironmentIdForWorktree,
|
||||
getSettingsForWorktreeRuntimeOwner,
|
||||
type WorktreeRuntimeOwnerState
|
||||
} from './worktree-runtime-owner'
|
||||
|
|
@ -9,10 +11,12 @@ const state: WorktreeRuntimeOwnerState = {
|
|||
settings: { activeRuntimeEnvironmentId: 'focused-env' },
|
||||
repos: [
|
||||
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'legacy-repo', connectionId: null, executionHostId: null },
|
||||
{ id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:owner-env' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }],
|
||||
'legacy-repo': [{ id: 'legacy-repo::wt-legacy', repoId: 'legacy-repo' }],
|
||||
'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }]
|
||||
},
|
||||
projectGroups: [
|
||||
|
|
@ -56,3 +60,60 @@ describe('getSettingsForWorktreeRuntimeOwner', () => {
|
|||
expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExplicitRuntimeEnvironmentIdForWorktree', () => {
|
||||
it('does not treat the focused runtime as ownership for legacy-local worktrees', () => {
|
||||
expect(getRuntimeEnvironmentIdForWorktree(state, 'legacy-repo::wt-legacy')).toBe('focused-env')
|
||||
expect(getExplicitRuntimeEnvironmentIdForWorktree(state, 'legacy-repo::wt-legacy')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the runtime owner when the repo or folder explicitly names one', () => {
|
||||
expect(getExplicitRuntimeEnvironmentIdForWorktree(state, 'runtime-repo::wt-b')).toBe(
|
||||
'owner-env'
|
||||
)
|
||||
expect(getExplicitRuntimeEnvironmentIdForWorktree(state, 'folder:runtime-folder')).toBe(
|
||||
'folder-env'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a worktree host id before the repo owner', () => {
|
||||
const hostOverrideState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
worktreesByRepo: {
|
||||
...state.worktreesByRepo,
|
||||
'runtime-repo': [
|
||||
{ id: 'runtime-repo::wt-local-override', repoId: 'runtime-repo', hostId: 'local' },
|
||||
{
|
||||
id: 'runtime-repo::wt-runtime-override',
|
||||
repoId: 'runtime-repo',
|
||||
hostId: 'runtime:worktree-env'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
getExplicitRuntimeEnvironmentIdForWorktree(
|
||||
hostOverrideState,
|
||||
'runtime-repo::wt-local-override'
|
||||
)
|
||||
).toBeNull()
|
||||
expect(getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override'))
|
||||
.toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override')).toBe(
|
||||
'local'
|
||||
)
|
||||
expect(
|
||||
getExplicitRuntimeEnvironmentIdForWorktree(
|
||||
hostOverrideState,
|
||||
'runtime-repo::wt-runtime-override'
|
||||
)
|
||||
).toBe('worktree-env')
|
||||
expect(
|
||||
getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override')
|
||||
).toBe('worktree-env')
|
||||
expect(
|
||||
getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override')
|
||||
).toBe('runtime:worktree-env')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,19 +13,19 @@ import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
|||
export type WorktreeRuntimeOwnerState = {
|
||||
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
|
||||
worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]>
|
||||
worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId' | 'hostId'>[]>
|
||||
folderWorkspaces?: readonly Pick<FolderWorkspace, 'id' | 'projectGroupId'>[]
|
||||
projectGroups?: readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
}
|
||||
|
||||
function findWorktreeRepoId(
|
||||
function findWorktreeRecord(
|
||||
worktreesByRepo: WorktreeRuntimeOwnerState['worktreesByRepo'],
|
||||
worktreeId: string
|
||||
): string | null {
|
||||
): Pick<Worktree, 'id' | 'repoId' | 'hostId'> | null {
|
||||
for (const worktrees of Object.values(worktreesByRepo ?? {})) {
|
||||
const match = worktrees.find((worktree) => worktree.id === worktreeId)
|
||||
if (match) {
|
||||
return match.repoId
|
||||
return match
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
|
@ -59,6 +59,37 @@ function getRuntimeEnvironmentIdForFolderWorkspace(
|
|||
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
|
||||
}
|
||||
|
||||
function getExplicitRuntimeEnvironmentIdFromHost(
|
||||
executionHostId: string | null | undefined
|
||||
): string | null {
|
||||
const parsed = parseExecutionHostId(executionHostId)
|
||||
return parsed?.kind === 'runtime' ? parsed.environmentId : null
|
||||
}
|
||||
|
||||
function getRuntimeEnvironmentIdFromWorktreeHost(
|
||||
hostId: string | null | undefined
|
||||
): string | null | undefined {
|
||||
if (!hostId?.trim()) {
|
||||
return undefined
|
||||
}
|
||||
return getExplicitRuntimeEnvironmentIdFromHost(hostId)
|
||||
}
|
||||
|
||||
function getExecutionHostIdFromWorktreeHost(
|
||||
hostId: string | null | undefined
|
||||
): ExecutionHostId | null {
|
||||
return parseExecutionHostId(hostId)?.id ?? null
|
||||
}
|
||||
|
||||
function getExplicitRuntimeEnvironmentIdForFolderWorkspace(
|
||||
state: WorktreeRuntimeOwnerState,
|
||||
folderWorkspaceId: string
|
||||
): string | null {
|
||||
return getExplicitRuntimeEnvironmentIdFromHost(
|
||||
findFolderProjectGroup(state, folderWorkspaceId)?.executionHostId
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionHostIdForFolderWorkspace(
|
||||
state: WorktreeRuntimeOwnerState,
|
||||
folderWorkspaceId: string
|
||||
|
|
@ -86,8 +117,14 @@ export function getRuntimeEnvironmentIdForWorktree(
|
|||
if (workspaceScope?.type === 'folder') {
|
||||
return getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId)
|
||||
}
|
||||
const repoId =
|
||||
findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const worktree = findWorktreeRecord(state.worktreesByRepo, worktreeId)
|
||||
const worktreeRuntimeEnvironmentId = getRuntimeEnvironmentIdFromWorktreeHost(worktree?.hostId)
|
||||
if (worktreeRuntimeEnvironmentId !== undefined) {
|
||||
// Why: the same repo can exist on local and remote hosts; a concrete
|
||||
// worktree host must override the repo-level default owner.
|
||||
return worktreeRuntimeEnvironmentId
|
||||
}
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = state.repos?.find((entry) => entry.id === repoId)
|
||||
const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
|
||||
if (repo && hasExplicitOwner) {
|
||||
|
|
@ -97,6 +134,34 @@ export function getRuntimeEnvironmentIdForWorktree(
|
|||
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
|
||||
}
|
||||
|
||||
export function getExplicitRuntimeEnvironmentIdForWorktree(
|
||||
state: WorktreeRuntimeOwnerState,
|
||||
worktreeId: string | null | undefined
|
||||
): string | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return getExplicitRuntimeEnvironmentIdForFolderWorkspace(
|
||||
state,
|
||||
workspaceScope.folderWorkspaceId
|
||||
)
|
||||
}
|
||||
const worktree = findWorktreeRecord(state.worktreesByRepo, worktreeId)
|
||||
if (worktree?.hostId) {
|
||||
return getExplicitRuntimeEnvironmentIdFromHost(worktree.hostId)
|
||||
}
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = state.repos?.find((entry) => entry.id === repoId)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
// Why: session mirroring is expensive; a merely focused runtime must not make
|
||||
// legacy/local worktrees look remote-owned.
|
||||
return getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo))
|
||||
}
|
||||
|
||||
export function getExecutionHostIdForWorktree(
|
||||
state: WorktreeRuntimeOwnerState,
|
||||
worktreeId: string | null | undefined
|
||||
|
|
@ -108,8 +173,14 @@ export function getExecutionHostIdForWorktree(
|
|||
if (workspaceScope?.type === 'folder') {
|
||||
return getExecutionHostIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId)
|
||||
}
|
||||
const repoId =
|
||||
findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const worktree = findWorktreeRecord(state.worktreesByRepo, worktreeId)
|
||||
const worktreeHostId = getExecutionHostIdFromWorktreeHost(worktree?.hostId)
|
||||
if (worktreeHostId) {
|
||||
// Why: per-worktree host ownership is more specific than the repo host
|
||||
// default, especially when local and runtime checkouts share a project.
|
||||
return worktreeHostId
|
||||
}
|
||||
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
const repo = state.repos?.find((entry) => entry.id === repoId)
|
||||
const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
|
||||
if (repo && hasExplicitOwner) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
clearWebSessionTabsTrackingForEnvironment,
|
||||
resolveHostSessionTabIdForWebSessionTab,
|
||||
resetWebSessionTabsSnapshotFreshnessForTests,
|
||||
shouldSyncAllRuntimeSessionTabs,
|
||||
shouldApplyWebSessionTabsSnapshot,
|
||||
shouldBootstrapInitialWebRuntimeTerminal,
|
||||
shouldRespawnWebRuntimeTerminalAfterWake,
|
||||
|
|
@ -268,29 +269,58 @@ describe('applyWebSessionTabsSnapshot', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
it('syncs session tabs for desktop remote runtime clients, not only web clients', () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
|
||||
|
||||
it('syncs active session tabs for desktop remote runtime clients using the worktree owner', () => {
|
||||
expect(
|
||||
shouldSyncRuntimeSessionTabs({
|
||||
activeRuntimeEnvironmentId: ENV,
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSyncRuntimeSessionTabs({
|
||||
activeRuntimeEnvironmentId: ENV,
|
||||
activeWorktreeId: WT,
|
||||
workspaceSessionReady: true,
|
||||
requireActiveWorktree: true
|
||||
activeWorktreeRuntimeEnvironmentId: ENV,
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSyncRuntimeSessionTabs({
|
||||
activeRuntimeEnvironmentId: null,
|
||||
activeWorktreeId: WT,
|
||||
activeWorktreeRuntimeEnvironmentId: null,
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldSyncRuntimeSessionTabs({
|
||||
activeWorktreeId: WT,
|
||||
activeWorktreeRuntimeEnvironmentId: 'other-env',
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSyncRuntimeSessionTabs({
|
||||
activeWorktreeRuntimeEnvironmentId: ENV,
|
||||
workspaceSessionReady: true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldSyncRuntimeSessionTabs({
|
||||
activeWorktreeId: WT,
|
||||
activeWorktreeRuntimeEnvironmentId: ENV,
|
||||
workspaceSessionReady: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('only starts the all-session mirror for paired web clients', () => {
|
||||
expect(
|
||||
shouldSyncAllRuntimeSessionTabs({
|
||||
activeRuntimeEnvironmentId: ENV,
|
||||
workspaceSessionReady: true,
|
||||
isWebClient: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSyncAllRuntimeSessionTabs({
|
||||
activeRuntimeEnvironmentId: ENV,
|
||||
workspaceSessionReady: true,
|
||||
isWebClient: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears web session tracking maps when the host removes a worktree snapshot', () => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import type { OpenFile } from '../store/slices/editor'
|
|||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
||||
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
||||
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
createWebRuntimeSessionTerminal,
|
||||
HOST_TERMINAL_SURFACE_SEPARATOR,
|
||||
|
|
@ -240,16 +241,24 @@ export function shouldRespawnWebRuntimeTerminalAfterWake(args: {
|
|||
}
|
||||
|
||||
export function shouldSyncRuntimeSessionTabs(args: {
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
activeWorktreeId?: string | null
|
||||
activeWorktreeRuntimeEnvironmentId?: string | null
|
||||
workspaceSessionReady: boolean
|
||||
requireActiveWorktree?: boolean
|
||||
}): boolean {
|
||||
const environmentId = args.activeRuntimeEnvironmentId?.trim()
|
||||
const environmentId = args.activeWorktreeRuntimeEnvironmentId?.trim()
|
||||
if (!environmentId || !args.workspaceSessionReady) {
|
||||
return false
|
||||
}
|
||||
return args.requireActiveWorktree === true ? Boolean(args.activeWorktreeId) : true
|
||||
return Boolean(args.activeWorktreeId?.trim())
|
||||
}
|
||||
|
||||
export function shouldSyncAllRuntimeSessionTabs(args: {
|
||||
activeRuntimeEnvironmentId: string | null | undefined
|
||||
workspaceSessionReady: boolean
|
||||
isWebClient: boolean
|
||||
}): boolean {
|
||||
const environmentId = args.activeRuntimeEnvironmentId?.trim()
|
||||
return Boolean(environmentId && args.workspaceSessionReady && args.isWebClient)
|
||||
}
|
||||
|
||||
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
|
||||
|
|
@ -2322,16 +2331,23 @@ export function useWebSessionTabsSync(): void {
|
|||
const activeRuntimeEnvironmentId = useAppStore(
|
||||
(state) => state.settings?.activeRuntimeEnvironmentId ?? null
|
||||
)
|
||||
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>
|
||||
getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)
|
||||
)
|
||||
const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady)
|
||||
const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true
|
||||
|
||||
useEffect(() => {
|
||||
const environmentId = activeRuntimeEnvironmentId?.trim()
|
||||
// Why: startup hydration writes browser-local session state; applying the
|
||||
// host snapshot before that point gets clobbered and leaves the sidebar stale.
|
||||
// Desktop clients should not mirror every remote session just because a
|
||||
// remote is connected; project discovery runs through separate repo APIs.
|
||||
if (
|
||||
!shouldSyncRuntimeSessionTabs({
|
||||
!shouldSyncAllRuntimeSessionTabs({
|
||||
activeRuntimeEnvironmentId,
|
||||
workspaceSessionReady
|
||||
workspaceSessionReady,
|
||||
isWebClient
|
||||
}) ||
|
||||
!environmentId
|
||||
) {
|
||||
|
|
@ -2439,16 +2455,15 @@ export function useWebSessionTabsSync(): void {
|
|||
// stale freshness/mapping entries should not live for the renderer lifetime.
|
||||
clearWebSessionTabsTrackingForEnvironment(environmentId)
|
||||
}
|
||||
}, [activeRuntimeEnvironmentId, workspaceSessionReady])
|
||||
}, [activeRuntimeEnvironmentId, isWebClient, workspaceSessionReady])
|
||||
|
||||
useEffect(() => {
|
||||
const environmentId = activeRuntimeEnvironmentId?.trim()
|
||||
const environmentId = activeWorktreeRuntimeEnvironmentId?.trim()
|
||||
if (
|
||||
!shouldSyncRuntimeSessionTabs({
|
||||
activeRuntimeEnvironmentId,
|
||||
activeWorktreeId,
|
||||
workspaceSessionReady,
|
||||
requireActiveWorktree: true
|
||||
activeWorktreeRuntimeEnvironmentId,
|
||||
workspaceSessionReady
|
||||
}) ||
|
||||
!environmentId ||
|
||||
!activeWorktreeId
|
||||
|
|
@ -2559,5 +2574,5 @@ export function useWebSessionTabsSync(): void {
|
|||
disposed = true
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [activeRuntimeEnvironmentId, activeWorktreeId, workspaceSessionReady])
|
||||
}, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId, workspaceSessionReady])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,4 +133,23 @@ describe('Cmd+J lifted creation actions', () => {
|
|||
})
|
||||
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps desktop terminal creation local when a local worktree overrides a runtime repo owner', async () => {
|
||||
delete pairedWebFlag.__ORCA_WEB_CLIENT__
|
||||
createWebRuntimeSessionTerminalMock.mockResolvedValue(false)
|
||||
const store = createTestStore()
|
||||
seedActiveWorkspace(store)
|
||||
store.setState({
|
||||
repos: [{ ...TEST_REPO, executionHostId: 'runtime:owner-runtime' }],
|
||||
worktreesByRepo: {
|
||||
[TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, hostId: 'local' })]
|
||||
},
|
||||
settings: { activeRuntimeEnvironmentId: null } as AppState['settings']
|
||||
})
|
||||
|
||||
await store.getState().openNewTerminalTabInActiveWorkspace('group-1')
|
||||
|
||||
expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled()
|
||||
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1507,6 +1507,10 @@ describe('reconnectPersistedTerminals', () => {
|
|||
// can pass them as sessionId to the daemon's createOrAttach.
|
||||
expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1')
|
||||
expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2')
|
||||
expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1'])
|
||||
// Why: inactive worktrees keep a wake hint but must not advertise live PTYs
|
||||
// until the user opens them and connectPanePty performs the actual reattach.
|
||||
expect(s.ptyIdsByTabId.tab2).toEqual([])
|
||||
expect(s.pendingReconnectWorktreeIds).toEqual([])
|
||||
// No eager spawn — PTY creation deferred to pane mount
|
||||
expect((mockApi.pty as Record<string, unknown>).spawn).not.toHaveBeenCalled()
|
||||
|
|
|
|||
|
|
@ -255,6 +255,35 @@ describe('hydrateWorkspaceSession', () => {
|
|||
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('restores the active repo main worktree when the session has no active terminal tabs', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/wt-main'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: '/wt-main',
|
||||
isMainWorktree: true
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().hydrateWorkspaceSession({
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
expect(store.getState().activeWorkspaceKey).toBe(`worktree:${worktreeId}`)
|
||||
expect(store.getState().worktreeNavHistory).toEqual([worktreeId])
|
||||
})
|
||||
|
||||
it('leaves nav history empty when no active worktree is restored', () => {
|
||||
const store = createTestStore()
|
||||
seedStore(store, { worktreesByRepo: {} })
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import {
|
|||
updateGroup
|
||||
} from './tab-group-state'
|
||||
import {
|
||||
ensurePtyDispatcher,
|
||||
restorePtyDataHandlersAfterFailedShutdown,
|
||||
unregisterPtyDataHandlers
|
||||
} from '@/components/terminal-pane/pty-transport'
|
||||
|
|
@ -2561,10 +2560,22 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
validWorktreeIds.has(record.worktreeId)
|
||||
)
|
||||
)
|
||||
const activeWorktreeId =
|
||||
session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)
|
||||
? session.activeWorktreeId
|
||||
const fallbackActiveWorktreeId =
|
||||
!session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId)
|
||||
? (s.worktreesByRepo[session.activeRepoId]?.find((worktree) => worktree.isMainWorktree)
|
||||
?.id ??
|
||||
s.worktreesByRepo[session.activeRepoId]?.[0]?.id ??
|
||||
null)
|
||||
: null
|
||||
const activeWorktreeId = (() => {
|
||||
if (session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)) {
|
||||
return session.activeWorktreeId
|
||||
}
|
||||
// Why: a workspace with no terminal tabs is still a valid workspace.
|
||||
// Falling back from the active repo prevents the blank landing screen
|
||||
// when session tabs were pruned or never created.
|
||||
return fallbackActiveWorktreeId
|
||||
})()
|
||||
const activeWorkspaceKey: WorkspaceKey | null =
|
||||
session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey)
|
||||
? session.activeWorkspaceKey
|
||||
|
|
@ -2783,7 +2794,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
pendingReconnectTabByWorktree,
|
||||
pendingReconnectPtyIdByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
tabsByWorktree
|
||||
tabsByWorktree,
|
||||
activeWorktreeId
|
||||
} = get()
|
||||
const ids = pendingReconnectWorktreeIds ?? []
|
||||
|
||||
|
|
@ -2807,8 +2819,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
// The layout's ptyIdsByLeafId (preserved from shutdown) already has per-leaf
|
||||
// mappings. For single-pane tabs without leaf mappings, store the tab-level
|
||||
// ptyId as a sentinel so connectPanePty knows to reattach.
|
||||
ensurePtyDispatcher()
|
||||
|
||||
for (const worktreeId of ids) {
|
||||
const tabs = tabsByWorktree[worktreeId] ?? []
|
||||
const worktree = Object.values(get().worktreesByRepo)
|
||||
|
|
@ -2859,6 +2869,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
`[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}`
|
||||
)
|
||||
if (tabLevelPtyId) {
|
||||
const shouldAdvertiseLivePtys = worktreeId === activeWorktreeId
|
||||
set((s) => {
|
||||
const next = { ...s.tabsByWorktree }
|
||||
if (!next[worktreeId]) {
|
||||
|
|
@ -2877,10 +2888,17 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
: [tabLevelPtyId]
|
||||
return {
|
||||
tabsByWorktree: next,
|
||||
ptyIdsByTabId: {
|
||||
...s.ptyIdsByTabId,
|
||||
[tabId]: allPtyIds
|
||||
}
|
||||
...(shouldAdvertiseLivePtys
|
||||
? {
|
||||
// Why: inactive worktrees only need wake hints. Publishing
|
||||
// their PTYs as live at startup starts global session/status
|
||||
// machinery before the user opens that workspace.
|
||||
ptyIdsByTabId: {
|
||||
...s.ptyIdsByTabId,
|
||||
[tabId]: allPtyIds
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -754,7 +754,48 @@ describe('fetchWorktrees', () => {
|
|||
|
||||
expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' })
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([sshWorktree])
|
||||
// Why: SSH worktrees are fetched via local IPC but belong to the SSH host;
|
||||
// they must carry the repo's ssh host id, not the local default.
|
||||
expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([
|
||||
{ ...sshWorktree, hostId: 'ssh:ssh-1' }
|
||||
])
|
||||
})
|
||||
|
||||
it('stamps remote runtime worktrees with the owning repo runtime host', async () => {
|
||||
const store = createTestStore()
|
||||
// Why: a remote runtime returns its worktrees from its own perspective, so
|
||||
// their hostId arrives as the default "local" even though they live remotely.
|
||||
const remote = makeWorktree({
|
||||
id: 'repo-remote::/remote/wt1',
|
||||
repoId: 'repo-remote',
|
||||
path: '/remote/wt1',
|
||||
branch: 'refs/heads/remote',
|
||||
hostId: 'local'
|
||||
})
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-remote',
|
||||
path: '/home/dvic/src/omarchy-dotfiles',
|
||||
displayName: 'omarchy-dotfiles',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
} as Partial<AppState>)
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: makeDetectedResult('repo-remote', [remote]),
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
await store.getState().fetchWorktrees('repo-remote')
|
||||
|
||||
expect(store.getState().worktreesByRepo['repo-remote']).toEqual([
|
||||
{ ...remote, hostId: 'runtime:env-1' }
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to legacy remote worktree.list when detectedList is unavailable', async () => {
|
||||
|
|
@ -1166,6 +1207,50 @@ describe('worktree lineage state', () => {
|
|||
expect(store.getState().sortEpoch).toBe(4)
|
||||
})
|
||||
|
||||
it('stamps the owning runtime host onto worktrees returned by a remote lineage update', async () => {
|
||||
const store = createTestStore()
|
||||
const lineage = makeLineage({
|
||||
worktreeId: 'repo-remote::/remote/child',
|
||||
parentWorktreeId: 'repo-remote::/remote/parent'
|
||||
})
|
||||
const child = makeWorktree({
|
||||
id: lineage.worktreeId,
|
||||
repoId: 'repo-remote',
|
||||
path: '/remote/child'
|
||||
})
|
||||
// Why: the remote returns the updated worktree from its own perspective, so
|
||||
// it arrives with the default local host even though the repo is remote.
|
||||
const updatedChild = { ...child, hostId: 'local' as const, lineage }
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-remote',
|
||||
path: '/home/dvic/src/omarchy-dotfiles',
|
||||
displayName: 'omarchy-dotfiles',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-remote': [child] }
|
||||
} as Partial<AppState>)
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'rpc-remote-lineage',
|
||||
ok: true,
|
||||
result: { worktree: updatedChild },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
await store.getState().updateWorktreeLineage(lineage.worktreeId, {
|
||||
parentWorktreeId: lineage.parentWorktreeId
|
||||
})
|
||||
|
||||
expect(store.getState().worktreesByRepo['repo-remote']?.[0]).toEqual({
|
||||
...updatedChild,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('assigns a parent through the active remote runtime environment and rethrows failures', async () => {
|
||||
const store = createTestStore()
|
||||
const lineage = makeLineage()
|
||||
|
|
@ -1639,6 +1724,46 @@ describe('createWorktree base status merge', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('stamps the owning runtime host onto worktrees created on a remote runtime', async () => {
|
||||
const store = createTestStore()
|
||||
const created = makeWorktree({
|
||||
id: 'repo-remote::/remote/feature',
|
||||
repoId: 'repo-remote',
|
||||
path: '/remote/feature',
|
||||
// Why: the remote creates the worktree and reports it from its own
|
||||
// perspective, so it comes back with the default local host.
|
||||
hostId: 'local'
|
||||
})
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-remote',
|
||||
path: '/home/dvic/src/omarchy-dotfiles',
|
||||
displayName: 'omarchy-dotfiles',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
} as Partial<AppState>)
|
||||
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) =>
|
||||
Promise.resolve({
|
||||
id: 'rpc-remote-create',
|
||||
ok: true,
|
||||
result: method === 'worktree.create' ? { worktree: created } : null,
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
)
|
||||
|
||||
await store.getState().createWorktree('repo-remote', 'feature', 'origin/main')
|
||||
|
||||
expect(mockApi.worktrees.create).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreesByRepo['repo-remote']?.[0]).toEqual({
|
||||
...created,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the active folder workspace as parent for in-app worktree creates', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { translate } from '@/i18n/i18n'
|
|||
import {
|
||||
getRepoExecutionHostId,
|
||||
getSettingsFocusedExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
|
|
@ -312,8 +313,31 @@ function toVisibleWorktree(worktree: DetectedWorktreeListResult['worktrees'][num
|
|||
return base
|
||||
}
|
||||
|
||||
function toVisibleWorktrees(result: DetectedWorktreeListResult): Worktree[] {
|
||||
return result.worktrees.filter((worktree) => worktree.visible).map(toVisibleWorktree)
|
||||
// Why: runtime worktree payloads arrive from the owning host's own perspective,
|
||||
// so their hostId defaults to "local" even for remote checkouts. Re-stamp them
|
||||
// with the repo's execution host so per-worktree host resolution doesn't route
|
||||
// remote terminals to the local machine. Local-owned repos are left untouched,
|
||||
// so an explicit local worktree still overrides a runtime repo owner.
|
||||
function withRepoHostId<T extends { hostId?: ExecutionHostId }>(
|
||||
worktree: T,
|
||||
hostId: ExecutionHostId
|
||||
): T {
|
||||
return hostId === LOCAL_EXECUTION_HOST_ID ? worktree : { ...worktree, hostId }
|
||||
}
|
||||
|
||||
function repoHostId(state: Pick<AppState, 'repos'>, repoId: string): ExecutionHostId {
|
||||
const repo = state.repos.find((entry) => entry.id === repoId)
|
||||
return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
function toVisibleWorktrees(
|
||||
result: DetectedWorktreeListResult,
|
||||
hostId: ExecutionHostId
|
||||
): Worktree[] {
|
||||
return result.worktrees
|
||||
.filter((worktree) => worktree.visible)
|
||||
.map(toVisibleWorktree)
|
||||
.map((worktree) => withRepoHostId(worktree, hostId))
|
||||
}
|
||||
|
||||
function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): string[] {
|
||||
|
|
@ -781,7 +805,13 @@ function applyWorktreeLineageUpdate(
|
|||
worktreesByRepo:
|
||||
result.target.kind === 'local' || !result.updatedRemoteWorktree
|
||||
? s.worktreesByRepo
|
||||
: replaceWorktreeInRepoLists(s.worktreesByRepo, result.updatedRemoteWorktree),
|
||||
: replaceWorktreeInRepoLists(
|
||||
s.worktreesByRepo,
|
||||
withRepoHostId(
|
||||
result.updatedRemoteWorktree,
|
||||
repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id))
|
||||
)
|
||||
),
|
||||
sortEpoch: s.sortEpoch + 1
|
||||
}
|
||||
})
|
||||
|
|
@ -1526,7 +1556,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
if (options?.requireAuthoritative && !detected.authoritative) {
|
||||
return false
|
||||
}
|
||||
const worktrees = toVisibleWorktrees(detected)
|
||||
const worktrees = toVisibleWorktrees(detected, repoHostId(get(), repoId))
|
||||
const current = get().worktreesByRepo[repoId]
|
||||
if (areWorktreesEqual(current, worktrees)) {
|
||||
set((s) => {
|
||||
|
|
@ -1619,7 +1649,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
settingsForRepoOwner(get(), r.id),
|
||||
r.id
|
||||
)
|
||||
const list = toVisibleWorktrees(detected)
|
||||
const list = toVisibleWorktrees(detected, repoHostId(get(), r.id))
|
||||
const current = get().worktreesByRepo[r.id]
|
||||
if (
|
||||
!areWorktreesEqual(current, list) &&
|
||||
|
|
@ -1957,15 +1987,16 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
// then produces a duplicate entry in worktreesByRepo, which gives
|
||||
// React duplicate keys and can corrupt terminal DOM containers.
|
||||
set((s) => {
|
||||
const createdWorktree = withRepoHostId(result.worktree, repoHostId(s, repoId))
|
||||
const current = s.worktreesByRepo[repoId] ?? []
|
||||
const alreadyPresent = current.some((w) => w.id === result.worktree.id)
|
||||
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
|
||||
const nextWorktrees = alreadyPresent
|
||||
? current.map((worktree) =>
|
||||
worktree.id === result.worktree.id
|
||||
? { ...worktree, ...result.worktree }
|
||||
worktree.id === createdWorktree.id
|
||||
? { ...worktree, ...createdWorktree }
|
||||
: worktree
|
||||
)
|
||||
: [...current, result.worktree]
|
||||
: [...current, createdWorktree]
|
||||
return {
|
||||
worktreesByRepo: {
|
||||
...s.worktreesByRepo,
|
||||
|
|
|
|||
Loading…
Reference in New Issue