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