mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
Merge remote-tracking branch 'origin/main' into mobile-rearch
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+6
-1
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user