mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* Support explicit remote branch reuse across workspaces - Preserve selected branch identity, tracking, and busy-branch handling - Add explicit branch-off intent with v2 runtime capability gating * Style existing workspace action in branch reuse UI * Preserve explicit branch reuse with versioned host support - Gate strict reuse on a new request capability while retaining legacy behavior - Warn when a reused remote branch cannot be configured as the push upstream * Clarify selected branch checkout options * Offer existing or new branch choices for worktrees * Preserve new-branch intent when reusing a branch name * Adapt branch composer tests and catalog after rebase * Refresh mobile RPC recordings for branch intent capabilities * Remove retired Russian localization entries * Remove retired German and Turkish localization entries * Add branch selection translations for existing workspaces * Localize mobile worktree branch selection
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { readAgentLaunchSupport, type AgentLaunchSupport } from './agent-launch-request'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
|
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
|
import { worktreeCreateCapabilityRead } from './mobile-workspace-create-operations'
|
|
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
|
|
import {
|
|
WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY,
|
|
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
|
|
} from '../../../src/shared/worktree-runtime-capabilities'
|
|
import {
|
|
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
|
|
resolveWorktreeCreateIdempotencySupport,
|
|
type WorktreeCreateIdempotencySupport
|
|
} from './worktree-create-idempotency-policy'
|
|
|
|
// Older hosts strip clientMutationId; never replay ambiguous creates without advertised idempotency.
|
|
export const MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY = 'worktree.create-idempotency.v1'
|
|
|
|
const STATUS_CUTOVER_MAX_RETRIES = 5
|
|
|
|
export type NewWorktreeRuntimeCapabilities = {
|
|
tasksSupported: boolean
|
|
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
|
|
agentLaunch: AgentLaunchSupport | false
|
|
branchIntentV2: boolean
|
|
branchIntentV2Request: boolean
|
|
hostPlatform: NodeJS.Platform | null
|
|
}
|
|
|
|
// Settle the replay-safe status probe before create, independently of slower provider probes.
|
|
export async function readNewWorktreeRuntimeCapabilities(
|
|
client: RpcClient
|
|
): Promise<NewWorktreeRuntimeCapabilities | null> {
|
|
for (let migrationRetry = 0; ; migrationRetry += 1) {
|
|
try {
|
|
const status = worktreeCreateCapabilityRead.interpret(
|
|
await worktreeCreateCapabilityRead.request(client)
|
|
)
|
|
if (!status.accepted) {
|
|
return null
|
|
}
|
|
const result = status.value
|
|
const capabilities = result.capabilities ?? []
|
|
const supportsIdempotency = capabilities.includes(
|
|
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
|
|
)
|
|
const advertisedIdempotency = result.worktreeCreateIdempotency
|
|
return {
|
|
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
|
|
agentLaunch: readAgentLaunchSupport(capabilities),
|
|
branchIntentV2: capabilities.includes(WORKTREE_OPEN_EXISTING_BRANCH_V2_RUNTIME_CAPABILITY),
|
|
branchIntentV2Request: capabilities.includes(
|
|
WORKTREE_OPEN_EXISTING_BRANCH_V2_REQUEST_RUNTIME_CAPABILITY
|
|
),
|
|
worktreeCreateIdempotency: supportsIdempotency
|
|
? advertisedIdempotency === undefined
|
|
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
|
|
: advertisedIdempotency !== null &&
|
|
typeof advertisedIdempotency === 'object' &&
|
|
!Array.isArray(advertisedIdempotency)
|
|
? resolveWorktreeCreateIdempotencySupport(
|
|
'dedupeTtlMs' in advertisedIdempotency
|
|
? advertisedIdempotency.dedupeTtlMs
|
|
: undefined
|
|
)
|
|
: { dedupeTtlMs: 0 }
|
|
: false,
|
|
hostPlatform: readMobileRuntimeHostPlatform(result)
|
|
}
|
|
} catch (error) {
|
|
if (!isLogicalClientCutoverError(error) || migrationRetry >= STATUS_CUTOVER_MAX_RETRIES) {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function useNewWorktreeRuntimeCapabilities(
|
|
client: RpcClient | null,
|
|
enabled: boolean
|
|
): {
|
|
tasksSupported: boolean
|
|
hostPlatform: NodeJS.Platform | null
|
|
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
|
|
getAgentLaunchSupport: () => Promise<AgentLaunchSupport | false>
|
|
getBranchIntentSupport: (intent: 'open-existing-v2' | 'create-new') => Promise<boolean>
|
|
} {
|
|
const [capabilityState, setCapabilityState] = useState<{
|
|
client: RpcClient
|
|
generation: number | undefined
|
|
connectedAt: number | null | undefined
|
|
capabilities: NewWorktreeRuntimeCapabilities
|
|
} | null>(null)
|
|
const capabilityProbeRef = useRef<{
|
|
client: RpcClient | null
|
|
generation: number | undefined
|
|
connectedAt: number | null | undefined
|
|
promise: Promise<NewWorktreeRuntimeCapabilities | null>
|
|
} | null>(null)
|
|
const getCapabilities = useCallback(async (): Promise<NewWorktreeRuntimeCapabilities | null> => {
|
|
for (;;) {
|
|
const generation = client?.getGeneration?.()
|
|
const connectedAt = client?.getLastConnectedAt()
|
|
let probe = capabilityProbeRef.current
|
|
if (
|
|
!probe ||
|
|
probe.client !== client ||
|
|
probe.generation !== generation ||
|
|
probe.connectedAt !== connectedAt
|
|
) {
|
|
// Queued creates can precede passive effects; share the same status barrier.
|
|
probe = {
|
|
client,
|
|
generation,
|
|
connectedAt,
|
|
promise: client ? readNewWorktreeRuntimeCapabilities(client) : Promise.resolve(null)
|
|
}
|
|
capabilityProbeRef.current = probe
|
|
}
|
|
const capabilities = await probe.promise
|
|
const current = capabilityProbeRef.current
|
|
if (
|
|
current === probe &&
|
|
client?.getGeneration?.() === generation &&
|
|
client?.getLastConnectedAt() === connectedAt
|
|
) {
|
|
if (!capabilities) {
|
|
// Probe failure does not establish missing host support.
|
|
capabilityProbeRef.current = null
|
|
}
|
|
return capabilities
|
|
}
|
|
// A reconnect or path switch keeps the host; wait for the current connection's answer.
|
|
if (current?.client !== client) {
|
|
return null
|
|
}
|
|
}
|
|
}, [client])
|
|
|
|
useEffect(() => {
|
|
if (capabilityProbeRef.current?.client !== client) {
|
|
capabilityProbeRef.current = null
|
|
}
|
|
}, [client])
|
|
|
|
// oxlint-disable-next-line react-doctor/effect-needs-cleanup -- Cleanup cancels the timer and fences async rearming.
|
|
useEffect(() => {
|
|
if (!enabled || !client) {
|
|
return
|
|
}
|
|
let stale = false
|
|
let refreshVersion = 0
|
|
let retry: ReturnType<typeof setTimeout> | undefined
|
|
let retryDelay = 1_000
|
|
const refresh = async () => {
|
|
if (stale) {
|
|
return
|
|
}
|
|
const version = ++refreshVersion
|
|
clearTimeout(retry)
|
|
const capabilities = await getCapabilities()
|
|
if (stale || version !== refreshVersion) {
|
|
return
|
|
}
|
|
if (capabilities) {
|
|
setCapabilityState({
|
|
client,
|
|
generation: client.getGeneration?.(),
|
|
connectedAt: client.getLastConnectedAt(),
|
|
capabilities
|
|
})
|
|
retryDelay = 1_000
|
|
}
|
|
if (!capabilityProbeRef.current && client.getState() === 'connected') {
|
|
retry = setTimeout(() => void refresh(), retryDelay)
|
|
retryDelay = Math.min(retryDelay * 2, 30_000)
|
|
}
|
|
}
|
|
const unsubscribe = client.onStateChange((state) => {
|
|
if (state === 'connected') {
|
|
setCapabilityState(null)
|
|
capabilityProbeRef.current = null
|
|
void refresh()
|
|
}
|
|
})
|
|
void refresh()
|
|
return () => {
|
|
stale = true
|
|
clearTimeout(retry)
|
|
unsubscribe()
|
|
}
|
|
}, [client, enabled, getCapabilities])
|
|
|
|
const getWorktreeCreateCutoverSupport = useCallback(
|
|
() =>
|
|
getCapabilities().then((capabilities) => capabilities?.worktreeCreateIdempotency ?? false),
|
|
[getCapabilities]
|
|
)
|
|
const getAgentLaunchSupport = useCallback(
|
|
() => getCapabilities().then((capabilities) => capabilities?.agentLaunch ?? false),
|
|
[getCapabilities]
|
|
)
|
|
const getBranchIntentSupport = useCallback(
|
|
(intent: 'open-existing-v2' | 'create-new') =>
|
|
getCapabilities().then((capabilities) =>
|
|
intent === 'open-existing-v2'
|
|
? capabilities?.branchIntentV2Request === true
|
|
: capabilities?.branchIntentV2 === true
|
|
),
|
|
[getCapabilities]
|
|
)
|
|
const capabilities =
|
|
capabilityState?.client === client &&
|
|
capabilityState?.generation === client?.getGeneration?.() &&
|
|
capabilityState?.connectedAt === client?.getLastConnectedAt()
|
|
? capabilityState?.capabilities
|
|
: null
|
|
return {
|
|
tasksSupported: capabilities?.tasksSupported ?? false,
|
|
hostPlatform: capabilities?.hostPlatform ?? null,
|
|
getWorktreeCreateCutoverSupport,
|
|
getAgentLaunchSupport,
|
|
getBranchIntentSupport
|
|
}
|
|
}
|