diff --git a/src/cli/format.ts b/src/cli/format.ts index 8707ceef092..0a297138364 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -245,6 +245,7 @@ export function formatCliStatus(status: CliStatusResult): string { `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, + `runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, `graphState: ${status.graph.state}` ].join('\n') diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9a855ae929..86b4869e9d8 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { isOrchestrationMutation, @@ -207,6 +208,10 @@ export class RuntimeClient { runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, + connectionState: runtimeHostConnectionState({ + hasStatusEntry: true, + status: response.result + }), runtimeId: response.result.runtimeId, ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), ...(response.result.remoteUpdateSupport diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 9cc595122ef..4c62be8d977 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -78,6 +78,7 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { expect(status.result.runtime).toMatchObject({ reachable: true, + connectionState: 'connected', runtimeId: 'runtime-legacy', state: 'ready', degradations: [expect.objectContaining({ code: 'browser_unavailable' })] diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index e4a181a8d51..8736f4cc177 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -1,4 +1,5 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import { findTransport } from '../../shared/runtime-bootstrap' import { tryReadMetadata } from './metadata' import { sendRequest } from './transport' @@ -51,6 +52,10 @@ export async function getCliStatus( runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, + connectionState: runtimeHostConnectionState({ + hasStatusEntry: true, + status: response.result + }), runtimeId: response.result.runtimeId, ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), ...(response.result.remoteUpdateSupport @@ -73,6 +78,7 @@ export async function getCliStatus( runtime: { state: running ? 'starting' : 'stale_bootstrap', reachable: false, + connectionState: 'disconnected', runtimeId: null }, graph: { diff --git a/src/main/claude-usage/claude-model-pricing.test.ts b/src/main/claude-usage/claude-model-pricing.test.ts index c8f7065392d..47fffcdebdf 100644 --- a/src/main/claude-usage/claude-model-pricing.test.ts +++ b/src/main/claude-usage/claude-model-pricing.test.ts @@ -37,8 +37,8 @@ describe('estimateCostUsd cache-write TTL rates', () => { }) it('never lowers a legacy long-context estimate as writes shift to 1-hour', () => { - const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map( - (write1h) => estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, write1h)! + const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map((write1h) => + estimateCostUsd('claude-sonnet-4-5', 0, 0, 0, 400_000, write1h)! ) for (let index = 1; index < costs.length; index++) { expect(costs[index]).toBeGreaterThan(costs[index - 1]) diff --git a/src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts b/src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts new file mode 100644 index 00000000000..bac6f7b58ff --- /dev/null +++ b/src/main/ipc/runtime-environment-diagnostics-broadcast.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getAllWindows = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows } })) + +import { + publishRuntimeEnvironmentDiagnostics, + RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL +} from './runtime-environment-diagnostics-broadcast' + +describe('runtime environment diagnostics broadcast', () => { + beforeEach(() => getAllWindows.mockReset()) + + it('publishes to live renderer windows and skips destroyed windows', () => { + const live = { isDestroyed: () => false, webContents: { send: vi.fn() } } + const destroyed = { isDestroyed: () => true, webContents: { send: vi.fn() } } + getAllWindows.mockReturnValue([live, destroyed]) + const event = { + environmentId: 'env-a', + transportGeneration: 2, + diagnostics: { + state: 'reconnecting' as const, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 1, + lastConnectedAt: 1, + lastClose: null, + lastError: 'offline' + } + } + + publishRuntimeEnvironmentDiagnostics(event) + + expect(live.webContents.send).toHaveBeenCalledWith( + RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, + event + ) + expect(destroyed.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/runtime-environment-diagnostics-broadcast.ts b/src/main/ipc/runtime-environment-diagnostics-broadcast.ts new file mode 100644 index 00000000000..3701170ee16 --- /dev/null +++ b/src/main/ipc/runtime-environment-diagnostics-broadcast.ts @@ -0,0 +1,26 @@ +import { BrowserWindow } from 'electron' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' +import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics' + +export { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } + +export type RuntimeEnvironmentDiagnosticsEvent = { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics +} + +export function publishRuntimeEnvironmentDiagnostics( + event: RuntimeEnvironmentDiagnosticsEvent +): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, event) + } catch { + // A renderer can disappear between isDestroyed() and send(). + } + } +} diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 3076405dbf8..c1f855697e5 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -12,6 +12,11 @@ import type { } from '../../shared/remote-runtime-shared-control-types' import { isRuntimeEnvironmentCapabilityPaused } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' +import { publishRuntimeEnvironmentDiagnostics } from './runtime-environment-diagnostics-broadcast' +import { + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' type CachedRuntimeConnection = { pairingKey: string @@ -142,14 +147,26 @@ function getSharedControlConnection( const pairingKey = getPairingKey(pairing) let cached = sharedControlConnections.get(environmentId) if (!cached || cached.pairingKey !== pairingKey) { + advanceRuntimeEnvironmentTransportGeneration(environmentId) cached?.connection.close() + const transportGeneration = getRuntimeEnvironmentTransportGeneration(environmentId) cached = { pairingKey, connection: new RemoteRuntimeSharedControlConnection(pairing, { environmentId, clientCapabilities: ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, isManuallyDisconnected: () => isRuntimeEnvironmentManuallyDisconnected(environmentId), - isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId) + isCapabilityPaused: () => isRuntimeEnvironmentCapabilityPaused(environmentId), + onDiagnosticsChanged: (diagnostics) => { + if (getRuntimeEnvironmentTransportGeneration(environmentId) !== transportGeneration) { + return + } + publishRuntimeEnvironmentDiagnostics({ + environmentId, + transportGeneration, + diagnostics + }) + } }) } sharedControlConnections.set(environmentId, cached) diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index a8bcfa8d5ae..fd70c8803d3 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -465,6 +465,43 @@ describe('paired runtime navigation isolation', () => { ).toBe('activateWorktree') }) + it('normalizes a paired focused terminal.create before host-renderer activation', async () => { + const harness = await startHarness() + const created = { handle: 'term-b', worktreeId: CLIENT_B_WORKTREE_ID, title: null } + const createTerminal = vi + .spyOn(harness.runtime, 'createTerminal') + .mockImplementation(async (_worktree, options) => { + if (options?.presentation === 'focused') { + harness.hostSelections.worktreeId = CLIENT_B_WORKTREE_ID + } + return created as never + }) + vi.spyOn(harness.runtime, 'dedupeTerminalCreate').mockImplementation( + async (_owner, worktree, _mutationId, _reconcile, run) => run(worktree, undefined) + ) + + send(harness.clientB, { + id: 'terminal-create-b', + method: 'terminal.create', + params: { + worktree: `id:${CLIENT_B_WORKTREE_ID}`, + presentation: 'focused' + } + }) + await expect(harness.readerB.next('terminal-create-b')).resolves.toMatchObject({ + ok: true, + result: { terminal: created } + }) + expect(createTerminal).toHaveBeenCalledWith( + `id:${CLIENT_B_WORKTREE_ID}`, + expect.objectContaining({ presentation: 'background', focus: false, activate: false }) + ) + expect(harness.hostSelections).toEqual({ + worktreeId: HOST_WORKTREE_ID, + tabId: 'host-tab' + }) + }) + it('still reveals a host-originated create-with-activate on the host and every client', async () => { const harness = await startHarness() await subscribeBothClientEventStreams(harness) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 661706e9316..e4c39a32583 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2220,6 +2220,92 @@ describe('OrcaRuntimeService', () => { expect(getRepos).not.toHaveBeenCalled() }) + it('does not block a targeted mobile session tab list on an unrelated worktree scan', async () => { + const remoteWorktreeId = 'repo-ssh::/remote/worktree' + const remotePtyId = 'ssh:ssh-target@@remote-pty' + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeRepoId: 'repo-ssh', + activeWorktreeId: remoteWorktreeId, + activeTabIdByWorktree: { [remoteWorktreeId]: 'remote-tab' }, + tabsByWorktree: { + [remoteWorktreeId]: [ + { + id: 'remote-tab', + ptyId: remotePtyId, + worktreeId: remoteWorktreeId, + title: 'Remote terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'remote-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId }) + } + }), + 'ssh:ssh-target' + ) + const remoteRepo = { + ...store.getRepos()[0], + id: 'repo-ssh', + connectionId: 'ssh-target' + } + runtimeStore.getRepos = () => [remoteRepo] + runtimeStore.getRepo = (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) + const runtime = new OrcaRuntimeService(runtimeStore as never) + const listProcesses = vi.fn(async () => [ + { + id: remotePtyId, + incarnationId: 'remote-incarnation', + terminalHandle: 'term_remote', + title: 'Remote terminal', + cwd: '/remote/worktree', + worktreeId: remoteWorktreeId + } + ]) + runtime.setPtyController({ + listProcesses, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const listWorktrees = vi.fn(() => new Promise(() => {})) + registerSshGitProvider('ssh-target', { listWorktrees } as never) + + vi.useFakeTimers() + try { + let timeoutId: ReturnType | undefined + const timeout = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(null), 1_000) + }) + const resultPromise = runtime.listMobileSessionTabs(`id:${remoteWorktreeId}`) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(1_000) + const result = await Promise.race([resultPromise, timeout]) + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + + expect(result).not.toBeNull() + expect(listWorktrees).not.toHaveBeenCalled() + expect(listProcesses).toHaveBeenCalledOnce() + expect(listProcesses).toHaveBeenCalledWith( + 'ssh-target', + expect.objectContaining({ deadlineMs: expect.any(Number) }) + ) + expect(result).toMatchObject({ + worktree: remoteWorktreeId, + tabs: [expect.objectContaining({ type: 'terminal', parentTabId: 'remote-tab' })] + }) + } finally { + vi.useRealTimers() + unregisterSshGitProvider('ssh-target') + } + }) + it('hydrates persisted tabs when the store cannot report repos', async () => { // Why: #9343 read the repo gate as `getRepos?.() ?? []`, so a store that cannot // report its inventory looked like "every repo is gone" and hydrated nothing — diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8c6fd058161..ca925364fe6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9685,10 +9685,13 @@ export class OrcaRuntimeService { private async refreshMobileSessionPtyInventory( targetWorktreeId: string | null = null ): Promise { + // Targeted mobile polls must not queue behind an aggregate census that may + // be waiting on an unrelated SSH provider. + if (targetWorktreeId !== null && targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + return this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) + } if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - // Non-floating refreshes all query the aggregate controller inventory; - // coalesce targeted and all-worktree callers so they cannot invalidate - // one another through the shared aggregate generation fence. + // Fleet-wide refreshes share one aggregate controller inventory. const pending = this.pendingMobileSessionPtyAggregateInventoryRefresh if (pending) { return pending @@ -9714,13 +9717,63 @@ export class OrcaRuntimeService { } // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID - const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() + const resolvedWorktrees = isFloatingWorkspace + ? [] + : targetWorktreeId + ? this.listResolvedWorktreesForExplicitTarget(targetWorktreeId) + : await this.listResolvedWorktrees() + // An explicit mobile worktree belongs to one execution host. Query only + // that provider; aggregate inventory would wait on unrelated SSH hosts. + const targetExecutionHost = targetWorktreeId + ? (resolvedWorktrees.find((worktree) => worktree.id === targetWorktreeId)?.hostId ?? + this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) + : null + const parsedTargetHost = targetExecutionHost ? parseExecutionHostId(targetExecutionHost) : null + // Paired/runtime-owned workspaces have a separate controller; this runtime + // cannot inspect them and must not silently query its local PTY provider. + if (parsedTargetHost?.kind === 'runtime') { + return null + } + const targetConnectionId = + parsedTargetHost?.kind === 'ssh' + ? parsedTargetHost.targetId + : targetWorktreeId + ? null + : undefined return await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, - isFloatingWorkspace ? targetWorktreeId : null + targetWorktreeId, + undefined, + targetConnectionId ) } + /** Targeted mobile opens must not wait for an unrelated SSH/Git worktree scan. */ + private listResolvedWorktreesForExplicitTarget(targetWorktreeId: string): ResolvedWorktree[] { + const cached = + this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > Date.now() + ? this.resolvedWorktreeCache.worktrees + : null + const targetWorktree = + cached?.find((worktree) => worktree.id === targetWorktreeId) ?? + (() => { + const scope = parseWorkspaceKey(targetWorktreeId) + if (scope?.type === 'folder') { + const folder = this.store + ?.getFolderWorkspaces?.() + .find((workspace) => workspace.id === scope.folderWorkspaceId) + return folder ? this.folderWorkspaceToResolvedWorktree(folder) : null + } + return this.buildResolvedWorktreeFromId(targetWorktreeId) + })() + if (!targetWorktree) { + return [] + } + return cached + ? includeTargetResolvedWorktree(cached, targetWorktree) + : this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) + } + async activateMobileSessionTab( worktreeSelector: string, tabId: string, @@ -34981,7 +35034,7 @@ export class OrcaRuntimeService { if (!parsed?.repoId || !parsed.worktreePath) { return null } - const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId) + const repo = this.store?.getRepos?.()?.find((entry) => entry.id === parsed.repoId) const git = { path: parsed.worktreePath, head: '', @@ -35015,7 +35068,9 @@ export class OrcaRuntimeService { } const target = splitWorktreeIdForFilesystem(targetWorktreeId) if (!target?.repoId || !target.worktreePath) { - return [] + // Folder workspace keys have no repo/path tuple, but the converted row + // is already authoritative for this explicit target. + return [targetWorktree] } const worktreeIds = new Set( Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => { @@ -35584,7 +35639,8 @@ export class OrcaRuntimeService { resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null, deadline?: number, - connectionId?: string | null + connectionId?: string | null, + retryStale = false ): Promise { if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() @@ -35658,6 +35714,18 @@ export class OrcaRuntimeService { inventoryGeneration && this.ptyControllerAggregateInventoryGeneration <= inventoryGeneration if (!isCurrentInventory) { + // A fleet census that began after this targeted poll must not turn a + // user-driven open into an empty result. Re-query the owning provider; + // the second generation is then fenced against both operations. + if (targetWorktreeId !== null && !retryStale) { + return this.refreshPtyWorktreeRecordsWithControllerInventory( + resolvedWorktrees, + targetWorktreeId, + deadline, + connectionId, + true + ) + } return null } const sessions = sessionsResult.value.processes diff --git a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts index b60a95a1269..51001605be0 100644 --- a/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts +++ b/src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts @@ -60,4 +60,76 @@ describe('terminal.create RPC idempotency', () => { ) expect(result).toEqual({ terminal }) }) + + it('does not let a paired focused create navigate the host by default', async () => { + const terminal = { handle: 'terminal-focused', worktreeId: 'worktree-1', title: null } + const createTerminal = vi.fn(async () => terminal) + const dedupeTerminalCreate = vi.fn( + async ( + _clientIdentity: string, + _worktree: string | undefined, + _mutationId: string | undefined, + _reconcileExisting: boolean, + run: (worktree: string | undefined, handle: string | undefined) => Promise + ) => run('id:worktree-1', undefined) + ) + const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + if (!method) { + throw new Error('terminal.create method missing') + } + + await method.handler( + { + worktree: 'id:worktree-1', + presentation: 'focused', + focus: true, + activate: true + }, + { + runtime: { createTerminal, dedupeTerminalCreate }, + pairedDeviceId: 'device-b', + clientKind: 'runtime' + } as unknown as RpcContext, + vi.fn() + ) + + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ + presentation: 'background', + focus: false, + activate: false + }) + ) + }) + + it('preserves focus for an in-process caller', async () => { + const createTerminal = vi.fn(async () => ({ handle: 'terminal-host' })) + const dedupeTerminalCreate = vi.fn( + async ( + _owner: string, + _worktree: string | undefined, + _mutationId: string | undefined, + _reconcile: boolean, + run: (worktree: string | undefined, handle: string | undefined) => Promise + ) => run('id:worktree-1', undefined) + ) + const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create') + if (!method) { + throw new Error('terminal.create method missing') + } + + await method.handler( + { worktree: 'id:worktree-1', presentation: 'focused', focus: true, activate: true }, + { + runtime: { createTerminal, dedupeTerminalCreate } + } as unknown as RpcContext, + vi.fn() + ) + + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ presentation: 'focused', focus: true, activate: true }) + ) + }) }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 37c4cda51b4..d052d3015fb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -33,38 +33,49 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.create', params: TerminalCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus: params.focus === true, - rendererBacked: params.rendererBacked === true, - activate: params.activate === true, - presentation: params.presentation, - tabId: params.tabId, - leafId: params.leafId, - ...(preAllocatedHandle ? { preAllocatedHandle } : {}) - }) - ) - }) + handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => { + // A focused terminal create predates paired-client navigation. Keep the + // authority boundary here so a remote caller cannot activate the host + // renderer. This legacy RPC remains a background create for paired viewers; + // caller-local selection belongs to the session-tab RPC flow. + const pairedViewer = clientKind !== undefined + const focus = pairedViewer ? false : params.focus === true + const activate = pairedViewer ? false : params.activate === true + const presentation = + pairedViewer && params.presentation === 'focused' ? 'background' : params.presentation + return { + terminal: await runtime.dedupeTerminalCreate( + pairedDeviceId ?? clientId ?? 'local', + params.worktree, + params.clientMutationId, + params.reconcileExisting === true, + (canonicalWorktreeSelector, preAllocatedHandle) => + runtime.createTerminal(canonicalWorktreeSelector, { + command: params.command, + startupCommandDelivery: params.startupCommandDelivery, + env: params.env, + envToDelete: params.envToDelete, + ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), + ...(params.launchToken ? { launchToken: params.launchToken } : {}), + ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), + title: params.title, + focus, + rendererBacked: params.rendererBacked === true, + activate, + presentation, + tabId: params.tabId, + leafId: params.leafId, + ...(preAllocatedHandle ? { preAllocatedHandle } : {}) + }) + ) + } + } }), defineMethod({ name: 'terminal.split', diff --git a/src/main/runtime/session-tabs-inventory-publication.test.ts b/src/main/runtime/session-tabs-inventory-publication.test.ts index 761504c273f..854059958bd 100644 --- a/src/main/runtime/session-tabs-inventory-publication.test.ts +++ b/src/main/runtime/session-tabs-inventory-publication.test.ts @@ -133,7 +133,7 @@ describe('authoritative session tab inventory publication', () => { expect(collections).toBe(4) }) - it('coalesces targeted and all-host PTY refreshes behind one aggregate census', async () => { + it('keeps targeted PTY refreshes independent from an aggregate census', async () => { const runtime = createInventoryRuntime() runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] }) @@ -143,7 +143,7 @@ describe('authoritative session tab inventory publication', () => { terminalIdentityByPtyId: new Map(), queriedHostIds: new Set(['local']) } - let resolveRefresh: ((inventory: typeof emptyInventory) => void) | undefined + const pendingResolves: ((inventory: typeof emptyInventory) => void)[] = [] const internals = runtime as unknown as { refreshMobileSessionPtyInventory: (targetWorktreeId?: string | null) => Promise performMobileSessionPtyRecordsRefresh: (targetWorktreeId: string | null) => Promise @@ -151,7 +151,7 @@ describe('authoritative session tab inventory publication', () => { const perform = vi.spyOn(internals, 'performMobileSessionPtyRecordsRefresh').mockImplementation( () => new Promise((resolve) => { - resolveRefresh = resolve + pendingResolves.push(resolve) }) ) @@ -160,10 +160,13 @@ describe('authoritative session tab inventory publication', () => { const targeted = internals.refreshMobileSessionPtyInventory('repo::/target') await Promise.resolve() - expect(perform).toHaveBeenCalledOnce() - resolveRefresh?.(emptyInventory) + expect(perform).toHaveBeenCalledTimes(2) + expect(perform).toHaveBeenNthCalledWith(1, null) + expect(perform).toHaveBeenNthCalledWith(2, 'repo::/target') + pendingResolves[1]?.(emptyInventory) await targeted + pendingResolves[0]?.(emptyInventory) await expect(allHosts).resolves.toEqual({ snapshots: [], authoritative: true }) }) diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts index 744c9f8e6cf..40cbaf57f27 100644 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ b/src/main/ssh/ssh-remote-cli-format.ts @@ -37,6 +37,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, + `runtimeConnectionState: ${status.runtime.connectionState ?? 'unknown'}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, `graphState: ${status.graph.state}` ].join('\n')}\n`, diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 9019a10e9b5..e7538a21baf 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -1,4 +1,5 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' import { projectRemoteAppStatus } from '../../shared/cli-app-status-projection' import { randomUUID } from 'node:crypto' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' @@ -182,6 +183,7 @@ async function dispatchRemoteCli( runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, + connectionState: runtimeHostConnectionState({ hasStatusEntry: true, status }), runtimeId: status.runtimeId }, graph: { state: status.graphStatus } diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index 353fb3cf74f..f7f553ec2c0 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -13,6 +13,7 @@ import type { BrowserClientHostPlacementPreparationRequest, BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' export type RuntimeEnvironmentSubscriptionHandle = { unsubscribe: () => void @@ -101,6 +102,13 @@ export type RuntimeApi = { observeOnly?: true }) => Promise> retryControlConnection?: (args: { selector: string }) => Promise + onSharedControlDiagnostics?: ( + callback: (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void + ) => () => void prepareBrowserClientHostPlacement: ( args: BrowserClientHostPlacementPreparationRequest ) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 4a22185c28c..16f7b82e712 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -197,6 +197,8 @@ import type { RuntimeTerminalPresentation } from '../shared/runtime-types' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../shared/remote-runtime-shared-control-types' +import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../shared/runtime-environment-diagnostics' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types' import type { @@ -4784,6 +4786,24 @@ const api = { ipcRenderer.invoke('runtimeEnvironments:getStatus', args), retryControlConnection: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), + onSharedControlDiagnostics: ( + callback: (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + } + ): void => callback(data) + ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + }, prepareBrowserClientHostPlacement: (args) => ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), retryConnectionsNow: (): Promise => diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx new file mode 100644 index 00000000000..a83b4b63201 --- /dev/null +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.behavior.test.tsx @@ -0,0 +1,455 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactI18Next from 'react-i18next' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' +import WorktreeJumpPalette from './WorktreeJumpPalette' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { + LEAF_ID, + makeAgentEntry, + makeGroup, + makeRecentTabState, + makeRepo, + makeUnifiedTab, + makeWorktree +} from './worktree-jump-palette-test-fixtures' + +vi.mock('react-i18next', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key + }) + } +}) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ + useSettingsNavigationMetadata: () => [] +})) + +vi.mock('@/components/sidebar/StatusIndicator', () => ({ + default: () => +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +vi.mock('@/components/cmd-j/palette-host-badge', () => ({ + getPaletteHostBadge: () => null +})) + +// Why: activation reaches into window.api and the whole worktree-reveal path; the palette's own +// contract is which result it hands over, so stub the boundary and assert on that. +const { activateWorkspaceTabPaletteResult } = vi.hoisted(() => ({ + activateWorkspaceTabPaletteResult: vi.fn((_result: unknown) => ({ status: 'activated' }) as const) +})) +vi.mock('@/lib/workspace-tab-palette-activation', () => ({ + activateWorkspaceTabPaletteResult: (result: unknown) => activateWorkspaceTabPaletteResult(result) +})) + +vi.mock('@/components/ui/command', async () => { + const React = await import('react') + return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled + // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. + CommandDialog: ({ + children, + open, + commandProps + }: { + children: React.ReactNode + open?: boolean + commandProps?: { value?: string; onValueChange?: (next: string) => void } + }) => { + return open ? ( +
+ {children} +
+ ) : null + }, + CommandInput: ({ + value, + onValueChange, + placeholder + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + }) => { + setCommandQuery = onValueChange ?? null + return ( + onValueChange?.(event.currentTarget.value)} + /> + ) + }, + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ) + }), + CommandEmpty: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value?: string + }) => ( + + ) + } +}) + +const initialAppState = useAppStore.getInitialState() +let testRoot: Root +let testContainer: HTMLDivElement +let setCommandQuery: ((next: string) => void) | null = null + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderPalette(overrides: Partial): Promise { + useAppStore.setState({ + activeModal: 'worktree-palette', + activeWorktreeId: null, + repos: [makeRepo()], + tabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + unifiedTabsByWorktree: {}, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + // Why explicit: the sweep exemption is what these cases probe, so it must + // not ride on whatever the store default happens to be. + alwaysShowDefaultBranchWorkspace: true, + lastVisitedAtByWorktreeId: {}, + ...overrides + } as Partial) + + await act(async () => { + testRoot.render() + }) + await flushEffects() +} + +function getRenderedRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item]')].map( + (node) => node.dataset.commandItem ?? '' + ) +} + +function getTabRowIds(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="workspace-tab:"]')] + .map((node) => node.dataset.commandItem ?? '') + .map((id) => id.replace('workspace-tab:', '')) +} + +describe('WorktreeJumpPalette recent chats & terminals', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + activateWorkspaceTabPaletteResult.mockClear() + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('excludes the current editor tab — no agent ladder can lift it out of "you are here"', async () => { + const fileId = '/repo/wt-alpha/notes.ts' + const state = makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'editor', + activeTabTypeByWorktree: { 'wt-alpha': 'editor' }, + activeFileId: fileId, + activeFileIdByWorktree: { 'wt-alpha': fileId }, + openFiles: [ + { + id: fileId, + filePath: fileId, + relativePath: 'notes.ts', + worktreeId: 'wt-alpha', + language: 'typescript', + isDirty: false, + mode: 'edit' + } + ] + }) + await renderPalette({ + ...state, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + 'wt-alpha': [ + { + ...makeUnifiedTab('tab-alpha-file', 'wt-alpha', fileId, 'notes.ts'), + contentType: 'editor' + }, + ...(state.unifiedTabsByWorktree?.['wt-alpha'] ?? []) + ] + }, + groupsByWorktree: { + ...state.groupsByWorktree, + 'wt-alpha': [makeGroup('wt-alpha', ['tab-alpha-file', 'tab-alpha'])] + } + }) + + expect(getTabRowIds()).not.toContain('tab-alpha-file') + expect(getTabRowIds()).toContain('tab-alpha') + + // Proves the exclusion is the current-tab rule, not a missing index entry: search still finds it. + await act(async () => { + setCommandQuery?.('notes') + }) + await flushEffects() + expect(getTabRowIds()).toContain('tab-alpha-file') + }) + + it('excludes an archived worktree tab even with a blocked agent', async () => { + const alpha = makeWorktree('wt-alpha', 'Alpha workspace', { isArchived: true }) + const beta = makeWorktree('wt-beta', 'Beta workspace') + await renderPalette( + makeRecentTabState({ + worktreesByRepo: { 'repo-1': [alpha, beta] }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) + } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('does not admit the current tab mid-open when it goes unread', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta']) + + await act(async () => { + useAppStore.setState({ unreadTerminalTabs: { 'term-alpha': true } } as Partial) + }) + await flushEffects() + + // Why frozen: membership shares the open-time snapshot with the row order, so a late arrival + // can't insert a row under the cursor and renumber ⌘1–6. It joins on the next open. + expect(getTabRowIds()).toEqual(['tab-beta']) + }) + + it('keeps a frozen current row listed after it quiets mid-open', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + unreadTerminalTabs: { 'term-alpha': true } + }) + ) + + expect(getTabRowIds()).toContain('tab-alpha') + + await act(async () => { + useAppStore.setState({ + unreadTerminalTabs: {}, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) + } + } as Partial) + }) + await flushEffects() + + // Why: a frozen row must retain its live badge while staying in its original slot. + expect(getTabRowIds()).toContain('tab-alpha') + expect(testContainer.textContent).toContain('Alpha chat') + expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working') + }) + + it('keeps a frozen current row listed when its agent finishes mid-open', async () => { + await renderPalette( + makeRecentTabState({ + activeWorktreeId: 'wt-alpha', + activeTabType: 'terminal', + activeTabId: 'term-alpha', + activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, + activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) + } + }) + ) + + expect(getTabRowIds()).toContain('tab-alpha') + + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now()) + } + } as Partial) + }) + await flushEffects() + + // Why: completion changes the frozen row's badge without removing its reserved slot. + expect(getTabRowIds()).toContain('tab-alpha') + expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Done') + }) + + it('activates the row a digit chord addresses while open', async () => { + await renderPalette( + makeRecentTabState({ + lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } + }) + ) + + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + + await act(async () => { + emitCmdJRowIndexJump(1) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-alpha' }) + ) + }) + + it('ignores a digit chord beyond the rendered recent rows', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + emitCmdJRowIndexJump(8) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('stops routing digit chords once a query is typed', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + await act(async () => { + emitCmdJRowIndexJump(0) + }) + await flushEffects() + + expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() + }) + + it('keeps the agent badge on an Open Tabs row a query surfaced', async () => { + await renderPalette( + makeRecentTabState({ + agentStatusByPaneKey: { + [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) + } + }) + ) + + // Why: require the setter so this cannot silently exercise the empty-query section. + const applyQuery = setCommandQuery + if (!applyQuery) { + throw new Error('CommandInput never installed a query setter') + } + await act(async () => { + applyQuery('Alpha') + }) + await flushEffects() + + // Why: searching for a tab is exactly when its status matters — the pip must survive the query. + expect(getTabRowIds()).toContain('tab-alpha') + expect(getTabRowIds()).not.toContain('tab-beta') + const alphaRow = testContainer.querySelector( + '[data-command-item="workspace-tab:tab-alpha"]' + ) + expect(alphaRow?.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working') + }) + + it('keeps create-worktree below the matches it would otherwise outrank', async () => { + await renderPalette(makeRecentTabState()) + + await act(async () => { + setCommandQuery?.('Alpha') + }) + await flushEffects() + + const rows = getRenderedRowIds().filter((id) => id.length > 0) + expect(rows.at(-1)).toBe('__create_worktree__') + expect(rows.length).toBeGreaterThan(1) + }) + + it('labels a folder workspace row with its display name, not a branch', async () => { + await renderPalette( + makeRecentTabState({ + worktreesByRepo: { + 'repo-1': [ + makeWorktree('wt-alpha', 'Alpha workspace', { + isMainWorktree: true, + branch: '' + }), + makeWorktree('wt-beta', 'Beta workspace') + ] + } + }) + ) + + expect(testContainer.textContent).toContain('Alpha workspace') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 3bc148ca8d6..52cdec1c683 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment happy-dom + import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,6 +21,7 @@ import { makeUnifiedTab, makeWorktree } from './worktree-jump-palette-test-fixtures' + vi.mock('react-i18next', async (importOriginal) => { const actual = await importOriginal() return { @@ -29,6 +31,7 @@ vi.mock('react-i18next', async (importOriginal) => { }) } }) + vi.mock('sonner', () => ({ toast: { success: vi.fn(), @@ -38,29 +41,39 @@ vi.mock('sonner', () => ({ message: vi.fn() } })) + vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ useSettingsNavigationMetadata: () => [] })) + vi.mock('@/components/sidebar/StatusIndicator', () => ({ default: () => })) + vi.mock('@/components/repo/RepoBadgeLabel', () => ({ RepoBadgeMark: () => })) + vi.mock('@/components/cmd-j/palette-host-badge', () => ({ getPaletteHostBadge: () => null })) + +// Why: activation reaches into window.api and the whole worktree-reveal path; the palette's own +// contract is which result it hands over, so stub the boundary and assert on that. const { activateWorkspaceTabPaletteResult } = vi.hoisted(() => ({ activateWorkspaceTabPaletteResult: vi.fn((_result: unknown) => ({ status: 'activated' }) as const) })) vi.mock('@/lib/workspace-tab-palette-activation', () => ({ activateWorkspaceTabPaletteResult: (result: unknown) => activateWorkspaceTabPaletteResult(result) })) + vi.mock('@/components/ui/command', async () => { const React = await import('react') return { Command: ({ children }: { children: React.ReactNode }) =>
{children}
, CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled + // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. CommandDialog: ({ children, open, @@ -124,17 +137,20 @@ vi.mock('@/components/ui/command', async () => { ) } }) + const initialAppState = useAppStore.getInitialState() let testRoot: Root let testContainer: HTMLDivElement let setCommandQuery: ((next: string) => void) | null = null let setCommandSelection: ((next: string) => void) | null = null + async function flushEffects(): Promise { await act(async () => { await Promise.resolve() await Promise.resolve() }) } + async function renderPalette(overrides: Partial): Promise { useAppStore.setState({ activeModal: 'worktree-palette', @@ -146,35 +162,42 @@ async function renderPalette(overrides: Partial): Promise { unifiedTabsByWorktree: {}, hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + // Why explicit: the sweep exemption is what these cases probe, so it must + // not ride on whatever the store default happens to be. alwaysShowDefaultBranchWorkspace: true, lastVisitedAtByWorktreeId: {}, ...overrides } as Partial) + await act(async () => { testRoot.render() }) await flushEffects() } + function getWorktreeRows(): string[] { return [...testContainer.querySelectorAll('[data-command-item^="worktree:"]')].map( (node) => node.textContent ?? '' ) } + function getRenderedRowIds(): string[] { return [...testContainer.querySelectorAll('[data-command-item]')].map( (node) => node.dataset.commandItem ?? '' ) } + /** The id cmdk would activate on Enter. */ function getCommandValue(): string { return ( testContainer.querySelector('[data-command-dialog]')?.dataset.commandValue ?? '' ) } + function getTabRowIds(): string[] { - return [ - ...testContainer.querySelectorAll('[data-command-item^="workspace-tab:"]') - ].map((node) => (node.dataset.commandItem ?? '').replace('workspace-tab:', '')) + return [...testContainer.querySelectorAll('[data-command-item^="workspace-tab:"]')] + .map((node) => node.dataset.commandItem ?? '') + .map((id) => id.replace('workspace-tab:', '')) } function getTabRowShortcutDigits(): string[] { return [ @@ -190,6 +213,7 @@ function clickSeeMore(): void { .find((button) => button.textContent?.includes('See more')) ?.click() } + describe('WorktreeJumpPalette recent chats & terminals', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -201,6 +225,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { document.body.appendChild(testContainer) testRoot = createRoot(testContainer) }) + afterEach(async () => { await act(async () => { testRoot.unmount() @@ -208,38 +233,48 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { document.body.replaceChildren() useAppStore.setState(initialAppState, true) }) + it('leads the empty-query list with the recent section', async () => { await renderPalette(makeRecentTabState()) + const rows = getRenderedRowIds().filter((id) => id.length > 0) expect(rows[0]).toMatch(/^workspace-tab:/) expect(rows.some((id) => id.startsWith('worktree:'))).toBe(true) expect(testContainer.textContent).toContain('Recent Chats & Terminals') expect(testContainer.textContent).toContain('Recent Worktrees') }) + it('keeps duplicate persisted tab ids as separate recent rows and digit targets', async () => { await renderPalette(makeDuplicateRecentTabState()) + expect( getRenderedRowIds().filter( (id) => id === 'workspace-tab:tab-duplicate' || id.includes(':workspace-tab:tab-duplicate') ) ).toEqual(['workspace-tab:tab-duplicate', 'palette-dup:1:workspace-tab:tab-duplicate']) + await act(async () => { emitCmdJRowIndexJump(1) }) await flushEffects() + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'tab-duplicate', worktreeId: 'wt-beta' }) ) }) + it('caps the recent section so the worktree header stays above the fold', async () => { await renderPalette(makeManyTabState(12)) + expect(getTabRowIds()).toHaveLength(6) expect(testContainer.textContent).toContain('Recent Worktrees') + // Why: the worktree section shrinks against the recent rows so the list holds at 10 total — + // it must never uncap, not even for the frame before the order snapshot lands. expect(getWorktreeRows().length).toBeLessThanOrEqual(4) }) it('shows more recent chats and terminals from the empty-query view', async () => { await renderPalette(makeManyTabState(12)) - const seeMoreButton = Array.from(testContainer.querySelectorAll('button')).find((button) => + const seeMoreButton = [...testContainer.querySelectorAll('button')].find((button) => button.textContent?.includes('See more') ) expect(seeMoreButton).toBeDefined() @@ -270,9 +305,13 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(getTabRowIds()).toHaveLength(12) expect(getTabRowShortcutDigits()).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']) }) + it('backfills past the cap when rows drop out of the frozen order', async () => { await renderPalette(makeManyTabState(12)) const before = getTabRowIds() + + // Why: closing the whole first page stands in for any mid-open narrowing (a filter chip does the + // same thing) — the section must fall through to the next ranked rows, not render empty. await act(async () => { useAppStore.setState({ unifiedTabsByWorktree: { @@ -283,10 +322,12 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } as Partial) }) await flushEffects() + const after = getTabRowIds() expect(after).toHaveLength(6) expect(after.some((id) => before.includes(id))).toBe(false) }) + /** A tab whose title starts with the query, against worktrees that only match mid-name. */ function makeTypedRelevanceState(): Partial { const weak = makeWorktree('wt-weak', 'improve-agent-dashboard-performance') @@ -307,47 +348,62 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { activeGroupIdByWorktree: { 'wt-host': 'group-wt-host' } } } + it('leads a typed query with the tab section when it holds the stronger match', async () => { await renderPalette(makeTypedRelevanceState()) + await act(async () => { setCommandQuery?.('perf') }) await flushEffects() + const rows = getRenderedRowIds().filter((id) => id.length > 0) expect(rows[0]).toBe('workspace-tab:tab-host') expect(rows).toContain('worktree:wt-weak') expect(getCommandValue()).toBe('workspace-tab:tab-host') }) + it('selects the new first result when cmdk reports the deferred list selection', async () => { await renderPalette(makeTypedRelevanceState()) + await act(async () => { setCommandQuery?.('improve') }) await flushEffects() expect(getCommandValue()).toBe('worktree:wt-weak') + await act(async () => { setCommandQuery?.('perf') setCommandSelection?.('worktree:wt-weak') }) await flushEffects() + expect(getRenderedRowIds().find((id) => id.length > 0)).toBe('workspace-tab:tab-host') expect(getCommandValue()).toBe('workspace-tab:tab-host') }) + + // Why: after typing, arrow moves must stick. Dropping onValueChange while cmdk already + // advanced its internal cursor made the next ArrowDown a no-op (Object.is short-circuit). it('keeps arrow selection after the typed query ranking has committed', async () => { await renderPalette(makeTypedRelevanceState()) + await act(async () => { setCommandQuery?.('perf') }) await flushEffects() expect(getCommandValue()).toBe('workspace-tab:tab-host') + const rows = getRenderedRowIds().filter((id) => id.length > 0) expect(rows.length).toBeGreaterThan(1) + await act(async () => { setCommandSelection?.(rows[1]) }) await flushEffects() + expect(getCommandValue()).toBe(rows[1]) }) + it('keeps worktrees ahead of tabs when a worktree holds the stronger match', async () => { await renderPalette({ ...makeTypedRelevanceState(), @@ -358,17 +414,22 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { ] } }) + await act(async () => { setCommandQuery?.('perf-d') }) await flushEffects() + const firstRow = getRenderedRowIds().find((id) => id.length > 0) expect(firstRow).toBe('worktree:wt-strong') }) + it('ranks a typed query by match position inside the worktree section', async () => { await renderPalette({ worktreesByRepo: { 'repo-1': [ + // Why this order: smart sort keeps the input order here, so a promoted prefix hit can only + // come from relevance re-ranking. makeWorktree('wt-word-a', 'improve-agent-dashboard-performance'), makeWorktree('wt-word-b', 'rc-perf-update-channels'), makeWorktree('wt-prefix', 'perf-diff-tighten') @@ -376,16 +437,21 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { }, showSleepingWorkspaces: true }) + await act(async () => { setCommandQuery?.('perf') }) await flushEffects() + + // Why word-b beats word-a despite input order: `perf` is a whole word in + // `rc-perf-update-channels` but only a prefix of `performance`. expect(getRenderedRowIds().filter((id) => id.startsWith('worktree:'))).toEqual([ 'worktree:wt-prefix', 'worktree:wt-word-b', 'worktree:wt-word-a' ]) }) + it('budget-caps the worktree section when nothing fills the recent one', async () => { await renderPalette({ worktreesByRepo: { @@ -395,10 +461,14 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { }, showSleepingWorkspaces: true }) + + // Why this shape: a filter chip that drops every open tab lands here too, and uncapping used to + // mount one row per workspace. expect(getTabRowIds()).toEqual([]) expect(getWorktreeRows()).toHaveLength(10) expect(testContainer.textContent).toContain('4 more') }) + it('captures the order when tabs hydrate after the palette is already open', async () => { const hydrated = makeRecentTabState() await renderPalette({ @@ -406,13 +476,16 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { tabsByWorktree: {}, unifiedTabsByWorktree: {} }) + expect(getTabRowIds()).toEqual([]) + // Why: cmdk claims the first row it sees, which before hydration is a worktree. const firstWorktreeId = getRenderedRowIds().find((id) => id.startsWith('worktree:')) expect(firstWorktreeId).toBeDefined() await act(async () => { setCommandSelection?.(firstWorktreeId ?? '') }) await flushEffects() + await act(async () => { useAppStore.setState({ tabsByWorktree: hydrated.tabsByWorktree, @@ -420,17 +493,23 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } as Partial) }) await flushEffects() + const [topRowId] = getTabRowIds() expect(getTabRowIds()).toHaveLength(2) + // Enter has to follow the rows up: ⌘1 already points at the first recent chat. expect(getCommandValue()).toBe(`workspace-tab:${topRowId}`) + + // Why here: an empty snapshot also left the digit chords addressing nothing until reopen. await act(async () => { emitCmdJRowIndexJump(0) }) await flushEffects() + expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( expect.objectContaining({ tabId: topRowId }) ) }) + it('leaves a deliberately moved selection alone when recents land late', async () => { const hydrated = makeRecentTabState() await renderPalette({ @@ -438,13 +517,16 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { tabsByWorktree: {}, unifiedTabsByWorktree: {} }) + const worktreeIds = getRenderedRowIds().filter((id) => id.startsWith('worktree:')) expect(worktreeIds.length).toBeGreaterThan(1) + // Why the second row: only a selection that differs from the auto-picked head proves the user moved it. const movedTo = worktreeIds[1] await act(async () => { setCommandSelection?.(movedTo) }) await flushEffects() + await act(async () => { useAppStore.setState({ tabsByWorktree: hydrated.tabsByWorktree, @@ -452,10 +534,14 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } as Partial) }) await flushEffects() + expect(getTabRowIds()).toHaveLength(2) expect(getCommandValue()).toBe(movedTo) }) + it('re-ranks once when terminal entities hydrate after unified tabs', async () => { + // Why split hydration: unified tabs can land before tabsByWorktree; without a re-capture every + // row ranks IDLE. A deliberate second-row highlight must survive that one re-rank. const hydrated = makeRecentTabState({ agentStatusByPaneKey: { [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) @@ -476,7 +562,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) expect(getCommandValue()).toBe(movedTo) }) + it('admits a high-signal current tab whose terminal entity hydrates late', async () => { + // Why: with no tabsByWorktree entity the current tab's badge is unknowable, so membership is + // too — an attention-ready capture there would freeze it out of Recent for the whole open. const hydrated = makeRecentTabState({ activeWorktreeId: 'wt-alpha', activeTabType: 'terminal', @@ -489,12 +578,15 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { }) await renderPalette({ ...hydrated, tabsByWorktree: {} }) expect(getTabRowIds()).toEqual(['tab-beta']) + await act(async () => { useAppStore.setState({ tabsByWorktree: hydrated.tabsByWorktree } as Partial) }) await flushEffects() + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) }) + it('ranks a blocked agent above a more recently visited idle tab', async () => { await renderPalette( makeRecentTabState({ @@ -504,15 +596,19 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } }) ) + expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) }) + it('freezes the order captured on open while statuses keep changing', async () => { await renderPalette( makeRecentTabState({ lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } }) ) + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) + await act(async () => { useAppStore.setState({ agentStatusByPaneKey: { @@ -521,14 +617,20 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } as Partial) }) await flushEffects() + expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) }) + it('captures the unfiltered order when reopened after a search', async () => { await renderPalette(makeRecentTabState()) + await act(async () => { setCommandQuery?.('Alpha') }) await flushEffects() + + // Why closed-then-reopened: the palette stays mounted, and the open effect clears the query one + // commit after the snapshot effect — so a naive capture would freeze the Alpha-only subset. await act(async () => { useAppStore.setState({ activeModal: undefined } as Partial) }) @@ -539,8 +641,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } as Partial) }) await flushEffects() + expect(getTabRowIds()).toHaveLength(2) }) + it('excludes the idle current tab from the recent section', async () => { await renderPalette( makeRecentTabState({ @@ -551,8 +655,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } }) ) + expect(getTabRowIds()).toEqual(['tab-beta']) }) + it('keeps the current tab in recent when its agent needs permission', async () => { await renderPalette( makeRecentTabState({ @@ -567,9 +673,13 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } }) ) + + // Why: high-signal current tabs stay scannable (ask-question / permission badge) even though + // idle "where you are" rows are still dropped. expect(getTabRowIds()).toEqual(['tab-alpha', 'tab-beta']) expect(testContainer.textContent).toContain('Current Tab') }) + it('keeps the current tab in recent when its agent is working', async () => { await renderPalette( makeRecentTabState({ @@ -583,8 +693,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } }) ) + expect(getTabRowIds()).toContain('tab-alpha') }) + it('keeps the current tab in recent when it has unread activity', async () => { await renderPalette( makeRecentTabState({ @@ -596,8 +708,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { unreadTerminalTabs: { 'term-alpha': true } }) ) + expect(getTabRowIds()).toContain('tab-alpha') }) + it.each([undefined, true])('excludes current terminal outcomes', async (interrupted) => { await renderPalette( makeRecentTabState({ @@ -613,8 +727,12 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } }) ) + + // Why: a completion you watched land needs no row — `done` outlives the unread auto-ack by the + // whole 30m staleness window, so the slot goes to a workspace off screen instead. expect(getTabRowIds()).toEqual(['tab-beta']) }) + it('still lists a non-current tab whose agent is done', async () => { await renderPalette( makeRecentTabState({ @@ -623,8 +741,11 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { } }) ) + + // Why: `done` only stops earning *entry* for the tab on screen — elsewhere it is still news. expect(getTabRowIds()).toContain('tab-alpha') }) + it('keeps the current tab in recent on a pane-only unread completion marker', async () => { await renderPalette( makeRecentTabState({ @@ -633,215 +754,12 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { activeTabId: 'term-alpha', activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, + // Why pane-keyed only: the narrower marker (unacked completion in one pane) is its own + // inclusion input — unreadTerminalTabs stays empty here. unreadAgentCompletionPanes: { [makePaneKey('term-alpha', LEAF_ID)]: true } }) ) + expect(getTabRowIds()).toContain('tab-alpha') }) - it('excludes the current editor tab — no agent ladder can lift it out of "you are here"', async () => { - const fileId = '/repo/wt-alpha/notes.ts' - const state = makeRecentTabState({ - activeWorktreeId: 'wt-alpha', - activeTabType: 'editor', - activeTabTypeByWorktree: { 'wt-alpha': 'editor' }, - activeFileId: fileId, - activeFileIdByWorktree: { 'wt-alpha': fileId }, - openFiles: [ - { - id: fileId, - filePath: fileId, - relativePath: 'notes.ts', - worktreeId: 'wt-alpha', - language: 'typescript', - isDirty: false, - mode: 'edit' - } - ] - }) - await renderPalette({ - ...state, - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - 'wt-alpha': [ - { - ...makeUnifiedTab('tab-alpha-file', 'wt-alpha', fileId, 'notes.ts'), - contentType: 'editor' - }, - ...(state.unifiedTabsByWorktree?.['wt-alpha'] ?? []) - ] - }, - groupsByWorktree: { - ...state.groupsByWorktree, - 'wt-alpha': [makeGroup('wt-alpha', ['tab-alpha-file', 'tab-alpha'])] - } - }) - expect(getTabRowIds()).not.toContain('tab-alpha-file') - expect(getTabRowIds()).toContain('tab-alpha') - await act(async () => { - setCommandQuery?.('notes') - }) - await flushEffects() - expect(getTabRowIds()).toContain('tab-alpha-file') - }) - it('excludes an archived worktree tab even with a blocked agent', async () => { - const alpha = makeWorktree('wt-alpha', 'Alpha workspace', { isArchived: true }) - const beta = makeWorktree('wt-beta', 'Beta workspace') - await renderPalette( - makeRecentTabState({ - worktreesByRepo: { 'repo-1': [alpha, beta] }, - agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'blocked', Date.now()) - } - }) - ) - expect(getTabRowIds()).toEqual(['tab-beta']) - }) - it('does not admit the current tab mid-open when it goes unread', async () => { - await renderPalette( - makeRecentTabState({ - activeWorktreeId: 'wt-alpha', - activeTabType: 'terminal', - activeTabId: 'term-alpha', - activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, - activeTabTypeByWorktree: { 'wt-alpha': 'terminal' } - }) - ) - expect(getTabRowIds()).toEqual(['tab-beta']) - await act(async () => { - useAppStore.setState({ unreadTerminalTabs: { 'term-alpha': true } } as Partial) - }) - await flushEffects() - expect(getTabRowIds()).toEqual(['tab-beta']) - }) - it('keeps a frozen current row listed after it quiets mid-open', async () => { - await renderPalette( - makeRecentTabState({ - activeWorktreeId: 'wt-alpha', - activeTabType: 'terminal', - activeTabId: 'term-alpha', - activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, - activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, - unreadTerminalTabs: { 'term-alpha': true } - }) - ) - expect(getTabRowIds()).toContain('tab-alpha') - await act(async () => { - useAppStore.setState({ - unreadTerminalTabs: {}, - agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) - } - } as Partial) - }) - await flushEffects() - expect(getTabRowIds()).toContain('tab-alpha') - expect(testContainer.textContent).toContain('Alpha chat') - expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working') - }) - it('keeps a frozen current row listed when its agent finishes mid-open', async () => { - await renderPalette( - makeRecentTabState({ - activeWorktreeId: 'wt-alpha', - activeTabType: 'terminal', - activeTabId: 'term-alpha', - activeTabIdByWorktree: { 'wt-alpha': 'term-alpha' }, - activeTabTypeByWorktree: { 'wt-alpha': 'terminal' }, - agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) - } - }) - ) - expect(getTabRowIds()).toContain('tab-alpha') - await act(async () => { - useAppStore.setState({ - agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'done', Date.now()) - } - } as Partial) - }) - await flushEffects() - expect(getTabRowIds()).toContain('tab-alpha') - expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Done') - }) - it('activates the row a digit chord addresses while open', async () => { - await renderPalette( - makeRecentTabState({ - lastVisitedAtByWorktreeId: { 'wt-beta': Date.now() } - }) - ) - expect(getTabRowIds()).toEqual(['tab-beta', 'tab-alpha']) - await act(async () => { - emitCmdJRowIndexJump(1) - }) - await flushEffects() - expect(activateWorkspaceTabPaletteResult).toHaveBeenCalledWith( - expect.objectContaining({ tabId: 'tab-alpha' }) - ) - }) - it('ignores a digit chord beyond the rendered recent rows', async () => { - await renderPalette(makeRecentTabState()) - await act(async () => { - emitCmdJRowIndexJump(8) - }) - await flushEffects() - expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() - }) - it('stops routing digit chords once a query is typed', async () => { - await renderPalette(makeRecentTabState()) - await act(async () => { - setCommandQuery?.('Alpha') - }) - await flushEffects() - await act(async () => { - emitCmdJRowIndexJump(0) - }) - await flushEffects() - expect(activateWorkspaceTabPaletteResult).not.toHaveBeenCalled() - }) - it('keeps the agent badge on an Open Tabs row a query surfaced', async () => { - await renderPalette( - makeRecentTabState({ - agentStatusByPaneKey: { - [makePaneKey('term-alpha', LEAF_ID)]: makeAgentEntry('term-alpha', 'working', Date.now()) - } - }) - ) - const applyQuery = setCommandQuery - if (!applyQuery) { - throw new Error('CommandInput never installed a query setter') - } - await act(async () => { - applyQuery('Alpha') - }) - await flushEffects() - expect(getTabRowIds()).toContain('tab-alpha') - expect(getTabRowIds()).not.toContain('tab-beta') - const alphaRow = testContainer.querySelector( - '[data-command-item="workspace-tab:tab-alpha"]' - ) - expect(alphaRow?.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working') - }) - it('keeps create-worktree below the matches it would otherwise outrank', async () => { - await renderPalette(makeRecentTabState()) - await act(async () => { - setCommandQuery?.('Alpha') - }) - await flushEffects() - const rows = getRenderedRowIds().filter((id) => id.length > 0) - expect(rows.at(-1)).toBe('__create_worktree__') - expect(rows.length).toBeGreaterThan(1) - }) - it('labels a folder workspace row with its display name, not a branch', async () => { - await renderPalette( - makeRecentTabState({ - worktreesByRepo: { - 'repo-1': [ - makeWorktree('wt-alpha', 'Alpha workspace', { isMainWorktree: true, branch: '' }), - makeWorktree('wt-beta', 'Beta workspace') - ] - } - }) - ) - expect(testContainer.textContent).toContain('Alpha workspace') - }) }) diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts index 2a4570e9521..21f6afd369e 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts @@ -55,7 +55,12 @@ export function useCombinedDiffTreeNavigation({ setActiveTreeSectionState({ entrySignature, key: null }) } const viewedSectionKeys = React.useMemo( - () => new Set(sections.filter((section) => isCombinedDiffSectionViewed(section)).map((section) => section.key)), + () => + new Set( + sections + .filter((section) => isCombinedDiffSectionViewed(section)) + .map((section) => section.key) + ), [sections] ) const handleTreeNavigate = useCallback( diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts index f9afaa51dfa..e4fa0f4723d 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-details-editing.ts @@ -53,7 +53,14 @@ export function useGitLabDetailsEditing( setLabelOptionsLoading(false) } } - }, [labelOptions, labelOptionsLoading, mountedRef, repoSelector]) + }, [ + labelOptions, + labelOptionsLoading, + mountedRef, + repoSelector, + setLabelOptions, + setLabelOptionsLoading + ]) const handleStartDetailsEdit = useCallback((): void => { if (!item || !details || item.type !== 'mr') { @@ -64,14 +71,22 @@ export function useGitLabDetailsEditing( setLabelDraft(formatGitLabLabelDraft(details.item.labels ?? item.labels)) setEditingDetails(true) void loadGitLabLabelOptions() - }, [details, item, loadGitLabLabelOptions]) + }, [ + details, + item, + loadGitLabLabelOptions, + setBodyDraft, + setEditingDetails, + setLabelDraft, + setTitleDraft + ]) const handleCancelDetailsEdit = useCallback((): void => { setEditingDetails(false) setTitleDraft('') setBodyDraft('') setLabelDraft('') - }, []) + }, [setBodyDraft, setEditingDetails, setLabelDraft, setTitleDraft]) const handleSaveDetails = useCallback(async (): Promise => { if (!item || !details || !repoSelector || item.type !== 'mr') { @@ -153,6 +168,13 @@ export function useGitLabDetailsEditing( labelDraft, mountedRef, repoSelector, + setBodyDraft, + setDetails, + setDetailsSaving, + setEditingDetails, + setLabelDraft, + setLabelOptions, + setTitleDraft, titleDraft ]) diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts index cb33401825d..0229cd4a0e8 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-pipeline-actions.ts @@ -63,7 +63,16 @@ export function useGitLabPipelineActions( } } }, - [details?.item.projectRef, expandedJobId, item, jobTraceById, mountedRef, repoSelector] + [ + details?.item.projectRef, + expandedJobId, + item, + jobTraceById, + mountedRef, + repoSelector, + setExpandedJobId, + setJobTraceById + ] ) const handleRetryJob = useCallback( @@ -113,7 +122,15 @@ export function useGitLabPipelineActions( } } }, - [details?.item.projectRef, handleRefresh, item, mountedRef, repoSelector] + [ + details?.item.projectRef, + handleRefresh, + item, + mountedRef, + repoSelector, + setDetails, + setRetryingJobId + ] ) return { handleRetryJob, handleToggleJobTrace } diff --git a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts index 9d9d3ed467e..ae4deb2e929 100644 --- a/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts +++ b/src/renderer/src/components/gitlab-item-dialog/use-gitlab-primary-actions.ts @@ -53,7 +53,7 @@ export function useGitLabPrimaryActions( setActionInFlight(null) } } - }, [item, repoSelector, mountedRef, handleRefresh]) + }, [handleRefresh, item, mountedRef, repoSelector, setActionInFlight]) const handleReopen = useCallback(async (): Promise => { if (!item || !repoSelector || item.type !== 'mr') { @@ -86,7 +86,7 @@ export function useGitLabPrimaryActions( setActionInFlight(null) } } - }, [item, repoSelector, mountedRef, handleRefresh]) + }, [handleRefresh, item, mountedRef, repoSelector, setActionInFlight]) const handleMerge = useCallback(async (): Promise => { if (!item || !repoSelector || item.type !== 'mr') { @@ -119,7 +119,7 @@ export function useGitLabPrimaryActions( setActionInFlight(null) } } - }, [item, repoSelector, mountedRef, handleRefresh]) + }, [handleRefresh, item, mountedRef, repoSelector, setActionInFlight]) const handleSubmitComment = useCallback(async (): Promise => { const bodyState = getCommentBodySubmitState(commentDraft) @@ -173,7 +173,16 @@ export function useGitLabPrimaryActions( setCommentSubmitting(false) } } - }, [commentDraft, item, itemId, repoSelector, mountedRef, handleRefresh]) + }, [ + commentDraft, + handleRefresh, + item, + itemId, + mountedRef, + repoSelector, + setCommentDraftState, + setCommentSubmitting + ]) return { handleClose, handleMerge, handleReopen, handleSubmitComment } } diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index 8c001f95086..2d05324bc92 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -203,6 +203,36 @@ describe('RuntimeEnvironmentsPane host details', () => { ).toBe('disconnected') }) + it.each(['closed', 'reconnecting'] as const)( + 'does not keep a ready details cache green when shared control is %s', + (state) => { + expect( + getRuntimeServerConnectionState( + details({ + status: 'ready', + runtimeStatus: { + runtimeId: 'runtime-live', + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: 1, + liveTabCount: 0, + liveLeafCount: 0, + remoteControl: { + state, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 1, + lastConnectedAt: 1, + lastClose: null, + lastError: null + } + } + }) + ) + ).not.toBe('connected') + } + ) + it('explains that selecting a saved server is the explicit default Host mode', () => { expect(getActiveServerModeDescription(true)).toContain('Use this computer by default') expect(getActiveServerModeDescription(true)).toContain('browser/mobile handoff') diff --git a/src/renderer/src/components/settings/runtime-environment-host-details.ts b/src/renderer/src/components/settings/runtime-environment-host-details.ts index 03030733e97..11a950c3278 100644 --- a/src/renderer/src/components/settings/runtime-environment-host-details.ts +++ b/src/renderer/src/components/settings/runtime-environment-host-details.ts @@ -12,6 +12,10 @@ import { WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { + runtimeHostConnectionState, + type RuntimeHostConnectionState +} from '../../../../shared/runtime-host-connection-state' export type RuntimeHostDetails = { status: 'loading' | 'ready' | 'error' @@ -147,7 +151,7 @@ export function isRuntimeEnvironmentRemovalBlocked( return activeRuntimeEnvironmentId === environmentId } -export type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected' +export type RuntimeServerConnectionState = RuntimeHostConnectionState export function getRuntimeServerConnectionState( details: RuntimeHostDetails | undefined @@ -158,11 +162,11 @@ export function getRuntimeServerConnectionState( if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') { return 'disconnected' } - // Why: an attached, reachable, compatible host is "Connected" (and exposes - // Disconnect). Whether it is the default *active* server is a separate concept, - // surfaced by the Advanced > Active Server selector and the row's help text — - // it must not change this connection label, or the dot/label/button disagree. - return 'connected' + // Older clients can report a ready details phase without embedding RuntimeStatus. + if (details.runtimeStatus === null) { + return 'connected' + } + return runtimeHostConnectionState({ hasStatusEntry: true, status: details.runtimeStatus }) } export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string { @@ -172,11 +176,21 @@ export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionSt 'auto.components.settings.RuntimeEnvironmentsPane.serverConnected', 'Connected' ) + case 'workspace-window-closed': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverWorkspaceWindowClosed', + 'Workspace window closed' + ) case 'checking': return translate( 'auto.components.settings.RuntimeEnvironmentsPane.serverChecking', 'Checking…' ) + case 'reconnecting': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverReconnecting', + 'Reconnecting' + ) case 'disconnected': return translate( 'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected', @@ -190,6 +204,8 @@ export function getRuntimeServerDotClass(state: RuntimeServerConnectionState): s case 'connected': return 'bg-emerald-500' case 'checking': + case 'workspace-window-closed': + case 'reconnecting': return 'bg-yellow-500' case 'disconnected': return 'bg-muted-foreground/40' diff --git a/src/renderer/src/components/settings/runtime-server-row.tsx b/src/renderer/src/components/settings/runtime-server-row.tsx index 965e99d1ffb..eba38caf763 100644 --- a/src/renderer/src/components/settings/runtime-server-row.tsx +++ b/src/renderer/src/components/settings/runtime-server-row.tsx @@ -3,6 +3,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-e import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' import { Button } from '../ui/button' import { getHostDetailsDescription, @@ -51,9 +52,27 @@ export function RuntimeServerRow({ onRemove }: RuntimeServerRowProps): React.JSX.Element { const detailsDescription = getHostDetailsDescription(details) - const connectionState = getRuntimeServerConnectionState(details) + const runtimeStatusEntry = useAppStore((state) => + state.runtimeStatusByEnvironmentId.get(environment.id) + ) + const connectionState = + details?.status === 'loading' && !runtimeStatusEntry?.status + ? 'checking' + : runtimeStatusEntry + ? getRuntimeServerConnectionState({ + ...(details ?? { + status: runtimeStatusEntry.status ? 'ready' : 'error', + runtimeStatus: null, + compatibility: null, + error: null + }), + status: runtimeStatusEntry.status ? 'ready' : 'error', + runtimeStatus: runtimeStatusEntry.status + }) + : getRuntimeServerConnectionState(details) // A connected host exposes Disconnect; otherwise Connect. - const isReachable = connectionState === 'connected' + const isReachable = + connectionState === 'connected' || connectionState === 'workspace-window-closed' const actionBusy = connecting || switching || disconnecting || removing return ( diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 5704f8e61f7..7aa5dd418cf 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -60,6 +60,14 @@ export function installAppLifetimeIpcEvents( ) const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) + const onSharedControlDiagnostics = window.api.runtimeEnvironments?.onSharedControlDiagnostics + if (onSharedControlDiagnostics) { + unsubs.push( + onSharedControlDiagnostics((event) => { + useAppStore.getState().publishRuntimeEnvironmentDiagnostics(event) + }) + ) + } const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) registerProjectCatalogIpcBridge( unsubs, diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index ea84f1b2811..5155aa6881f 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -28,6 +28,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'runtime.onNativeChatLaunchDraftResolved', 'runtime.onTerminalDriverChanged', 'runtime.onTerminalFitOverrideChanged', + 'runtimeEnvironments.onSharedControlDiagnostics', 'settings.onChanged', 'ssh.onCredentialRequest', 'ssh.onCredentialResolved', @@ -102,6 +103,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onMobileMarkdownRequest', 'automations.onChanged', + 'runtimeEnvironments.onSharedControlDiagnostics', 'repos.onChanged', 'worktrees.onChanged', 'worktrees.onHeadIdentitiesChanged', @@ -374,8 +376,9 @@ describe('useIpcEvents App-lifetime lifecycle', () => { ).toEqual([ 'ui.onMobileMarkdownRequest', 'automations.onChanged', + 'runtimeEnvironments.onSharedControlDiagnostics', 'runtimeEnvironments.subscribe', - ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(2) + ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(3) ]) const groupOrder = (names: readonly string[]): string[] => registrationOrder.filter((entry) => names.includes(entry)) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 38548079d65..94d2857da25 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7926,7 +7926,9 @@ "3f67e8078a": "Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.", "2c85efb3e8": "Selecting a saved server makes this browser use that paired Orca runtime as its default Host.", "serverConnected": "Connected", + "serverWorkspaceWindowClosed": "Workspace window closed", "serverChecking": "Checking…", + "serverReconnecting": "Reconnecting", "serverDisconnected": "Disconnected", "disconnectedServer": "Disconnected from {{value0}}.", "connectToRemoteServers": "Connect to remote servers", diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts index c3e65533f2e..a2ab00b561c 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.ts @@ -1,72 +1,7 @@ -import type { RuntimeStatus } from '../../../shared/runtime-types' -import { isRuntimeWorkspaceWindowClosed } from '../../../shared/runtime-workspace-window-availability' - -export type HostStatus = 'connected' | 'disconnected' | 'connecting' - -// Why: 'workspace-window-closed' is a reachable host that cannot serve graph-backed -// work — connected for counting purposes, but not interchangeable with 'connected'. -export type RuntimeHostConnectionState = - | 'connected' - | 'workspace-window-closed' - | 'checking' - | 'reconnecting' - | 'disconnected' - -// Why: one derivation for every host surface (status bar + Settings > Available Hosts), -// so a degraded host can never read "Connected" in one place and "Ready" in the other. -export function runtimeHostConnectionState({ - hasStatusEntry, - status -}: { - hasStatusEntry: boolean - status: RuntimeStatus | null | undefined -}): RuntimeHostConnectionState { - if (!hasStatusEntry) { - return 'checking' - } - const remoteControl = status?.remoteControl - if (remoteControl?.state === 'reconnecting') { - return 'reconnecting' - } - if (!status) { - return 'disconnected' - } - // Why no lastError requirement: a clean close (server restart, host sleep, network - // blip) leaves lastError null, and demanding an error string painted those hosts green. - if (remoteControl?.state === 'closed') { - return 'disconnected' - } - // Why: the socket is up but ready/auth has not completed, so nothing can run there yet. - if (remoteControl && remoteControl.state !== 'ready') { - return 'checking' - } - // Why: reachable but graph-less — the transport is fine, so this is not a network - // disconnect, but calling it "Connected" hides that nothing will run there. - if (isRuntimeWorkspaceWindowClosed(status)) { - return 'workspace-window-closed' - } - // Why: "connected" means attached/reachable, NOT "is the active default host". - // Both surfaces must agree on that single definition, or a reachable-but-not-active - // host reads "Connected" in one place and "Available" in the other. Active/default is - // a separate concept (surfaced elsewhere), so it must not change this state. - return 'connected' -} - -export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus { - switch (state) { - // Why: a closed workspace window is a degraded host, not a lost connection — - // it must keep counting toward the connected-host total. - case 'connected': - case 'workspace-window-closed': - return 'connected' - case 'checking': - case 'reconnecting': - return 'connecting' - case 'disconnected': - return 'disconnected' - } -} - -export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { - return state === 'connected' || state === 'workspace-window-closed' -} +export { + isConnectedRuntimeHostState, + runtimeHostConnectionState, + runtimeStatusForOverall, + type HostStatus, + type RuntimeHostConnectionState +} from '../../../shared/runtime-host-connection-state' diff --git a/src/renderer/src/store/slices/runtime-status-connection-generation.ts b/src/renderer/src/store/slices/runtime-status-connection-generation.ts new file mode 100644 index 00000000000..2ad1e0d2504 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-connection-generation.ts @@ -0,0 +1,24 @@ +const connectionGenerationByEnvironment = new Map() + +export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number { + return connectionGenerationByEnvironment.get(environmentId) ?? 0 +} + +export function setRuntimeEnvironmentConnectionGenerationForTests( + environmentId: string, + generation: number +): void { + connectionGenerationByEnvironment.set(environmentId, generation) +} + +export function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { + const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 + connectionGenerationByEnvironment.set(environmentId, next) + return next +} + +export function clearRuntimeEnvironmentConnectionGenerations(): Iterable { + const environmentIds = [...connectionGenerationByEnvironment.keys()] + connectionGenerationByEnvironment.clear() + return environmentIds +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts new file mode 100644 index 00000000000..22ba23cbbb1 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-diagnostics-generation.ts @@ -0,0 +1,52 @@ +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' +import type { RuntimeEnvironmentStatus } from './runtime-status' + +const diagnosticsGenerationByEnvironment = new Map() + +export function updateRuntimeEnvironmentStatusOverlay( + state: Map, + environmentId: string, + status: RuntimeEnvironmentStatus +): Map { + const current = state.get(environmentId) + if (!current || current.status?.runtimeId !== status.status?.runtimeId) { + return state + } + return new Map(state).set(environmentId, status) +} + +export function acceptRuntimeEnvironmentDiagnosticsGeneration( + environmentId: string, + transportGeneration: number +): boolean { + const previous = diagnosticsGenerationByEnvironment.get(environmentId) + if (previous !== undefined && transportGeneration < previous) { + return false + } + diagnosticsGenerationByEnvironment.set(environmentId, transportGeneration) + return true +} + +export function clearRuntimeEnvironmentDiagnosticsGenerationsForTests(): void { + diagnosticsGenerationByEnvironment.clear() +} + +export function mergePushedRuntimeEnvironmentDiagnostics(args: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + current: RuntimeEnvironmentStatus | undefined + publish: (status: RuntimeEnvironmentStatus) => void +}): void { + if ( + !args.current?.status?.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) || + !acceptRuntimeEnvironmentDiagnosticsGeneration(args.environmentId, args.transportGeneration) + ) { + return + } + args.publish({ + ...args.current, + status: { ...args.current.status, remoteControl: args.diagnostics } + }) +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts new file mode 100644 index 00000000000..59dee33eadd --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-diagnostics-publish.ts @@ -0,0 +1,80 @@ +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' +import type { AppState } from '../types' +import type { RuntimeEnvironmentStatus } from './runtime-status' +import * as diagnosticsGeneration from './runtime-status-diagnostics-generation' + +export function updateRuntimeStatusStore( + state: AppState, + updater: (state: Map) => Map +): AppState | Pick { + const next = updater(state.runtimeStatusByEnvironmentId) + return next === state.runtimeStatusByEnvironmentId + ? state + : { runtimeStatusByEnvironmentId: next } +} + +export function publishRuntimeEnvironmentDiagnostics(args: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + getCurrent: () => RuntimeEnvironmentStatus | undefined + updateState: (status: RuntimeEnvironmentStatus) => boolean + afterPublish?: (status: RuntimeEnvironmentStatus) => void +}): void { + diagnosticsGeneration.mergePushedRuntimeEnvironmentDiagnostics({ + environmentId: args.environmentId, + transportGeneration: args.transportGeneration, + diagnostics: args.diagnostics, + current: args.getCurrent(), + publish: (status) => { + if (args.updateState(status)) { + args.afterPublish?.(status) + } + } + }) +} + +export function applyRuntimeEnvironmentStatusOverlay(args: { + environmentId: string + status: RuntimeEnvironmentStatus + setState: ( + updater: (state: Map) => Map + ) => void +}): boolean { + let updated = false + args.setState((state) => { + const next = diagnosticsGeneration.updateRuntimeEnvironmentStatusOverlay( + state, + args.environmentId, + args.status + ) + updated = next !== state + return next + }) + return updated +} + +export function createRuntimeEnvironmentDiagnosticsPublisher(args: { + getCurrent: (environmentId: string) => RuntimeEnvironmentStatus | undefined + setState: ( + updater: (state: Map) => Map + ) => void + afterPublish: (environmentId: string, status: RuntimeEnvironmentStatus) => void +}): (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics +}) => void { + return (event) => + publishRuntimeEnvironmentDiagnostics({ + ...event, + getCurrent: () => args.getCurrent(event.environmentId), + updateState: (status) => + applyRuntimeEnvironmentStatusOverlay({ + environmentId: event.environmentId, + status, + setState: args.setState + }), + afterPublish: (status) => args.afterPublish(event.environmentId, status) + }) +} diff --git a/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts new file mode 100644 index 00000000000..9faecdcabf1 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-diagnostics.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { create } from 'zustand' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' + +function makeStatus(overrides: Partial = {}): RuntimeStatus { + return { + runtimeId: 'runtime-a', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 3, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3, + capabilities: ['browser.screencast.v1'], + ...overrides + } as RuntimeStatus +} + +function createSliceStore() { + return create()((...a) => ({ + ...createRuntimeStatusSlice(...(a as unknown as Parameters)) + })) +} + +describe('runtime-status diagnostics', () => { + it('merges transport diagnostics into the complete status and fences stale pushes', () => { + const store = createSliceStore() + const status = makeStatus({ + capabilities: ['browser.screencast.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) + const closed = { + state: 'closed' as const, + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 1, + lastClose: { code: 1006, reason: 'network' }, + lastError: 'connection lost' + } + store.getState().publishRuntimeEnvironmentDiagnostics({ + environmentId: 'env-a', + transportGeneration: 3, + diagnostics: closed + }) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toMatchObject({ + runtimeId: 'runtime-a', + capabilities: expect.arrayContaining([ + 'browser.screencast.v1', + REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY + ]), + liveTabCount: 3, + remoteControl: closed + }) + store.getState().publishRuntimeEnvironmentDiagnostics({ + environmentId: 'env-a', + transportGeneration: 2, + diagnostics: { ...closed, state: 'ready' } + }) + expect( + store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.remoteControl?.state + ).toBe('closed') + }) + + it('ignores diagnostics after the latest status drops shared-control support', () => { + const store = createSliceStore() + const status = makeStatus({ capabilities: [] }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status, checkedAt: 1 }) + + store.getState().publishRuntimeEnvironmentDiagnostics({ + environmentId: 'env-a', + transportGeneration: 3, + diagnostics: { + state: 'reconnecting', + pendingRequestCount: 0, + subscriptionCount: 1, + reconnectAttempt: 2, + lastConnectedAt: 1, + lastClose: { code: 1006, reason: 'network' }, + lastError: 'connection lost' + } + }) + + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(status) + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status-recheck.ts b/src/renderer/src/store/slices/runtime-status-recheck.ts index bd2d922fd3f..600bb28f796 100644 --- a/src/renderer/src/store/slices/runtime-status-recheck.ts +++ b/src/renderer/src/store/slices/runtime-status-recheck.ts @@ -15,6 +15,14 @@ type RecheckState = { publish: (status: RuntimeStatus | null) => void } +type RuntimeStatusStore = { + runtimeEnvironments: readonly { id: string }[] + setRuntimeEnvironmentStatus: ( + environmentId: string, + status: { status: RuntimeStatus | null; checkedAt: number } + ) => void +} + const rechecks = new Map() export function reconcileRuntimeStatusRecheck(args: { @@ -55,6 +63,27 @@ export function reconcileRuntimeStatusRecheck(args: { armRuntimeStatusRecheck(args.environmentId, state) } +export function reconcileRuntimeStatusForSlice( + environmentId: string, + status: RuntimeStatus | null, + get: () => RuntimeStatusStore, + getConnectionGeneration: () => number +): void { + reconcileRuntimeStatusRecheck({ + environmentId, + status, + connectionGeneration: getConnectionGeneration(), + environmentExists: () => + get().runtimeEnvironments.some((environment) => environment.id === environmentId), + getConnectionGeneration, + publish: (nextStatus) => + get().setRuntimeEnvironmentStatus(environmentId, { + status: nextStatus, + checkedAt: Date.now() + }) + }) +} + export function cancelRuntimeStatusRecheck(environmentId: string): void { const state = rechecks.get(environmentId) if (!state) { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 2dca89c2432..b479b44670f 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality' import { clearRecentRuntimeCompatibilityFailure, @@ -16,13 +17,19 @@ import { import { reconcileCatalogRows } from './repo-identity-reconcile' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' +import * as runtimeStatusDiagnostics from './runtime-status-diagnostics-generation' +import * as runtimeStatusDiagnosticsPublish from './runtime-status-diagnostics-publish' +import { + advanceRuntimeEnvironmentConnectionGeneration, + clearRuntimeEnvironmentConnectionGenerations, + getRuntimeEnvironmentConnectionGeneration +} from './runtime-status-connection-generation' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' import { ensureBrowserClientHostForRestartedRuntime, ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' import * as runtimeStatusRecheck from './runtime-status-recheck' - /** 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 * sidebar can still distinguish "unknown/unreachable" from "never checked". */ @@ -75,6 +82,12 @@ export type RuntimeStatusSlice = { status: RuntimeEnvironmentStatus, options?: { suppressDisconnectToast?: boolean } ) => void + /** Merges main-owned transport diagnostics into a complete runtime status snapshot. */ + publishRuntimeEnvironmentDiagnostics: (args: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void /** Drops a removed environment so stale hosts don't linger in the registry. */ clearRuntimeEnvironmentStatus: (environmentId: string) => void /** Drops every entry whose id is not in the saved-environments set. */ @@ -92,28 +105,14 @@ export type RuntimeStatusSlice = { hydrateRuntimeEnvironmentStatuses: () => Promise } -const connectionGenerationByEnvironment = new Map() - -export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number { - return connectionGenerationByEnvironment.get(environmentId) ?? 0 -} +export { + getRuntimeEnvironmentConnectionGeneration, + setRuntimeEnvironmentConnectionGenerationForTests +} from './runtime-status-connection-generation' export const clearRuntimeEnvironmentConnectionGenerationsForTests = (): void => { - runtimeStatusRecheck.cancelRuntimeStatusRechecks(connectionGenerationByEnvironment.keys()) - connectionGenerationByEnvironment.clear() -} - -export const setRuntimeEnvironmentConnectionGenerationForTests = ( - environmentId: string, - generation: number -): void => { - connectionGenerationByEnvironment.set(environmentId, generation) -} - -function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { - const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 - connectionGenerationByEnvironment.set(environmentId, next) - return next + runtimeStatusRecheck.cancelRuntimeStatusRechecks(clearRuntimeEnvironmentConnectionGenerations()) + runtimeStatusDiagnostics.clearRuntimeEnvironmentDiagnosticsGenerationsForTests() } export const createRuntimeStatusSlice: StateCreator = ( @@ -287,19 +286,9 @@ export const createRuntimeStatusSlice: StateCreator - get().runtimeEnvironments.some((environment) => environment.id === environmentId), - getConnectionGeneration: () => getRuntimeEnvironmentConnectionGeneration(environmentId), - publish: (nextStatus) => - get().setRuntimeEnvironmentStatus(environmentId, { - status: nextStatus, - checkedAt: Date.now() - }) - }) + runtimeStatusRecheck.reconcileRuntimeStatusForSlice(environmentId, status.status, get, () => + getRuntimeEnvironmentConnectionGeneration(environmentId) + ) if (runtimeRestarted) { void ensureBrowserClientHostForRestartedRuntime(get(), environmentId) } @@ -312,6 +301,17 @@ export const createRuntimeStatusSlice: StateCreator get().runtimeStatusByEnvironmentId.get(environmentId), + setState: (updater) => + set((s) => runtimeStatusDiagnosticsPublish.updateRuntimeStatusStore(s, updater)), + afterPublish: (environmentId, status) => + runtimeStatusRecheck.reconcileRuntimeStatusForSlice(environmentId, status.status, get, () => + getRuntimeEnvironmentConnectionGeneration(environmentId) + ) + }), + clearRuntimeEnvironmentStatus: (environmentId) => { runtimeStatusRecheck.cancelRuntimeStatusRecheck(environmentId) dismissRuntimeDisconnectedToast(environmentId) diff --git a/src/shared/remote-runtime-shared-control-connection-actions.ts b/src/shared/remote-runtime-shared-control-connection-actions.ts new file mode 100644 index 00000000000..301018c47da --- /dev/null +++ b/src/shared/remote-runtime-shared-control-connection-actions.ts @@ -0,0 +1,93 @@ +import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' +import * as sharedControlState from './remote-runtime-shared-control-state' +import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close' +import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' +import * as sharedControlSend from './remote-runtime-shared-control-send' +import type { + SharedControlLogicalSubscription, + SharedControlPendingRequest +} from './remote-runtime-shared-control-types' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' + +export function sendSharedControlRequest(args: { + pendingRequests: Map> + requestId: string + state: Parameters[0]['state'] + ws: Parameters[0]['ws'] + sharedKey: Parameters< + typeof sharedControlProtocol.sendSharedControlEncryptedSerialized + >[0]['sharedKey'] +}): void { + sharedControlSend.sendSharedControlRequest({ + pendingRequests: args.pendingRequests, + requestId: args.requestId, + send: (serialized) => + sharedControlProtocol.sendSharedControlEncryptedSerialized({ + state: args.state, + ws: args.ws, + sharedKey: args.sharedKey, + serialized + }), + reject: (id, error) => + sharedControlState.rejectSharedControlPendingRequest(args.pendingRequests, id, error) + }) +} + +export function sendSharedControlSubscription(args: { + subscriptions: Map> + subscription: SharedControlLogicalSubscription + deviceToken: string + send: (payload: unknown) => boolean +}): void { + sharedControlSend.sendSharedControlSubscription(args) +} + +export function replaySharedControlSubscriptions(args: { + subscriptions: Map> + send: (subscription: SharedControlLogicalSubscription) => void + tagReplayedResponses: boolean +}): boolean { + sharedControlSubscriptions.replaySharedControlSubscriptions(args) + return true +} + +export function replayRuntimeControlSubscriptions(args: { + subscriptions: Map> + deviceToken: string + send: (payload: unknown) => boolean + tagReplayedResponses: boolean +}): boolean { + return replaySharedControlSubscriptions({ + subscriptions: args.subscriptions, + send: (subscription) => + sendSharedControlSubscription({ + subscriptions: args.subscriptions, + subscription, + deviceToken: args.deviceToken, + send: args.send + }), + tagReplayedResponses: args.tagReplayedResponses + }) +} + +export function closeSharedControlSubscription(args: { + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + requestId: string + deviceToken: string + send: (payload: unknown) => boolean +}): void { + closeSharedControlConnectionSubscription(args) +} + +export function closeRuntimeControlSubscription(args: { + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + requestId: string + deviceToken: string + send: (payload: unknown) => boolean + clearWhenIdle: (isIdle: boolean) => void +}): void { + closeSharedControlSubscription(args) + args.clearWhenIdle(args.subscriptions.size === 0) +} diff --git a/src/shared/remote-runtime-shared-control-connection-frame.ts b/src/shared/remote-runtime-shared-control-connection-frame.ts new file mode 100644 index 00000000000..a55dcf3c5a8 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-connection-frame.ts @@ -0,0 +1,55 @@ +import { handleSharedControlTextFrame } from './remote-runtime-shared-control-frame-handler' +import type { RemoteRuntimeClientError } from './remote-runtime-client-error' +import type { RuntimeCapability } from './protocol-version' +import type { + SharedControlConnectionState, + SharedControlLogicalSubscription, + SharedControlPendingRequest, + SharedControlReadyWaiter +} from './remote-runtime-shared-control-types' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' + +export function handleRuntimeControlTextFrame(args: { + frame: string + socketGeneration: number + isCurrent: (generation: number) => boolean + getState: () => SharedControlConnectionState + getSharedKey: () => Uint8Array | null + environmentId?: string + deviceToken: string + clientCapabilities: readonly RuntimeCapability[] + pendingRequests: Map> + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + readyWaiters: SharedControlReadyWaiter[] + setState: (state: SharedControlConnectionState) => void + handleSocketClosed: (error: RemoteRuntimeClientError) => void + sendEncrypted: (payload: unknown) => boolean + markReady: () => void + replaySubscriptions: () => void + publishDiagnostics: () => void +}): void { + if (!args.isCurrent(args.socketGeneration)) { + return + } + handleSharedControlTextFrame({ + frame: args.frame, + state: args.getState(), + sharedKey: args.getSharedKey(), + environmentId: args.environmentId, + deviceToken: args.deviceToken, + clientCapabilities: args.clientCapabilities, + pendingRequests: args.pendingRequests, + subscriptions: args.subscriptions, + retiredRequestIds: args.retiredRequestIds, + readyWaiters: args.readyWaiters, + setState: (state) => { + args.setState(state) + args.publishDiagnostics() + }, + handleSocketClosed: args.handleSocketClosed, + sendEncrypted: args.sendEncrypted, + markReady: args.markReady, + replaySubscriptions: args.replaySubscriptions + }) +} diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index f6832ccae08..acd804e1f99 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -22,7 +22,10 @@ afterEach(closeSharedControlTestServers) describe('RemoteRuntimeSharedControlConnection', () => { it('routes multiple one-shot RPCs over one authenticated WebSocket', async () => { const server = await createServer() - const connection = new RemoteRuntimeSharedControlConnection(server.pairing) + const states: string[] = [] + const connection = new RemoteRuntimeSharedControlConnection(server.pairing, { + onDiagnosticsChanged: ({ state }) => states.push(state) + }) const first = await connection.request('worktree.ps', undefined, 1000) const second = await connection.request('session.tabs.listAll', null, 1000) @@ -39,8 +42,9 @@ describe('RemoteRuntimeSharedControlConnection', () => { 'worktree.ps', 'session.tabs.listAll' ]) - - connection.close() + expect((connection.close(), states)).toEqual( + expect.arrayContaining(['awaiting_ready', 'ready', 'closed']) + ) }) it('preserves orchestration authority fields on shared-control requests', async () => { @@ -679,7 +683,8 @@ describe('RemoteRuntimeSharedControlConnection', () => { pendingRequestCount: 0, lastClose: { code: 4001, reason: 'test close' } }) - + connection.pauseStandingRetry() + expect(connection.getDiagnostics()).toMatchObject({ state: 'closed' }) connection.close() }) }) diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 5c6c07e03bc..58375f36d42 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -4,24 +4,30 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { remoteRuntimeClientCapabilities } from './remote-runtime-client-capabilities' import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' import { openSharedControlSocket } from './remote-runtime-shared-control-open' -import { handleSharedControlTextFrame } from './remote-runtime-shared-control-frame-handler' -import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import * as sharedControlReady from './remote-runtime-shared-control-ready' +import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect' import { requestSharedControl } from './remote-runtime-shared-control-requests' import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability' import * as sharedControlState from './remote-runtime-shared-control-state' -import * as sharedControlSend from './remote-runtime-shared-control-send' import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close' -import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close' -import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start' import { SharedControlSocketGeneration } from './remote-runtime-shared-control-socket-generation' import { refreshRemoteRuntimeSharedControl } from './remote-runtime-shared-control-refresh' +import { SharedControlDiagnosticsTracker } from './remote-runtime-shared-control-diagnostics' +import { ensureSharedControlReady } from './remote-runtime-shared-control-ready-wait' +import { handleRuntimeControlTextFrame } from './remote-runtime-shared-control-connection-frame' +import { + closeSharedControlSubscription, + replayRuntimeControlSubscriptions, + sendSharedControlRequest, + sendSharedControlSubscription +} from './remote-runtime-shared-control-connection-actions' import type * as SharedControlTypes from './remote-runtime-shared-control-types' type PendingRequest = SharedControlTypes.SharedControlPendingRequest type LogicalSubscription = SharedControlTypes.SharedControlLogicalSubscription + export class RemoteRuntimeSharedControlConnection { private state: SharedControlTypes.SharedControlConnectionState = 'closed' private ws: WebSocket | null = null @@ -30,26 +36,23 @@ export class RemoteRuntimeSharedControlConnection { private readonly reconnect = new SharedControlReconnectScheduler() private readonly readyStableReset: SharedControlReadyStableResetTimer private intentionallyClosed = false - private readonly diag = { - lastConnectedAt: null as number | null, - lastClose: null as { code: number; reason: string } | null, - lastError: null as string | null - } + private readonly diagnostics: SharedControlDiagnosticsTracker private readonly pendingRequests = new Map() private readonly subscriptions = new Map() private readonly retiredRequestIds = new SharedControlRetiredRequestIds() private readonly readyWaiters: SharedControlTypes.SharedControlReadyWaiter[] = [] private everReady = false private readonly socketGeneration = new SharedControlSocketGeneration() + constructor( private readonly pairing: PairingOffer, private readonly options: SharedControlTypes.RemoteRuntimeSharedControlConnectionOptions = {} ) { + this.diagnostics = new SharedControlDiagnosticsTracker(options) this.readyStableReset = new SharedControlReadyStableResetTimer( options.reconnectStableResetMs ?? 30_000 ) } - request( method: string, params: unknown, @@ -65,12 +68,18 @@ export class RemoteRuntimeSharedControlConnection { timeoutMs, envelope, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs, signal), - send: (requestId) => this.sendRequest(requestId), + send: (requestId) => + sendSharedControlRequest({ + pendingRequests: this.pendingRequests, + requestId, + state: this.state, + ws: this.ws, + sharedKey: this.sharedKey + }), retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId), signal }) } - async subscribe( method: string, params: unknown, @@ -84,11 +93,16 @@ export class RemoteRuntimeSharedControlConnection { params, callbacks, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), - sendSubscription: (subscription) => this.sendSubscription(subscription), + sendSubscription: (subscription) => + sendSharedControlSubscription({ + subscriptions: this.subscriptions, + subscription, + deviceToken: this.pairing.deviceToken, + send: (payload) => this.sendEncrypted(payload) + }), closeSubscription: (requestId) => this.closeSubscription(requestId) }) } - close(error?: Error): void { this.intentionallyClosed = true this.socketGeneration.invalidate() @@ -97,31 +111,42 @@ export class RemoteRuntimeSharedControlConnection { this.closeSubscription(subscription.requestId) } this.closeSocket(error) + this.publishDiagnostics() } - readonly retryNow = (): boolean => this.reconnect.retryNow() - pauseStandingRetry(): void { if (this.subscriptions.size === 0) { this.reconnect.clear() + this.publishDiagnostics() } } - getDiagnostics(): SharedControlTypes.RemoteRuntimeSharedConnectionDiagnostics { - return sharedControlState.buildSharedControlDiagnostics({ + private publishDiagnostics(): void { + this.diagnostics.publish({ state: this.state, reconnecting: this.reconnect.isScheduled, pendingRequestCount: this.pendingRequests.size, subscriptionCount: this.subscriptions.size, - reconnectAttempt: this.reconnect.attemptCount, - diag: this.diag + reconnectAttempt: this.reconnect.attemptCount + }) + } + getDiagnostics(): SharedControlTypes.RemoteRuntimeSharedConnectionDiagnostics { + return this.diagnostics.get({ + state: this.state, + reconnecting: this.reconnect.isScheduled, + pendingRequestCount: this.pendingRequests.size, + subscriptionCount: this.subscriptions.size, + reconnectAttempt: this.reconnect.attemptCount }) } - reconnectNow(): void { refreshRemoteRuntimeSharedControl({ intentionallyClosed: this.intentionallyClosed, - ready: this.isReady(), + ready: sharedControlReady.isSharedControlReady({ + state: this.state, + ws: this.ws, + sharedKey: this.sharedKey + }), refresh: () => { this.closeSocket( remoteRuntimeUnavailableError('Refreshing remote runtime control transport.'), @@ -131,12 +156,11 @@ export class RemoteRuntimeSharedControlConnection { } }) } - private ensureReadyWithTimeout(timeoutMs: number, signal?: AbortSignal): Promise { - if (this.isReady()) { - return Promise.resolve() - } - return sharedControlReady.waitForSharedControlReadyWithTimeout({ + return ensureSharedControlReady({ + state: this.state, + ws: this.ws, + sharedKey: this.sharedKey, readyWaiters: this.readyWaiters, timeoutMs, signal, @@ -144,14 +168,6 @@ export class RemoteRuntimeSharedControlConnection { }) } - private isReady(): boolean { - return sharedControlReady.isSharedControlReady({ - state: this.state, - ws: this.ws, - sharedKey: this.sharedKey - }) - } - private open(): void { if (this.intentionallyClosed) { sharedControlState.rejectSharedControlReadyWaiters( @@ -166,7 +182,7 @@ export class RemoteRuntimeSharedControlConnection { getCurrentSocket: () => this.ws, onClose: (close, error) => { if (this.socketGeneration.isCurrent(socketGeneration)) { - this.diag.lastClose = close + this.diagnostics.markClose(close) } this.handleSocketClosed(error, socketGeneration) }, @@ -185,16 +201,16 @@ export class RemoteRuntimeSharedControlConnection { this.sharedKey = opened.socket.sharedKey this.socketCleanup = opened.socket.cleanup this.state = 'awaiting_ready' + this.publishDiagnostics() } private handleTextFrame(frame: string, socketGeneration: number): void { - if (!this.socketGeneration.isCurrent(socketGeneration)) { - return - } - handleSharedControlTextFrame({ + handleRuntimeControlTextFrame({ frame, - state: this.state, - sharedKey: this.sharedKey, + socketGeneration, + isCurrent: (generation) => this.socketGeneration.isCurrent(generation), + getState: () => this.state, + getSharedKey: () => this.sharedKey, environmentId: this.options.environmentId, deviceToken: this.pairing.deviceToken, clientCapabilities: remoteRuntimeClientCapabilities(this.options.clientCapabilities), @@ -208,57 +224,32 @@ export class RemoteRuntimeSharedControlConnection { handleSocketClosed: (error) => this.handleSocketClosed(error, socketGeneration), sendEncrypted: (payload) => this.sendEncrypted(payload), markReady: () => { - this.diag.lastConnectedAt = Date.now() + this.diagnostics.markReady() // Why cleared here: these describe the attempt that just succeeded's predecessor. // Left set, a recovered host reads "Connected" next to a stale failure forever. - this.diag.lastError = null - this.diag.lastClose = null + this.publishDiagnostics() this.readyStableReset.schedule({ getState: () => this.state, getSocket: () => this.ws, reset: () => this.reconnect.resetAttempt() }) }, - replaySubscriptions: () => this.replaySubscriptions() - }) - } - - private sendRequest(requestId: string): void { - sharedControlSend.sendSharedControlRequest({ - pendingRequests: this.pendingRequests, - requestId, - send: (serialized) => - sharedControlProtocol.sendSharedControlEncryptedSerialized({ - state: this.state, - ws: this.ws, - sharedKey: this.sharedKey, - serialized - }), - reject: (id, error) => - sharedControlState.rejectSharedControlPendingRequest(this.pendingRequests, id, error) - }) - } - - private sendSubscription(subscription: LogicalSubscription): void { - sharedControlSend.sendSharedControlSubscription({ - subscriptions: this.subscriptions, - subscription, - deviceToken: this.pairing.deviceToken, - send: (payload) => this.sendEncrypted(payload) + replaySubscriptions: () => this.replaySubscriptions(), + publishDiagnostics: () => this.publishDiagnostics() }) } private replaySubscriptions(): void { - sharedControlSubscriptions.replaySharedControlSubscriptions({ + this.everReady = replayRuntimeControlSubscriptions({ subscriptions: this.subscriptions, - send: (subscription) => this.sendSubscription(subscription), + deviceToken: this.pairing.deviceToken, + send: (payload) => this.sendEncrypted(payload), tagReplayedResponses: this.everReady }) - this.everReady = true } private closeSubscription(requestId: string): void { - closeSharedControlConnectionSubscription({ + closeSharedControlSubscription({ subscriptions: this.subscriptions, retiredRequestIds: this.retiredRequestIds, requestId, @@ -288,7 +279,7 @@ export class RemoteRuntimeSharedControlConnection { ) { return } - this.diag.lastError = error.message + this.diagnostics.markError(error.message) this.reconnect.scheduleAfterSocketClose({ intentionallyClosed: this.intentionallyClosed, manuallyDisconnected: this.options.isManuallyDisconnected?.() ?? false, @@ -296,6 +287,7 @@ export class RemoteRuntimeSharedControlConnection { subscriptionCount: this.subscriptions.size, open: () => this.open() }) + this.publishDiagnostics() } private closeSocket(error?: Error, preserveReadyWaitersAndPendingRequests = false): void { @@ -305,7 +297,7 @@ export class RemoteRuntimeSharedControlConnection { pendingRequests: this.pendingRequests, subscriptions: this.subscriptions, readyWaiters: this.readyWaiters, - lastClose: this.diag.lastClose, + lastClose: this.getDiagnostics().lastClose, socketCleanup: this.socketCleanup, ws: this.ws, error, @@ -315,5 +307,6 @@ export class RemoteRuntimeSharedControlConnection { this.ws = this.sharedKey = null this.socketCleanup = null this.state = 'closed' + this.publishDiagnostics() } } diff --git a/src/shared/remote-runtime-shared-control-diagnostics.ts b/src/shared/remote-runtime-shared-control-diagnostics.ts new file mode 100644 index 00000000000..124a99260ea --- /dev/null +++ b/src/shared/remote-runtime-shared-control-diagnostics.ts @@ -0,0 +1,74 @@ +import type { + RemoteRuntimeSharedConnectionDiagnostics, + RemoteRuntimeSharedControlConnectionOptions, + SharedControlConnectionState +} from './remote-runtime-shared-control-types' + +type DiagnosticClose = { code: number; reason: string } | null + +export class SharedControlDiagnosticsTracker { + private lastConnectedAt: number | null = null + private lastClose: DiagnosticClose = null + private lastError: string | null = null + private lastPublished: RemoteRuntimeSharedConnectionDiagnostics | null = null + + constructor(private readonly options: RemoteRuntimeSharedControlConnectionOptions) {} + + markClose(close: DiagnosticClose): void { + this.lastClose = close + } + + markReady(): void { + this.lastConnectedAt = Date.now() + this.lastError = null + this.lastClose = null + } + + markError(error: string): void { + this.lastError = error + } + + get(args: { + state: SharedControlConnectionState + reconnecting: boolean + pendingRequestCount: number + subscriptionCount: number + reconnectAttempt: number + }): RemoteRuntimeSharedConnectionDiagnostics { + return { + state: args.reconnecting ? 'reconnecting' : args.state, + pendingRequestCount: args.pendingRequestCount, + subscriptionCount: args.subscriptionCount, + reconnectAttempt: args.reconnectAttempt, + lastConnectedAt: this.lastConnectedAt, + lastClose: this.lastClose, + lastError: this.lastError + } + } + + publish(args: Parameters[0]): void { + const diagnostics = this.get(args) + const previous = this.lastPublished + const closeUnchanged = + previous?.lastClose?.code === diagnostics.lastClose?.code && + previous?.lastClose?.reason === diagnostics.lastClose?.reason + if ( + previous && + previous.state === diagnostics.state && + previous.pendingRequestCount === diagnostics.pendingRequestCount && + previous.subscriptionCount === diagnostics.subscriptionCount && + previous.reconnectAttempt === diagnostics.reconnectAttempt && + previous.lastConnectedAt === diagnostics.lastConnectedAt && + closeUnchanged && + previous.lastError === diagnostics.lastError + ) { + return + } + this.lastPublished = diagnostics + try { + this.options.onDiagnosticsChanged?.(diagnostics) + } catch (error) { + console.warn('[remote-runtime.shared-control] diagnostics callback failed:', error) + } + } +} diff --git a/src/shared/remote-runtime-shared-control-ready-wait.ts b/src/shared/remote-runtime-shared-control-ready-wait.ts new file mode 100644 index 00000000000..3dfc0eec512 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-ready-wait.ts @@ -0,0 +1,29 @@ +import type WebSocket from 'ws' +import type { + SharedControlConnectionState, + SharedControlReadyWaiter +} from './remote-runtime-shared-control-types' +import { + isSharedControlReady, + waitForSharedControlReadyWithTimeout +} from './remote-runtime-shared-control-ready' + +export function ensureSharedControlReady(args: { + state: SharedControlConnectionState + ws: WebSocket | null + sharedKey: Uint8Array | null + readyWaiters: SharedControlReadyWaiter[] + timeoutMs: number + signal?: AbortSignal + open: () => void +}): Promise { + if (isSharedControlReady(args)) { + return Promise.resolve() + } + return waitForSharedControlReadyWithTimeout({ + readyWaiters: args.readyWaiters, + timeoutMs: args.timeoutMs, + signal: args.signal, + open: args.open + }) +} diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index 6de0745575f..39d1b01f613 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -77,6 +77,8 @@ export type RemoteRuntimeSharedControlConnectionOptions = { clientCapabilities?: readonly RuntimeCapability[] isManuallyDisconnected?: () => boolean isCapabilityPaused?: () => boolean + /** Publishes local transport diagnostics after a meaningful state transition. */ + onDiagnosticsChanged?: (diagnostics: RemoteRuntimeSharedConnectionDiagnostics) => void reconnectStableResetMs?: number liveness?: RemoteRuntimeSocketLivenessOptions } diff --git a/src/shared/runtime-environment-diagnostics.ts b/src/shared/runtime-environment-diagnostics.ts new file mode 100644 index 00000000000..6a04158db16 --- /dev/null +++ b/src/shared/runtime-environment-diagnostics.ts @@ -0,0 +1,2 @@ +export const RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL = + 'runtimeEnvironments:sharedControlDiagnostics' diff --git a/src/shared/runtime-host-connection-state.ts b/src/shared/runtime-host-connection-state.ts new file mode 100644 index 00000000000..bc5151f1c9c --- /dev/null +++ b/src/shared/runtime-host-connection-state.ts @@ -0,0 +1,58 @@ +import type { RuntimeStatus } from './runtime-session-contracts' +import { isRuntimeWorkspaceWindowClosed } from './runtime-workspace-window-availability' + +export type RuntimeHostConnectionState = + | 'connected' + | 'workspace-window-closed' + | 'checking' + | 'reconnecting' + | 'disconnected' + +/** Derives the runtime transport verdict shared by the renderer and agents. */ +export function runtimeHostConnectionState({ + hasStatusEntry, + status +}: { + hasStatusEntry: boolean + status: RuntimeStatus | null | undefined +}): RuntimeHostConnectionState { + if (!hasStatusEntry) { + return 'checking' + } + const remoteControl = status?.remoteControl + if (remoteControl?.state === 'reconnecting') { + return 'reconnecting' + } + if (!status) { + return 'disconnected' + } + if (remoteControl?.state === 'closed') { + return 'disconnected' + } + if (remoteControl && remoteControl.state !== 'ready') { + return 'checking' + } + if (isRuntimeWorkspaceWindowClosed(status)) { + return 'workspace-window-closed' + } + return 'connected' +} + +export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState): boolean { + return state === 'connected' || state === 'workspace-window-closed' +} + +export type HostStatus = 'connected' | 'disconnected' | 'connecting' + +export function runtimeStatusForOverall(state: RuntimeHostConnectionState): HostStatus { + switch (state) { + case 'connected': + case 'workspace-window-closed': + return 'connected' + case 'checking': + case 'reconnecting': + return 'connecting' + case 'disconnected': + return 'disconnected' + } +} diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 0e4711c0bd2..cd0dd7a5cc7 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -1,6 +1,7 @@ import type { AgentStatusOrchestrationContext } from './agent-status-types' import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' +import type { RuntimeHostConnectionState } from './runtime-host-connection-state' import type { RuntimeCapability } from './protocol-version' import type { RuntimeBrowserUnavailableReason, @@ -111,6 +112,8 @@ export type CliStatusResult = { runtime: { state: CliRuntimeState reachable: boolean + /** Canonical runtime transport verdict, when the caller has runtime evidence. */ + connectionState?: RuntimeHostConnectionState runtimeId: string | null appVersion?: string remoteUpdateSupport?: RemoteServerUpdateSupport