refactor(renderer): split web preload API

This commit is contained in:
Neil
2026-08-23 17:29:05 -07:00
parent 0a613d5fed
commit a2ff15e438
61 changed files with 5479 additions and 4535 deletions
-1
View File
@@ -157,7 +157,6 @@ inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/terminals.ts
inline src/renderer/src/store/slices/ui.ts
inline src/renderer/src/store/slices/workspace-cleanup.ts
inline src/renderer/src/web/web-preload-api.ts
inline src/renderer/src/web/web-runtime-client.ts
inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
@@ -0,0 +1,49 @@
import type { PreloadApi } from '../../../../preload/api-types'
export function createMiniMaxCredentialsApi(): NonNullable<
Partial<PreloadApi>['minimaxCredentials']
> {
const notConfigured = { configured: false }
const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.')
return {
getStatus: () => Promise.resolve(notConfigured),
saveCookie: () => Promise.reject(unsupportedError),
clearCookie: () => Promise.resolve(notConfigured)
}
}
export function createGrokAccountsApi(): NonNullable<Partial<PreloadApi>['grokAccounts']> {
const unsigned = {
signedIn: false,
email: null,
teamId: null,
tokenFresh: false,
error: null
}
return {
getStatus: () => Promise.resolve(unsigned)
}
}
export function createAccountsApi(): never {
const empty = {
accounts: [],
activeAccountId: null,
activeAccountIdsByRuntime: { host: null, wsl: {} }
}
return {
list: () => Promise.resolve(empty),
add: () => Promise.resolve(empty),
cancelPendingLogin: () => Promise.resolve(false),
reauthenticate: () => Promise.resolve(empty),
remove: () => Promise.resolve(empty),
select: () => Promise.resolve(empty),
// Why: launch accounts are recorded on the host that owns the PTY, which the
// web client never is — report no stale panes rather than reject the sweep.
listStalePanes: () => Promise.resolve([]),
// Why empty rather than absent: the same host owns both records, so a web
// client has no recorded lane to offer and every pane falls to derivation.
listRecordedPaneLanes: () => Promise.resolve({}),
forgetStalePanes: () => Promise.resolve()
} as never
}
@@ -0,0 +1,42 @@
import type { PreloadApi } from '../../../../preload/api-types'
export function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
const status = (
agent:
| 'claude'
| 'openclaude'
| 'codex'
| 'gemini'
| 'antigravity'
| 'amp'
| 'cursor'
| 'droid'
| 'command-code'
| 'grok'
| 'copilot'
| 'hermes'
| 'devin'
) =>
Promise.resolve({
agent,
state: 'not_installed',
configPath: '',
managedHooksPresent: false,
detail: 'Agent hook status is only available on the Orca server.'
} as const)
return {
claudeStatus: () => status('claude'),
openClaudeStatus: () => status('openclaude'),
codexStatus: () => status('codex'),
geminiStatus: () => status('gemini'),
antigravityStatus: () => status('antigravity'),
ampStatus: () => status('amp'),
cursorStatus: () => status('cursor'),
droidStatus: () => status('droid'),
commandCodeStatus: () => status('command-code'),
grokStatus: () => status('grok'),
copilotStatus: () => status('copilot'),
hermesStatus: () => status('hermes'),
devinStatus: () => status('devin')
}
}
@@ -0,0 +1,23 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { noopUnsubscribe } from './web-storage'
export function createWebAgentStatusApi(): Partial<PreloadApi> {
return {
agentStatus: {
onSet: () => noopUnsubscribe,
onClear: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
inferInterrupt: () => Promise.resolve(false),
inferQuestionAnswered: () => Promise.resolve(false),
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
onLegacyWorkerTerminalRecovery: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
drop: () => {},
dropByTabPrefix: () => {},
retirePaneAuthority: () => {},
restorePaneAuthority: () => {},
transferPaneAuthority: () => {}
}
}
}
@@ -0,0 +1,93 @@
import type { PreloadApi } from '../../../../preload/api-types'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../../../shared/ai-vault-resume-preparation'
import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault-session-deletion'
import type {
AiVaultSessionTitlesArgs,
AiVaultSessionTitlesResult
} from '../../../../shared/ai-vault-session-title'
import type { AiVaultListArgs, AiVaultListResult } from '../../../../shared/ai-vault-types'
import {
normalizeExecutionHostScope,
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironment } from './web-runtime-session'
import { noopUnsubscribe } from './web-storage'
import { translate } from '@/i18n/i18n'
export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault']> {
return {
listSessions: (args?: AiVaultListArgs) => {
const environment = requireActiveEnvironment()
const executionHostId = toRuntimeExecutionHostId(environment.id)
const requestedScope = normalizeExecutionHostScope(
args?.executionHostScope ?? executionHostId
)
if (requestedScope !== 'all' && requestedScope !== executionHostId) {
return Promise.resolve(webAiVaultUnavailableResult(requestedScope))
}
// Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host.
return callRuntimeResult<AiVaultListResult>('aiVault.listSessions', {
limit: args?.limit,
force: args?.force,
scopePaths: args?.scopePaths,
executionHostId
})
},
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => {
const environment = requireActiveEnvironment()
const executionHostId = toRuntimeExecutionHostId(environment.id)
if (
args.executionHostScope &&
normalizeExecutionHostScope(args.executionHostScope) !== executionHostId
) {
return Promise.resolve({ titles: [] })
}
return callRuntimeResult<AiVaultSessionTitlesResult>('aiVault.resolveSessionTitles', {
requests: args.requests
}).catch(() => ({ titles: [] }))
},
// Why: the runtime RPC transport has no cancel verb, so the in-flight scan
// settles on its own timeout. The renderer's refreshId guard already drops
// the late result; this only means web pays for a scan nobody reads.
cancelListSessions: () => Promise.resolve(),
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) =>
callRuntimeResult<AiVaultPrepareSessionResumeResult>('aiVault.prepareSessionResume', args),
// Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result.
listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }),
// Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text.
getFirstUserPrompt: () => Promise.resolve({ prompt: null }),
// Why: session deletion is local-only and has no runtime RPC; a web
// client's sessions are runtime-hosted, so report the same non-local
// rejection the UI already gates on rather than pretend to delete.
deleteSession: (args: AiVaultDeleteSessionArgs) =>
Promise.resolve({
outcome: 'rejected',
agent: args.agent,
reason: 'non-local-host' as const
}),
onWindowFocused: () => noopUnsubscribe
}
}
export function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult {
return {
sessions: [],
issues: [
{
executionHostId,
agent: 'codex',
path: executionHostId,
message: translate(
'auto.web.webPreloadApi.aiVaultUnavailableForHost',
'Agent Session History is not available for this execution host.'
)
}
],
scannedAt: new Date().toISOString()
}
}
@@ -0,0 +1,55 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session'
import { sessionStorageKeyForHost } from './web-workspace-session-api'
import { mergeWebUIState } from './web-preference-normalization'
import { readLocalWebUIState } from './web-preferences-store'
import { UI_STORAGE_KEY, writeJson } from './web-storage'
export function createWebAppApi(): Partial<PreloadApi> {
return {
app: {
getIdentity: () =>
Promise.resolve({
name: 'Orca',
isDev: false,
devLabel: null,
devBranch: null,
devWorktreeName: null,
devRepoRoot: null,
dockBadgeLabel: null
}),
getFeatureWallAssetBaseUrl: () => Promise.resolve('/'),
relaunch: () => Promise.resolve(window.location.reload()),
restart: () => Promise.resolve(window.location.reload()),
reload: () => Promise.resolve(window.location.reload()),
stageBeforeUnloadSync: ({ sessions, ui }) => {
// Why: beforeunload cannot await the paired runtime, so the web adapter
// guarantees immediate browser-local durability for the final snapshot.
for (const { state, hostId } of sessions) {
writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state))
}
writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui))
},
// Staging already wrote through to browser storage, so there is nothing left to join.
awaitBeforeUnloadCheckpoint: () => Promise.resolve(),
awaitFirstWindowStartupServices: () => Promise.resolve(),
recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(),
startupDiagnostic: () => Promise.resolve(),
getKeyboardInputSourceId: () => Promise.resolve(null),
// The web client cannot inspect local Mission Control shortcuts.
getMacCapturedDigitRowChords: () => Promise.resolve([]),
getKeyboardLayoutSnapshot: () => Promise.resolve(null),
onKeyboardLayoutChanged: () => () => undefined,
setUnreadDockBadgeCount: () => Promise.resolve(),
getFloatingTerminalCwd: () => Promise.resolve(''),
getFloatingMarkdownDirectory: () => Promise.resolve(''),
pickFloatingMarkdownDocument: () => Promise.resolve(null),
pickFloatingWorkspaceDirectory: () => Promise.resolve(null),
// Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted.
writeTerminalRenderDesyncEvidence: () =>
Promise.reject(
new Error('Terminal render evidence is unavailable in the browser fallback.')
)
}
}
}
@@ -0,0 +1,101 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { translate } from '@/i18n/i18n'
import { noopUnsubscribe } from './web-storage'
export function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']> {
return {
registerGuest: () => Promise.resolve(false),
isGuestRegistered: () => Promise.resolve(false),
repairGuestRegistration: () => Promise.resolve(false),
unregisterGuest: () => Promise.resolve(),
openDevTools: () => Promise.resolve(false),
setViewportOverride: () => Promise.resolve(false),
setAnnotationViewportBridge: () => Promise.resolve(false),
onGuestLoadFailed: () => noopUnsubscribe,
onCertificateFailureChanged: () => noopUnsubscribe,
proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }),
onPermissionDenied: () => noopUnsubscribe,
onPopup: () => noopUnsubscribe,
onDownloadRequested: () => noopUnsubscribe,
onDownloadProgress: () => noopUnsubscribe,
onDownloadFinished: () => noopUnsubscribe,
onContextMenuRequested: () => noopUnsubscribe,
onContextMenuDismissed: () => noopUnsubscribe,
onNavigationUpdate: () => noopUnsubscribe,
onActivateView: () => noopUnsubscribe,
onPaneFocus: () => noopUnsubscribe,
onOpenLinkInOrcaTab: () => noopUnsubscribe,
cancelDownload: () => Promise.resolve(false),
setGrabMode: () =>
Promise.resolve({
ok: false,
error: translate(
'auto.web.web.preload.api.31bea294d5',
'Grab mode is unavailable in the web client.'
)
}),
awaitGrabSelection: () =>
Promise.resolve({
ok: false,
error: translate(
'auto.web.web.preload.api.31bea294d5',
'Grab mode is unavailable in the web client.'
)
}),
cancelGrab: () => Promise.resolve(false),
captureSelectionScreenshot: () =>
Promise.resolve({
ok: false,
error: translate(
'auto.web.web.preload.api.8dfcb7a351',
'Selection screenshots are unavailable in the web client.'
)
}),
extractHoverPayload: () =>
Promise.resolve({
ok: false,
error: translate(
'auto.web.web.preload.api.275a776357',
'Hover extraction is unavailable in the web client.'
)
}),
onGrabModeToggle: () => noopUnsubscribe,
onGrabActionShortcut: () => noopUnsubscribe,
sessionListProfiles: () => Promise.resolve([]),
sessionCreateProfile: () => Promise.resolve(null),
sessionDeleteProfile: () => Promise.resolve(false),
sessionImportCookies: () =>
Promise.resolve({
ok: false,
summary: null,
error: translate(
'auto.web.web.preload.api.67ec964791',
'Cookie import is unavailable in the web client.'
)
}),
sessionResolvePartition: () => Promise.resolve(null),
sessionDetectBrowsers: () => Promise.resolve([]),
sessionImportFromBrowser: () =>
Promise.resolve({
ok: false,
summary: null,
error: translate(
'auto.web.web.preload.api.67ec964791',
'Cookie import is unavailable in the web client.'
)
}),
sessionClearDefaultCookies: () => Promise.resolve(false),
notifyActiveTabChanged: () => Promise.resolve(false)
} as unknown as NonNullable<Partial<PreloadApi>['browser']>
}
export function createEmulatorApi(): NonNullable<Partial<PreloadApi>['emulator']> {
return {
onPaneFocus: () => noopUnsubscribe,
onAutoAttach: () => noopUnsubscribe,
startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')),
stopFrameStream: () => Promise.resolve(),
onFrameStreamFrame: () => noopUnsubscribe,
onFrameStreamError: () => noopUnsubscribe
} as unknown as NonNullable<Partial<PreloadApi>['emulator']>
}
@@ -0,0 +1,27 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { getBrowserPlatform } from './web-storage'
export function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
const status = {
platform: getBrowserPlatform(),
commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca',
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason: 'launch_mode_unavailable',
detail: 'CLI registration is managed on the Orca server, not in the web browser.'
} as const
return {
getInstallStatus: () => Promise.resolve(status),
install: () => Promise.resolve(status),
remove: () => Promise.resolve(status),
getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status),
installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status),
removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status)
} as NonNullable<Partial<PreloadApi>['cli']>
}
@@ -0,0 +1,177 @@
import {
CLIPBOARD_IMAGE_MAX_BASE64_CHARS,
CLIPBOARD_IMAGE_MAX_PIXELS,
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
CLIPBOARD_IMAGE_TOO_LARGE_ERROR,
assertClipboardImageByteLengthWithinLimit,
assertClipboardImageDimensionsWithinLimit
} from '../../../../shared/clipboard-image'
import { assertClipboardTextWriteWithinLimitWithYield } from '../../../../shared/clipboard-text'
import { copyClipboardTextViaExecCommand } from '../web-clipboard-copy-fallback'
import { callRuntimeEnvelope, callRuntimeResult } from './web-runtime-calls'
export const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS
export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES
export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS
export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
export const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : ''
const commaIndex = result.indexOf(',')
resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1))
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image'))
reader.readAsDataURL(blob)
})
}
export function assertClipboardImageBlobWithinLimit(blob: Blob): void {
assertClipboardImageByteLengthWithinLimit(blob.size)
}
export async function convertImageBlobToPng(blob: Blob): Promise<Blob> {
assertClipboardImageBlobWithinLimit(blob)
const bitmap = await createImageBitmap(blob)
try {
assertClipboardImageDimensionsWithinLimit(bitmap)
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const context = canvas.getContext('2d')
if (!context || canvas.width <= 0 || canvas.height <= 0) {
throw new Error('Clipboard image could not be decoded')
}
context.drawImage(bitmap, 0, 0)
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((png) => {
if (!png) {
reject(new Error('Clipboard image could not be encoded as PNG'))
return
}
try {
assertClipboardImageBlobWithinLimit(png)
} catch (error) {
reject(error)
return
}
resolve(png)
}, 'image/png')
})
} finally {
bitmap.close()
}
}
export async function readClipboardImagePngBase64(): Promise<string | null> {
const clipboard = navigator.clipboard as
| (Clipboard & { read?: () => Promise<ClipboardItem[]> })
| undefined
if (!clipboard?.read) {
return null
}
const items = await clipboard.read()
for (const item of items) {
const imageType = item.types.find((type) => type.startsWith('image/'))
if (!imageType) {
continue
}
const blob = await item.getType(imageType)
assertClipboardImageBlobWithinLimit(blob)
const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob)
return blobToBase64(pngBlob)
}
return null
}
export async function writeWebClipboardText(text: string): Promise<void> {
await assertClipboardTextWriteWithinLimitWithYield(text)
const clipboard = navigator.clipboard
if (typeof clipboard?.writeText === 'function') {
try {
await clipboard.writeText(text)
return
} catch (error) {
// Preserve the current user-activation turn for the synchronous fallback.
if (copyClipboardTextViaExecCommand(text)) {
return
}
throw error
}
}
if (!copyClipboardTextViaExecCommand(text)) {
throw new Error('Clipboard write is unavailable in this browser context')
}
}
export async function saveClipboardImageAsTempFileInRuntime(
contentBase64: string,
args?: { connectionId?: string | null; runtimeEnvironmentId?: string | null }
): Promise<string> {
if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) {
throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR)
}
const connectionId = args?.connectionId ?? null
const startResponse = await callRuntimeEnvelope<{ uploadId: string }>(
'clipboard.startImageUpload',
{ expectedBase64Length: contentBase64.length, connectionId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
if (!startResponse.ok) {
if (
startResponse.error.code === 'method_not_found' &&
contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS
) {
return callRuntimeResult<string>(
'clipboard.saveImageAsTempFile',
{ contentBase64, connectionId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
}
throw new Error(startResponse.error.message)
}
const { uploadId } = startResponse.result
try {
for (
let offset = 0;
offset < contentBase64.length;
offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS
) {
await callRuntimeResult(
'clipboard.appendImageUploadChunk',
{
uploadId,
offset,
contentBase64: contentBase64.slice(
offset,
offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS
)
},
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
}
return await callRuntimeResult<string>(
'clipboard.commitImageUpload',
{ uploadId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
} catch (error) {
// Why: after chunked paste holds server-side state, release the bounded slot on failure rather than wait for TTL cleanup.
await callRuntimeResult(
'clipboard.abortImageUpload',
{ uploadId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
).catch(() => {})
throw error
}
}
@@ -0,0 +1,41 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { translate } from '@/i18n/i18n'
export function createWebDiagnosticsApi(): Partial<PreloadApi> {
return {
crashReports: {
getLatestPending: () => Promise.resolve(null),
getLatestReport: () => Promise.resolve(null),
dismiss: () => Promise.resolve(null),
recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }),
recordBreadcrumb: () => {},
submit: () =>
Promise.resolve({
ok: false,
status: null,
error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.')
}),
copyLatestDiagnostics: () =>
Promise.resolve({
ok: false,
error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.')
}),
// Why: no Electron process on web; the caller falls back to performance.memory.
readHeapStatistics: () => null
},
diagnostics: {
getStatus: () =>
Promise.resolve({
localFileEnabled: false,
bundleEnabled: false,
traceFilePath: '',
traceFamilySize: 0
}),
collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')),
openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')),
discardBundlePreview: () => Promise.resolve(),
uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')),
deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.'))
}
}
}
@@ -0,0 +1,10 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { webE2EConfig } from './web-e2e-config'
export function createWebE2EApi(): Partial<PreloadApi> {
return {
e2e: {
getConfig: () => webE2EConfig
}
}
}
@@ -0,0 +1,11 @@
import { createE2EConfig } from '../../../../shared/e2e-config'
// Why: this module stays dependency-light so the query/storage snapshot evaluates before runtime state.
const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true'
const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null
export const webE2EConfig = createE2EConfig({
exposeStore: webE2EExposeStore,
terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null,
terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null
})
@@ -0,0 +1,63 @@
import { noopUnsubscribe } from './web-storage'
export function withFallback<T extends object>(target: T, path: string[]): T {
return new Proxy(target, {
get(current, property, receiver) {
if (property in current) {
const value = Reflect.get(current, property, receiver) as unknown
if (value && typeof value === 'object' && !Array.isArray(value)) {
return withFallback(value as object, [...path, String(property)])
}
return value
}
return createFallbackProxy([...path, String(property)])
}
})
}
export function createFallbackProxy(
path: string[],
applyOverride?: (path: string[], args: unknown[]) => unknown
): never {
const fn = () => undefined
return new Proxy(fn, {
get(_target, property) {
if (property === 'then') {
return undefined
}
return createFallbackProxy([...path, String(property)], applyOverride)
},
apply(_target, _thisArg, args) {
if (applyOverride) {
return applyOverride(path, args)
}
return getFallbackResult(path, args)
}
}) as never
}
export function getFallbackResult(path: string[], args: unknown[]): unknown {
const name = path.at(-1) ?? ''
if (name.startsWith('on')) {
return noopUnsubscribe
}
if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') {
return Promise.resolve(false)
}
if (name.startsWith('list') || name.startsWith('detect')) {
return Promise.resolve([])
}
if (name.startsWith('preview')) {
return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] })
}
if (name.startsWith('get') && name.endsWith('Status')) {
return Promise.resolve([])
}
if (name === 'write' || name === 'resize' || name === 'reportGeometry') {
return undefined
}
if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) {
return 0
}
return Promise.resolve(undefined)
}
@@ -0,0 +1,200 @@
import type { PreloadApi } from '../../../../preload/api-types'
import type { SearchResult } from '../../../../shared/code-search-types'
import { assertFileMutationOwnershipCapability } from '../../../../shared/file-mutation-ownership'
import type { DirEntry } from '../../../../shared/filesystem-entry-types'
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { createWebFileMutationMethods } from '../web-file-mutation-methods'
import { callRuntimeResult } from './web-runtime-calls'
import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls'
import {
getClientForEnvironment,
requireActiveEnvironment,
requireActiveEnvironmentOrNull,
runtimeCallQueuePool,
updateEnvironmentFromResponse,
webRuntimeState
} from './web-runtime-session'
import { isMissingPathError, resolveRuntimeFilePath } from './web-runtime-worktree-catalog'
import { noopUnsubscribe } from './web-storage'
export function createFileApi(): NonNullable<Partial<PreloadApi>['fs']> {
return {
readDir: async ({ dirPath }) => {
const file = await resolveRuntimeFilePath(dirPath)
return callRuntimeResult<DirEntry[]>('files.readDir', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
relativePath: file.relativePath
})
},
readFile: async ({ filePath }) => {
const file = await resolveRuntimeFilePath(filePath)
return callRuntimeResult('files.readPreview', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
relativePath: file.relativePath
})
},
readLocalLogTail: async () => {
throw new Error('Local log tailing is unavailable in paired web clients.')
},
startLocalLogTail: async () => {
throw new Error('Local log tailing is unavailable in paired web clients.')
},
stopLocalLogTail: async () => {},
onLocalLogTailChanged: () => noopUnsubscribe,
downloadFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
downloadFolder: async () => {
throw new Error('Remote folder download is unavailable in paired web clients.')
},
saveDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
startDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
appendDownloadedFileChunk: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
finishDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
cancelDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
listMarkdownDocuments: async ({ rootPath }) => {
const file = await resolveRuntimeFilePath(rootPath)
return callRuntimeResult('files.listMarkdownDocuments', {
worktree: toRuntimeWorktreeSelector(file.worktree.id)
})
},
...createWebFileMutationMethods({
captureSession: captureWebFileMutationSession
}),
authorizeExternalPath: () => Promise.resolve(),
stat: async ({ filePath }) => {
const file = await resolveRuntimeFilePath(filePath)
return callRuntimeResult('files.stat', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
relativePath: file.relativePath
})
},
pathExists: async ({ filePath }) => {
try {
const file = await resolveRuntimeFilePath(filePath)
await callRuntimeResult('files.stat', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
relativePath: file.relativePath
})
return true
} catch (error) {
if (isMissingPathError(error)) {
return false
}
throw error
}
},
listFiles: async ({ rootPath, excludePaths }) => {
const file = await resolveRuntimeFilePath(rootPath)
const result = await callRuntimeResult<{ files: { relativePath: string }[] }>(
'files.listAll',
{
worktree: toRuntimeWorktreeSelector(file.worktree.id),
excludePaths
}
)
return result.files.map((entry) => entry.relativePath)
},
cancelListFiles: async () => {
// Why: paired-web lists files over runtime RPC with its own timeout; there's no host-side scan to abort here.
},
search: async (args) => {
const file = await resolveRuntimeFilePath(args.rootPath)
return callRuntimeResult<SearchResult>('files.search', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
query: args.query,
caseSensitive: args.caseSensitive,
wholeWord: args.wholeWord,
useRegex: args.useRegex,
includePattern: args.includePattern,
excludePattern: args.excludePattern,
maxResults: args.maxResults
})
},
importExternalPaths: async () => ({ results: [] }),
stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }),
resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }),
watchWorktree: () => Promise.resolve(),
unwatchWorktree: () => Promise.resolve(),
onFsChanged: () => noopUnsubscribe
}
}
export function captureWebFileMutationSession(): {
resolveFilePath: (filePath: string) => Promise<Awaited<ReturnType<typeof resolveRuntimeFilePath>>>
assertMutationSupported: () => Promise<void>
callRuntimeResult: WebRuntimeResultCaller
getSshState: (targetId: string) => Promise<SshConnectionState | null>
} {
const environment = requireActiveEnvironment()
const client = getClientForEnvironment(environment)
const assertCurrent = (): void => {
if (
webRuntimeState.activeClient !== client ||
requireActiveEnvironmentOrNull()?.id !== environment.id
) {
throw new Error('Runtime pairing changed; refresh and try again')
}
}
const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async <TResult>(
method: string,
params?: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> => {
assertCurrent()
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => {
assertCurrent()
return client.call(method, params, { timeoutMs })
})
assertCurrent()
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
const callBoundRuntimeResult: WebRuntimeResultCaller = async <TResult>(
method: string,
params?: unknown,
timeoutMs?: number
): Promise<TResult> => {
const response = await callBoundRuntimeEnvelope<TResult>(method, params, timeoutMs)
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
}
return {
resolveFilePath: (filePath) =>
resolveRuntimeFilePath(
filePath,
undefined,
callBoundRuntimeResult,
callBoundRuntimeEnvelope,
false,
environment.id
),
assertMutationSupported: async () => {
assertFileMutationOwnershipCapability(
await callBoundRuntimeResult<RuntimeStatus>('status.get', undefined, 15_000)
)
},
callRuntimeResult: callBoundRuntimeResult,
getSshState: async (targetId) =>
(
await callBoundRuntimeResult<{ state: SshConnectionState | null }>('ssh.getState', {
targetId
})
).state
}
}
@@ -0,0 +1,291 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { callAbortableRuntimeEnvironment } from '../../runtime/abortable-runtime-environment-call'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { translate } from '@/i18n/i18n'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironment, updateEnvironmentFromResponse } from './web-runtime-session'
import {
resolveRuntimeFilePath,
resolveRuntimeWorktreeByPath
} from './web-runtime-worktree-catalog'
export const webGitStatusAbortControllers = new Map<string, AbortController>()
export async function callAbortableRuntimeStatus<TResult>(
requestToken: string,
params: unknown
): Promise<TResult> {
const environment = requireActiveEnvironment()
webGitStatusAbortControllers.get(requestToken)?.abort()
const controller = new AbortController()
webGitStatusAbortControllers.set(requestToken, controller)
try {
const response = await callAbortableRuntimeEnvironment(
environment.id,
'git.status',
params,
undefined,
controller.signal
)
updateEnvironmentFromResponse(environment, response)
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
} finally {
if (webGitStatusAbortControllers.get(requestToken) === controller) {
webGitStatusAbortControllers.delete(requestToken)
}
}
}
export function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
return {
status: async ({
worktreePath,
includeIgnored,
bypassEffectiveUpstreamNegativeCache,
reuseLineStats,
branchLineTotalMergeBase,
requestToken
}) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
const params = {
worktree: toRuntimeWorktreeSelector(worktree.id),
includeIgnored,
bypassEffectiveUpstreamNegativeCache,
reuseLineStats,
...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {})
}
// Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort.
if (!requestToken) {
return callRuntimeResult('git.status', params)
}
return callAbortableRuntimeStatus(requestToken, params)
},
cancelStatus: async ({ requestToken }) => {
webGitStatusAbortControllers.get(requestToken)?.abort()
},
setStatusUpstreamRefWatch: async () => {},
submoduleStatus: async ({ worktreePath, submodulePath, area }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.submoduleStatus', {
worktree: toRuntimeWorktreeSelector(worktree.id),
submodulePath,
area
})
},
checkIgnored: async ({ worktreePath, paths }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.checkIgnored', {
worktree: toRuntimeWorktreeSelector(worktree.id),
paths
})
},
// Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates.
findHugeFoldersToIgnore: async () => [],
appendGitignore: async () => false,
history: async ({ worktreePath, limit, baseRef }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.history', {
worktree: toRuntimeWorktreeSelector(worktree.id),
limit,
baseRef
})
},
conflictOperation: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.conflictOperation', {
worktree: toRuntimeWorktreeSelector(worktree.id)
})
},
abortMerge: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.abortMerge', {
worktree: toRuntimeWorktreeSelector(worktree.id)
})
},
abortRebase: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.abortRebase', {
worktree: toRuntimeWorktreeSelector(worktree.id)
})
},
diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
return callRuntimeResult('git.diff', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
filePath: file.relativePath,
staged,
compareAgainstHead
})
},
branchCompare: async ({ worktreePath, baseRef }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.branchCompare', {
worktree: toRuntimeWorktreeSelector(worktree.id),
baseRef
})
},
commitCompare: async ({ worktreePath, commitId }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.commitCompare', {
worktree: toRuntimeWorktreeSelector(worktree.id),
commitId
})
},
upstreamStatus: async ({ worktreePath, pushTarget }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.upstreamStatus', {
worktree: toRuntimeWorktreeSelector(worktree.id),
pushTarget
})
},
fetch: async ({ worktreePath, pushTarget }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.fetch', {
worktree: toRuntimeWorktreeSelector(worktree.id),
pushTarget
})
},
syncFork: async ({ worktreePath, expectedUpstream }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult(
'git.forkSync',
{
worktree: toRuntimeWorktreeSelector(worktree.id),
expectedUpstream
},
60_000
)
},
push: async ({ worktreePath, publish, pushTarget }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.push', {
worktree: toRuntimeWorktreeSelector(worktree.id),
publish,
pushTarget
})
},
pull: async ({ worktreePath, pushTarget }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.pull', {
worktree: toRuntimeWorktreeSelector(worktree.id),
pushTarget
})
},
fastForward: async ({ worktreePath, pushTarget }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.fastForward', {
worktree: toRuntimeWorktreeSelector(worktree.id),
pushTarget
})
},
rebaseFromBase: async ({ worktreePath, baseRef }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.rebaseFromBase', {
worktree: toRuntimeWorktreeSelector(worktree.id),
baseRef
})
},
branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
return callRuntimeResult('git.branchDiff', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
filePath: file.relativePath,
compare,
oldPath
})
},
commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
return callRuntimeResult('git.commitDiff', {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
filePath: file.relativePath,
commitOid,
parentOid,
oldPath
})
},
commit: async ({ worktreePath, message }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.commit', {
worktree: toRuntimeWorktreeSelector(worktree.id),
message
})
},
generateCommitMessage: async () => ({
success: false,
error: translate(
'auto.web.web.preload.api.9fc90740b6',
'Commit message generation is unavailable in the web client.'
)
}),
discoverCommitMessageModels: async () => ({
success: false,
error: translate(
'auto.web.web.preload.api.e57c82d276',
'Commit message model discovery is unavailable in the web client.'
)
}),
cancelGenerateCommitMessage: () => Promise.resolve(),
generatePullRequestFields: async () => ({
success: false,
error: translate(
'auto.web.web.preload.api.b8a1618172',
'Pull request detail generation is unavailable in the web client.'
)
}),
cancelGeneratePullRequestFields: () => Promise.resolve(),
stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath),
bulkStage: async ({ worktreePath, filePaths }) =>
mutateGitPaths('git.bulkStage', worktreePath, filePaths),
unstage: async ({ worktreePath, filePath }) =>
mutateGitPath('git.unstage', worktreePath, filePath),
bulkUnstage: async ({ worktreePath, filePaths }) =>
mutateGitPaths('git.bulkUnstage', worktreePath, filePaths),
discard: async ({ worktreePath, filePath }) =>
mutateGitPath('git.discard', worktreePath, filePath),
bulkDiscard: async ({ worktreePath, filePaths }) => {
for (const filePath of filePaths) {
await mutateGitPath('git.discard', worktreePath, filePath)
}
},
remoteFileUrl: async ({ worktreePath, relativePath, line }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.remoteFileUrl', {
worktree: toRuntimeWorktreeSelector(worktree.id),
relativePath,
line
})
},
remoteCommitUrl: async ({ worktreePath, sha }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.remoteCommitUrl', {
worktree: toRuntimeWorktreeSelector(worktree.id),
sha
})
}
}
}
export async function mutateGitPath(
method: string,
worktreePath: string,
filePath: string
): Promise<void> {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
await callRuntimeResult(method, {
worktree: toRuntimeWorktreeSelector(file.worktree.id),
filePath: file.relativePath
})
}
export async function mutateGitPaths(
method: string,
worktreePath: string,
filePaths: string[]
): Promise<void> {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths })
}
@@ -0,0 +1,193 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { translate } from '@/i18n/i18n'
import { GITHUB_WEB_RPC_METHODS } from './web-github-routes'
import type { WebGitHubRuntimeMethod } from './web-github-routes'
import { mapRepoPathArg } from './web-review-api'
import { callRuntimeResult } from './web-runtime-calls'
import { noopUnsubscribe } from './web-storage'
export type WebGitHubApi = NonNullable<PreloadApi['gh']>
export type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>>
export function createGitHubApi(): WebGitHubApi {
const route = <Result>(method: WebGitHubRuntimeMethod, args?: unknown): Promise<Result> =>
callRuntimeResult<Result>(method, mapRepoPathArg(args))
const githubApi = {
viewer: () => Promise.resolve(null),
repoSlug: (args) => route<WebGitHubResult<'repoSlug'>>(GITHUB_WEB_RPC_METHODS.repoSlug, args),
repoUpstream: (args) =>
route<WebGitHubResult<'repoUpstream'>>(GITHUB_WEB_RPC_METHODS.repoUpstream, args),
prForBranch: (args) =>
route<WebGitHubResult<'prForBranch'>>(GITHUB_WEB_RPC_METHODS.prForBranch, args),
refreshPRNow: async ({ candidate }) => {
const acceptMergedFallbackPR =
candidate.linkedPRNumber == null &&
candidate.fallbackPRNumber != null &&
candidate.fallbackPRSource != null
const pr = await route<WebGitHubResult<'prForBranch'>>(GITHUB_WEB_RPC_METHODS.prForBranch, {
repoPath: candidate.repoPath,
repoId: candidate.repoId,
branch: candidate.branch,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
currentHeadOid: candidate.currentHeadOid ?? null,
...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {})
})
return pr
? { kind: 'found', pr, fetchedAt: Date.now() }
: { kind: 'no-pr', fetchedAt: Date.now() }
},
enqueuePRRefresh: () => Promise.resolve(false),
reportVisiblePRRefreshCandidates: () => Promise.resolve(false),
onPRRefreshEvent: () => noopUnsubscribe,
issue: (args) => route<WebGitHubResult<'issue'>>(GITHUB_WEB_RPC_METHODS.issue, args),
workItem: (args) => route<WebGitHubResult<'workItem'>>(GITHUB_WEB_RPC_METHODS.workItem, args),
workItemByOwnerRepo: ({ repo: ownerRepo, ...args }) =>
route<WebGitHubResult<'workItemByOwnerRepo'>>(GITHUB_WEB_RPC_METHODS.workItemByOwnerRepo, {
...args,
ownerRepo
}),
workItemDetails: (args) =>
route<WebGitHubResult<'workItemDetails'>>(GITHUB_WEB_RPC_METHODS.workItemDetails, args),
notifyWorkItemMutated: () => Promise.resolve(false),
prFileContents: (args) =>
route<WebGitHubResult<'prFileContents'>>(GITHUB_WEB_RPC_METHODS.prFileContents, args),
listIssues: (args) =>
route<WebGitHubResult<'listIssues'>>(GITHUB_WEB_RPC_METHODS.listIssues, args),
createIssue: (args) =>
route<WebGitHubResult<'createIssue'>>(GITHUB_WEB_RPC_METHODS.createIssue, args),
countWorkItems: (args) =>
route<WebGitHubResult<'countWorkItems'>>(GITHUB_WEB_RPC_METHODS.countWorkItems, args),
listWorkItems: (args) =>
route<WebGitHubResult<'listWorkItems'>>(GITHUB_WEB_RPC_METHODS.listWorkItems, args),
prChecks: (args) => route<WebGitHubResult<'prChecks'>>(GITHUB_WEB_RPC_METHODS.prChecks, args),
prCheckDetails: (args) =>
route<WebGitHubResult<'prCheckDetails'>>(GITHUB_WEB_RPC_METHODS.prCheckDetails, args),
rerunPRChecks: (args) =>
route<WebGitHubResult<'rerunPRChecks'>>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args),
prComments: (args) =>
route<WebGitHubResult<'prComments'>>(GITHUB_WEB_RPC_METHODS.prComments, args),
setPRCommentReaction: (args) =>
route<WebGitHubResult<'setPRCommentReaction'>>(
GITHUB_WEB_RPC_METHODS.setPRCommentReaction,
args
),
resolveReviewThread: (args) =>
route<WebGitHubResult<'resolveReviewThread'>>(
GITHUB_WEB_RPC_METHODS.resolveReviewThread,
args
),
setPRFileViewed: (args) =>
route<WebGitHubResult<'setPRFileViewed'>>(GITHUB_WEB_RPC_METHODS.setPRFileViewed, args),
updatePRTitle: (args) =>
route<WebGitHubResult<'updatePRTitle'>>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args),
mergePR: (args) => route<WebGitHubResult<'mergePR'>>(GITHUB_WEB_RPC_METHODS.mergePR, args),
setPRAutoMerge: (args) =>
route<WebGitHubResult<'setPRAutoMerge'>>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args),
updatePRState: (args) =>
route<WebGitHubResult<'updatePRState'>>(GITHUB_WEB_RPC_METHODS.updatePRState, args),
requestPRReviewers: (args) =>
route<WebGitHubResult<'requestPRReviewers'>>(GITHUB_WEB_RPC_METHODS.requestPRReviewers, args),
removePRReviewers: (args) =>
route<WebGitHubResult<'removePRReviewers'>>(GITHUB_WEB_RPC_METHODS.removePRReviewers, args),
updateIssue: (args) =>
route<WebGitHubResult<'updateIssue'>>(GITHUB_WEB_RPC_METHODS.updateIssue, args),
addIssueComment: (args) =>
route<WebGitHubResult<'addIssueComment'>>(GITHUB_WEB_RPC_METHODS.addIssueComment, args),
addPRReviewCommentReply: (args) =>
route<WebGitHubResult<'addPRReviewCommentReply'>>(
GITHUB_WEB_RPC_METHODS.addPRReviewCommentReply,
args
),
addPRReviewComment: (args) =>
route<WebGitHubResult<'addPRReviewComment'>>(GITHUB_WEB_RPC_METHODS.addPRReviewComment, args),
listLabels: (args) =>
route<WebGitHubResult<'listLabels'>>(GITHUB_WEB_RPC_METHODS.listLabels, args),
listAssignableUsers: (args) =>
route<WebGitHubResult<'listAssignableUsers'>>(
GITHUB_WEB_RPC_METHODS.listAssignableUsers,
args
),
onWorkItemMutated: () => noopUnsubscribe,
checkOrcaStarred: () => Promise.resolve(null),
starOrca: () => Promise.resolve(false),
rateLimit: (args) =>
route<WebGitHubResult<'rateLimit'>>(GITHUB_WEB_RPC_METHODS.rateLimit, args),
diagnoseAuth: () =>
Promise.resolve({
ok: false,
message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.')
} as never),
listAccessibleProjects: (args) =>
route<WebGitHubResult<'listAccessibleProjects'>>(
GITHUB_WEB_RPC_METHODS.listAccessibleProjects,
args
),
resolveProjectRef: (args) =>
route<WebGitHubResult<'resolveProjectRef'>>(GITHUB_WEB_RPC_METHODS.resolveProjectRef, args),
listProjectViews: (args) =>
route<WebGitHubResult<'listProjectViews'>>(GITHUB_WEB_RPC_METHODS.listProjectViews, args),
getProjectViewTable: (args) =>
route<WebGitHubResult<'getProjectViewTable'>>(
GITHUB_WEB_RPC_METHODS.getProjectViewTable,
args
),
projectWorkItemDetailsBySlug: (args) =>
route<WebGitHubResult<'projectWorkItemDetailsBySlug'>>(
GITHUB_WEB_RPC_METHODS.projectWorkItemDetailsBySlug,
args
),
updateProjectItemField: (args) =>
route<WebGitHubResult<'updateProjectItemField'>>(
GITHUB_WEB_RPC_METHODS.updateProjectItemField,
args
),
clearProjectItemField: (args) =>
route<WebGitHubResult<'clearProjectItemField'>>(
GITHUB_WEB_RPC_METHODS.clearProjectItemField,
args
),
updateIssueBySlug: (args) =>
route<WebGitHubResult<'updateIssueBySlug'>>(GITHUB_WEB_RPC_METHODS.updateIssueBySlug, args),
updatePullRequestBySlug: (args) =>
route<WebGitHubResult<'updatePullRequestBySlug'>>(
GITHUB_WEB_RPC_METHODS.updatePullRequestBySlug,
args
),
addIssueCommentBySlug: (args) =>
route<WebGitHubResult<'addIssueCommentBySlug'>>(
GITHUB_WEB_RPC_METHODS.addIssueCommentBySlug,
args
),
updateIssueCommentBySlug: (args) =>
route<WebGitHubResult<'updateIssueCommentBySlug'>>(
GITHUB_WEB_RPC_METHODS.updateIssueCommentBySlug,
args
),
deleteIssueCommentBySlug: (args) =>
route<WebGitHubResult<'deleteIssueCommentBySlug'>>(
GITHUB_WEB_RPC_METHODS.deleteIssueCommentBySlug,
args
),
listLabelsBySlug: (args) =>
route<WebGitHubResult<'listLabelsBySlug'>>(GITHUB_WEB_RPC_METHODS.listLabelsBySlug, args),
listAssignableUsersBySlug: (args) =>
route<WebGitHubResult<'listAssignableUsersBySlug'>>(
GITHUB_WEB_RPC_METHODS.listAssignableUsersBySlug,
args
),
listIssueTypesBySlug: (args) =>
route<WebGitHubResult<'listIssueTypesBySlug'>>(
GITHUB_WEB_RPC_METHODS.listIssueTypesBySlug,
args
),
updateIssueTypeBySlug: (args) =>
route<WebGitHubResult<'updateIssueTypeBySlug'>>(
GITHUB_WEB_RPC_METHODS.updateIssueTypeBySlug,
args
)
} satisfies WebGitHubApi
return githubApi
}
@@ -0,0 +1,19 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { GITHUB_CACHE_STORAGE_KEY, readJson, writeJson } from './web-storage'
export function createWebGithubCacheApi(): Partial<PreloadApi> {
return {
cache: {
getGitHub: () =>
Promise.resolve(
readJson(GITHUB_CACHE_STORAGE_KEY, {
pr: {},
issue: {}
})
),
setGitHub: async ({ cache }) => {
writeJson(GITHUB_CACHE_STORAGE_KEY, cache)
}
}
}
}
@@ -0,0 +1,150 @@
export type WebGitHubRouteKey =
| 'repoSlug'
| 'repoUpstream'
| 'prForBranch'
| 'issue'
| 'workItem'
| 'workItemByOwnerRepo'
| 'workItemDetails'
| 'prFileContents'
| 'listIssues'
| 'createIssue'
| 'countWorkItems'
| 'listWorkItems'
| 'prChecks'
| 'prCheckDetails'
| 'rerunPRChecks'
| 'prComments'
| 'setPRCommentReaction'
| 'resolveReviewThread'
| 'setPRFileViewed'
| 'updatePRTitle'
| 'mergePR'
| 'setPRAutoMerge'
| 'updatePRState'
| 'requestPRReviewers'
| 'removePRReviewers'
| 'updateIssue'
| 'addIssueComment'
| 'addPRReviewCommentReply'
| 'addPRReviewComment'
| 'listLabels'
| 'listAssignableUsers'
| 'rateLimit'
| 'listAccessibleProjects'
| 'resolveProjectRef'
| 'listProjectViews'
| 'getProjectViewTable'
| 'projectWorkItemDetailsBySlug'
| 'updateProjectItemField'
| 'clearProjectItemField'
| 'updateIssueBySlug'
| 'updatePullRequestBySlug'
| 'addIssueCommentBySlug'
| 'updateIssueCommentBySlug'
| 'deleteIssueCommentBySlug'
| 'listLabelsBySlug'
| 'listAssignableUsersBySlug'
| 'listIssueTypesBySlug'
| 'updateIssueTypeBySlug'
export type WebGitHubRuntimeMethod =
| 'github.repoSlug'
| 'github.repoUpstream'
| 'github.prForBranch'
| 'github.issue'
| 'github.workItem'
| 'github.workItemByOwnerRepo'
| 'github.workItemDetails'
| 'github.prFileContents'
| 'github.listIssues'
| 'github.createIssue'
| 'github.countWorkItems'
| 'github.listWorkItems'
| 'github.prChecks'
| 'github.prCheckDetails'
| 'github.rerunPRChecks'
| 'github.prComments'
| 'github.setPRCommentReaction'
| 'github.resolveReviewThread'
| 'github.setPRFileViewed'
| 'github.updatePRTitle'
| 'github.mergePR'
| 'github.setPRAutoMerge'
| 'github.updatePRState'
| 'github.requestPRReviewers'
| 'github.removePRReviewers'
| 'github.updateIssue'
| 'github.addIssueComment'
| 'github.addPRReviewCommentReply'
| 'github.addPRReviewComment'
| 'github.listLabels'
| 'github.listAssignableUsers'
| 'github.rateLimit'
| 'github.project.listAccessible'
| 'github.project.resolveRef'
| 'github.project.listViews'
| 'github.project.viewTable'
| 'github.project.workItemDetailsBySlug'
| 'github.project.updateItemField'
| 'github.project.clearItemField'
| 'github.project.updateIssueBySlug'
| 'github.project.updatePullRequestBySlug'
| 'github.project.addIssueCommentBySlug'
| 'github.project.updateIssueCommentBySlug'
| 'github.project.deleteIssueCommentBySlug'
| 'github.project.listLabelsBySlug'
| 'github.project.listAssignableUsersBySlug'
| 'github.project.listIssueTypesBySlug'
| 'github.project.updateIssueTypeBySlug'
export const GITHUB_WEB_RPC_METHODS = {
repoSlug: 'github.repoSlug',
repoUpstream: 'github.repoUpstream',
prForBranch: 'github.prForBranch',
issue: 'github.issue',
workItem: 'github.workItem',
workItemByOwnerRepo: 'github.workItemByOwnerRepo',
workItemDetails: 'github.workItemDetails',
prFileContents: 'github.prFileContents',
listIssues: 'github.listIssues',
createIssue: 'github.createIssue',
countWorkItems: 'github.countWorkItems',
listWorkItems: 'github.listWorkItems',
prChecks: 'github.prChecks',
prCheckDetails: 'github.prCheckDetails',
rerunPRChecks: 'github.rerunPRChecks',
prComments: 'github.prComments',
setPRCommentReaction: 'github.setPRCommentReaction',
resolveReviewThread: 'github.resolveReviewThread',
setPRFileViewed: 'github.setPRFileViewed',
updatePRTitle: 'github.updatePRTitle',
mergePR: 'github.mergePR',
setPRAutoMerge: 'github.setPRAutoMerge',
updatePRState: 'github.updatePRState',
requestPRReviewers: 'github.requestPRReviewers',
removePRReviewers: 'github.removePRReviewers',
updateIssue: 'github.updateIssue',
addIssueComment: 'github.addIssueComment',
addPRReviewCommentReply: 'github.addPRReviewCommentReply',
addPRReviewComment: 'github.addPRReviewComment',
listLabels: 'github.listLabels',
listAssignableUsers: 'github.listAssignableUsers',
rateLimit: 'github.rateLimit',
listAccessibleProjects: 'github.project.listAccessible',
resolveProjectRef: 'github.project.resolveRef',
listProjectViews: 'github.project.listViews',
getProjectViewTable: 'github.project.viewTable',
projectWorkItemDetailsBySlug: 'github.project.workItemDetailsBySlug',
updateProjectItemField: 'github.project.updateItemField',
clearProjectItemField: 'github.project.clearItemField',
updateIssueBySlug: 'github.project.updateIssueBySlug',
updatePullRequestBySlug: 'github.project.updatePullRequestBySlug',
addIssueCommentBySlug: 'github.project.addIssueCommentBySlug',
updateIssueCommentBySlug: 'github.project.updateIssueCommentBySlug',
deleteIssueCommentBySlug: 'github.project.deleteIssueCommentBySlug',
listLabelsBySlug: 'github.project.listLabelsBySlug',
listAssignableUsersBySlug: 'github.project.listAssignableUsersBySlug',
listIssueTypesBySlug: 'github.project.listIssueTypesBySlug',
updateIssueTypeBySlug: 'github.project.updateIssueTypeBySlug'
} as const satisfies Record<WebGitHubRouteKey, WebGitHubRuntimeMethod>
@@ -0,0 +1,71 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { GITLAB_WEB_RPC_METHODS } from './web-gitlab-routes'
import type { WebGitLabRuntimeMethod } from './web-gitlab-routes'
import { mapRepoPathArg } from './web-review-api'
import { callRuntimeResult } from './web-runtime-calls'
export type WebGitLabApi = NonNullable<PreloadApi['gl']>
export type WebGitLabResult<K extends keyof WebGitLabApi> = Awaited<ReturnType<WebGitLabApi[K]>>
export function createGitLabApi(): WebGitLabApi {
const route = <Result>(method: WebGitLabRuntimeMethod, args?: unknown): Promise<Result> =>
callRuntimeResult<Result>(method, mapRepoPathArg(args))
const gitLabApi = {
viewer: () => Promise.resolve(null),
diagnoseAuth: () => route<WebGitLabResult<'diagnoseAuth'>>(GITLAB_WEB_RPC_METHODS.diagnoseAuth),
rateLimit: (args) =>
route<WebGitLabResult<'rateLimit'>>(GITLAB_WEB_RPC_METHODS.rateLimit, args),
projectSlug: () => Promise.resolve(null),
mrForBranch: () => Promise.resolve(null),
mr: () => Promise.resolve(null),
listMRs: (args) => route<WebGitLabResult<'listMRs'>>(GITLAB_WEB_RPC_METHODS.listMRs, args),
listWorkItems: (args) =>
route<WebGitLabResult<'listWorkItems'>>(GITLAB_WEB_RPC_METHODS.listWorkItems, args),
issue: () => Promise.resolve(null),
listIssues: (args) =>
route<WebGitLabResult<'listIssues'>>(GITLAB_WEB_RPC_METHODS.listIssues, args),
createIssue: (args) =>
route<WebGitLabResult<'createIssue'>>(GITLAB_WEB_RPC_METHODS.createIssue, args),
updateIssue: (args) =>
route<WebGitLabResult<'updateIssue'>>(GITLAB_WEB_RPC_METHODS.updateIssue, args),
addIssueComment: (args) =>
route<WebGitLabResult<'addIssueComment'>>(GITLAB_WEB_RPC_METHODS.addIssueComment, args),
listLabels: (args) =>
route<WebGitLabResult<'listLabels'>>(GITLAB_WEB_RPC_METHODS.listLabels, args),
listAssignableUsers: () => Promise.resolve([]),
todos: (args) => route<WebGitLabResult<'todos'>>(GITLAB_WEB_RPC_METHODS.todos, args),
workItemDetails: (args) =>
route<WebGitLabResult<'workItemDetails'>>(GITLAB_WEB_RPC_METHODS.workItemDetails, args),
closeMR: (args) =>
route<WebGitLabResult<'closeMR'>>(GITLAB_WEB_RPC_METHODS.closeMR, {
...args,
state: 'closed'
}),
reopenMR: (args) =>
route<WebGitLabResult<'reopenMR'>>(GITLAB_WEB_RPC_METHODS.reopenMR, {
...args,
state: 'opened'
}),
mergeMR: (args) => route<WebGitLabResult<'mergeMR'>>(GITLAB_WEB_RPC_METHODS.mergeMR, args),
updateMR: (args) => route<WebGitLabResult<'updateMR'>>(GITLAB_WEB_RPC_METHODS.updateMR, args),
updateMRReviewers: (args) =>
route<WebGitLabResult<'updateMRReviewers'>>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args),
addMRComment: (args) =>
route<WebGitLabResult<'addMRComment'>>(GITLAB_WEB_RPC_METHODS.addMRComment, args),
addMRInlineComment: (args) =>
route<WebGitLabResult<'addMRInlineComment'>>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args),
resolveMRDiscussion: (args) =>
route<WebGitLabResult<'resolveMRDiscussion'>>(
GITLAB_WEB_RPC_METHODS.resolveMRDiscussion,
args
),
jobTrace: (args) => route<WebGitLabResult<'jobTrace'>>(GITLAB_WEB_RPC_METHODS.jobTrace, args),
retryJob: (args) => route<WebGitLabResult<'retryJob'>>(GITLAB_WEB_RPC_METHODS.retryJob, args),
workItemByPath: (args) =>
route<WebGitLabResult<'workItemByPath'>>(GITLAB_WEB_RPC_METHODS.workItemByPath, args)
} satisfies WebGitLabApi
return gitLabApi
}
@@ -0,0 +1,71 @@
export type WebGitLabRouteKey =
| 'diagnoseAuth'
| 'rateLimit'
| 'listMRs'
| 'listWorkItems'
| 'listIssues'
| 'createIssue'
| 'updateIssue'
| 'addIssueComment'
| 'listLabels'
| 'todos'
| 'workItemDetails'
| 'closeMR'
| 'reopenMR'
| 'mergeMR'
| 'updateMR'
| 'updateMRReviewers'
| 'addMRComment'
| 'addMRInlineComment'
| 'resolveMRDiscussion'
| 'jobTrace'
| 'retryJob'
| 'workItemByPath'
export type WebGitLabRuntimeMethod =
| 'gitlab.diagnoseAuth'
| 'gitlab.rateLimit'
| 'gitlab.listMRs'
| 'gitlab.listWorkItems'
| 'gitlab.listIssues'
| 'gitlab.createIssue'
| 'gitlab.updateIssue'
| 'gitlab.addIssueComment'
| 'gitlab.listLabels'
| 'gitlab.todos'
| 'gitlab.workItemDetails'
| 'gitlab.updateMRState'
| 'gitlab.mergeMR'
| 'gitlab.updateMR'
| 'gitlab.updateMRReviewers'
| 'gitlab.addMRComment'
| 'gitlab.addMRInlineComment'
| 'gitlab.resolveMRDiscussion'
| 'gitlab.jobTrace'
| 'gitlab.retryJob'
| 'gitlab.workItemByPath'
export const GITLAB_WEB_RPC_METHODS = {
diagnoseAuth: 'gitlab.diagnoseAuth',
rateLimit: 'gitlab.rateLimit',
listMRs: 'gitlab.listMRs',
listWorkItems: 'gitlab.listWorkItems',
listIssues: 'gitlab.listIssues',
createIssue: 'gitlab.createIssue',
updateIssue: 'gitlab.updateIssue',
addIssueComment: 'gitlab.addIssueComment',
listLabels: 'gitlab.listLabels',
todos: 'gitlab.todos',
workItemDetails: 'gitlab.workItemDetails',
closeMR: 'gitlab.updateMRState',
reopenMR: 'gitlab.updateMRState',
mergeMR: 'gitlab.mergeMR',
updateMR: 'gitlab.updateMR',
updateMRReviewers: 'gitlab.updateMRReviewers',
addMRComment: 'gitlab.addMRComment',
addMRInlineComment: 'gitlab.addMRInlineComment',
resolveMRDiscussion: 'gitlab.resolveMRDiscussion',
jobTrace: 'gitlab.jobTrace',
retryJob: 'gitlab.retryJob',
workItemByPath: 'gitlab.workItemByPath'
} as const satisfies Record<WebGitLabRouteKey, WebGitLabRuntimeMethod>
@@ -0,0 +1,195 @@
import type {
PreflightStatus,
PreloadApi,
RefreshAgentsResult
} from '../../../../preload/api-types'
import type {
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../../../../shared/computer-use-permissions-types'
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
import type { SkillDiscoveryResult } from '../../../../shared/skills'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironmentOrNull } from './web-runtime-session'
import { getBrowserPlatform } from './web-storage'
export function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
const fallbackStatus: PreflightStatus = {
git: { installed: false },
gh: { installed: false, authenticated: false },
glab: { installed: false, authenticated: false },
bitbucket: { configured: false, authenticated: false, account: null },
azureDevOps: {
configured: false,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: false
},
gitea: {
configured: false,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: false
}
}
const fallbackRefreshAgents: RefreshAgentsResult = {
agents: [],
addedPathSegments: [],
shellHydrationOk: false,
pathSource: 'sync_seed_only',
pathFailureReason: 'spawn_error'
}
type WindowsTerminalCapabilityBridgeResult = {
wslAvailable: boolean
wslDistros: string[]
pwshAvailable: boolean
gitBashAvailable: boolean
hostPlatform: NodeJS.Platform | null
}
const fallbackWindowsTerminalCapabilities = {
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: null
}
return {
check: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return fallbackStatus
}
return callRuntimeResult<PreflightStatus>('preflight.check', args)
},
detectAgents: async () => {
if (!requireActiveEnvironmentOrNull()) {
return []
}
return callRuntimeResult<string[]>('preflight.detectAgents').catch(() => [])
},
refreshAgents: () =>
requireActiveEnvironmentOrNull()
? callRuntimeResult('preflight.refreshAgents')
.then((result) => result as RefreshAgentsResult)
.catch(() => fallbackRefreshAgents)
: Promise.resolve(fallbackRefreshAgents),
detectRemoteAgents: async (args) =>
requireActiveEnvironmentOrNull()
? callRuntimeResult<string[]>('preflight.detectRemoteAgents', args).catch(() => [])
: [],
detectRemoteWindowsTerminalCapabilities: async (args) =>
requireActiveEnvironmentOrNull()
? callRuntimeResult<WindowsTerminalCapabilityBridgeResult>(
'preflight.detectRemoteWindowsTerminalCapabilities',
args
).catch(() => fallbackWindowsTerminalCapabilities)
: Promise.resolve(fallbackWindowsTerminalCapabilities)
}
}
export function createDeveloperPermissionsApi(): NonNullable<
Partial<PreloadApi>['developerPermissions']
> {
return {
getStatus: () => Promise.resolve([]),
request: ({ id }) =>
Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const),
openSettings: () => Promise.resolve(),
testLocalNetworkConnection: ({ host, port }) =>
Promise.resolve({
ok: false,
host,
port,
testedAt: Date.now(),
failure: 'unsupported'
} as const)
}
}
export function createComputerUsePermissionsApi(): NonNullable<
Partial<PreloadApi>['computerUsePermissions']
> {
return {
getStatus: () =>
callRuntimeResult<ComputerUsePermissionStatusResult>(
'computer.permissionsStatus',
{},
15_000
),
openSetup: (args) =>
callRuntimeResult<ComputerUsePermissionSetupResult>(
'computer.permissions',
args ?? {},
15_000
).catch(() => ({
platform: getBrowserPlatform(),
helperAppPath: null,
openedSettings: false,
launchedHelper: false,
nextStep: 'Computer-use permissions are managed on the Orca server.'
})),
reset: () =>
Promise.resolve({
platform: getBrowserPlatform(),
helperAppPath: null,
helperUnavailableReason: 'web_client',
bundleId: null,
permissions: []
})
}
}
export function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
return {
discover: (target) =>
callRuntimeResult<SkillDiscoveryResult>('skills.discover', target, 15_000),
// Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it.
freshnessInventory: (): Promise<SkillFreshnessInventory> =>
Promise.resolve({
schemaVersion: 1,
installations: [],
eligibleUpdateNames: [],
scanIssues: [],
scannedAt: Date.now()
}),
// Why: with no local skill homes there is nothing to update, so the run rail
// reports a permanently idle state rather than spawning anything.
startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }),
cancelUpdateRun: () => Promise.resolve(),
acknowledgeUpdateRun: () => Promise.resolve(),
getUpdateRun: () => Promise.resolve({ state: 'idle' as const }),
prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')),
publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')),
cancelShare: () => Promise.resolve(),
releaseShare: () => Promise.resolve(),
resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')),
installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
installBundleShare: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
installPackageVersion: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
installBundlePackageVersion: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
cancelInstall: () => Promise.resolve({ cancelled: false }),
previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
previewBundleInstall: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
listManagedInstalls: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
listOwnedShares: () =>
Promise.reject(new Error('Skill package management requires the desktop app.')),
revokeShare: () =>
Promise.reject(new Error('Skill package management requires the desktop app.')),
deletePackageVersion: () =>
Promise.reject(new Error('Skill package management requires the desktop app.')),
deletePackage: () =>
Promise.reject(new Error('Skill package management requires the desktop app.')),
listWslDistros: () => Promise.resolve([]),
onInstallProgress: () => () => {},
onShareProgress: () => () => {},
onUpdateRun: () => () => {}
}
}
@@ -0,0 +1,174 @@
import {
findKeybindingConflicts,
isKeybindingActionId,
normalizeKeybindingArrayForAction
} from '../../../../shared/keybindings'
import type {
KeybindingActionId,
KeybindingFileDiagnostic,
KeybindingOverrides,
KeybindingPlatform
} from '../../../../shared/keybindings'
import { translate } from '@/i18n/i18n'
export type WebKeybindingDocument = {
version: 1
keybindings: KeybindingOverrides
platforms: Partial<Record<KeybindingPlatform, KeybindingOverrides>>
}
export const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
export function isJsonObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function normalizeStoredWebOverrides(
value: unknown,
section: string,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({
severity: 'error',
section,
message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', {
value0: section
})
})
return {}
}
const overrides: KeybindingOverrides = {}
for (const [actionId, rawBindings] of Object.entries(value)) {
if (!isKeybindingActionId(actionId)) {
diagnostics.push({
severity: 'warning',
section,
actionId,
message: translate(
'auto.web.web.preload.api.36761d9604',
'Unknown keybinding action "{{value0}}" was ignored.',
{ value0: actionId }
)
})
continue
}
if (
!Array.isArray(rawBindings) ||
!rawBindings.every((binding) => typeof binding === 'string')
) {
diagnostics.push({
severity: 'error',
section,
actionId,
message: translate(
'auto.web.web.preload.api.10898045f3',
'Shortcut for "{{value0}}" was ignored: Use a string array.',
{ value0: actionId }
)
})
continue
}
const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings)
if (!Array.isArray(normalized)) {
const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error
diagnostics.push({
severity: 'error',
section,
actionId,
message: translate(
'auto.web.web.preload.api.76122208ca',
'Shortcut for "{{value0}}" was ignored: {{value1}}',
{ value0: actionId, value1: error }
)
})
continue
}
overrides[actionId] = normalized
}
return overrides
}
export function normalizeWebPlatformOverrides(
value: unknown,
diagnostics: KeybindingFileDiagnostic[]
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({
severity: 'error',
section: 'platforms',
message: translate(
'auto.web.web.preload.api.0a69fcd8bc',
'platforms must be an object with darwin, linux, or win32 sections.'
)
})
return {}
}
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
for (const [platform, overrides] of Object.entries(value)) {
if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) {
diagnostics.push({
severity: 'warning',
section: `platforms.${platform}`,
message: translate(
'auto.web.web.preload.api.32f15bdb0f',
'Unknown platform "{{value0}}" was ignored.',
{ value0: platform }
)
})
continue
}
result[platform as KeybindingPlatform] = normalizeStoredWebOverrides(
overrides,
`platforms.${platform}`,
diagnostics
)
}
return result
}
export function removeConflictingWebOverrides(
platform: KeybindingPlatform,
overrides: KeybindingOverrides,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
let next = { ...overrides }
for (let attempt = 0; attempt < 20; attempt++) {
const conflicts = findKeybindingConflicts(platform, next)
const conflictingOverrides = new Set<KeybindingActionId>()
for (const conflict of conflicts) {
for (const actionId of conflict.actionIds) {
if (Object.hasOwn(next, actionId)) {
conflictingOverrides.add(actionId)
}
}
}
if (conflictingOverrides.size === 0) {
return next
}
for (const actionId of conflictingOverrides) {
delete next[actionId]
}
diagnostics.push({
severity: 'error',
message: translate(
'auto.web.web.preload.api.52bee9d8a0',
'Conflicting custom shortcuts were ignored: {{value0}}.',
{
value0: Array.from(conflictingOverrides)
.map((actionId) => actionId)
.join(', ')
}
)
})
}
return next
}
@@ -0,0 +1,174 @@
import type { PreloadApi } from '../../../../preload/api-types'
import {
findKeybindingConflicts,
formatKeybindingList,
getKeybindingPlatform,
isKeybindingActionId,
normalizeKeybindingArrayForAction
} from '../../../../shared/keybindings'
import type {
KeybindingActionId,
KeybindingFileDiagnostic,
KeybindingFileSnapshot,
KeybindingOverrides,
KeybindingPlatform
} from '../../../../shared/keybindings'
import {
isJsonObject,
normalizeStoredWebOverrides,
normalizeWebPlatformOverrides,
removeConflictingWebOverrides
} from './web-keybinding-normalization'
import type { WebKeybindingDocument } from './web-keybinding-normalization'
import { KEYBINDINGS_STORAGE_KEY, getBrowserPlatform, readJson, writeJson } from './web-storage'
export type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']>
export const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>()
export function createEmptyWebKeybindingDocument(): WebKeybindingDocument {
return {
version: 1,
keybindings: {},
platforms: {
darwin: {},
linux: {},
win32: {}
}
}
}
export function getWebKeybindingPlatform(): KeybindingPlatform {
return getKeybindingPlatform(getBrowserPlatform())
}
export function readWebKeybindingDocument(): WebKeybindingDocument {
const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument())
return {
version: 1,
keybindings: isJsonObject(document.keybindings)
? (document.keybindings as KeybindingOverrides)
: {},
platforms: isJsonObject(document.platforms)
? (document.platforms as Partial<Record<KeybindingPlatform, KeybindingOverrides>>)
: {}
}
}
export function getWebKeybindingSnapshot(): KeybindingFileSnapshot {
const platform = getWebKeybindingPlatform()
const diagnostics: KeybindingFileDiagnostic[] = []
const document = readWebKeybindingDocument()
const commonOverrides = normalizeStoredWebOverrides(
document.keybindings,
'keybindings',
diagnostics
)
const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics)
const overrides = removeConflictingWebOverrides(
platform,
{
...commonOverrides,
...platformOverrides[platform]
},
diagnostics
)
return {
path: 'Browser local storage',
platform,
exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null,
overrides,
commonOverrides,
platformOverrides,
diagnostics
}
}
export function writeWebKeybindingAction(
actionId: KeybindingActionId,
bindings: string[] | null
): KeybindingFileSnapshot {
if (!isKeybindingActionId(actionId)) {
throw new Error(`Unknown keybinding action "${String(actionId)}".`)
}
const normalizedBindings =
bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings)
if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) {
throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error)
}
const platform = getWebKeybindingPlatform()
const currentSnapshot = getWebKeybindingSnapshot()
const candidateOverrides = { ...currentSnapshot.overrides }
if (normalizedBindings === null) {
delete candidateOverrides[actionId]
} else {
candidateOverrides[actionId] = normalizedBindings
}
const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) =>
conflict.actionIds.includes(actionId)
)
if (blockingConflict) {
throw new Error(
`${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.`
)
}
const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] }
if (normalizedBindings === null) {
delete activePlatform[actionId]
} else {
activePlatform[actionId] = normalizedBindings
}
writeJson(KEYBINDINGS_STORAGE_KEY, {
version: 1,
keybindings: currentSnapshot.commonOverrides,
platforms: {
...currentSnapshot.platformOverrides,
darwin: currentSnapshot.platformOverrides.darwin ?? {},
linux: currentSnapshot.platformOverrides.linux ?? {},
win32: currentSnapshot.platformOverrides.win32 ?? {},
[platform]: activePlatform
}
} satisfies WebKeybindingDocument)
const snapshot = getWebKeybindingSnapshot()
notifyWebKeybindingListeners(snapshot)
return snapshot
}
export function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void {
for (const listener of webKeybindingListeners) {
listener(snapshot)
}
}
export function createWebKeybindingsApi(): WebKeybindingsApi {
return {
get: () => Promise.resolve(getWebKeybindingSnapshot()),
ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()),
setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings),
reload: () => {
const snapshot = getWebKeybindingSnapshot()
notifyWebKeybindingListeners(snapshot)
return Promise.resolve(snapshot)
},
openFile: () => Promise.resolve(getWebKeybindingSnapshot()),
revealFile: () => Promise.resolve(getWebKeybindingSnapshot()),
onChanged: (callback) => {
webKeybindingListeners.add(callback)
const onStorage = (event: StorageEvent): void => {
if (event.key === KEYBINDINGS_STORAGE_KEY) {
callback(getWebKeybindingSnapshot())
}
}
window.addEventListener('storage', onStorage)
return () => {
webKeybindingListeners.delete(callback)
window.removeEventListener('storage', onStorage)
}
}
}
}
@@ -0,0 +1,13 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { noopUnsubscribe } from './web-storage'
export function createMacosTccPromptsApi(): NonNullable<Partial<PreloadApi>['macosTccPrompts']> {
// Why: TCC is a macOS-desktop concept; the web client has no log stream to watch.
return {
onThreshold: () => noopUnsubscribe,
consumePending: () => Promise.resolve(null),
acknowledgePending: () => Promise.resolve(),
releasePending: () => Promise.resolve(),
dismiss: () => Promise.resolve()
}
}
@@ -0,0 +1,24 @@
import type { MemorySnapshot } from '../../../../shared/process-stats-types'
import { getBrowserPlatform } from './web-storage'
export function createEmptyMemorySnapshot(): MemorySnapshot {
const emptyUsage = { cpu: 0, memory: 0 }
return {
app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] },
worktrees: [],
host: {
totalMemory: 0,
freeMemory: 0,
availableMemory: 0,
availableMemorySource: 'free-memory',
usedMemory: 0,
memoryUsagePercent: 0,
cpuCoreCount: navigator.hardwareConcurrency || 1,
loadAverage1m: 0
},
processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss',
totalCpu: 0,
totalMemory: 0,
collectedAt: Date.now()
}
}
@@ -0,0 +1,26 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { webRuntimeState } from './web-runtime-session'
import { noopUnsubscribe } from './web-storage'
export function createWebMobileApi(): Partial<PreloadApi> {
return {
mobile: {
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),
getPairingQR: () => Promise.resolve({ available: false }),
getWindowsFirewallStatus: () => Promise.resolve({ supported: false }),
repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }),
openWindowsNetworkSettings: () => Promise.resolve(false),
getRuntimePairingUrl: () => Promise.resolve({ available: false }),
listDevices: () => Promise.resolve({ devices: [] }),
revokeDevice: () => Promise.resolve({ revoked: false }),
listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }),
revokeRuntimeAccess: () => Promise.resolve({ revoked: false }),
isWebSocketReady: () =>
Promise.resolve({ ready: Boolean(webRuntimeState.activeEnvironment), endpoint: null }),
getRelayStatus: () => Promise.resolve({ status: 'offline' as const }),
onRelayStatusChanged: () => noopUnsubscribe,
consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false),
onUnpairedDeviceAuthFailure: () => noopUnsubscribe
}
}
}
@@ -0,0 +1,156 @@
import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types'
import { buildNativeChatUnsubscribe } from '../../../../shared/native-chat-stream-unsubscribe'
import {
parseRuntimeNativeChatReadSessionResult,
parseRuntimeNativeChatTurnLifecycle
} from '@/components/native-chat/native-chat-runtime-contract'
import { translate } from '@/i18n/i18n'
import { callRuntimeResult } from './web-runtime-calls'
import { getClientForEnvironment, requireActiveEnvironmentOrNull } from './web-runtime-session'
export function createWebNativeChatApi(): NativeChatApi {
return {
readSession: async (agent, sessionId, limit, transcriptPath) =>
parseRuntimeNativeChatReadSessionResult(
await callRuntimeResult<unknown>('nativeChat.readSession', {
agent,
sessionId,
limit,
transcriptPath
})
),
subscribe: (args, onFrame) => {
// No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed.
const environment = requireActiveEnvironmentOrNull()
if (!environment) {
onFrame({
type: 'snapshot',
messages: [],
hasMore: false,
error: translate(
'components.native-chat.state.pairHost',
'Pair a host to view agent chat history.'
)
})
return () => {}
}
let handle: { unsubscribe: () => void } | null = null
let cancelled = false
let receivedInitial = false
void getClientForEnvironment(environment)
.subscribe(
'nativeChat.subscribe',
{
agent: args.agent,
sessionId: args.sessionId,
subscriptionId: args.subscriptionId,
transcriptPath: args.transcriptPath,
limit: args.limit
},
{
onResponse: (response) => {
if (cancelled) {
return
}
if (!response.ok) {
if (!receivedInitial) {
receivedInitial = true
onFrame({
type: 'snapshot',
messages: [],
hasMore: false,
error: response.error.message
})
}
return
}
const result = response.result as {
type?: string
messages?: NativeChatAppendedMessages
hasMore?: boolean
error?: string
lifecycle?: unknown
}
const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle)
if (
(result?.type === 'appended' ||
result?.type === 'snapshot' ||
result?.type === 'replacement') &&
Array.isArray(result.messages)
) {
if (!receivedInitial) {
receivedInitial = true
onFrame({
type: 'snapshot',
messages: result.messages,
hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300),
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
} else if (result.type === 'snapshot') {
onFrame({
type: 'snapshot',
messages: result.messages,
hasMore: result.hasMore ?? false,
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
})
} else {
onFrame(
result.type === 'replacement'
? {
type: 'replacement',
messages: result.messages,
hasMore: result.hasMore ?? false,
...(lifecycle ? { lifecycle } : {})
}
: {
type: 'appended',
messages: result.messages,
...(lifecycle ? { lifecycle } : {})
}
)
}
} else if (!receivedInitial) {
// Why: an unrecognized ok payload never flips receivedInitial, stranding the view on 'loading'; settle it empty instead.
receivedInitial = true
onFrame({
type: 'snapshot',
messages: [],
hasMore: false,
...(result?.error ? { error: result.error } : {})
})
}
}
},
{
// Why: unsubscribe reaps the fs-watcher on view-toggle (leak fix); echo the pane token so two panes don't tear down each other's watcher.
buildUnsubscribe: () =>
buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId)
}
)
.then((h) => {
if (cancelled) {
h.unsubscribe()
} else {
handle = h
}
})
.catch((err: unknown) => {
if (!cancelled && !receivedInitial) {
receivedInitial = true
onFrame({
type: 'snapshot',
messages: [],
hasMore: false,
error: err instanceof Error ? err.message : String(err)
})
}
})
return () => {
cancelled = true
handle?.unsubscribe()
}
}
}
}
@@ -0,0 +1,14 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { getBrowserPlatform } from './web-storage'
export function createNotificationsApi(): NonNullable<Partial<PreloadApi>['notifications']> {
return {
dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }),
dismiss: () => Promise.resolve({ dismissed: 0 }),
openSystemSettings: () => Promise.resolve(),
getPermissionStatus: () =>
Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }),
probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }),
playSound: () => Promise.resolve({ played: false, reason: 'missing-path' })
}
}
@@ -0,0 +1,56 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { ONBOARDING_FLOW_VERSION, getDefaultOnboardingState } from '../../../../shared/constants'
import type { OnboardingState } from '../../../../shared/onboarding-state-types'
import { ONBOARDING_STORAGE_KEY, readJson, writeJson } from './web-storage'
export function getStoredOnboarding(): OnboardingState {
const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY)
if (storedRaw) {
const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState())
if (stored.checklist.dismissed) {
return stored
}
const closed = closeWebOnboarding(stored)
writeJson(ONBOARDING_STORAGE_KEY, closed)
return closed
}
const closed = closeWebOnboarding(getDefaultOnboardingState())
// Why: paired clients already have an Orca server; skip desktop first-run onboarding that would probe browser-local tools.
writeJson(ONBOARDING_STORAGE_KEY, closed)
return closed
}
export function closeWebOnboarding(base: OnboardingState): OnboardingState {
return {
...base,
flowVersion: ONBOARDING_FLOW_VERSION,
closedAt: Date.now(),
outcome: 'dismissed',
checklist: {
...base.checklist,
dismissed: true
}
}
}
export function createWebOnboardingApi(): Partial<PreloadApi> {
return {
onboarding: {
get: () => Promise.resolve(getStoredOnboarding()),
update: async (updates) => {
const current = getStoredOnboarding()
const next: OnboardingState = {
...current,
...updates,
flowVersion: ONBOARDING_FLOW_VERSION,
checklist: {
...current.checklist,
...updates.checklist
}
}
writeJson(ONBOARDING_STORAGE_KEY, next)
return next
}
}
}
}
@@ -0,0 +1,70 @@
import type { PreloadApi } from '../../../../preload/api-types'
import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
createDefaultLocalOrcaProfile
} from '../../../../shared/orca-profiles'
export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
const webOrcaProfileAuthStatus = () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
configured: false,
state: 'unconfigured' as const,
persistence: 'none' as const,
setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.'
})
return {
orcaProfiles: {
list: () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [createDefaultLocalOrcaProfile(0)],
multiProfileUi: false
}),
authStatus: webOrcaProfileAuthStatus,
createLocal: () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [createDefaultLocalOrcaProfile(0)],
profile: createDefaultLocalOrcaProfile(0)
}),
createCloudLinked: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
switchProfile: () => Promise.resolve({ status: 'already-active' }),
transferProject: (args) =>
Promise.resolve({
status: 'duplicate-target',
sourceProfileId: args.sourceProfileId,
targetProfileId: args.targetProfileId,
sourceRepoId: args.repoId,
duplicateRepoId: args.repoId
}),
findProjectProfiles: async () => ({ projects: [] }),
connectCurrent: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
refreshAuth: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
signOutCurrent: async () => ({
status: 'signed-out',
auth: await webOrcaProfileAuthStatus(),
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [createDefaultLocalOrcaProfile(0)]
}),
selectOrg: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
orgMembersList: async () => ({ status: 'unconfigured' }),
orgMemberInvite: async () => ({ status: 'unconfigured' }),
orgInviteRevoke: async () => ({ status: 'unconfigured' }),
orgMemberChangeRole: async () => ({ status: 'unconfigured' }),
orgMemberRemove: async () => ({ status: 'unconfigured' })
}
}
}
@@ -0,0 +1,16 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { getBrowserPlatform } from './web-storage'
export function createWebPlatformApi(): Partial<PreloadApi> {
return {
platform: {
get: () => ({
platform: getBrowserPlatform(),
osRelease: '',
arch: '',
shell: '',
displayServer: null
})
}
}
}
@@ -0,0 +1,164 @@
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings'
import {
getDefaultSettings,
normalizeAgentActivityDisplayMode,
normalizeWorktreeCardProperties
} from '../../../../shared/constants'
import { normalizeContextualTourIds } from '../../../../shared/contextual-tours'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions'
import type {
FeatureInteractionId,
FeatureInteractionState
} from '../../../../shared/feature-interactions'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { PairedUiState, PairingLocalUiField } from '../../../../shared/pairing-local-ui-fields'
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes'
import {
normalizeTuiAgentArgsRecord,
normalizeTuiAgentEnvRecord
} from '../../../../shared/tui-agent-launch-defaults'
import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
import { normalizeUiLanguage } from '../../../../shared/ui-language'
import { normalizeUsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import { mergeWorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup-ui-state'
export function mergeWebUIState(
base: PersistedUIState,
updates: Partial<PersistedUIState>
): PersistedUIState {
const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } =
updates as Partial<PersistedUIState> & {
featureInteractionTelemetryBuckets?: unknown
}
void _reserved
return {
...base,
...safeUpdates,
workspaceCleanup: mergeWorkspaceCleanupUIState(
base.workspaceCleanup,
safeUpdates.workspaceCleanup
),
worktreeCardProperties: normalizeWorktreeCardProperties(
safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties
),
_worktreeCardModeDefaulted:
safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted,
agentActivityDisplayMode: normalizeAgentActivityDisplayMode(
safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode
),
usagePercentageDisplay: normalizeUsagePercentageDisplay(
safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay
),
statusBarUsageMode: normalizeStatusBarUsageMode(
safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode
)
}
}
export function mergeHostWebUIState(
local: PersistedUIState,
incoming: PairedUiState
): PersistedUIState {
// Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in
// PersistedUIState, so Pick would accept a literal that silently skipped a newly added member.
const pinned = {
hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true,
manualRepoOrder: local.manualRepoOrder,
workspaceHostOrder: local.workspaceHostOrder
} satisfies Record<PairingLocalUiField, unknown> & Partial<PersistedUIState>
return { ...mergeWebUIState(local, incoming), ...pinned }
}
export function mergeFeatureInteractionState(
current: PersistedUIState['featureInteractions'],
incoming: PersistedUIState['featureInteractions']
): FeatureInteractionState {
const currentNormalized = normalizeFeatureInteractions(current)
const incomingNormalized = normalizeFeatureInteractions(incoming)
const merged: FeatureInteractionState = { ...currentNormalized }
for (const [id, incomingRecord] of Object.entries(incomingNormalized)) {
const featureId = id as FeatureInteractionId
const currentRecord = currentNormalized[featureId]
merged[featureId] = currentRecord
? {
firstInteractedAt: Math.min(
currentRecord.firstInteractedAt,
incomingRecord.firstInteractedAt
),
interactionCount: Math.max(
currentRecord.interactionCount,
incomingRecord.interactionCount
)
}
: incomingRecord
}
return merged
}
export function mergeContextualTourSeenIds(
current: PersistedUIState['contextualToursSeenIds'],
incoming: PersistedUIState['contextualToursSeenIds']
): ContextualTourId[] {
const merged = new Set<ContextualTourId>(normalizeContextualTourIds(current))
for (const id of normalizeContextualTourIds(incoming)) {
merged.add(id)
}
return [...merged]
}
export function mergeOsc52ClipboardNoticePending(
current: PersistedUIState,
incoming: PersistedUIState
): boolean {
return (
current.osc52ClipboardDefaultOnNoticePending === true ||
incoming.osc52ClipboardDefaultOnNoticePending === true
)
}
export function mergeSettings(
base: GlobalSettings,
updates: Partial<GlobalSettings>,
options: { preserveAutoRenameBranchFromWorkUpdate?: boolean } = {}
): GlobalSettings {
const defaults = getDefaultSettings('~')
const merged = {
...base,
...updates,
notifications: {
...base.notifications,
...updates.notifications
},
githubProjects: {
...(base.githubProjects ?? defaults.githubProjects),
...updates.githubProjects
} as GlobalSettings['githubProjects'],
disabledTuiAgents: normalizeDisabledTuiAgents(
updates.disabledTuiAgents ?? base.disabledTuiAgents
),
agentDefaultArgs: normalizeTuiAgentArgsRecord(
updates.agentDefaultArgs ?? base.agentDefaultArgs
),
agentDefaultEnv: normalizeTuiAgentEnvRecord(updates.agentDefaultEnv ?? base.agentDefaultEnv),
voice: {
...(base.voice ?? defaults.voice),
...updates.voice
} as NonNullable<GlobalSettings['voice']>,
activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId')
? (updates.activeRuntimeEnvironmentId ?? null)
: (base.activeRuntimeEnvironmentId ?? null),
terminalCustomThemes: normalizeTerminalCustomThemes(
updates.terminalCustomThemes ?? base.terminalCustomThemes
),
uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage)
}
return {
...merged,
...normalizeAutoRenameBranchFromWorkDefaultOn(merged, {
preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate
})
}
}
@@ -0,0 +1,297 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings'
import {
getDefaultSettings,
getDefaultUIState,
getWorktreeCardModeProperties
} from '../../../../shared/constants'
import { normalizeWorktreeVisibilityDefaults } from '../../../../shared/external-worktree-visibility'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
normalizeOsc52ClipboardDefaultOn,
osc52ClipboardDefaultOnOverridesPersistedOff
} from '../../../../shared/osc52-clipboard-settings'
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import {
applyPRBotAuthorOverride,
normalizePRBotAuthorOverrides
} from '../../../../shared/pr-bot-author-overrides'
import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings'
import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes'
import { normalizeUiLanguage } from '../../../../shared/ui-language'
import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment'
import { mergeSettings, mergeWebUIState } from './web-preference-normalization'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironmentOrNull, webRuntimeState } from './web-runtime-session'
import { SETTINGS_STORAGE_KEY, UI_STORAGE_KEY, readJson, writeJson } from './web-storage'
export type WebSettingsApi = NonNullable<PreloadApi['settings']>
export function getStoredSettings(): GlobalSettings {
webRuntimeState.activeEnvironment =
webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment()
const defaults = getDefaultSettings('~')
const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY)
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
const migratedStored = {
...stored,
...normalizeAutoRenameBranchFromWorkDefaultOn(stored),
...normalizeTerminalCursorStyleDefault(stored),
...normalizeOsc52ClipboardDefaultOn(stored),
terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes),
uiLanguage: normalizeUiLanguage(stored.uiLanguage)
}
if (
rawStoredSettings &&
(stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork ||
stored.autoRenameBranchFromWorkDefaultedOn !==
migratedStored.autoRenameBranchFromWorkDefaultedOn ||
stored.terminalCursorStyle !== migratedStored.terminalCursorStyle ||
stored.terminalCursorStyleDefaultedToBlock !==
migratedStored.terminalCursorStyleDefaultedToBlock ||
// Kept even though the terminalCustomThemes reference compare below already forces
// this branch for every stored blob: no migration should rely on that accident.
stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard ||
stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !==
migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers ||
stored.terminalCustomThemes !== migratedStored.terminalCustomThemes ||
stored.uiLanguage !== migratedStored.uiLanguage)
) {
try {
const parsed = JSON.parse(rawStoredSettings) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
writeJson(SETTINGS_STORAGE_KEY, migratedStored)
if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) {
// Why a raw merge, not readLocalWebUIState(): that path calls back into
// getStoredSettings(), and writing through it here would recurse.
writeJson(UI_STORAGE_KEY, {
...readJson<Partial<PersistedUIState>>(UI_STORAGE_KEY, {}),
osc52ClipboardDefaultOnNoticePending: true
})
}
}
} catch {
// Keep readJson's invalid-JSON fallback non-destructive.
}
}
return mergeSettings(
{
...defaults,
floatingTerminalEnabled: false,
rightSidebarOpenByDefault: false,
activeRuntimeEnvironmentId: null
},
migratedStored
)
}
export function writeStoredSettings(
settings: GlobalSettings,
explicitActiveRuntimeEnvironmentId?: string | null
): void {
const durable = { ...settings }
if (explicitActiveRuntimeEnvironmentId !== undefined) {
durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId
} else {
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) {
durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null
} else {
delete durable.activeRuntimeEnvironmentId
}
}
writeJson(SETTINGS_STORAGE_KEY, durable)
}
export async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
const local = getStoredSettings()
const requestedEnvironment = requireActiveEnvironmentOrNull()
if (!requestedEnvironment) {
return local
}
try {
const result = await callRuntimeResult<{ settings: Partial<GlobalSettings> }>(
'settings.get',
undefined,
15_000
)
const runtimeSettings: Partial<GlobalSettings> = {}
const currentEnvironment = requireActiveEnvironmentOrNull()
if (currentEnvironment?.id === requestedEnvironment.id) {
const visibilityDefaults = normalizeWorktreeVisibilityDefaults(
result.settings.worktreeVisibilityDefaults
)
webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = visibilityDefaults
? requestedEnvironment.id
: null
webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = visibilityDefaults ?? null
}
if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') {
runtimeSettings.experimentalNewWorktreeCardStyle =
result.settings.experimentalNewWorktreeCardStyle
}
if (typeof result.settings.compactWorktreeCards === 'boolean') {
runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards
}
if (typeof result.settings.minimaxGroupId === 'string') {
runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId
}
if (typeof result.settings.minimaxUsageModels === 'string') {
runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels
}
if (Array.isArray(result.settings.prBotAuthorOverrides)) {
runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides(
result.settings.prBotAuthorOverrides
)
}
// Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never
// sends it back, so web shows what the host enforces instead of a local value it ignores.
if (typeof result.settings.artifactSharingEnabled === 'boolean') {
runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled
}
if (typeof result.settings.agentSkillSharingEnabled === 'boolean') {
runtimeSettings.agentSkillSharingEnabled = result.settings.agentSkillSharingEnabled
}
const next = mergeSettings(local, runtimeSettings)
writeStoredSettings(next)
return settingsForActiveVisibilityOwner(next)
} catch {
// Why: unpaired/offline web clients keep a local settings fallback.
return settingsForActiveVisibilityOwner(local)
}
}
export function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings {
const environment = requireActiveEnvironmentOrNull()
if (!environment) {
return settings
}
if (
environment.id === webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId &&
webRuntimeState.worktreeVisibilityDefaultsRuntimeValue
) {
return {
...settings,
worktreeVisibilityDefaults: webRuntimeState.worktreeVisibilityDefaultsRuntimeValue
}
}
const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings
return supportedSettings as GlobalSettings
}
export async function syncRuntimeBackedSettings(
updates: Partial<GlobalSettings>,
localNext: GlobalSettings
): Promise<GlobalSettings> {
const requestedEnvironment = requireActiveEnvironmentOrNull()
if (!requestedEnvironment) {
return localNext
}
const runtimeUpdates: Partial<GlobalSettings> = {}
const visibilityDefaults = normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults)
if (visibilityDefaults) {
runtimeUpdates.worktreeVisibilityDefaults = visibilityDefaults
}
if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') {
runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle
}
if (typeof updates.compactWorktreeCards === 'boolean') {
runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards
}
if (typeof updates.minimaxGroupId === 'string') {
runtimeUpdates.minimaxGroupId = updates.minimaxGroupId
}
if (typeof updates.minimaxUsageModels === 'string') {
runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels
}
if (Array.isArray(updates.prBotAuthorOverrides)) {
runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides(
updates.prBotAuthorOverrides
)
}
if (Object.keys(runtimeUpdates).length === 0) {
return localNext
}
try {
const result = await callRuntimeResult<{ settings: Partial<GlobalSettings> }>(
'settings.update',
runtimeUpdates,
15_000
)
const runtimeSettings = { ...result.settings }
delete runtimeSettings.activeRuntimeEnvironmentId
const updatedVisibilityDefaults = normalizeWorktreeVisibilityDefaults(
runtimeSettings.worktreeVisibilityDefaults
)
if (
requireActiveEnvironmentOrNull()?.id === requestedEnvironment.id &&
updatedVisibilityDefaults
) {
webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = requestedEnvironment.id
webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = updatedVisibilityDefaults
}
delete runtimeSettings.worktreeVisibilityDefaults
const next = mergeSettings(localNext, runtimeSettings)
writeStoredSettings(next)
return next
} catch (error) {
if (visibilityDefaults) {
throw error
}
// Why: unpaired/offline web clients still need local settings persistence.
return localNext
}
}
export async function updateRuntimePRBotAuthorOverride(args: {
author: string
isBot: boolean
}): Promise<GlobalSettings> {
const local = getStoredSettings()
if (requireActiveEnvironmentOrNull()) {
// Why: don't report a successful mark the authoritative runtime failed to persist and will later overwrite.
const result = await callRuntimeResult<{ settings: Partial<GlobalSettings> }>(
'settings.updatePRBotAuthorOverride',
args,
15_000
)
const next = mergeSettings(local, {
prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides)
})
writeStoredSettings(next)
return next
}
const next = mergeSettings(local, {
prBotAuthorOverrides: applyPRBotAuthorOverride(
local.prBotAuthorOverrides,
args.author,
args.isBot
)
})
writeStoredSettings(next)
return next
}
export function readLocalWebUIState(): PersistedUIState {
const defaults = getDefaultUIState()
// Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the
// notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that
// every caller then writes back, erasing the arm the stamp can never raise again.
const storedSettings = getStoredSettings()
const stored = readJson<Partial<PersistedUIState>>(UI_STORAGE_KEY, {})
const base = {
...defaults,
// Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable.
worktreeCardProperties: getWorktreeCardModeProperties(
storedSettings.compactWorktreeCards ? 'Compact' : 'Default'
)
}
if (typeof stored.rightSidebarOpen === 'boolean') {
return mergeWebUIState(base, stored)
}
return mergeWebUIState(base, {
...stored,
// Why: web fallback lacks main-process normalization; migrate the retired setting only when local UI preference is absent.
rightSidebarOpen: storedSettings.rightSidebarOpenByDefault
})
}
@@ -0,0 +1,36 @@
import type { PreloadApi } from '../../../../preload/api-types'
import type { RateLimitState } from '../../../../shared/rate-limit-types'
import { noopUnsubscribe } from './web-storage'
export function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
const empty: RateLimitState = {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
kimi: null,
antigravity: null,
minimax: null,
grok: null,
minimaxCookieConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
codexTarget: { runtime: 'host', wslDistro: null },
inactiveClaudeAccounts: [],
inactiveCodexAccounts: []
}
return {
get: () => Promise.resolve(empty),
refresh: () => Promise.resolve(empty),
refreshCodexForTarget: () => Promise.resolve(empty),
// Why: web clients don't own local Codex auth; report the safe no-credit outcome since redemption is desktop-only.
consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }),
refreshClaudeForTarget: () => Promise.resolve(empty),
setPollingInterval: () => Promise.resolve(),
fetchInactiveClaudeAccounts: () => Promise.resolve(),
fetchInactiveCodexAccounts: () => Promise.resolve(),
refreshMiniMax: () => Promise.resolve(empty),
refreshGrok: () => Promise.resolve(empty),
onUpdate: () => noopUnsubscribe
}
}
@@ -0,0 +1,130 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { legacyBaseRefSearchResult } from '../../../../shared/base-ref-search-result'
import type { Repo } from '../../../../shared/repo-types'
import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults'
import {
callRuntimeResult,
callRuntimeResultWithOwner,
withRuntimeRepoMutationOwner,
withRuntimeRepoOwner
} from './web-runtime-calls'
import { assertActiveEnvironment, invalidateRuntimeWorktreeCaches } from './web-runtime-session'
import { noopUnsubscribe } from './web-storage'
export function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
return {
list: async () => {
const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list')
return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId))
},
add: async ({ path, kind }) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>(
'repo.add',
{ path, kind }
)
return withRuntimeRepoMutationOwner(owned.result, owned.hostId)
},
remove: async ({ repoId }) => {
await callRuntimeResult('repo.rm', { repo: repoId })
invalidateRuntimeWorktreeCaches()
},
// Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state.
removeForHost: () => {
throw new Error('Forgetting a host is unavailable in paired web clients.')
},
reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }),
// Why: this persists desktop-owned local/SSH rows; paired web clients own one runtime and use repo.reorder directly.
reorderForHost: async () => {
throw new Error('Host-scoped project reordering is unavailable in paired web clients.')
},
update: async ({ repoId, updates }) => {
const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.update', {
repo: repoId,
updates
})
return withRuntimeRepoOwner(owned.result.repo, owned.hostId)
},
pickFolder: () => Promise.resolve(null),
pickFolders: () => Promise.resolve([]),
pickDirectory: () => Promise.resolve(null),
clone: async ({ url, destination }) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ repo: Repo }>(
'repo.clone',
{ url, destination },
10 * 60_000
)
return withRuntimeRepoOwner(owned.result.repo, owned.hostId)
},
cloneRemote: async () => {
// Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path.
throw new Error('SSH clone is unavailable in paired web clients.')
},
createRemote: async () => {
// Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC.
throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.')
},
cloneAbort: () => Promise.resolve(),
addRemote: async ({ remotePath, displayName, kind }) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', {
path: remotePath,
kind
})
const result = {
repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId)
}
if (!displayName) {
return result
}
assertActiveEnvironment(owned.environmentId)
return {
repo: await createReposApi().update({
repoId: result.repo.id,
updates: { displayName }
})
}
},
create: async ({ parentPath, name, kind }) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>(
'repo.create',
{ parentPath, name, kind }
)
return withRuntimeRepoMutationOwner(owned.result, owned.hostId)
},
isGitAvailable: async () =>
(await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available,
getDefaultCreateProjectParent: async () => {
const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', {
path: '~'
})
return getDefaultCreateProjectParent(result.resolvedPath)
},
onCloneProgress: () => noopUnsubscribe,
getGitUsername: () => Promise.resolve(''),
getBaseRefDefault: async ({ repoId }) =>
callRuntimeResult('repo.baseRefDefault', { repo: repoId }),
searchBaseRefs: async ({ repoId, query, limit }) =>
(
await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', {
repo: repoId,
query,
limit
})
).refs,
searchBaseRefDetails: async ({ repoId, query, limit }) => {
const result = await callRuntimeResult<{
refs: string[]
refDetails?: { refName: string; localBranchName: string }[]
}>('repo.searchRefs', {
repo: repoId,
query,
limit
})
return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult)
},
onChanged: () => noopUnsubscribe
}
}
@@ -0,0 +1,44 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { createFallbackProxy } from './web-fallback-api'
import { callRuntimeResult } from './web-runtime-calls'
export function createRuntimeNamespaceApi(prefix: string): never {
return createFallbackProxy([prefix], (path, args) => {
const method = `${prefix}.${path.at(-1) ?? ''}`
return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0]))
}) as never
}
export function createHooksApi(): NonNullable<Partial<PreloadApi>['hooks']> {
return {
check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }),
inspectSetupScriptImports: async ({ repoId }) =>
callRuntimeResult('repo.setupScriptImports', { repo: repoId }),
createIssueCommandRunner: async () => ({ launched: false }) as never,
readIssueCommand: async ({ repoId }) =>
callRuntimeResult('repo.issueCommandRead', { repo: repoId }),
writeIssueCommand: async ({ repoId, content }) => {
await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content })
}
}
}
export function mapRepoPathArg(args: unknown): unknown {
if (!args || typeof args !== 'object' || !('repoPath' in args)) {
return args
}
const record = args as Record<string, unknown>
const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null
return {
...record,
// Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes.
repo: repoId ? `id:${repoId}` : record.repoPath
}
}
export function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown {
if (prefix !== 'hostedReview') {
return args
}
return mapRepoPathArg(args)
}
@@ -0,0 +1,21 @@
import type { PreloadApi } from '../../../../preload/api-types'
import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types'
import { callRuntimeEnvelope, getRemoteRuntimeStatus } from './web-runtime-calls'
import { noopUnsubscribe } from './web-storage'
export function createWebRuntimeApi(): NonNullable<Partial<PreloadApi>['runtime']> {
return {
syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(),
getStatus: () => getRemoteRuntimeStatus(),
call: ({ method, params }) => callRuntimeEnvelope(method, params),
getTerminalFitOverrides: () => Promise.resolve([]),
getTerminalDrivers: () => Promise.resolve([]),
getBrowserDrivers: () => Promise.resolve([]),
restoreTerminalFit: () => Promise.resolve({ restored: false }),
reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }),
onTerminalFitOverrideChanged: () => noopUnsubscribe,
onTerminalDriverChanged: () => noopUnsubscribe,
onNativeChatLaunchDraftResolved: () => noopUnsubscribe,
onBrowserDriverChanged: () => noopUnsubscribe
}
}
@@ -0,0 +1,120 @@
import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
getClientForEnvironment,
manuallyDisconnectedEnvironmentIds,
manuallyDisconnectedResponse,
requireActiveEnvironment,
resolveEnvironment,
runtimeCallQueuePool,
updateEnvironmentFromResponse
} from './web-runtime-session'
export type WebRuntimeResultCaller = <TResult>(
method: string,
params?: unknown,
timeoutMs?: number
) => Promise<TResult>
export type WebRuntimeEnvelopeCaller = <TResult>(
method: string,
params?: unknown,
timeoutMs?: number
) => Promise<RuntimeRpcResponse<TResult>>
export async function callRuntimeEnvelope<TResult = unknown>(
method: string,
params?: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
const environment = requireActiveEnvironment()
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => {
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return Promise.resolve(manuallyDisconnectedResponse(environment))
}
return getClientForEnvironment(environment).call(method, params, { timeoutMs })
})
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
export async function callEnvironmentEnvelope<TResult = unknown>(
selector: string,
method: string,
params?: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
const environment = resolveEnvironment(selector)
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => {
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return Promise.resolve(manuallyDisconnectedResponse(environment))
}
return getClientForEnvironment(environment).call(method, params, { timeoutMs })
})
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
export async function callRuntimeResult<TResult>(
method: string,
params?: unknown,
timeoutMs?: number
): Promise<TResult> {
const response = await callRuntimeEnvelope(method, params, timeoutMs)
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
}
export async function callRuntimeResultWithOwner<TResult>(
method: string,
params?: unknown,
timeoutMs?: number
): Promise<{ result: TResult; hostId: ExecutionHostId; environmentId: string }> {
const environmentId = requireActiveEnvironment().id
const result = await callRuntimeResult<TResult>(method, params, timeoutMs)
return { result, hostId: toRuntimeExecutionHostId(environmentId), environmentId }
}
export function withRuntimeRepoOwner(repo: Repo, hostId: ExecutionHostId): Repo {
return { ...repo, executionHostId: hostId }
}
export function withRuntimeRepoMutationOwner(
result: { repo: Repo } | { error: string },
hostId: ExecutionHostId
): { repo: Repo } | { error: string } {
return 'repo' in result ? { ...result, repo: withRuntimeRepoOwner(result.repo, hostId) } : result
}
export function withRuntimeWorktreeOwner<T extends Worktree>(
worktree: T,
hostId: ExecutionHostId
): T {
const runtimeOwner = parseExecutionHostId(hostId)
if (runtimeOwner?.kind !== 'runtime') {
return worktree
}
return { ...worktree, runtimeOwnerEnvironmentId: runtimeOwner.environmentId }
}
export async function getRemoteRuntimeStatus(): Promise<RuntimeStatus> {
return callRuntimeResult<RuntimeStatus>('status.get', undefined, 15_000)
}
@@ -0,0 +1,198 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
import { verifyRemotePairingRuntimeStatus } from '../../../../shared/remote-pairing-verification'
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { parseWebPairingInput } from '../web-pairing'
import { WebRuntimeClient } from '../web-runtime-client'
import { isWebRuntimeUnauthorizedError } from '../web-runtime-client-error'
import {
createStoredWebRuntimeEnvironment,
redactStoredWebRuntimeEnvironment,
saveStoredWebRuntimeEnvironment
} from '../web-runtime-environment'
import { translate } from '@/i18n/i18n'
import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy'
import { callEnvironmentEnvelope } from './web-runtime-calls'
import {
closeActiveRuntimeClients,
disconnectActiveRuntimeEnvironment,
getClientForEnvironment,
manuallyDisconnectedEnvironmentIds,
removeActiveRuntimeEnvironment,
requireActiveEnvironmentOrNull,
resolveEnvironment,
webRuntimeState
} from './web-runtime-session'
export function createRuntimeEnvironmentsApi(): NonNullable<
Partial<PreloadApi>['runtimeEnvironments']
> {
return {
list: async () => {
const environment = requireActiveEnvironmentOrNull()
return environment ? [redactStoredWebRuntimeEnvironment(environment)] : []
},
addFromPairingCode: async ({ name, pairingCode }) => {
const offer = parseWebPairingInput(pairingCode)
if (!offer) {
throw new Error('Invalid Orca pairing code.')
}
const previousEnvironment = webRuntimeState.activeEnvironment
closeActiveRuntimeClients()
webRuntimeState.activeEnvironment = createStoredWebRuntimeEnvironment({
name,
offer,
previousEnvironment
})
manuallyDisconnectedEnvironmentIds.clear()
saveStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment)
return { environment: redactStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment) }
},
verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => {
const parsed = parseHostAccessLink(pairingCode)
if (!parsed.ok) {
return {
ok: false,
kind: 'access-link-invalid',
message: translateHostAccessLinkError(parsed.kind)
}
}
if (parsed.value.endpointKind === 'loopback' && !allowLoopback) {
return {
ok: false,
kind: 'host-unreachable',
message: translate(
'auto.web.webPreloadApi.loopbackPairingBlocked',
'This access link points back to this device.'
)
}
}
let client: WebRuntimeClient | null = null
let runtimeStatus: RuntimeStatus
try {
client = new WebRuntimeClient(parsed.value.pairing)
const response = (await client.call('status.get', undefined, {
timeoutMs: 15_000
})) as RuntimeRpcResponse<RuntimeStatus>
if (!response.ok) {
return {
ok: false,
kind: 'connection-interrupted',
message: response.error.message
}
}
const statusVerification = verifyRemotePairingRuntimeStatus(response.result)
if (!statusVerification.ok) {
return statusVerification
}
runtimeStatus = statusVerification.runtimeStatus
} catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid public key')) {
return {
ok: false,
kind: 'access-link-invalid',
message: translate(
'auto.web.webPreloadApi.remotePairingInvalidDetails',
'This access link contains invalid connection details.'
)
}
}
if (
isWebRuntimeUnauthorizedError(error) ||
(error instanceof Error && error.message.startsWith('Unauthorized.'))
) {
return {
ok: false,
kind: 'access-link-invalid',
message: error.message
}
}
return {
ok: false,
kind: 'host-unreachable',
message: translate(
'auto.web.webPreloadApi.remotePairingUnreachable',
'Cannot reach Orca at {{endpoint}}.',
{ endpoint: parsed.value.displayEndpoint }
)
}
} finally {
client?.close()
}
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true
const nextEnvironment = {
...createStoredWebRuntimeEnvironment({
name,
offer: parsed.value.pairing,
previousEnvironment: webRuntimeState.activeEnvironment,
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
}),
...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {})
}
// Why: a browser storage failure must leave the currently active host usable.
try {
saveStoredWebRuntimeEnvironment(nextEnvironment)
} catch {
return {
ok: false,
kind: 'environment-save-failed',
message: translate(
'auto.web.webPreloadApi.remotePairingSaveFailed',
'Orca verified the host but could not save it. Check browser storage and try again.'
)
}
}
manuallyDisconnectedEnvironmentIds.clear()
closeActiveRuntimeClients()
webRuntimeState.activeEnvironment = nextEnvironment
return {
ok: true,
environment: redactStoredWebRuntimeEnvironment(nextEnvironment),
runtimeStatus
}
},
resolve: async ({ selector }) =>
redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)),
remove: async ({ selector }) => {
const environment = resolveEnvironment(selector)
if (webRuntimeState.activeEnvironment?.id === environment.id) {
removeActiveRuntimeEnvironment()
}
manuallyDisconnectedEnvironmentIds.delete(environment.id)
return { removed: redactStoredWebRuntimeEnvironment(environment) }
},
disconnect: async ({ selector }) => {
const environment = resolveEnvironment(selector)
if (webRuntimeState.activeEnvironment?.id === environment.id) {
manuallyDisconnectedEnvironmentIds.add(environment.id)
disconnectActiveRuntimeEnvironment()
}
return { disconnected: redactStoredWebRuntimeEnvironment(environment) }
},
connect: ({ selector, timeoutMs }) => {
const environment = resolveEnvironment(selector)
manuallyDisconnectedEnvironmentIds.delete(environment.id)
return callEnvironmentEnvelope<RuntimeStatus>(
environment.id,
'status.get',
undefined,
timeoutMs
)
},
getStatus: ({ selector, timeoutMs }) =>
callEnvironmentEnvelope<RuntimeStatus>(selector, 'status.get', undefined, timeoutMs),
call: ({ selector, method, params, timeoutMs }) =>
callEnvironmentEnvelope(selector, method, params, timeoutMs),
subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => {
const environment = resolveEnvironment(selector)
const client = getClientForEnvironment(environment)
const subscription = await client.subscribe(method, params, callbacks, { timeoutMs })
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
subscription.unsubscribe()
throw new Error('runtime_manually_disconnected')
}
return subscription
}
}
}
@@ -0,0 +1,145 @@
import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types'
import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue'
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type { Worktree } from '../../../../shared/worktree/types'
import { WebRuntimeClient } from '../web-runtime-client'
import {
clearStoredWebRuntimeEnvironment,
getPreferredWebPairingOffer,
readStoredWebRuntimeEnvironment,
updateStoredEnvironmentRuntimeId
} from '../web-runtime-environment'
import type { StoredWebRuntimeEnvironment } from '../web-runtime-environment'
import { translate } from '@/i18n/i18n'
export const webRuntimeState: {
activeEnvironment: StoredWebRuntimeEnvironment | null
worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null
worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null
activeClient: WebRuntimeClient | null
activeClientEnvironmentId: string | null
cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null
cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null
} = {
activeEnvironment: readStoredWebRuntimeEnvironment(),
worktreeVisibilityDefaultsRuntimeEnvironmentId: null,
worktreeVisibilityDefaultsRuntimeValue: null,
activeClient: null,
activeClientEnvironmentId: null,
cachedWorktrees: null,
cachedDetectedWorktrees: null
}
export const manuallyDisconnectedEnvironmentIds = new Set<string>()
export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
export function invalidateRuntimeWorktreeCaches(): void {
webRuntimeState.cachedWorktrees = null
webRuntimeState.cachedDetectedWorktrees = null
}
export function getClientForEnvironment(
environment: StoredWebRuntimeEnvironment
): WebRuntimeClient {
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
throw new Error('runtime_manually_disconnected')
}
if (
!webRuntimeState.activeClient ||
webRuntimeState.activeClientEnvironmentId !== environment.id
) {
webRuntimeState.activeClient?.close()
webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment))
webRuntimeState.activeClientEnvironmentId = environment.id
}
return webRuntimeState.activeClient
}
export function closeActiveRuntimeClients(): void {
webRuntimeState.activeClient?.close()
webRuntimeState.activeClient = null
webRuntimeState.activeClientEnvironmentId = null
invalidateRuntimeWorktreeCaches()
}
export function disconnectActiveRuntimeEnvironment(): void {
closeActiveRuntimeClients()
}
export function removeActiveRuntimeEnvironment(): void {
disconnectActiveRuntimeEnvironment()
clearStoredWebRuntimeEnvironment()
webRuntimeState.activeEnvironment = null
}
export function manuallyDisconnectedResponse(
environment: StoredWebRuntimeEnvironment
): RuntimeRpcResponse<never> {
return {
id: 'runtime.manualDisconnect',
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: translate(
'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected',
'Runtime environment is manually disconnected.'
)
},
_meta: { runtimeId: environment.runtimeId }
}
}
export function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment {
const environment = requireActiveEnvironment()
if (selector === environment.id || selector === environment.name || selector === 'active') {
return environment
}
if (environment.compatibleEnvironmentIds?.includes(selector)) {
return environment
}
throw new Error(`Unknown Orca runtime environment: ${selector}`)
}
export function requireActiveEnvironment(): StoredWebRuntimeEnvironment {
webRuntimeState.activeEnvironment =
webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment()
if (!webRuntimeState.activeEnvironment) {
throw new Error('Pair this web client with an Orca server first.')
}
return webRuntimeState.activeEnvironment
}
export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null {
webRuntimeState.activeEnvironment =
webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment()
return webRuntimeState.activeEnvironment
}
export function assertActiveEnvironment(environmentId: string): void {
if (requireActiveEnvironment().id !== environmentId) {
throw new Error('The paired Orca server changed while the request was in progress.')
}
}
export function updateEnvironmentFromResponse(
environment: StoredWebRuntimeEnvironment,
response: RuntimeRpcResponse<unknown>
): void {
if (webRuntimeState.activeEnvironment?.id !== environment.id) {
return
}
const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null)
const pairedDeviceId =
response.ok &&
typeof response.result === 'object' &&
response.result !== null &&
typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string'
? (response.result as { pairedDeviceId: string }).pairedDeviceId
: undefined
webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId(
environment,
runtimeId,
pairedDeviceId
)
}
@@ -0,0 +1,183 @@
import { relativePathInsideRoot } from '../../../../shared/cross-platform-path'
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types'
import {
callRuntimeEnvelope,
callRuntimeResult,
callRuntimeResultWithOwner,
withRuntimeWorktreeOwner
} from './web-runtime-calls'
import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls'
import {
assertActiveEnvironment,
requireActiveEnvironment,
webRuntimeState
} from './web-runtime-session'
export const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000
export async function listAllRuntimeWorktrees(): Promise<Worktree[]> {
if (
webRuntimeState.cachedWorktrees &&
Date.now() - webRuntimeState.cachedWorktrees.loadedAt < 5_000
) {
return webRuntimeState.cachedWorktrees.worktrees
}
const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', {
limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT
})
const worktrees = owned.result.worktrees.map((worktree) =>
withRuntimeWorktreeOwner(worktree, owned.hostId)
)
assertActiveEnvironment(owned.environmentId)
webRuntimeState.cachedWorktrees = { loadedAt: Date.now(), worktrees }
return worktrees
}
export async function listAllRuntimeDetectedWorktrees(
callResult: WebRuntimeResultCaller = callRuntimeResult,
callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope,
useCache = true,
expectedEnvironmentId = requireActiveEnvironment().id
): Promise<Worktree[]> {
if (
useCache &&
webRuntimeState.cachedDetectedWorktrees &&
Date.now() - webRuntimeState.cachedDetectedWorktrees.loadedAt < 5_000
) {
return webRuntimeState.cachedDetectedWorktrees.worktrees
}
assertActiveEnvironment(expectedEnvironmentId)
const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos
const detectedLists = await Promise.all(
repos.map((repo) =>
callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope)
)
)
const worktrees = detectedLists.flatMap((result) => result.worktrees)
assertActiveEnvironment(expectedEnvironmentId)
if (useCache) {
webRuntimeState.cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees }
}
return worktrees
}
export async function callRuntimeDetectedWorktrees(
repoId: string,
expectedEnvironmentId = requireActiveEnvironment().id,
callResult: WebRuntimeResultCaller = callRuntimeResult,
callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope
): Promise<DetectedWorktreeListResult> {
assertActiveEnvironment(expectedEnvironmentId)
const hostId = toRuntimeExecutionHostId(expectedEnvironmentId)
const response = await callEnvelope<DetectedWorktreeListResult>(
'worktree.detectedList',
{ repo: repoId },
15_000
)
if (response.ok) {
return {
...response.result,
worktrees: response.result.worktrees.map((worktree) =>
withRuntimeWorktreeOwner(worktree, hostId)
)
}
}
if (response.error.code !== 'method_not_found') {
throw new Error(response.error.message)
}
assertActiveEnvironment(expectedEnvironmentId)
const legacy = await callResult<{ worktrees: Worktree[] }>(
'worktree.list',
{ repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT },
15_000
)
return toLegacyDetectedWorktreeResult(
repoId,
legacy.worktrees.map((worktree) => withRuntimeWorktreeOwner(worktree, hostId))
)
}
export function toLegacyDetectedWorktreeResult(
repoId: string,
worktrees: Worktree[]
): DetectedWorktreeListResult {
return {
repoId,
authoritative: true,
source: 'session-fallback',
worktrees: worktrees.map((worktree) => ({
...worktree,
ownership: 'orca-managed',
selectedCheckout: false,
visible: true
}))
}
}
export function isMissingPathError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
return /\bENOENT\b|not found|no such file/i.test(error.message)
}
export async function resolveRuntimeWorktreeByPath(
worktreePath: string,
callResult: WebRuntimeResultCaller = callRuntimeResult,
callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope,
useDetectedWorktreeCache = true,
expectedEnvironmentId = requireActiveEnvironment().id
): Promise<Worktree> {
// Why: hidden-but-open worktrees must still resolve, but `worktree.list` is sidebar-visible only — resolve via detected rows.
const worktrees = await listAllRuntimeDetectedWorktrees(
callResult,
callEnvelope,
useDetectedWorktreeCache,
expectedEnvironmentId
)
const match = worktrees
.map((worktree) => ({
worktree,
relativePath: relativePathInsideRoot(worktree.path, worktreePath)
}))
.filter((entry) => entry.relativePath !== null)
.sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0]
if (!match) {
throw new Error(`No runtime worktree owns ${worktreePath}`)
}
return match.worktree
}
export async function resolveRuntimeFilePath(
filePath: string,
preferredWorktreePath?: string,
callResult: WebRuntimeResultCaller = callRuntimeResult,
callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope,
useDetectedWorktreeCache = true,
expectedEnvironmentId = requireActiveEnvironment().id
): Promise<{ worktree: Worktree; relativePath: string }> {
const worktree = preferredWorktreePath
? await resolveRuntimeWorktreeByPath(
preferredWorktreePath,
callResult,
callEnvelope,
useDetectedWorktreeCache,
expectedEnvironmentId
)
: await resolveRuntimeWorktreeByPath(
filePath,
callResult,
callEnvelope,
useDetectedWorktreeCache,
expectedEnvironmentId
)
const relativePath = relativePathInsideRoot(worktree.path, filePath)
if (relativePath === null) {
throw new Error(`File is outside runtime worktree: ${filePath}`)
}
return { worktree, relativePath }
}
@@ -0,0 +1,118 @@
import type { PreloadApi } from '../../../../preload/api-types'
import {
computerAwakeSettingsForMode,
normalizeComputerAwakeMode
} from '../../../../shared/computer-awake-mode'
import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings'
import { mergeSettings } from './web-preference-normalization'
import {
getRuntimeBackedStoredSettings,
getStoredSettings,
settingsForActiveVisibilityOwner,
syncRuntimeBackedSettings,
updateRuntimePRBotAuthorOverride,
writeStoredSettings
} from './web-preferences-store'
import type { WebSettingsApi } from './web-preferences-store'
import {
requireActiveEnvironmentOrNull,
resolveEnvironment,
webRuntimeState
} from './web-runtime-session'
import { noopUnsubscribe } from './web-storage'
export function createWebSettingsApi(): Partial<PreloadApi> {
return {
settings: {
get: async () => getRuntimeBackedStoredSettings(),
// Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop.
getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()),
set: async (updates) => {
const sanitizedUpdates = { ...updates }
const runtimeEnvironment = requireActiveEnvironmentOrNull()
delete sanitizedUpdates.activeRuntimeEnvironmentId
if (
'worktreeVisibilityDefaults' in sanitizedUpdates &&
runtimeEnvironment &&
runtimeEnvironment.id !== webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId
) {
delete sanitizedUpdates.worktreeVisibilityDefaults
}
if ('worktreeVisibilityDefaults' in sanitizedUpdates) {
sanitizedUpdates.worktreeVisibilityDefaults = {
...settingsForActiveVisibilityOwner(getStoredSettings()).worktreeVisibilityDefaults,
...sanitizedUpdates.worktreeVisibilityDefaults
}
}
if ('computerAwakeMode' in sanitizedUpdates) {
Object.assign(
sanitizedUpdates,
computerAwakeSettingsForMode(
normalizeComputerAwakeMode(
sanitizedUpdates.computerAwakeMode,
sanitizedUpdates.keepComputerAwakeWhileAgentsRun
)
)
)
} else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) {
Object.assign(
sanitizedUpdates,
computerAwakeSettingsForMode(
sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off'
)
)
}
if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) {
sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true
}
if ('terminalCursorStyle' in sanitizedUpdates) {
Object.assign(
sanitizedUpdates,
normalizeTerminalCursorStyleDefault(
{ terminalCursorStyle: sanitizedUpdates.terminalCursorStyle },
{ preserveExplicitValue: true }
)
)
}
const localUpdates = { ...sanitizedUpdates }
if (runtimeEnvironment) {
delete localUpdates.worktreeVisibilityDefaults
}
const next = mergeSettings(getStoredSettings(), localUpdates, {
preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates
})
writeStoredSettings(next)
return settingsForActiveVisibilityOwner(
await syncRuntimeBackedSettings(sanitizedUpdates, next)
)
},
setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => {
const requestedEnvironmentId = environmentId?.trim() || null
const activeRuntimeEnvironmentId = requestedEnvironmentId
? resolveEnvironment(requestedEnvironmentId).id
: null
const next = mergeSettings(getStoredSettings(), {
activeRuntimeEnvironmentId
})
writeStoredSettings(next, activeRuntimeEnvironmentId)
return next
},
updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args),
listFonts: () => Promise.resolve([]),
onChanged: () => noopUnsubscribe
} satisfies Partial<WebSettingsApi> as unknown as WebSettingsApi,
agentAwake: {
getStatus: async () => {
const settings = getStoredSettings()
return {
mode: normalizeComputerAwakeMode(
settings.computerAwakeMode,
settings.keepComputerAwakeWhileAgentsRun
),
active: false
}
},
onChanged: () => noopUnsubscribe
}
}
}
@@ -0,0 +1,30 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { resolveRuntimeFilePath } from './web-runtime-worktree-catalog'
export function createShellApi(): NonNullable<Partial<PreloadApi>['shell']> {
const openResult = { ok: true } as const
return {
openPath: (path) =>
Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never),
openInFileManager: () => Promise.resolve(openResult),
openInExternalEditor: () => Promise.resolve(openResult),
openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never),
openFilePath: () => Promise.resolve(false),
openFileUri: (uri) =>
Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never),
pathExists: async (path) => {
try {
await resolveRuntimeFilePath(path)
return true
} catch {
return false
}
},
pickAttachment: () => Promise.resolve(null),
pickImage: () => Promise.resolve(null),
pickRepoIconImage: () => Promise.resolve(null),
pickAudio: () => Promise.resolve(null),
pickDirectory: () => Promise.resolve(null),
copyFile: () => Promise.resolve()
}
}
@@ -0,0 +1,21 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { noopUnsubscribe } from './web-storage'
export function createWebStarNagApi(): Partial<PreloadApi> {
return {
starNag: {
onShow: () => noopUnsubscribe,
onHide: () => noopUnsubscribe,
dismiss: () => Promise.resolve(),
later: () => Promise.resolve(),
complete: () => Promise.resolve(),
disable: () => Promise.resolve(),
openWeb: () => Promise.resolve(),
starOrca: () => Promise.resolve(false),
forceShow: () => Promise.resolve(),
agentValueMoment: () => Promise.resolve({ status: 'skipped' }),
showAgentValueMoment: () => Promise.resolve(),
onboardingCompleted: () => Promise.resolve()
}
}
}
@@ -0,0 +1,43 @@
export const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
export const UI_STORAGE_KEY = 'orca.web.ui.v1'
export const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1'
export const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1'
export const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1'
export const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1'
export function getBrowserPlatform(): NodeJS.Platform {
if (navigator.userAgent.includes('Windows')) {
return 'win32'
}
if (navigator.userAgent.includes('Linux')) {
return 'linux'
}
return 'darwin'
}
export function readJson<T>(key: string, fallback: T): T {
const raw = window.localStorage.getItem(key)
if (!raw) {
return cloneJson(fallback)
}
try {
return { ...cloneJson(fallback), ...JSON.parse(raw) } as T
} catch {
return cloneJson(fallback)
}
}
export function writeJson<T>(key: string, value: T): void {
window.localStorage.setItem(key, JSON.stringify(value))
}
export function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
export function noopUnsubscribe(): void {}
@@ -0,0 +1,11 @@
import type { PreloadApi } from '../../../../preload/api-types'
export function createWebTelemetryApi(): Partial<PreloadApi> {
return {
telemetryTrack: () => Promise.resolve(),
telemetrySetOptIn: () => Promise.resolve(),
telemetryGetConsentState: () =>
Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never),
telemetryAcknowledgeBanner: () => Promise.resolve()
}
}
@@ -0,0 +1,180 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../../shared/pty-delivery-diagnostics'
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironmentOrNull } from './web-runtime-session'
import { noopUnsubscribe } from './web-storage'
export function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
return {
spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')),
write: () => {},
writeAccepted: () => Promise.resolve(false),
resize: () => {},
claimViewport: () => {},
reportGeometry: () => {},
signal: () => {},
// Web panes clear the host buffer via the terminal.clearBuffer runtime RPC.
clearBuffer: () => {},
kill: () => Promise.resolve(),
ackColdRestore: () => {},
ackData: () => {},
onDeliveryResyncRequest: () => noopUnsubscribe,
respondDeliveryResync: () => {},
// Why: web terminals bypass main's delivery gate; a zero-in-flight reply keeps the watchdog idle.
reportRendererDeliveryState: () =>
Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }),
getPtyDataListenerCount: () => 0,
rendererDispatcherReady: () => {},
setActiveRendererPty: () => {},
setRendererPtyVisible: () => {},
setHiddenRendererPty: () => {},
setPtyDeliveryInterest: () => {},
// Why: remote-runtime PTYs are never hidden-gate markable, so there's no main-side responder to feed.
publishTerminalViewAttributes: () => {},
hasChildProcesses: () => Promise.resolve(false),
getForegroundProcess: () => Promise.resolve(null),
inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')),
// Why: paired web panes cannot provide a local post-boundary process scan.
confirmForegroundProcess: () => Promise.resolve(null),
getCwd: () => Promise.resolve('~'),
getSize: () => Promise.resolve(null),
listSessions: () => Promise.resolve([]),
getAuthoritativeBufferSnapshotCapabilities: (ids) =>
Promise.resolve(ids.map((id) => ({ id, authoritative: false }))),
hasPty: () => Promise.resolve(null),
getMainBufferSnapshot: () => Promise.resolve(null),
// Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative.
onSideEffect: () => noopUnsubscribe,
getSideEffectSnapshot: () => Promise.resolve(null),
getRendererDeliveryDebugSnapshot: () =>
Promise.resolve({
pendingPtyCount: 0,
pendingChars: 0,
maxPendingCharsByPty: 0,
rendererInFlightPtyCount: 0,
rendererInFlightChars: 0,
maxRendererInFlightCharsByPty: 0,
activeRendererPtyCount: 0,
flushScheduled: false,
peakPendingChars: 0,
peakMaxPendingCharsByPty: 0,
peakRendererInFlightChars: 0,
peakMaxRendererInFlightCharsByPty: 0,
ackGatedFlushSkipCount: 0,
hiddenDeliveryGatedPtyCount: 0,
hiddenDeliveryGatedVisiblePtyCount: 0,
hiddenDeliveryGatedActivePtyCount: 0,
deliveryInterestPtyCount: 0,
hiddenDeliveryDroppedChars: 0,
hiddenDeliveryDroppedChunks: 0,
pendingDroppedChars: 0,
diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS,
rendererLifecycleResetCount: 0,
lastLifecycleResetClearedChars: 0,
rendererPtyDispatcherReady: false,
rendererDispatcherReadyForcedCount: 0
}),
resetRendererDeliveryDebug: () => Promise.resolve(),
onData: () => noopUnsubscribe,
onReplay: () => noopUnsubscribe,
onModelRestoreNeeded: () => noopUnsubscribe,
onExit: () => noopUnsubscribe,
onSpawned: () => noopUnsubscribe,
onSerializeBufferRequest: () => noopUnsubscribe,
onClearBufferRequest: () => noopUnsubscribe,
sendSerializedBuffer: () => {},
declarePendingPaneSerializer: () => Promise.resolve(0),
settlePaneSerializer: () => Promise.resolve(),
clearPendingPaneSerializer: () => Promise.resolve(),
reportRendererSerializerReady: () => Promise.resolve(),
management: {
listSessions: () => Promise.resolve({ sessions: [], degraded: false }),
killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }),
killOne: () => Promise.resolve({ success: false }),
restart: () => Promise.resolve({ success: false }),
// Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden.
macTccAttribution: () => Promise.resolve({ health: 'unknown' as const })
}
}
}
export function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
return {
// Why: SSH is owned by the paired host; route read/connect to runtime RPC for state/reconnect (STA-1468). Target mgmt is desktop-only.
listTargets: async () => {
if (!requireActiveEnvironmentOrNull()) {
return []
}
const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>(
'ssh.listTargetSummaries'
)
return targets
},
listRemovedTargetLabels: async () => {
if (!requireActiveEnvironmentOrNull()) {
return {}
}
const { labels } = await callRuntimeResult<{ labels: Record<string, string> }>(
'ssh.listRemovedTargetLabels'
)
return labels
},
addTarget: () =>
Promise.reject(new Error('SSH target management is unavailable in the web client.')),
updateTarget: () =>
Promise.reject(new Error('SSH target management is unavailable in the web client.')),
removeTarget: () => Promise.resolve(),
importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }),
listConfigHosts: () =>
Promise.resolve({
hosts: [],
totalHostCount: 0,
newHostCount: 0,
matchCount: 0,
hasMore: false
}),
resolveConfigHost: () => Promise.resolve(null),
connect: async (args) => {
const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>(
'ssh.connect',
{ targetId: args.targetId }
)
return state
},
disconnect: () => Promise.resolve(),
terminateSessions: () => Promise.resolve(),
resetRelay: () => Promise.resolve(),
getState: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return null
}
const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>(
'ssh.getState',
{ targetId: args.targetId }
)
return state
},
needsPassphrasePrompt: () => Promise.resolve(false),
testConnection: () =>
Promise.resolve({
success: false,
error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.')
}),
onStateChanged: () => noopUnsubscribe,
addPortForward: () =>
Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')),
updatePortForward: () =>
Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')),
removePortForward: () => Promise.resolve(null),
listPortForwards: () => Promise.resolve([]),
listDetectedPorts: () => Promise.resolve([]),
onPortForwardsChanged: () => noopUnsubscribe,
onDetectedPortsChanged: () => noopUnsubscribe,
browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }),
onCredentialRequest: () => noopUnsubscribe,
onCredentialResolved: () => noopUnsubscribe,
submitCredential: () => Promise.resolve()
}
}
@@ -0,0 +1,241 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text'
import type { ReadClipboardTextOptions } from '../../../../shared/clipboard-text'
import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions'
import type { FeatureInteractionId } from '../../../../shared/feature-interactions'
import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields'
import type { PairedUiState } from '../../../../shared/pairing-local-ui-fields'
import {
readClipboardImagePngBase64,
saveClipboardImageAsTempFileInRuntime,
writeWebClipboardText
} from './web-clipboard-api'
import {
mergeContextualTourSeenIds,
mergeFeatureInteractionState,
mergeHostWebUIState,
mergeOsc52ClipboardNoticePending,
mergeWebUIState
} from './web-preference-normalization'
import { readLocalWebUIState } from './web-preferences-store'
import { callRuntimeResult } from './web-runtime-calls'
import { requireActiveEnvironmentOrNull } from './web-runtime-session'
import { UI_STORAGE_KEY, noopUnsubscribe, writeJson } from './web-storage'
export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
let zoomLevel = readLocalWebUIState().uiZoomLevel
return {
get: async () => {
try {
const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000)
const local = readLocalWebUIState()
const next = {
...mergeHostWebUIState(local, result.ui),
osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui),
featureInteractions: mergeFeatureInteractionState(
local.featureInteractions,
result.ui.featureInteractions
),
contextualToursSeenIds: mergeContextualTourSeenIds(
local.contextualToursSeenIds,
result.ui.contextualToursSeenIds
)
}
writeJson(UI_STORAGE_KEY, next)
zoomLevel = next.uiZoomLevel
return next
} catch {
return readLocalWebUIState()
}
},
set: async (updates) => {
const next = mergeWebUIState(readLocalWebUIState(), updates)
writeJson(UI_STORAGE_KEY, next)
zoomLevel = next.uiZoomLevel
// Why strip here too when the host also strips: an old host predating that strip would
// otherwise persist this browser's runtime:web-* keys over the desktop profile's order.
const hostUpdates = omitPairingLocalUiFields(updates)
try {
await callRuntimeResult('ui.set', hostUpdates, 15_000)
} catch {
// Why: unpaired/offline web clients still need local UI persistence.
}
},
recordFeatureInteraction: async (id: FeatureInteractionId) => {
const current = readLocalWebUIState()
const featureInteractions = normalizeFeatureInteractions(current.featureInteractions)
const existing = featureInteractions[id]
const optimistic = mergeWebUIState(current, {
featureInteractions: {
...featureInteractions,
[id]: {
firstInteractedAt: existing?.firstInteractedAt ?? Date.now(),
interactionCount: (existing?.interactionCount ?? 0) + 1
}
}
})
writeJson(UI_STORAGE_KEY, optimistic)
try {
const result = await callRuntimeResult<{ ui: PairedUiState }>(
'ui.recordFeatureInteraction',
id,
15_000
)
const local = readLocalWebUIState()
const next = {
...mergeHostWebUIState(local, result.ui),
osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui),
featureInteractions: mergeFeatureInteractionState(
local.featureInteractions,
result.ui.featureInteractions
),
contextualToursSeenIds: mergeContextualTourSeenIds(
local.contextualToursSeenIds,
result.ui.contextualToursSeenIds
)
}
writeJson(UI_STORAGE_KEY, next)
zoomLevel = next.uiZoomLevel
return next
} catch {
return optimistic
}
},
readClipboardText: async (options?: ReadClipboardTextOptions) =>
assertClipboardTextWithinLimitWithYield(
await (navigator.clipboard?.readText?.() ?? ''),
options
),
readSelectionClipboardText: () =>
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
saveClipboardImageAsTempFile: async (args?: {
connectionId?: string | null
runtimeEnvironmentId?: string | null
}) => {
if (!requireActiveEnvironmentOrNull()) {
return null
}
const contentBase64 = await readClipboardImagePngBase64()
if (!contentBase64) {
return null
}
return saveClipboardImageAsTempFileInRuntime(contentBase64, args)
},
writeClipboardText: writeWebClipboardText,
writeTerminalClipboardText: writeWebClipboardText,
writeSelectionClipboardText: () =>
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
writeClipboardImage: () => Promise.resolve(),
writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }),
performNativePaste: () => {
document.execCommand?.('paste')
},
performNativeSelectionAction: (action) => {
document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll')
},
onExportPdfRequested: () => noopUnsubscribe,
onAppMenuPaste: () => noopUnsubscribe,
onAppMenuSelectionAction: () => noopUnsubscribe,
onEditableContextPaste: () => noopUnsubscribe,
getZoomLevel: () => zoomLevel,
setZoomLevel: (level) => {
zoomLevel = level
},
isMaximized: () => Promise.resolve(false),
onOpenSettings: () => noopUnsubscribe,
// Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume.
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenSkillShare: () => noopUnsubscribe,
consumePendingSkillShare: () => Promise.resolve(null),
onOpenSetupGuide: () => noopUnsubscribe,
onOpenFeatureTour: () => noopUnsubscribe,
onOpenCrashReport: () => noopUnsubscribe,
// No desktop main process to push state changes; the web client re-reads via ui.get on interaction.
onStateChanged: () => noopUnsubscribe,
onToggleLeftSidebar: () => noopUnsubscribe,
onToggleRightSidebar: () => noopUnsubscribe,
onToggleWorktreePalette: () => noopUnsubscribe,
onToggleFloatingTerminal: () => noopUnsubscribe,
onTerminalShortcutCaptured: () => noopUnsubscribe,
onOpenQuickOpen: () => noopUnsubscribe,
onToggleQuickCommandsMenu: () => noopUnsubscribe,
onOpenTasks: () => noopUnsubscribe,
onOpenNewWorkspace: () => noopUnsubscribe,
onDeleteCurrentWorkspace: () => noopUnsubscribe,
onOpenWorkspaceBoard: () => noopUnsubscribe,
onToggleAgentDashboard: () => noopUnsubscribe,
onJumpToWorktreeIndex: () => noopUnsubscribe,
onJumpToTabIndex: () => noopUnsubscribe,
onWorktreeHistoryNavigate: () => noopUnsubscribe,
onNewBrowserTab: () => noopUnsubscribe,
onNewMarkdownTab: () => noopUnsubscribe,
onNewSimulatorTab: () => noopUnsubscribe,
onRequestTabCreate: () => noopUnsubscribe,
replyTabCreate: () => {},
onRequestTabSetProfile: () => noopUnsubscribe,
replyTabSetProfile: () => {},
onRequestTabClose: () => noopUnsubscribe,
replyTabClose: () => {},
onNewTerminalTab: () => noopUnsubscribe,
onFocusBrowserAddressBar: () => noopUnsubscribe,
onFindInBrowserPage: () => noopUnsubscribe,
onReloadBrowserPage: () => noopUnsubscribe,
onBrowserHistoryNavigate: () => noopUnsubscribe,
onZoomBrowserPage: () => noopUnsubscribe,
onHardReloadBrowserPage: () => noopUnsubscribe,
onCloseActiveTab: () => noopUnsubscribe,
onCloseFloatingItem: () => noopUnsubscribe,
onSelectFloatingIndex: () => noopUnsubscribe,
onSwitchTab: () => noopUnsubscribe,
onSwitchTabAcrossAllTypes: () => noopUnsubscribe,
onSwitchRecentTab: () => noopUnsubscribe,
onSwitchTerminalTab: () => noopUnsubscribe,
onCtrlTabKeyDown: () => noopUnsubscribe,
onCtrlTabKeyUp: () => noopUnsubscribe,
onToggleStatusBar: () => noopUnsubscribe,
onDictationKeyDown: () => noopUnsubscribe,
onActivateWorktree: () => noopUnsubscribe,
onCreateTerminal: () => noopUnsubscribe,
onRequestTerminalCreate: () => noopUnsubscribe,
onRequestTerminalTabMount: () => noopUnsubscribe,
replyTerminalCreate: () => {},
onSplitTerminal: () => noopUnsubscribe,
onRenameTerminal: () => noopUnsubscribe,
onFocusTerminal: () => noopUnsubscribe,
onFocusEditorTab: () => noopUnsubscribe,
onCloseSessionTab: () => noopUnsubscribe,
onSessionTabCloseRequest: () => noopUnsubscribe,
respondSessionTabClose: () => {},
onMoveSessionTab: () => noopUnsubscribe,
onOpenFileFromMobile: () => noopUnsubscribe,
onOpenDiffFromMobile: () => noopUnsubscribe,
onMobileMarkdownRequest: () => noopUnsubscribe,
respondMobileMarkdownRequest: () => {},
onCloseTerminal: () => noopUnsubscribe,
onTerminalTabCloseRequest: () => noopUnsubscribe,
respondTerminalTabClose: () => {},
onSleepWorktree: () => noopUnsubscribe,
// Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web.
onResumeSleepingAgents: () => noopUnsubscribe,
onTerminalZoom: () => noopUnsubscribe,
// Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery.
onSystemResumed: () => noopUnsubscribe,
onFileDrop: () => noopUnsubscribe,
syncTrafficLights: () => {},
setMarkdownEditorFocused: () => {},
setRichMarkdownContextMenuTarget: () => {},
setTerminalInputFocused: () => {},
setFloatingFocus: () => {},
setShortcutRecorderFocused: () => {},
onRichMarkdownContextCommand: () => noopUnsubscribe,
onFullscreenChanged: () => noopUnsubscribe,
minimize: () => {},
maximize: () => {},
onMaximizeChanged: () => noopUnsubscribe,
requestClose: () => {},
popupMenu: () => {},
onWindowCloseRequested: () => noopUnsubscribe,
confirmWindowClose: () => {},
notifyWindowRevealed: () => {}
}
}
@@ -0,0 +1,34 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { translate } from '@/i18n/i18n'
import { noopUnsubscribe } from './web-storage'
export function createUpdaterApi(): NonNullable<Partial<PreloadApi>['updater']> {
// Why: the linux-package-install recovery status can only originate in the native main process, so
// the web renderer never reaches these branches — reject loudly rather than resolve a fake result.
// A fresh Error per rejection: one shared instance would carry this function's stack, not the caller's.
const desktopOnlyMessage = 'Linux package install recovery is only available in the desktop app.'
return {
getVersion: () => Promise.resolve('web'),
getStatus: () => Promise.resolve({ state: 'idle' } as never),
check: () => Promise.resolve(),
download: () => Promise.resolve(),
quitAndInstall: () => Promise.resolve(),
dismissNudge: () => Promise.resolve(),
dismissAvailableUpdate: () => Promise.resolve(),
getLinuxPackageInstallInstructions: () => Promise.reject(new Error(desktopOnlyMessage)),
showLinuxPackage: () => Promise.reject(new Error(desktopOnlyMessage)),
// Why: the web client cannot install a desktop build, so channel switching
// reports unavailable rather than an empty list that looks like a fetch miss.
listBuilds: (channel) =>
Promise.resolve({
ok: false,
channel,
message: translate(
'auto.components.settings.ReleaseChannelSection.webUnavailable',
'Switching builds is only available in the desktop app.'
)
}),
onStatus: () => noopUnsubscribe,
onClearDismissal: () => noopUnsubscribe
}
}
@@ -0,0 +1,23 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { getBrowserPlatform, noopUnsubscribe } from './web-storage'
export function createWebWorkspacePortsApi(): Partial<PreloadApi> {
return {
workspacePorts: {
// Why: browser-local workspaces have no host process to inspect; return capability state instead of the generic undefined fallback.
scan: () =>
Promise.resolve({
platform: getBrowserPlatform(),
scannedAt: Date.now(),
ports: [],
unavailableReason: 'Workspace port scanning is unavailable for browser-local workspaces.'
}),
kill: () =>
Promise.resolve({
ok: false,
reason: 'Workspace port management is unavailable for browser-local workspaces.'
}),
onAdvertisedUrlChanged: () => noopUnsubscribe
}
}
}
@@ -0,0 +1,71 @@
import type { PreloadApi } from '../../../../preload/api-types'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId
} from '../../../../shared/execution-host'
import type {
WorkspaceSessionPatch,
WorkspaceSessionState
} from '../../../../shared/workspace-session-state-types'
import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session'
import { readLocalWebUIState } from './web-preferences-store'
import { requireActiveEnvironmentOrNull } from './web-runtime-session'
import { SESSION_STORAGE_KEY, readJson, writeJson } from './web-storage'
export function sessionStorageKeyForHost(hostId?: string | null): string {
const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
return resolved === LOCAL_EXECUTION_HOST_ID
? SESSION_STORAGE_KEY
: `${SESSION_STORAGE_KEY}.${resolved}`
}
export function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState {
const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID
if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) {
return sanitizeWebRuntimeWorkspaceSession(
readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession())
)
}
const localSession = sanitizeWebRuntimeWorkspaceSession(
readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession())
)
if (!requireActiveEnvironmentOrNull()) {
return localSession
}
const ui = readLocalWebUIState()
// Why: replaying browser-local terminal handles first creates stale remote PTYs; mirror host session-tabs instead.
return sanitizeWebRuntimeWorkspaceSession({
...getDefaultWorkspaceSession(),
activeRepoId: ui.lastActiveRepoId,
activeWorktreeId: ui.lastActiveWorktreeId,
lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId
})
}
export function createWebWorkspaceSessionApi(): Partial<PreloadApi> {
return {
session: {
// Mirrors desktop bridge: non-local hosts persist under a host-suffixed key so their sessions stay isolated from local.
get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)),
set: async (session, hostId) => {
writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session))
},
patch: async (patch: WorkspaceSessionPatch, hostId) => {
writeJson(
sessionStorageKeyForHost(hostId),
sanitizeWebRuntimeWorkspaceSession({
...getStoredWorkspaceSession(hostId),
...patch
})
)
},
// localStorage writes synchronously, so there is no deferred web flush.
flush: async () => {},
readTerminalScrollback: () => null,
setSync: (session, hostId) => {
writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session))
}
}
}
}
@@ -0,0 +1,188 @@
import type { PreloadApi } from '../../../../preload/api-types'
import type {
ForceDeleteWorktreeBranchResult,
RemoveWorktreeResult
} from '../../../../shared/worktree/create-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import { readRetiredNameRegistryForRepo } from '../../../../shared/worktree/retired-name-cache'
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry'
import type { Worktree } from '../../../../shared/worktree/types'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import {
callRuntimeResult,
callRuntimeResultWithOwner,
withRuntimeWorktreeOwner
} from './web-runtime-calls'
import { invalidateRuntimeWorktreeCaches } from './web-runtime-session'
import {
WEB_RUNTIME_WORKTREE_LIST_LIMIT,
callRuntimeDetectedWorktrees,
listAllRuntimeWorktrees
} from './web-runtime-worktree-catalog'
import { noopUnsubscribe } from './web-storage'
export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
return {
list: async ({ repoId }) => {
const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', {
repo: repoId,
limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT
})
return owned.result.worktrees.map((worktree) =>
withRuntimeWorktreeOwner(worktree, owned.hostId)
)
},
// Why the catch: a host predating this method answers `method_not_found`, and an empty list
// degrades the suggestion to the pre-existing behavior rather than blocking workspace create.
listRetiredNames: async ({ repoId }) => {
try {
return readRetiredNameRegistryForRepo(
await callRuntimeResult<unknown>('worktree.listRetiredNames', { repo: repoId }),
repoId
)
} catch {
return EMPTY_RETIRED_NAME_REGISTRY
}
},
listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId),
listAll: () => listAllRuntimeWorktrees(),
create: async (args) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', {
repo: args.repoId,
name: args.name,
// Absent means user-typed, which is what the host must assume — so send it only when true.
...(args.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch: args.baseBranch,
compareBaseRef: args.compareBaseRef,
branchNameOverride: args.branchNameOverride,
linkedIssue: args.linkedIssue,
linkedPR: args.linkedPR,
linkedLinearIssue: args.linkedLinearIssue,
linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey,
linkedGitLabIssue: args.linkedGitLabIssue,
linkedGitLabMR: args.linkedGitLabMR,
linkedBitbucketPR: args.linkedBitbucketPR,
linkedAzureDevOpsPR: args.linkedAzureDevOpsPR,
linkedGiteaPR: args.linkedGiteaPR,
displayName: args.displayName,
sparseCheckout: args.sparseCheckout,
pushTarget: args.pushTarget,
setupDecision: args.setupDecision,
createdWithAgent: args.createdWithAgent,
pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename,
...(args.startup
? {
startupCommand: args.startup.command,
...(args.startup.env ? { startupEnv: args.startup.env } : {}),
...(args.startup.launchConfig
? { startupLaunchConfig: args.startup.launchConfig }
: {}),
...(args.startup.startupCommandDelivery
? { startupCommandDelivery: args.startup.startupCommandDelivery }
: {}),
activate: true
}
: {}),
parentWorkspace: args.parentWorkspace,
workspaceStatus: args.workspaceStatus,
manualOrder: args.manualOrder,
automationProvenanceRequest: args.automationProvenanceRequest
})
return {
...owned.result,
worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId)
}
},
// Why: adoption verifies a desktop-owned hidden SSH target and is intentionally not a remote-server operation.
adoptProvisionedRoot: () =>
Promise.reject(
new Error('Provisioned-root recipes require a direct SSH connection from the desktop app.')
),
// Why: the runtime create path emits no two-phase progress, so the panel falls back to an indeterminate spinner.
onCreateProgress: () => noopUnsubscribe,
prefetchCreateBase: async ({ repoId, baseBranch }) => {
await callRuntimeResult('worktree.prefetchCreateBase', {
repo: repoId,
baseBranch
})
},
resolvePrBase: async ({ repoId, prNumber, headRefName, baseRefName, isCrossRepository }) =>
callRuntimeResult('worktree.resolvePrBase', {
repo: repoId,
prNumber,
headRefName,
baseRefName,
isCrossRepository
}),
resolveMrBase: async ({ repoId, mrIid, sourceBranch, targetBranch, isCrossRepository }) =>
callRuntimeResult('worktree.resolveMrBase', {
repo: repoId,
mrIid,
sourceBranch,
targetBranch,
isCrossRepository
}),
remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => {
invalidateRuntimeWorktreeCaches()
return callRuntimeResult<RemoveWorktreeResult>('worktree.rm', {
worktree: toRuntimeWorktreeSelector(worktreeId),
...(hostId ? { hostId } : {}),
force,
// Why (#11960): the web client renders the same Force Delete affordances, so
// dropping this field here would leave paired clients permanently wedged.
allowUnverifiedPtyStop,
runHooks: skipArchive !== true
})
},
// Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state.
forgetLocal: () => {
throw new Error('Forgetting a workspace is unavailable in paired web clients.')
},
forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) =>
callRuntimeResult<ForceDeleteWorktreeBranchResult>('worktree.forceDeleteBranch', {
worktree: toRuntimeWorktreeSelector(worktreeId),
branchName,
expectedHead,
...(hostId ? { hostId } : {})
}),
updateMeta: async ({ worktreeId, updates }) => {
const rpcUpdates =
Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined
? { ...updates, pushTarget: null }
: updates
const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', {
worktree: toRuntimeWorktreeSelector(worktreeId),
...rpcUpdates
})
return withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId)
},
listLineage: async () =>
await callRuntimeResult<{
lineage: Record<string, WorktreeLineage>
workspaceLineage?: Record<string, WorkspaceLineage>
}>('worktree.lineageList'),
updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => {
invalidateRuntimeWorktreeCaches()
const result = await callRuntimeResult<{
worktree: Worktree & { lineage?: WorktreeLineage | null }
}>('worktree.set', {
worktree: toRuntimeWorktreeSelector(worktreeId),
parentWorktree: parentWorktreeId,
noParent
})
return result.worktree.lineage ?? null
},
persistSortOrder: async ({ orderedIds }) => {
await callRuntimeResult('worktree.persistSortOrder', { orderedIds })
},
// Why: the capture lives in desktop main memory, unexposed over pairing; the dialog falls back to the persisted excerpt.
getBranchRenameFailureOutput: async () => null,
onChanged: () => noopUnsubscribe,
onGitStatusMetadataChanged: () => noopUnsubscribe,
onHeadIdentitiesChanged: () => noopUnsubscribe,
onBaseStatus: () => noopUnsubscribe,
onRemoteBranchConflict: () => noopUnsubscribe
}
}
@@ -301,8 +301,9 @@ describe('web UI preload API', () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, installWebPreloadApi } =
await import('./web-preload-api')
const { installWebPreloadApi } = await import('./web-preload-api')
const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS } =
await import('./preload-api/web-clipboard-api')
const contentBase64 = `${'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)}AAAA`
installClipboardImageBase64(contentBase64)
installWebPreloadApi()
@@ -414,8 +415,9 @@ describe('web UI preload API', () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS, installWebPreloadApi } =
await import('./web-preload-api')
const { installWebPreloadApi } = await import('./web-preload-api')
const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS } =
await import('./preload-api/web-clipboard-api')
installClipboardImageBase64('A'.repeat(CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + 4))
installWebPreloadApi()
@@ -578,8 +580,8 @@ describe('web UI preload API', () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES, installWebPreloadApi } =
await import('./web-preload-api')
const { installWebPreloadApi } = await import('./web-preload-api')
const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES } = await import('./preload-api/web-clipboard-api')
const clipboard = installClipboardImageBlob(
new Blob([new Uint8Array(MAX_CLIPBOARD_IMAGE_SOURCE_BYTES + 1)], { type: 'image/png' })
)
@@ -624,7 +626,8 @@ describe('web UI preload API', () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { MAX_CLIPBOARD_IMAGE_PIXELS, installWebPreloadApi } = await import('./web-preload-api')
const { installWebPreloadApi } = await import('./web-preload-api')
const { MAX_CLIPBOARD_IMAGE_PIXELS } = await import('./preload-api/web-clipboard-api')
installClipboardImageBlob(new Blob(['small'], { type: 'image/jpeg' }))
vi.stubGlobal(
'createImageBitmap',
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installBrowserGlobals } from './web-preload-api-test-harness'
describe('web preload API composition', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.doUnmock('../../../shared/e2e-config')
})
it('installs the exact concrete surface without enumerating fallback namespaces', async () => {
const globals = installBrowserGlobals('Linux')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
expect(Object.keys(globals.window.api)).toEqual([
'app',
'starNag',
'platform',
'workspacePorts',
'orcaProfiles',
'e2e',
'settings',
'agentAwake',
'keybindings',
'ui',
'crashReports',
'diagnostics',
'session',
'onboarding',
'cache',
'runtime',
'nativeChat',
'runtimeEnvironments',
'repos',
'worktrees',
'fs',
'git',
'browser',
'emulator',
'gh',
'gl',
'hostedReview',
'linear',
'hooks',
'stats',
'memory',
'aiVault',
'preflight',
'notifications',
'rateLimits',
'minimaxCredentials',
'grokAccounts',
'codexAccounts',
'claudeAccounts',
'cli',
'agentHooks',
'macosTccPrompts',
'codexConfigSync',
'developerPermissions',
'computerUsePermissions',
'updater',
'shell',
'skills',
'pty',
'ssh',
'wsl',
'pwsh',
'gitBash',
'agentStatus',
'mobile',
'telemetryTrack',
'telemetrySetOptIn',
'telemetryGetConsentState',
'telemetryAcknowledgeBanner'
])
expect(Object.keys(globals.window.api.projects)).toEqual([])
expect(Reflect.get(globals.window.api.projects, 'then')).toBeUndefined()
expect(Object.keys(globals.window.electron)).toEqual([])
})
it('snapshots E2E config before runtime storage initialization', async () => {
const evaluationOrder: string[] = []
const createE2EConfig = vi.fn((config: unknown) => {
evaluationOrder.push('e2e')
return config
})
vi.doMock('../../../shared/e2e-config', () => ({ createE2EConfig }))
vi.stubEnv('VITE_EXPOSE_STORE', 'true')
const globals = installBrowserGlobals('Linux')
Object.assign(globals.window.location, {
search: '?orcaE2ETerminalParkingDelayMs=23&orcaE2ETerminalRetentionLimit=7'
})
const readStored = globals.storage.getItem.bind(globals.storage)
vi.spyOn(globals.storage, 'getItem').mockImplementation((key) => {
evaluationOrder.push(`storage:${key}`)
return readStored(key)
})
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
expect(evaluationOrder[0]).toBe('e2e')
expect(createE2EConfig).toHaveBeenCalledWith({
exposeStore: true,
terminalParkingDelayMs: 23,
terminalRetentionLimit: 7
})
expect(globals.window.api.e2e.getConfig()).toEqual({
exposeStore: true,
terminalParkingDelayMs: 23,
terminalRetentionLimit: 7
})
})
})
@@ -105,7 +105,8 @@ describe('web GitHub preload API', () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { GITHUB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api')
const { installWebPreloadApi } = await import('./web-preload-api')
const { GITHUB_WEB_RPC_METHODS } = await import('./preload-api/web-github-routes')
installWebPreloadApi()
const api = globals.window.api
const repoPath = '/workspace/repo'
@@ -56,7 +56,8 @@ describe('web GitLab preload API', () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { GITLAB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api')
const { installWebPreloadApi } = await import('./web-preload-api')
const { GITLAB_WEB_RPC_METHODS } = await import('./preload-api/web-gitlab-routes')
installWebPreloadApi()
const api = globals.window.api
const repoPath = '/workspace/repo'
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
installBrowserGlobals,
writeStoredRuntimeEnvironment
} from './web-preload-api-test-harness'
describe('web preload runtime calls', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('preserves success and failure envelopes while persisting response runtime metadata', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
if (method === 'runtime.failure') {
return Promise.resolve({
id: method,
ok: false,
error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } },
_meta: { runtimeId: 'runtime-failure' }
})
}
return Promise.resolve({
id: method,
ok: true,
result: { value: 42 },
_meta: { runtimeId: 'runtime-success' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(globals.window.api.runtime.call({ method: 'runtime.success' })).resolves.toEqual({
id: 'runtime.success',
ok: true,
result: { value: 42 },
_meta: { runtimeId: 'runtime-success' }
})
expect(
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
).toMatchObject({ runtimeId: 'runtime-success' })
await expect(globals.window.api.runtime.call({ method: 'runtime.failure' })).resolves.toEqual({
id: 'runtime.failure',
ok: false,
error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } },
_meta: { runtimeId: 'runtime-failure' }
})
expect(
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
).toMatchObject({ runtimeId: 'runtime-failure' })
})
it('unwraps domain failures to their message after persisting failure metadata', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
return Promise.resolve({
id: method,
ok: false,
error: {
code: 'repo_unavailable',
message: 'Repository catalog is unavailable',
data: { host: 'runtime-a' }
},
_meta: { runtimeId: 'runtime-domain-failure' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
let rejection: unknown
try {
await globals.window.api.repos.list()
} catch (error) {
rejection = error
}
expect(rejection).toEqual(new Error('Repository catalog is unavailable'))
if (!(rejection instanceof Error)) {
throw new Error('Expected a domain Error rejection')
}
expect(Reflect.get(rejection, 'code')).toBeUndefined()
expect(
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
).toMatchObject({ runtimeId: 'runtime-domain-failure' })
})
it('surfaces per-environment queue overload without invoking the client', async () => {
let release!: () => void
const blocked = new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
release = () =>
resolve({
id: 'runtime.blocked',
ok: true,
result: null,
_meta: { runtimeId: 'runtime-a' }
})
})
const call = vi.fn(() => blocked)
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call = call
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const accepted = Array.from({ length: 264 }, (_, index) =>
globals.window.api.runtime.call({ method: `runtime.blocked.${index}` })
)
const overloaded = globals.window.api.runtime.call({ method: 'runtime.overloaded' })
await expect(overloaded).rejects.toMatchObject({
code: 'runtime_rpc_queue_overloaded',
scope: 'selector'
})
expect(call).toHaveBeenCalledTimes(8)
release()
await expect(Promise.all(accepted)).resolves.toHaveLength(264)
})
it('does not spend a selected call timeout while queued and forwards it unchanged', async () => {
vi.useFakeTimers()
let release!: () => void
const blocked = new Promise<RuntimeRpcResponse<unknown>>((resolve) => {
release = () =>
resolve({
id: 'runtime.blocker',
ok: true,
result: null,
_meta: { runtimeId: 'runtime-a' }
})
})
const call = vi.fn((method: string): Promise<RuntimeRpcResponse<unknown>> => {
if (method.startsWith('runtime.blocker')) {
return blocked
}
return Promise.resolve({
id: method,
ok: true,
result: 'done',
_meta: { runtimeId: 'runtime-a' }
})
})
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call = call
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const blockers = Array.from({ length: 8 }, (_, index) =>
globals.window.api.runtime.call({ method: `runtime.blocker.${index}` })
)
const queued = globals.window.api.runtimeEnvironments.call({
selector: 'web-server-a',
method: 'runtime.queued-timeout',
timeoutMs: 25
})
let settled = false
void queued.then(
() => {
settled = true
},
() => {
settled = true
}
)
await vi.advanceTimersByTimeAsync(60_000)
expect(settled).toBe(false)
expect(call).toHaveBeenCalledTimes(8)
release()
await Promise.all(blockers)
await expect(queued).resolves.toMatchObject({ ok: true, result: 'done' })
expect(call).toHaveBeenLastCalledWith('runtime.queued-timeout', undefined, { timeoutMs: 25 })
})
})
@@ -525,4 +525,59 @@ describe('web runtime environment identity', () => {
message: 'Access grant rejected.'
})
})
it('replaces API closures on reinstall while retaining the runtime client singleton', async () => {
let clientCount = 0
const call = vi.fn(
(method: string): Promise<RuntimeRpcResponse<unknown>> =>
Promise.resolve({
id: method,
ok: true,
result: { method },
_meta: { runtimeId: 'runtime-a' }
})
)
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
constructor() {
clientCount += 1
}
call = call
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const firstApi = globals.window.api
const initialZoom = firstApi.ui.getZoomLevel()
firstApi.ui.setZoomLevel(initialZoom + 1)
await expect(firstApi.runtime.call({ method: 'status.first' })).resolves.toEqual({
id: 'status.first',
ok: true,
result: { method: 'status.first' },
_meta: { runtimeId: 'runtime-a' }
})
expect(
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
).toMatchObject({ runtimeId: 'runtime-a' })
installWebPreloadApi()
const secondApi = globals.window.api
await secondApi.runtime.call({ method: 'status.second' })
await firstApi.runtime.call({ method: 'status.old-capture' })
expect(secondApi).not.toBe(firstApi)
expect(secondApi.ui.getZoomLevel()).toBe(initialZoom)
expect(firstApi.ui.getZoomLevel()).toBe(initialZoom + 1)
expect(clientCount).toBe(1)
expect(call.mock.calls.map(([method]) => method)).toEqual([
'status.first',
'status.second',
'status.old-capture'
])
})
})
@@ -343,6 +343,40 @@ describe('web worktree preload API', () => {
])
})
it('reuses the worktree catalog cache until disconnect invalidates its runtime owner', async () => {
const calls: string[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
calls.push(method)
return Promise.resolve({
id: method,
ok: true,
result:
method === 'status.get'
? { runtimeId: 'runtime-a', version: '1.0.0' }
: { worktrees: [{ id: 'worktree-a', repoId: 'repo-a', path: '/srv/a' }] },
_meta: { runtimeId: 'runtime-a' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await globals.window.api.worktrees.listAll()
await globals.window.api.worktrees.listAll()
await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' })
await globals.window.api.runtimeEnvironments.connect({ selector: 'web-server-a' })
await globals.window.api.worktrees.listAll()
expect(calls).toEqual(['worktree.list', 'status.get', 'worktree.list'])
})
it('preserves runtime-routed detected-worktree host ownership in the compatibility shape', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WebRuntimeClient } from './web-runtime-client'
class TimeoutBudgetWebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
readyState = TimeoutBudgetWebSocket.CONNECTING
binaryType = 'arraybuffer'
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
close = vi.fn()
send = vi.fn()
}
describe('WebRuntimeClient timeout budget', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', {
setTimeout,
clearTimeout,
setInterval,
clearInterval,
atob: (value: string) => Buffer.from(value, 'base64').toString('binary'),
btoa: (value: string) => Buffer.from(value, 'binary').toString('base64')
})
vi.stubGlobal('WebSocket', TimeoutBudgetWebSocket)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('starts a fresh request timeout after the connection wait completes', async () => {
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
client.close()
let resolveConnection!: () => void
const connection = new Promise<void>((resolve) => {
resolveConnection = resolve
})
// Test seam: isolate call's connection and request phases from socket handshaking.
const internals = client as unknown as {
waitForConnected: (timeoutMs?: number) => Promise<void>
sendEncrypted: (message: unknown) => boolean
}
internals.waitForConnected = vi.fn(() => connection)
internals.sendEncrypted = vi.fn(() => true)
const call = client.call('runtime.two-phase-timeout', undefined, { timeoutMs: 25 })
let settled = false
void call.then(
() => {
settled = true
},
() => {
settled = true
}
)
await vi.advanceTimersByTimeAsync(60_000)
expect(settled).toBe(false)
expect(internals.waitForConnected).toHaveBeenCalledWith(25)
resolveConnection()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(24)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await expect(call).rejects.toThrow('Request timed out: runtime.two-phase-timeout')
})
})