Merge remote-tracking branch 'origin/main' into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-08-31 15:40:23 -04:00
47 changed files with 1896 additions and 465 deletions
+1
View File
@@ -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')
+5
View File
@@ -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
+1
View File
@@ -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' })]
+6
View File
@@ -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: {
@@ -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])
@@ -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()
})
})
@@ -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().
}
}
}
@@ -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)
@@ -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)
+86
View File
@@ -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<never>(() => {}))
registerSshGitProvider('ssh-target', { listWorktrees } as never)
vi.useFakeTimers()
try {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<null>((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 —
+76 -8
View File
@@ -9685,10 +9685,13 @@ export class OrcaRuntimeService {
private async refreshMobileSessionPtyInventory(
targetWorktreeId: string | null = null
): Promise<PtyControllerInventory | null> {
// 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<PtyControllerInventory | null> {
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
@@ -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<typeof terminal>
) => 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<unknown>
) => 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 })
)
})
})
@@ -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',
@@ -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<unknown>
performMobileSessionPtyRecordsRefresh: (targetWorktreeId: string | null) => Promise<unknown>
@@ -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 })
})
+1
View File
@@ -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`,
+2
View File
@@ -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 }
+8
View File
@@ -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<RuntimeRpcResponse<RuntimeStatus>>
retryControlConnection?: (args: { selector: string }) => Promise<void>
onSharedControlDiagnostics?: (
callback: (event: {
environmentId: string
transportGeneration: number
diagnostics: RemoteRuntimeSharedConnectionDiagnostics
}) => void
) => () => void
prepareBrowserClientHostPlacement: (
args: BrowserClientHostPlacementPreparationRequest
) => Promise<BrowserPageCreationPlacement>
+20
View File
@@ -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<void> =>
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<void> =>
@@ -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<typeof ReactI18Next>()
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: () => <span data-status-indicator="true" />
}))
vi.mock('@/components/repo/RepoBadgeLabel', () => ({
RepoBadgeMark: () => <span data-repo-badge-mark="true" />
}))
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 }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
// 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 ? (
<div data-command-dialog="true" data-command-value={commandProps?.value ?? ''}>
{children}
</div>
) : null
},
CommandInput: ({
value,
onValueChange,
placeholder
}: {
value?: string
onValueChange?: (next: string) => void
placeholder?: string
}) => {
setCommandQuery = onValueChange ?? null
return (
<input
data-command-input="true"
placeholder={placeholder}
value={value}
onChange={(event) => onValueChange?.(event.currentTarget.value)}
/>
)
},
CommandList: React.forwardRef(function CommandList(
{ children }: { children: React.ReactNode },
ref: React.ForwardedRef<HTMLDivElement>
) {
return (
<div ref={ref} data-command-list="true">
{children}
</div>
)
}),
CommandEmpty: ({ children }: { children: React.ReactNode }) => (
<div data-command-empty="true">{children}</div>
),
CommandItem: ({
children,
onSelect,
value
}: {
children: React.ReactNode
onSelect?: (value: string) => void
value?: string
}) => (
<button data-command-item={value ?? ''} onClick={() => onSelect?.(value ?? '')} type="button">
{children}
</button>
)
}
})
const initialAppState = useAppStore.getInitialState()
let testRoot: Root
let testContainer: HTMLDivElement
let setCommandQuery: ((next: string) => void) | null = null
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function renderPalette(overrides: Partial<AppState>): Promise<void> {
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<AppState>)
await act(async () => {
testRoot.render(<WorktreeJumpPalette />)
})
await flushEffects()
}
function getRenderedRowIds(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[data-command-item]')].map(
(node) => node.dataset.commandItem ?? ''
)
}
function getTabRowIds(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<HTMLElement>(
'[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')
})
})
@@ -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<typeof ReactI18Next>()
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: () => <span data-status-indicator="true" />
}))
vi.mock('@/components/repo/RepoBadgeLabel', () => ({
RepoBadgeMark: () => <span data-repo-badge-mark="true" />
}))
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 }) => <div>{children}</div>,
CommandGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
// 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<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function renderPalette(overrides: Partial<AppState>): Promise<void> {
useAppStore.setState({
activeModal: 'worktree-palette',
@@ -146,35 +162,42 @@ async function renderPalette(overrides: Partial<AppState>): Promise<void> {
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<AppState>)
await act(async () => {
testRoot.render(<WorktreeJumpPalette />)
})
await flushEffects()
}
function getWorktreeRows(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[data-command-item^="worktree:"]')].map(
(node) => node.textContent ?? ''
)
}
function getRenderedRowIds(): string[] {
return [...testContainer.querySelectorAll<HTMLElement>('[data-command-item]')].map(
(node) => node.dataset.commandItem ?? ''
)
}
/** The id cmdk would activate on Enter. */
function getCommandValue(): string {
return (
testContainer.querySelector<HTMLElement>('[data-command-dialog]')?.dataset.commandValue ?? ''
)
}
function getTabRowIds(): string[] {
return [
...testContainer.querySelectorAll<HTMLElement>('[data-command-item^="workspace-tab:"]')
].map((node) => (node.dataset.commandItem ?? '').replace('workspace-tab:', ''))
return [...testContainer.querySelectorAll<HTMLElement>('[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<AppState>)
})
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<AppState> {
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
@@ -539,8 +641,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => {
} as Partial<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<AppState>)
})
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<HTMLElement>(
'[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')
})
})
@@ -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(
@@ -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<void> => {
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
])
@@ -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 }
@@ -53,7 +53,7 @@ export function useGitLabPrimaryActions(
setActionInFlight(null)
}
}
}, [item, repoSelector, mountedRef, handleRefresh])
}, [handleRefresh, item, mountedRef, repoSelector, setActionInFlight])
const handleReopen = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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 }
}
@@ -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')
@@ -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'
@@ -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 (
@@ -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,
@@ -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))
+2
View File
@@ -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",
@@ -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'
@@ -0,0 +1,24 @@
const connectionGenerationByEnvironment = new Map<string, number>()
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<string> {
const environmentIds = [...connectionGenerationByEnvironment.keys()]
connectionGenerationByEnvironment.clear()
return environmentIds
}
@@ -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<string, number>()
export function updateRuntimeEnvironmentStatusOverlay(
state: Map<string, RuntimeEnvironmentStatus>,
environmentId: string,
status: RuntimeEnvironmentStatus
): Map<string, RuntimeEnvironmentStatus> {
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 }
})
}
@@ -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<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
): AppState | Pick<AppState, 'runtimeStatusByEnvironmentId'> {
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<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
) => 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<string, RuntimeEnvironmentStatus>) => Map<string, RuntimeEnvironmentStatus>
) => 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)
})
}
@@ -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> = {}): 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<RuntimeStatusSlice>()((...a) => ({
...createRuntimeStatusSlice(...(a as unknown as Parameters<typeof createRuntimeStatusSlice>))
}))
}
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)
})
})
@@ -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<string, RecheckState>()
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) {
+34 -34
View File
@@ -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<void>
}
const connectionGenerationByEnvironment = new Map<string, number>()
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<AppState, [], [], RuntimeStatusSlice> = (
@@ -287,19 +286,9 @@ export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeSta
...(environmentsChanged ? { runtimeEnvironments } : {})
}
})
runtimeStatusRecheck.reconcileRuntimeStatusRecheck({
environmentId,
status: status.status,
connectionGeneration: getRuntimeEnvironmentConnectionGeneration(environmentId),
environmentExists: () =>
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<AppState, [], [], RuntimeSta
}
},
publishRuntimeEnvironmentDiagnostics:
runtimeStatusDiagnosticsPublish.createRuntimeEnvironmentDiagnosticsPublisher({
getCurrent: (environmentId) => 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)
@@ -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<string, SharedControlPendingRequest<unknown>>
requestId: string
state: Parameters<typeof sharedControlProtocol.sendSharedControlEncryptedSerialized>[0]['state']
ws: Parameters<typeof sharedControlProtocol.sendSharedControlEncryptedSerialized>[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<string, SharedControlLogicalSubscription<unknown>>
subscription: SharedControlLogicalSubscription<unknown>
deviceToken: string
send: (payload: unknown) => boolean
}): void {
sharedControlSend.sendSharedControlSubscription(args)
}
export function replaySharedControlSubscriptions(args: {
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
send: (subscription: SharedControlLogicalSubscription<unknown>) => void
tagReplayedResponses: boolean
}): boolean {
sharedControlSubscriptions.replaySharedControlSubscriptions(args)
return true
}
export function replayRuntimeControlSubscriptions(args: {
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
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<string, SharedControlLogicalSubscription<unknown>>
retiredRequestIds: SharedControlRetiredRequestIds
requestId: string
deviceToken: string
send: (payload: unknown) => boolean
}): void {
closeSharedControlConnectionSubscription(args)
}
export function closeRuntimeControlSubscription(args: {
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
retiredRequestIds: SharedControlRetiredRequestIds
requestId: string
deviceToken: string
send: (payload: unknown) => boolean
clearWhenIdle: (isIdle: boolean) => void
}): void {
closeSharedControlSubscription(args)
args.clearWhenIdle(args.subscriptions.size === 0)
}
@@ -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<string, SharedControlPendingRequest<unknown>>
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
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
})
}
@@ -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()
})
})
@@ -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<unknown>
type LogicalSubscription = SharedControlTypes.SharedControlLogicalSubscription<unknown>
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<string, PendingRequest>()
private readonly subscriptions = new Map<string, LogicalSubscription>()
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<TResult>(
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<TResult>(
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<void> {
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()
}
}
@@ -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<SharedControlDiagnosticsTracker['get']>[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)
}
}
}
@@ -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<void> {
if (isSharedControlReady(args)) {
return Promise.resolve()
}
return waitForSharedControlReadyWithTimeout({
readyWaiters: args.readyWaiters,
timeoutMs: args.timeoutMs,
signal: args.signal,
open: args.open
})
}
@@ -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
}
@@ -0,0 +1,2 @@
export const RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL =
'runtimeEnvironments:sharedControlDiagnostics'
@@ -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'
}
}
+3
View File
@@ -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