mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
3124 lines
111 KiB
TypeScript
3124 lines
111 KiB
TypeScript
/* eslint-disable max-lines -- Why: the web preload adapter is the browser-side
|
|
replacement for Electron preload, so the compatibility surface is necessarily
|
|
centralized at this boundary. */
|
|
import type { PreloadApi, PreflightStatus, RefreshAgentsResult } from '../../../preload/api-types'
|
|
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
|
import type {
|
|
ComputerUsePermissionSetupResult,
|
|
ComputerUsePermissionStatusResult
|
|
} from '../../../shared/computer-use-permissions-types'
|
|
import type {
|
|
DetectedWorktreeListResult,
|
|
DirEntry,
|
|
ForceDeleteWorktreeBranchResult,
|
|
GlobalSettings,
|
|
MemorySnapshot,
|
|
OnboardingState,
|
|
PersistedUIState,
|
|
Repo,
|
|
RemoveWorktreeResult,
|
|
SearchResult,
|
|
StatsSummary,
|
|
Worktree,
|
|
WorktreeLineage,
|
|
WorkspaceLineage,
|
|
WorkspaceSessionPatch,
|
|
WorkspaceSessionState
|
|
} from '../../../shared/types'
|
|
import type { SkillDiscoveryResult } from '../../../shared/skills'
|
|
import {
|
|
getDefaultOnboardingState,
|
|
getDefaultSettings,
|
|
getDefaultUIState,
|
|
getDefaultWorkspaceSession,
|
|
normalizeAgentActivityDisplayMode,
|
|
normalizeWorktreeCardProperties,
|
|
ONBOARDING_FLOW_VERSION
|
|
} from '../../../shared/constants'
|
|
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
|
|
import { createE2EConfig } from '../../../shared/e2e-config'
|
|
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
|
|
import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId } from '../../../shared/execution-host'
|
|
import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector'
|
|
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
|
|
import {
|
|
normalizeTuiAgentArgsRecord,
|
|
normalizeTuiAgentEnvRecord
|
|
} from '../../../shared/tui-agent-launch-defaults'
|
|
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings'
|
|
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
|
|
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
|
|
import { normalizeUiLanguage } from '../../../shared/ui-language'
|
|
import type { RateLimitState } from '../../../shared/rate-limit-types'
|
|
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
|
|
import {
|
|
findKeybindingConflicts,
|
|
formatKeybindingList,
|
|
getKeybindingPlatform,
|
|
isKeybindingActionId,
|
|
normalizeKeybindingArrayForAction,
|
|
type KeybindingActionId,
|
|
type KeybindingFileDiagnostic,
|
|
type KeybindingFileSnapshot,
|
|
type KeybindingOverrides,
|
|
type KeybindingPlatform
|
|
} from '../../../shared/keybindings'
|
|
import {
|
|
clearStoredWebRuntimeEnvironment,
|
|
createStoredWebRuntimeEnvironment,
|
|
getPreferredWebPairingOffer,
|
|
readStoredWebRuntimeEnvironment,
|
|
redactStoredWebRuntimeEnvironment,
|
|
saveStoredWebRuntimeEnvironment,
|
|
updateStoredEnvironmentRuntimeId,
|
|
type StoredWebRuntimeEnvironment
|
|
} from './web-runtime-environment'
|
|
import { parseWebPairingInput } from './web-pairing'
|
|
import { WebRuntimeClient } from './web-runtime-client'
|
|
import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue'
|
|
import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session'
|
|
import {
|
|
normalizeFeatureInteractions,
|
|
type FeatureInteractionId,
|
|
type FeatureInteractionState
|
|
} from '../../../shared/feature-interactions'
|
|
import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours'
|
|
import { translate } from '@/i18n/i18n'
|
|
import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults'
|
|
|
|
const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
|
|
const UI_STORAGE_KEY = 'orca.web.ui.v1'
|
|
const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1'
|
|
const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1'
|
|
const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1'
|
|
const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1'
|
|
// Why: browser-paired clients need desktop parity for large dev sessions; the
|
|
// runtime's no-limit default remains capped for lower-level RPC callers.
|
|
const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000
|
|
const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024
|
|
export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
|
|
export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
|
|
const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000
|
|
|
|
let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment()
|
|
let activeClient: WebRuntimeClient | null = null
|
|
let activeClientEnvironmentId: string | null = null
|
|
let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
|
|
let cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
|
|
const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
|
|
|
|
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)
|
|
})
|
|
}
|
|
|
|
async function convertImageBlobToPng(blob: Blob): Promise<Blob> {
|
|
const bitmap = await createImageBitmap(blob)
|
|
try {
|
|
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
|
|
}
|
|
resolve(png)
|
|
}, 'image/png')
|
|
})
|
|
} finally {
|
|
bitmap.close()
|
|
}
|
|
}
|
|
|
|
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)
|
|
const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob)
|
|
return blobToBase64(pngBlob)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function invalidateRuntimeWorktreeCaches(): void {
|
|
cachedWorktrees = null
|
|
cachedDetectedWorktrees = null
|
|
}
|
|
|
|
type WebSettingsApi = NonNullable<PreloadApi['settings']>
|
|
type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']>
|
|
type WebGitHubApi = NonNullable<PreloadApi['gh']>
|
|
type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>>
|
|
type WebGitHubRouteKey =
|
|
| 'repoSlug'
|
|
| 'repoUpstream'
|
|
| 'prForBranch'
|
|
| 'issue'
|
|
| 'workItem'
|
|
| 'workItemByOwnerRepo'
|
|
| 'workItemDetails'
|
|
| 'prFileContents'
|
|
| 'listIssues'
|
|
| 'createIssue'
|
|
| 'countWorkItems'
|
|
| 'listWorkItems'
|
|
| 'prChecks'
|
|
| 'prCheckDetails'
|
|
| 'rerunPRChecks'
|
|
| 'prComments'
|
|
| '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'
|
|
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.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'
|
|
type WebGitLabApi = NonNullable<PreloadApi['gl']>
|
|
type WebGitLabResult<K extends keyof WebGitLabApi> = Awaited<ReturnType<WebGitLabApi[K]>>
|
|
type WebGitLabRouteKey =
|
|
| 'diagnoseAuth'
|
|
| 'rateLimit'
|
|
| 'listMRs'
|
|
| 'listWorkItems'
|
|
| 'listIssues'
|
|
| 'createIssue'
|
|
| 'updateIssue'
|
|
| 'addIssueComment'
|
|
| 'listLabels'
|
|
| 'todos'
|
|
| 'workItemDetails'
|
|
| 'closeMR'
|
|
| 'reopenMR'
|
|
| 'mergeMR'
|
|
| 'updateMR'
|
|
| 'updateMRReviewers'
|
|
| 'addMRComment'
|
|
| 'addMRInlineComment'
|
|
| 'resolveMRDiscussion'
|
|
| 'jobTrace'
|
|
| 'retryJob'
|
|
| 'workItemByPath'
|
|
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'
|
|
type WebKeybindingDocument = {
|
|
version: 1
|
|
keybindings: KeybindingOverrides
|
|
platforms: Partial<Record<KeybindingPlatform, KeybindingOverrides>>
|
|
}
|
|
|
|
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',
|
|
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>
|
|
|
|
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>
|
|
|
|
const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
|
|
const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>()
|
|
|
|
export function installWebPreloadApi(): void {
|
|
activeEnvironment = readStoredWebRuntimeEnvironment()
|
|
const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean }
|
|
webWindow.__ORCA_WEB_CLIENT__ = true
|
|
window.electron = createFallbackProxy(['electron']) as Window['electron']
|
|
window.api = withFallback(createWebPreloadApi(), []) as PreloadApi
|
|
}
|
|
|
|
function createWebPreloadApi(): 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()),
|
|
awaitFirstWindowStartupServices: () => Promise.resolve(),
|
|
getKeyboardInputSourceId: () => Promise.resolve(null),
|
|
setUnreadDockBadgeCount: () => Promise.resolve(),
|
|
getFloatingTerminalCwd: () => Promise.resolve(''),
|
|
getFloatingMarkdownDirectory: () => Promise.resolve(''),
|
|
pickFloatingMarkdownDocument: () => Promise.resolve(null),
|
|
pickFloatingWorkspaceDirectory: () => Promise.resolve(null)
|
|
},
|
|
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()
|
|
},
|
|
platform: {
|
|
get: () => ({
|
|
platform: getBrowserPlatform(),
|
|
osRelease: ''
|
|
})
|
|
},
|
|
e2e: {
|
|
getConfig: () => createE2EConfig({})
|
|
},
|
|
settings: {
|
|
get: async () => getRuntimeBackedStoredSettings(),
|
|
set: async (updates) => {
|
|
if (updates.activeRuntimeEnvironmentId === null) {
|
|
disconnectActiveRuntimeEnvironment()
|
|
}
|
|
const sanitizedUpdates = { ...updates }
|
|
if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) {
|
|
sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true
|
|
}
|
|
const next = mergeSettings(getStoredSettings(), sanitizedUpdates, {
|
|
preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates
|
|
})
|
|
writeJson(SETTINGS_STORAGE_KEY, next)
|
|
return syncRuntimeBackedSettings(sanitizedUpdates, next)
|
|
},
|
|
listFonts: () => Promise.resolve([]),
|
|
onChanged: () => noopUnsubscribe
|
|
} satisfies Partial<WebSettingsApi> as unknown as WebSettingsApi,
|
|
keybindings: createWebKeybindingsApi(),
|
|
ui: createWebUiApi(),
|
|
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.')
|
|
})
|
|
},
|
|
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.'))
|
|
},
|
|
session: {
|
|
// hostId mirrors the desktop bridge: omitted/'local' targets the existing
|
|
// storage key; non-local hosts persist under a host-suffixed key so their
|
|
// sessions stay isolated from the local one.
|
|
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
|
|
})
|
|
)
|
|
},
|
|
readTerminalScrollback: () => null,
|
|
setSync: (session, hostId) => {
|
|
writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session))
|
|
}
|
|
},
|
|
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
|
|
}
|
|
},
|
|
cache: {
|
|
getGitHub: () =>
|
|
Promise.resolve(
|
|
readJson(GITHUB_CACHE_STORAGE_KEY, {
|
|
pr: {},
|
|
issue: {}
|
|
})
|
|
),
|
|
setGitHub: async ({ cache }) => {
|
|
writeJson(GITHUB_CACHE_STORAGE_KEY, cache)
|
|
}
|
|
},
|
|
runtime: createRuntimeApi(),
|
|
runtimeEnvironments: createRuntimeEnvironmentsApi(),
|
|
repos: createReposApi(),
|
|
worktrees: createWorktreesApi(),
|
|
fs: createFileApi(),
|
|
git: createGitApi(),
|
|
browser: createBrowserApi(),
|
|
emulator: createEmulatorApi(),
|
|
gh: createGitHubApi(),
|
|
gl: createGitLabApi(),
|
|
hostedReview: createRuntimeNamespaceApi('hostedReview'),
|
|
linear: createRuntimeNamespaceApi('linear'),
|
|
hooks: createHooksApi(),
|
|
stats: {
|
|
getSummary: async () =>
|
|
callRuntimeResult<StatsSummary>('stats.summary').catch(() => ({
|
|
totalAgentsSpawned: 0,
|
|
totalPRsCreated: 0,
|
|
totalAgentTimeMs: 0,
|
|
firstEventAt: null
|
|
}))
|
|
},
|
|
memory: {
|
|
getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot())
|
|
},
|
|
aiVault: {
|
|
listSessions: () =>
|
|
Promise.resolve({
|
|
sessions: [],
|
|
issues: [],
|
|
scannedAt: new Date().toISOString()
|
|
})
|
|
},
|
|
preflight: createPreflightApi(),
|
|
notifications: createNotificationsApi(),
|
|
rateLimits: createRateLimitsApi(),
|
|
codexAccounts: createAccountsApi(),
|
|
claudeAccounts: createAccountsApi(),
|
|
cli: createCliApi(),
|
|
agentHooks: createAgentHooksApi(),
|
|
developerPermissions: createDeveloperPermissionsApi(),
|
|
computerUsePermissions: createComputerUsePermissionsApi(),
|
|
updater: createUpdaterApi(),
|
|
shell: createShellApi(),
|
|
skills: createSkillsApi(),
|
|
pty: createPtyApi(),
|
|
ssh: createSshApi(),
|
|
wsl: {
|
|
isAvailable: () => callRuntimeResult<boolean>('host.wsl.isAvailable').catch(() => false),
|
|
listDistros: () => callRuntimeResult<string[]>('host.wsl.listDistros').catch(() => [])
|
|
},
|
|
pwsh: {
|
|
isAvailable: () => callRuntimeResult<boolean>('host.pwsh.isAvailable').catch(() => false)
|
|
},
|
|
gitBash: {
|
|
isAvailable: () => callRuntimeResult<boolean>('host.gitBash.isAvailable').catch(() => false)
|
|
},
|
|
agentStatus: {
|
|
onSet: () => noopUnsubscribe,
|
|
onClear: () => noopUnsubscribe,
|
|
getSnapshot: () => Promise.resolve([]),
|
|
inferInterrupt: () => Promise.resolve(false),
|
|
onMigrationUnsupported: () => noopUnsubscribe,
|
|
onMigrationUnsupportedClear: () => noopUnsubscribe,
|
|
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
|
|
drop: () => {}
|
|
},
|
|
mobile: {
|
|
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),
|
|
getPairingQR: () => Promise.resolve({ available: 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(activeEnvironment), endpoint: null })
|
|
},
|
|
telemetryTrack: () => Promise.resolve(),
|
|
telemetrySetOptIn: () => Promise.resolve(),
|
|
telemetryGetConsentState: () =>
|
|
Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never),
|
|
telemetryAcknowledgeBanner: () => Promise.resolve()
|
|
}
|
|
}
|
|
|
|
function createEmptyWebKeybindingDocument(): WebKeybindingDocument {
|
|
return {
|
|
version: 1,
|
|
keybindings: {},
|
|
platforms: {
|
|
darwin: {},
|
|
linux: {},
|
|
win32: {}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getWebKeybindingPlatform(): KeybindingPlatform {
|
|
return getKeybindingPlatform(getBrowserPlatform())
|
|
}
|
|
|
|
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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.prototype.hasOwnProperty.call(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
|
|
}
|
|
|
|
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>>)
|
|
: {}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function writeWebKeybindingAction(
|
|
actionId: KeybindingActionId,
|
|
bindings: string[] | null
|
|
): KeybindingFileSnapshot {
|
|
if (!isKeybindingActionId(actionId)) {
|
|
throw new Error(`Unknown keybinding action "${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
|
|
}
|
|
|
|
function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void {
|
|
for (const listener of webKeybindingListeners) {
|
|
listener(snapshot)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createRuntimeApi(): 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,
|
|
onBrowserDriverChanged: () => noopUnsubscribe
|
|
}
|
|
}
|
|
|
|
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.')
|
|
}
|
|
closeActiveRuntimeClients()
|
|
activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer })
|
|
saveStoredWebRuntimeEnvironment(activeEnvironment)
|
|
return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) }
|
|
},
|
|
resolve: async ({ selector }) =>
|
|
redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)),
|
|
remove: async ({ selector }) => {
|
|
const environment = resolveEnvironment(selector)
|
|
if (activeEnvironment?.id === environment.id) {
|
|
disconnectActiveRuntimeEnvironment()
|
|
}
|
|
return { removed: redactStoredWebRuntimeEnvironment(environment) }
|
|
},
|
|
disconnect: async ({ selector }) => {
|
|
const environment = resolveEnvironment(selector)
|
|
if (activeEnvironment?.id === environment.id) {
|
|
disconnectActiveRuntimeEnvironment()
|
|
}
|
|
return { disconnected: redactStoredWebRuntimeEnvironment(environment) }
|
|
},
|
|
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)
|
|
return client.subscribe(method, params, callbacks, { timeoutMs })
|
|
}
|
|
}
|
|
}
|
|
|
|
function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
|
|
return {
|
|
list: async () => (await callRuntimeResult<{ repos: Repo[] }>('repo.list')).repos,
|
|
add: async ({ path, kind }) => {
|
|
invalidateRuntimeWorktreeCaches()
|
|
return callRuntimeResult('repo.add', { path, kind })
|
|
},
|
|
remove: async ({ repoId }) => {
|
|
await callRuntimeResult('repo.rm', { repo: repoId })
|
|
invalidateRuntimeWorktreeCaches()
|
|
},
|
|
reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }),
|
|
update: async ({ repoId, updates }) =>
|
|
(await callRuntimeResult<{ repo: Repo }>('repo.update', { repo: repoId, updates })).repo,
|
|
pickFolder: () => Promise.resolve(null),
|
|
pickFolders: () => Promise.resolve([]),
|
|
pickDirectory: () => Promise.resolve(null),
|
|
clone: async ({ url, destination }) => {
|
|
invalidateRuntimeWorktreeCaches()
|
|
return (
|
|
await callRuntimeResult<{ repo: Repo }>('repo.clone', { url, destination }, 10 * 60_000)
|
|
).repo
|
|
},
|
|
cloneRemote: async () => {
|
|
// Why: SSH relay cloning is owned by the desktop main process; paired web
|
|
// clients must not pretend they can run that local IPC path directly.
|
|
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 cannot create folders through 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 result = await callRuntimeResult<{ repo: Repo }>('repo.add', {
|
|
path: remotePath,
|
|
kind
|
|
})
|
|
return displayName
|
|
? {
|
|
repo: await createReposApi().update({
|
|
repoId: result.repo.id,
|
|
updates: { displayName }
|
|
})
|
|
}
|
|
: result
|
|
},
|
|
create: async ({ parentPath, name, kind }) => {
|
|
invalidateRuntimeWorktreeCaches()
|
|
return callRuntimeResult('repo.create', { parentPath, name, kind })
|
|
},
|
|
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
|
|
}
|
|
}
|
|
|
|
function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
|
|
return {
|
|
list: async ({ repoId }) =>
|
|
(
|
|
await callRuntimeResult<{ worktrees: Worktree[] }>('worktree.list', {
|
|
repo: repoId,
|
|
limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT
|
|
})
|
|
).worktrees,
|
|
listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId),
|
|
listAll: () => listAllRuntimeWorktrees(),
|
|
create: async (args) => {
|
|
invalidateRuntimeWorktreeCaches()
|
|
return callRuntimeResult('worktree.create', {
|
|
repo: args.repoId,
|
|
name: args.name,
|
|
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,
|
|
parentWorkspace: args.parentWorkspace,
|
|
workspaceStatus: args.workspaceStatus,
|
|
manualOrder: args.manualOrder
|
|
})
|
|
},
|
|
// Why: the runtime create path emits no two-phase progress, so the web
|
|
// client's creation panel simply 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, force }) => {
|
|
invalidateRuntimeWorktreeCaches()
|
|
return callRuntimeResult<RemoveWorktreeResult>('worktree.rm', {
|
|
worktree: toRuntimeWorktreeSelector(worktreeId),
|
|
force
|
|
})
|
|
},
|
|
forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead }) =>
|
|
callRuntimeResult<ForceDeleteWorktreeBranchResult>('worktree.forceDeleteBranch', {
|
|
worktree: toRuntimeWorktreeSelector(worktreeId),
|
|
branchName,
|
|
expectedHead
|
|
}),
|
|
updateMeta: async ({ worktreeId, updates }) =>
|
|
(
|
|
await callRuntimeResult<{ worktree: Worktree }>('worktree.set', {
|
|
worktree: toRuntimeWorktreeSelector(worktreeId),
|
|
...updates
|
|
})
|
|
).worktree,
|
|
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 })
|
|
},
|
|
onChanged: () => noopUnsubscribe,
|
|
onBaseStatus: () => noopUnsubscribe,
|
|
onRemoteBranchConflict: () => noopUnsubscribe
|
|
}
|
|
}
|
|
|
|
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
|
|
})
|
|
},
|
|
downloadFile: 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)
|
|
})
|
|
},
|
|
writeFile: async ({ filePath, content }) => {
|
|
const file = await resolveRuntimeFilePath(filePath)
|
|
await callRuntimeResult('files.write', {
|
|
worktree: toRuntimeWorktreeSelector(file.worktree.id),
|
|
relativePath: file.relativePath,
|
|
content
|
|
})
|
|
},
|
|
createFile: async ({ filePath }) => {
|
|
const file = await resolveRuntimeFilePath(filePath)
|
|
await callRuntimeResult('files.createFile', {
|
|
worktree: toRuntimeWorktreeSelector(file.worktree.id),
|
|
relativePath: file.relativePath
|
|
})
|
|
},
|
|
createDir: async ({ dirPath }) => {
|
|
const file = await resolveRuntimeFilePath(dirPath)
|
|
await callRuntimeResult('files.createDir', {
|
|
worktree: toRuntimeWorktreeSelector(file.worktree.id),
|
|
relativePath: file.relativePath
|
|
})
|
|
},
|
|
rename: async ({ oldPath, newPath }) => {
|
|
const oldFile = await resolveRuntimeFilePath(oldPath)
|
|
const newFile = await resolveRuntimeFilePath(newPath)
|
|
await callRuntimeResult('files.rename', {
|
|
worktree: toRuntimeWorktreeSelector(oldFile.worktree.id),
|
|
oldRelativePath: oldFile.relativePath,
|
|
newRelativePath: newFile.relativePath
|
|
})
|
|
},
|
|
copy: async ({ sourcePath, destinationPath }) => {
|
|
const source = await resolveRuntimeFilePath(sourcePath)
|
|
const destination = await resolveRuntimeFilePath(destinationPath)
|
|
await callRuntimeResult('files.copy', {
|
|
worktree: toRuntimeWorktreeSelector(source.worktree.id),
|
|
sourceRelativePath: source.relativePath,
|
|
destinationRelativePath: destination.relativePath
|
|
})
|
|
},
|
|
deletePath: async ({ targetPath, recursive }) => {
|
|
const file = await resolveRuntimeFilePath(targetPath)
|
|
await callRuntimeResult('files.delete', {
|
|
worktree: toRuntimeWorktreeSelector(file.worktree.id),
|
|
relativePath: file.relativePath,
|
|
recursive
|
|
})
|
|
},
|
|
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)
|
|
},
|
|
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
|
|
}
|
|
}
|
|
|
|
function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
|
|
return {
|
|
status: async ({ worktreePath, includeIgnored }) => {
|
|
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
|
return callRuntimeResult('git.status', {
|
|
worktree: toRuntimeWorktreeSelector(worktree.id),
|
|
includeIgnored
|
|
})
|
|
},
|
|
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 a local-desktop helper;
|
|
// in the web runtime there's no offer, so return no candidates / no-op.
|
|
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
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']> {
|
|
return {
|
|
registerGuest: () => Promise.resolve(),
|
|
unregisterGuest: () => Promise.resolve(),
|
|
openDevTools: () => Promise.resolve(false),
|
|
setViewportOverride: () => Promise.resolve(false),
|
|
setAnnotationViewportBridge: () => Promise.resolve(false),
|
|
onGuestLoadFailed: () => noopUnsubscribe,
|
|
onPermissionDenied: () => noopUnsubscribe,
|
|
onPopup: () => noopUnsubscribe,
|
|
onDownloadRequested: () => noopUnsubscribe,
|
|
onDownloadProgress: () => noopUnsubscribe,
|
|
onDownloadFinished: () => noopUnsubscribe,
|
|
onContextMenuRequested: () => noopUnsubscribe,
|
|
onContextMenuDismissed: () => noopUnsubscribe,
|
|
onNavigationUpdate: () => noopUnsubscribe,
|
|
onActivateView: () => noopUnsubscribe,
|
|
onPaneFocus: () => noopUnsubscribe,
|
|
onOpenLinkInOrcaTab: () => noopUnsubscribe,
|
|
acceptDownload: () =>
|
|
Promise.resolve({ ok: false, reason: 'Downloads are handled by the server browser.' }),
|
|
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']>
|
|
}
|
|
|
|
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']>
|
|
}
|
|
|
|
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 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
|
|
})
|
|
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),
|
|
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),
|
|
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: () =>
|
|
route<WebGitHubResult<'listAccessibleProjects'>>(
|
|
GITHUB_WEB_RPC_METHODS.listAccessibleProjects
|
|
),
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 })
|
|
}
|
|
}
|
|
}
|
|
|
|
function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|
let zoomLevel = readLocalWebUIState().uiZoomLevel
|
|
return {
|
|
get: async () => {
|
|
try {
|
|
const result = await callRuntimeResult<{ ui: PersistedUIState }>(
|
|
'ui.get',
|
|
undefined,
|
|
15_000
|
|
)
|
|
const local = readLocalWebUIState()
|
|
const next = {
|
|
...mergeWebUIState(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
|
|
try {
|
|
await callRuntimeResult('ui.set', updates, 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: PersistedUIState }>(
|
|
'ui.recordFeatureInteraction',
|
|
id,
|
|
15_000
|
|
)
|
|
const local = readLocalWebUIState()
|
|
const next = {
|
|
...mergeWebUIState(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: () => navigator.clipboard?.readText?.() ?? Promise.resolve(''),
|
|
readSelectionClipboardText: () =>
|
|
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
|
|
saveClipboardImageAsTempFile: async (args?: { connectionId?: string | null }) => {
|
|
if (!requireActiveEnvironmentOrNull()) {
|
|
return null
|
|
}
|
|
const contentBase64 = await readClipboardImagePngBase64()
|
|
if (!contentBase64) {
|
|
return null
|
|
}
|
|
return saveClipboardImageAsTempFileInRuntime(contentBase64, args)
|
|
},
|
|
writeClipboardText: (text) => navigator.clipboard?.writeText?.(text) ?? Promise.resolve(),
|
|
writeSelectionClipboardText: () =>
|
|
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
|
|
writeClipboardImage: () => Promise.resolve(),
|
|
getZoomLevel: () => zoomLevel,
|
|
setZoomLevel: (level) => {
|
|
zoomLevel = level
|
|
},
|
|
isMaximized: () => Promise.resolve(false),
|
|
onOpenSettings: () => noopUnsubscribe,
|
|
onOpenSetupGuide: () => noopUnsubscribe,
|
|
onOpenFeatureTour: () => noopUnsubscribe,
|
|
onOpenCrashReport: () => noopUnsubscribe,
|
|
// No desktop main process to push state changes; the web client re-reads
|
|
// via ui.get on interaction instead.
|
|
onStateChanged: () => noopUnsubscribe,
|
|
onToggleLeftSidebar: () => noopUnsubscribe,
|
|
onToggleRightSidebar: () => noopUnsubscribe,
|
|
onToggleWorktreePalette: () => noopUnsubscribe,
|
|
onToggleFloatingTerminal: () => noopUnsubscribe,
|
|
onTerminalShortcutCaptured: () => noopUnsubscribe,
|
|
onOpenQuickOpen: () => noopUnsubscribe,
|
|
onOpenTasks: () => noopUnsubscribe,
|
|
onOpenNewWorkspace: () => noopUnsubscribe,
|
|
onDeleteCurrentWorkspace: () => noopUnsubscribe,
|
|
onOpenWorkspaceBoard: () => 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,
|
|
onSwitchTab: () => noopUnsubscribe,
|
|
onSwitchTabAcrossAllTypes: () => noopUnsubscribe,
|
|
onSwitchRecentTab: () => noopUnsubscribe,
|
|
onSwitchTerminalTab: () => noopUnsubscribe,
|
|
onCtrlTabKeyDown: () => noopUnsubscribe,
|
|
onCtrlTabKeyUp: () => noopUnsubscribe,
|
|
onToggleStatusBar: () => noopUnsubscribe,
|
|
onDictationKeyDown: () => noopUnsubscribe,
|
|
onActivateWorktree: () => noopUnsubscribe,
|
|
onCreateTerminal: () => noopUnsubscribe,
|
|
onRequestTerminalCreate: () => noopUnsubscribe,
|
|
replyTerminalCreate: () => {},
|
|
onSplitTerminal: () => noopUnsubscribe,
|
|
onRenameTerminal: () => noopUnsubscribe,
|
|
onFocusTerminal: () => noopUnsubscribe,
|
|
onFocusEditorTab: () => noopUnsubscribe,
|
|
onCloseSessionTab: () => noopUnsubscribe,
|
|
onMoveSessionTab: () => noopUnsubscribe,
|
|
onOpenFileFromMobile: () => noopUnsubscribe,
|
|
onOpenDiffFromMobile: () => noopUnsubscribe,
|
|
onMobileMarkdownRequest: () => noopUnsubscribe,
|
|
respondMobileMarkdownRequest: () => {},
|
|
onCloseTerminal: () => noopUnsubscribe,
|
|
onSleepWorktree: () => noopUnsubscribe,
|
|
onTerminalZoom: () => noopUnsubscribe,
|
|
onFileDrop: () => noopUnsubscribe,
|
|
syncTrafficLights: () => {},
|
|
setMarkdownEditorFocused: () => {},
|
|
setTerminalInputFocused: () => {},
|
|
setFloatingTerminalInputFocused: () => {},
|
|
setShortcutRecorderFocused: () => {},
|
|
onRichMarkdownContextCommand: () => noopUnsubscribe,
|
|
onFullscreenChanged: () => noopUnsubscribe,
|
|
minimize: () => {},
|
|
maximize: () => {},
|
|
onMaximizeChanged: () => noopUnsubscribe,
|
|
requestClose: () => {},
|
|
popupMenu: () => {},
|
|
onWindowCloseRequested: () => noopUnsubscribe,
|
|
confirmWindowClose: () => {}
|
|
}
|
|
}
|
|
|
|
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'
|
|
}
|
|
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(() => [])
|
|
: []
|
|
}
|
|
}
|
|
|
|
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']>
|
|
}
|
|
|
|
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')
|
|
}
|
|
}
|
|
|
|
function createDeveloperPermissionsApi(): NonNullable<Partial<PreloadApi>['developerPermissions']> {
|
|
return {
|
|
getStatus: () => Promise.resolve([]),
|
|
request: ({ id }) =>
|
|
Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const),
|
|
openSettings: () => Promise.resolve()
|
|
}
|
|
}
|
|
|
|
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: []
|
|
})
|
|
}
|
|
}
|
|
|
|
function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
|
return {
|
|
discover: () =>
|
|
callRuntimeResult<SkillDiscoveryResult>('skills.discover', undefined, 15_000).catch(() => ({
|
|
skills: [],
|
|
sources: [],
|
|
scannedAt: Date.now()
|
|
}))
|
|
}
|
|
}
|
|
|
|
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 }),
|
|
requestPermission: () =>
|
|
Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }),
|
|
playSound: () => Promise.resolve({ played: false, reason: 'missing-path' })
|
|
}
|
|
}
|
|
|
|
function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
|
|
const empty: RateLimitState = {
|
|
claude: null,
|
|
codex: null,
|
|
gemini: null,
|
|
opencodeGo: null,
|
|
kimi: null,
|
|
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 do not own local Codex auth, so reset-credit
|
|
// redemption remains desktop-only and reports the safe no-credit outcome.
|
|
consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }),
|
|
refreshClaudeForTarget: () => Promise.resolve(empty),
|
|
setPollingInterval: () => Promise.resolve(),
|
|
fetchInactiveClaudeAccounts: () => Promise.resolve(),
|
|
fetchInactiveCodexAccounts: () => Promise.resolve(),
|
|
onUpdate: () => noopUnsubscribe
|
|
}
|
|
}
|
|
|
|
function createAccountsApi(): never {
|
|
const empty = {
|
|
accounts: [],
|
|
activeAccountId: null,
|
|
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
|
}
|
|
return {
|
|
list: () => Promise.resolve(empty),
|
|
add: () => Promise.resolve(empty),
|
|
reauthenticate: () => Promise.resolve(empty),
|
|
remove: () => Promise.resolve(empty),
|
|
select: () => Promise.resolve(empty)
|
|
} as never
|
|
}
|
|
|
|
function createUpdaterApi(): NonNullable<Partial<PreloadApi>['updater']> {
|
|
return {
|
|
getVersion: () => Promise.resolve('web'),
|
|
getStatus: () => Promise.resolve({ state: 'idle' } as never),
|
|
check: () => Promise.resolve(),
|
|
download: () => Promise.resolve(),
|
|
quitAndInstall: () => Promise.resolve(),
|
|
dismissNudge: () => Promise.resolve(),
|
|
onStatus: () => noopUnsubscribe,
|
|
onClearDismissal: () => noopUnsubscribe
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
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: () => {},
|
|
reportGeometry: () => {},
|
|
signal: () => {},
|
|
kill: () => Promise.resolve(),
|
|
ackColdRestore: () => {},
|
|
ackData: () => {},
|
|
setActiveRendererPty: () => {},
|
|
hasChildProcesses: () => Promise.resolve(false),
|
|
getForegroundProcess: () => Promise.resolve(null),
|
|
getCwd: () => Promise.resolve('~'),
|
|
listSessions: () => Promise.resolve([]),
|
|
getMainBufferSnapshot: () => 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
|
|
}),
|
|
resetRendererDeliveryDebug: () => Promise.resolve(),
|
|
onData: () => noopUnsubscribe,
|
|
onReplay: () => noopUnsubscribe,
|
|
onExit: () => noopUnsubscribe,
|
|
onSerializeBufferRequest: () => noopUnsubscribe,
|
|
onClearBufferRequest: () => noopUnsubscribe,
|
|
sendSerializedBuffer: () => {},
|
|
declarePendingPaneSerializer: () => Promise.resolve(0),
|
|
settlePaneSerializer: () => Promise.resolve(),
|
|
clearPendingPaneSerializer: () => Promise.resolve(),
|
|
management: {
|
|
listSessions: () => Promise.resolve({ sessions: [] }),
|
|
killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0 }),
|
|
killOne: () => Promise.resolve({ success: false }),
|
|
restart: () => Promise.resolve({ success: false })
|
|
}
|
|
}
|
|
}
|
|
|
|
function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
|
|
return {
|
|
listTargets: () => Promise.resolve([]),
|
|
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([]),
|
|
connect: () => Promise.resolve(null),
|
|
disconnect: () => Promise.resolve(),
|
|
terminateSessions: () => Promise.resolve(),
|
|
resetRelay: () => Promise.resolve(),
|
|
getState: () => Promise.resolve(null),
|
|
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: '' }),
|
|
onCredentialRequest: () => noopUnsubscribe,
|
|
onCredentialResolved: () => noopUnsubscribe,
|
|
submitCredential: () => Promise.resolve()
|
|
}
|
|
}
|
|
|
|
async function callRuntimeEnvelope<TResult = unknown>(
|
|
method: string,
|
|
params?: unknown,
|
|
timeoutMs?: number
|
|
): Promise<RuntimeRpcResponse<TResult>> {
|
|
const environment = requireActiveEnvironment()
|
|
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
|
|
getClientForEnvironment(environment).call(method, params, { timeoutMs })
|
|
)
|
|
updateEnvironmentFromResponse(environment, response)
|
|
return response as RuntimeRpcResponse<TResult>
|
|
}
|
|
|
|
async function callEnvironmentEnvelope<TResult = unknown>(
|
|
selector: string,
|
|
method: string,
|
|
params?: unknown,
|
|
timeoutMs?: number
|
|
): Promise<RuntimeRpcResponse<TResult>> {
|
|
const environment = resolveEnvironment(selector)
|
|
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
|
|
getClientForEnvironment(environment).call(method, params, { timeoutMs })
|
|
)
|
|
updateEnvironmentFromResponse(environment, response)
|
|
return response as RuntimeRpcResponse<TResult>
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async function saveClipboardImageAsTempFileInRuntime(
|
|
contentBase64: string,
|
|
args?: { connectionId?: string | null }
|
|
): Promise<string> {
|
|
if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) {
|
|
throw new Error('Clipboard image is too large')
|
|
}
|
|
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: once chunked paste has created server-side state, failed append or
|
|
// commit must not wait for TTL cleanup before releasing the bounded slot.
|
|
await callRuntimeResult(
|
|
'clipboard.abortImageUpload',
|
|
{ uploadId },
|
|
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
|
|
).catch(() => {})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function getRemoteRuntimeStatus(): Promise<RuntimeStatus> {
|
|
return callRuntimeResult<RuntimeStatus>('status.get', undefined, 15_000)
|
|
}
|
|
|
|
function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebRuntimeClient {
|
|
if (!activeClient || activeClientEnvironmentId !== environment.id) {
|
|
activeClient?.close()
|
|
activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment))
|
|
activeClientEnvironmentId = environment.id
|
|
}
|
|
return activeClient
|
|
}
|
|
|
|
function closeActiveRuntimeClients(): void {
|
|
activeClient?.close()
|
|
activeClient = null
|
|
activeClientEnvironmentId = null
|
|
invalidateRuntimeWorktreeCaches()
|
|
}
|
|
|
|
function disconnectActiveRuntimeEnvironment(): void {
|
|
closeActiveRuntimeClients()
|
|
clearStoredWebRuntimeEnvironment()
|
|
activeEnvironment = null
|
|
}
|
|
|
|
function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment {
|
|
const environment = requireActiveEnvironment()
|
|
if (selector === environment.id || selector === environment.name || selector === 'active') {
|
|
return environment
|
|
}
|
|
if (selector.startsWith('web-') && environment.id.startsWith('web-')) {
|
|
// Why: persisted terminal ids can outlive a web-client re-pair, which creates
|
|
// a fresh web-* environment id even when it points at the same active server.
|
|
return environment
|
|
}
|
|
throw new Error(`Unknown Orca runtime environment: ${selector}`)
|
|
}
|
|
|
|
function requireActiveEnvironment(): StoredWebRuntimeEnvironment {
|
|
activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment()
|
|
if (!activeEnvironment) {
|
|
throw new Error('Pair this web client with an Orca server first.')
|
|
}
|
|
return activeEnvironment
|
|
}
|
|
|
|
function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null {
|
|
activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment()
|
|
return activeEnvironment
|
|
}
|
|
|
|
function updateEnvironmentFromResponse(
|
|
environment: StoredWebRuntimeEnvironment,
|
|
response: RuntimeRpcResponse<unknown>
|
|
): void {
|
|
const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null)
|
|
activeEnvironment = updateStoredEnvironmentRuntimeId(environment, runtimeId)
|
|
}
|
|
|
|
function getStoredSettings(): GlobalSettings {
|
|
const environment = (activeEnvironment = 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),
|
|
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 ||
|
|
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)
|
|
}
|
|
} catch {
|
|
// Keep readJson's invalid-JSON fallback non-destructive.
|
|
}
|
|
}
|
|
return mergeSettings(
|
|
{
|
|
...defaults,
|
|
floatingTerminalEnabled: false,
|
|
rightSidebarOpenByDefault: false,
|
|
activeRuntimeEnvironmentId: environment?.id ?? null
|
|
},
|
|
migratedStored
|
|
)
|
|
}
|
|
|
|
async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
|
|
const local = getStoredSettings()
|
|
if (!requireActiveEnvironmentOrNull()) {
|
|
return local
|
|
}
|
|
try {
|
|
const result = await callRuntimeResult<{ settings: Partial<GlobalSettings> }>(
|
|
'settings.get',
|
|
undefined,
|
|
15_000
|
|
)
|
|
const runtimeSettings: Partial<GlobalSettings> = {}
|
|
if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') {
|
|
runtimeSettings.experimentalNewWorktreeCardStyle =
|
|
result.settings.experimentalNewWorktreeCardStyle
|
|
}
|
|
const next = mergeSettings(local, runtimeSettings)
|
|
writeJson(SETTINGS_STORAGE_KEY, next)
|
|
return next
|
|
} catch {
|
|
// Why: unpaired/offline web clients keep a local settings fallback.
|
|
return local
|
|
}
|
|
}
|
|
|
|
async function syncRuntimeBackedSettings(
|
|
updates: Partial<GlobalSettings>,
|
|
localNext: GlobalSettings
|
|
): Promise<GlobalSettings> {
|
|
if (!requireActiveEnvironmentOrNull()) {
|
|
return localNext
|
|
}
|
|
const runtimeUpdates: Partial<GlobalSettings> = {}
|
|
if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') {
|
|
runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle
|
|
}
|
|
if (Object.keys(runtimeUpdates).length === 0) {
|
|
return localNext
|
|
}
|
|
try {
|
|
const result = await callRuntimeResult<{ settings: Partial<GlobalSettings> }>(
|
|
'settings.update',
|
|
runtimeUpdates,
|
|
15_000
|
|
)
|
|
const next = mergeSettings(localNext, result.settings)
|
|
writeJson(SETTINGS_STORAGE_KEY, next)
|
|
return next
|
|
} catch {
|
|
// Why: unpaired/offline web clients still need local settings persistence.
|
|
return localNext
|
|
}
|
|
}
|
|
|
|
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: pairing already means the user has an Orca server. Desktop first-run
|
|
// onboarding would incorrectly probe browser-local tools and block the client.
|
|
writeJson(ONBOARDING_STORAGE_KEY, closed)
|
|
return closed
|
|
}
|
|
|
|
/** Resolve the localStorage key for a session partition. Non-'local' hosts get
|
|
* a host-suffixed key so their sessions never clobber the local one. */
|
|
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}`
|
|
}
|
|
|
|
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: paired web clients mirror host session-tabs after startup. Replaying
|
|
// browser-local terminal handles first creates stale remote PTYs and errors.
|
|
return sanitizeWebRuntimeWorkspaceSession({
|
|
...getDefaultWorkspaceSession(),
|
|
activeRepoId: ui.lastActiveRepoId,
|
|
activeWorktreeId: ui.lastActiveWorktreeId,
|
|
lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId
|
|
})
|
|
}
|
|
|
|
function closeWebOnboarding(base: OnboardingState): OnboardingState {
|
|
return {
|
|
...base,
|
|
flowVersion: ONBOARDING_FLOW_VERSION,
|
|
closedAt: Date.now(),
|
|
outcome: 'dismissed',
|
|
checklist: {
|
|
...base.checklist,
|
|
dismissed: true
|
|
}
|
|
}
|
|
}
|
|
|
|
function readLocalWebUIState(): PersistedUIState {
|
|
const defaults = getDefaultUIState()
|
|
const stored = readJson<Partial<PersistedUIState>>(UI_STORAGE_KEY, {})
|
|
if (typeof stored.rightSidebarOpen === 'boolean') {
|
|
return mergeWebUIState(defaults, stored)
|
|
}
|
|
const storedSettings = getStoredSettings()
|
|
return mergeWebUIState(defaults, {
|
|
...stored,
|
|
// Why: web fallback lacks main-process normalization, so migrate the
|
|
// retired setting only when the local UI preference is still absent.
|
|
rightSidebarOpen: storedSettings.rightSidebarOpenByDefault
|
|
})
|
|
}
|
|
|
|
function mergeWebUIState(
|
|
base: PersistedUIState,
|
|
updates: Partial<PersistedUIState>
|
|
): PersistedUIState {
|
|
const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } =
|
|
updates as Partial<PersistedUIState> & {
|
|
featureInteractionTelemetryBuckets?: unknown
|
|
}
|
|
void _reserved
|
|
return {
|
|
...base,
|
|
...safeUpdates,
|
|
worktreeCardProperties: normalizeWorktreeCardProperties(
|
|
safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties
|
|
),
|
|
_worktreeCardModeDefaulted:
|
|
safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted,
|
|
agentActivityDisplayMode: normalizeAgentActivityDisplayMode(
|
|
safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode
|
|
)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
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: activeEnvironment?.id ?? updates.activeRuntimeEnvironmentId ?? null,
|
|
terminalCustomThemes: normalizeTerminalCustomThemes(
|
|
updates.terminalCustomThemes ?? base.terminalCustomThemes
|
|
),
|
|
uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage)
|
|
}
|
|
return {
|
|
...merged,
|
|
...normalizeAutoRenameBranchFromWorkDefaultOn(merged, {
|
|
preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate
|
|
})
|
|
}
|
|
}
|
|
|
|
async function listAllRuntimeWorktrees(): Promise<Worktree[]> {
|
|
if (cachedWorktrees && Date.now() - cachedWorktrees.loadedAt < 5_000) {
|
|
return cachedWorktrees.worktrees
|
|
}
|
|
const result = await callRuntimeResult<{ worktrees: Worktree[] }>('worktree.list', {
|
|
limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT
|
|
})
|
|
cachedWorktrees = { loadedAt: Date.now(), worktrees: result.worktrees }
|
|
return result.worktrees
|
|
}
|
|
|
|
async function listAllRuntimeDetectedWorktrees(): Promise<Worktree[]> {
|
|
if (cachedDetectedWorktrees && Date.now() - cachedDetectedWorktrees.loadedAt < 5_000) {
|
|
return cachedDetectedWorktrees.worktrees
|
|
}
|
|
|
|
const repos = (await callRuntimeResult<{ repos: Repo[] }>('repo.list')).repos
|
|
const detectedLists = await Promise.all(
|
|
repos.map((repo) => callRuntimeDetectedWorktrees(repo.id))
|
|
)
|
|
const worktrees = detectedLists.flatMap((result) => result.worktrees)
|
|
cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees }
|
|
return worktrees
|
|
}
|
|
|
|
async function callRuntimeDetectedWorktrees(repoId: string): Promise<DetectedWorktreeListResult> {
|
|
const response = await callRuntimeEnvelope<DetectedWorktreeListResult>(
|
|
'worktree.detectedList',
|
|
{ repo: repoId },
|
|
15_000
|
|
)
|
|
if (response.ok) {
|
|
return response.result
|
|
}
|
|
if (response.error.code !== 'method_not_found') {
|
|
throw new Error(response.error.message)
|
|
}
|
|
|
|
const legacy = await callRuntimeResult<{ worktrees: Worktree[] }>(
|
|
'worktree.list',
|
|
{ repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT },
|
|
15_000
|
|
)
|
|
return toLegacyDetectedWorktreeResult(repoId, legacy.worktrees)
|
|
}
|
|
|
|
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
|
|
}))
|
|
}
|
|
}
|
|
|
|
function isMissingPathError(error: unknown): boolean {
|
|
if (!(error instanceof Error)) {
|
|
return false
|
|
}
|
|
return /\bENOENT\b|not found|no such file/i.test(error.message)
|
|
}
|
|
|
|
async function resolveRuntimeWorktreeByPath(worktreePath: string): Promise<Worktree> {
|
|
// Why: hidden-but-open worktrees must still resolve for git/file operations.
|
|
// `worktree.list` is sidebar-visible only, so path resolution uses detected rows.
|
|
const worktrees = await listAllRuntimeDetectedWorktrees()
|
|
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
|
|
}
|
|
|
|
async function resolveRuntimeFilePath(
|
|
filePath: string,
|
|
preferredWorktreePath?: string
|
|
): Promise<{ worktree: Worktree; relativePath: string }> {
|
|
const worktree = preferredWorktreePath
|
|
? await resolveRuntimeWorktreeByPath(preferredWorktreePath)
|
|
: await resolveRuntimeWorktreeByPath(filePath)
|
|
const relativePath = relativePathInsideRoot(worktree.path, filePath)
|
|
if (relativePath === null) {
|
|
throw new Error(`File is outside runtime worktree: ${filePath}`)
|
|
}
|
|
return { worktree, relativePath }
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
async function mutateGitPaths(
|
|
method: string,
|
|
worktreePath: string,
|
|
filePaths: string[]
|
|
): Promise<void> {
|
|
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
|
|
await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths })
|
|
}
|
|
|
|
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: runtime repo selectors accept loose path/name forms, but duplicate
|
|
// checked-out repos can make those ambiguous. The renderer already passes
|
|
// Orca's repo id on task calls, so prefer the explicit selector.
|
|
repo: repoId ? `id:${repoId}` : record.repoPath
|
|
}
|
|
}
|
|
|
|
function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown {
|
|
if (prefix !== 'hostedReview') {
|
|
return args
|
|
}
|
|
return mapRepoPathArg(args)
|
|
}
|
|
|
|
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,
|
|
usedMemory: 0,
|
|
memoryUsagePercent: 0,
|
|
cpuCoreCount: navigator.hardwareConcurrency || 1,
|
|
loadAverage1m: 0
|
|
},
|
|
totalCpu: 0,
|
|
totalMemory: 0,
|
|
collectedAt: Date.now()
|
|
}
|
|
}
|
|
|
|
function getBrowserPlatform(): NodeJS.Platform {
|
|
if (navigator.userAgent.includes('Windows')) {
|
|
return 'win32'
|
|
}
|
|
if (navigator.userAgent.includes('Linux')) {
|
|
return 'linux'
|
|
}
|
|
return 'darwin'
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
function writeJson<T>(key: string, value: T): void {
|
|
window.localStorage.setItem(key, JSON.stringify(value))
|
|
}
|
|
|
|
function cloneJson<T>(value: T): T {
|
|
return JSON.parse(JSON.stringify(value)) as T
|
|
}
|
|
|
|
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)])
|
|
}
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function noopUnsubscribe(): void {}
|