From f48c038a93b1650f18ae29be49d12c2d9a36f736 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:46:36 -0700 Subject: [PATCH 01/37] Extract local PTY launch helpers (#16778) --- .../providers/local-pty-launch-helpers.ts | 98 ++++++++++++ src/main/providers/local-pty-provider.ts | 140 ++---------------- 2 files changed, 111 insertions(+), 127 deletions(-) create mode 100644 src/main/providers/local-pty-launch-helpers.ts diff --git a/src/main/providers/local-pty-launch-helpers.ts b/src/main/providers/local-pty-launch-helpers.ts new file mode 100644 index 00000000000..220669d7cb7 --- /dev/null +++ b/src/main/providers/local-pty-launch-helpers.ts @@ -0,0 +1,98 @@ +import { basename, delimiter, win32 as pathWin32 } from 'node:path' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { parseWslPath } from '../wsl' +import { resolvePathEnvKey } from '../pty/windows-environment-path' +import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' +import { resolveSafePtyDefaultCwd } from './pty-default-cwd' + +const PANE_IDENTITY_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN' +] as const + +export function getDefaultCwd(): string { + return resolveSafePtyDefaultCwd() +} + +export function removeUnspecifiedPaneIdentityEnv( + env: Record, + explicitEnv: Record | undefined +): void { + for (const key of PANE_IDENTITY_ENV_KEYS) { + if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { + delete env[key] + } + } +} + +export function promoteAgentTeamsShimPath( + env: Record, + requestedPath: string | undefined +): void { + if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { + return + } + const normalizedRequestedPath = + process.platform === 'win32' + ? expandWindowsEnvironmentVariables(requestedPath, env) + : requestedPath + const pathDelimiter = process.platform === 'win32' ? ';' : delimiter + const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] + if (!shimDir) { + return + } + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) +} + +export function getWslContextFromWorktreeId( + worktreeId: string | undefined +): { distro: string; treatPosixCwdAsWsl: true } | undefined { + // Why: strip any synthetic `::workspace:` suffix so WSL detection parses the real path, not a nonexistent identifier. + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + const wslInfo = worktreePath ? parseWslPath(worktreePath) : null + return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined +} + +export function getWslContextFromPreferredDistro( + distro: string | null | undefined +): { distro: string; treatPosixCwdAsWsl: true } | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined +} + +export function normalizeLocalCallerSessionId( + sessionId: string | undefined, + allowNumeric = false +): string | null { + const requested = sessionId?.trim() + return !requested || (!allowNumeric && /^\d+$/.test(requested)) ? null : requested +} + +export function normalizeForegroundProcessName( + processName: string | null | undefined +): string | null { + const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' + return !trimmed || trimmed === 'xterm-256color' ? null : trimmed.split(/[\\/]/).pop() || null +} + +export function resolveForegroundFallbackProcess( + processName: string | null | undefined, + shellName: string | undefined +): string | null { + if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) { + return processName || null + } + return shellName ?? processName ?? null +} + +/** Basename of the spawned shell path, parsed for the *target* platform. + * Why: POSIX `basename` won't split a Windows `\` path (non-Windows host/CI), so it'd break the foreground comparison. */ +export function getSpawnedShellName(shellPath: string): string { + return process.platform === 'win32' ? pathWin32.basename(shellPath) : basename(shellPath) +} diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 223168b65c0..1ad88aaa2ba 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */ -import { basename, delimiter, win32 as pathWin32 } from 'node:path' +import { basename, win32 as pathWin32 } from 'node:path' import { randomUUID } from 'node:crypto' import { ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, @@ -15,7 +15,6 @@ import { resolveProcessCwd } from './process-cwd' import { existsSync } from 'node:fs' import * as pty from 'node-pty' import { getDefaultWslDistro, parseWslPath, isWslAvailableAsync } from '../wsl' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' import { injectHistoryEnv, @@ -76,7 +75,7 @@ import { } from './windows-cached-agent-revalidation' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' -import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' +import { assertSafeAgentStartupCwd } from './pty-default-cwd' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' @@ -94,18 +93,19 @@ import { createShellPromptReadinessProbe, type ShellPromptReadinessProbe } from '../shell-prompt-readiness-probe' -import { - expandWindowsEnvironmentVariables, - expandWindowsPathEnvironmentVariables -} from '../../shared/windows-environment-expansion' +import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' -const PANE_IDENTITY_ENV_KEYS = [ - 'ORCA_PANE_KEY', - 'ORCA_TAB_ID', - 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN' -] as const +import { + getDefaultCwd, + getSpawnedShellName, + getWslContextFromPreferredDistro, + getWslContextFromWorktreeId, + normalizeLocalCallerSessionId, + promoteAgentTeamsShimPath, + removeUnspecifiedPaneIdentityEnv, + resolveForegroundFallbackProcess +} from './local-pty-launch-helpers' let ptyCounter = 0 const ptyProcesses = new Map() @@ -174,51 +174,6 @@ const dataListeners = new Set() const exitListeners = new Set() const startupIngressByPty = new Map() -/** - * Returns a stable default cwd for locally spawned PTYs. - */ -function getDefaultCwd(): string { - return resolveSafePtyDefaultCwd() -} - -/** - * Removes inherited pane identity unless this PTY explicitly supplies it. - */ -function removeUnspecifiedPaneIdentityEnv( - env: Record, - explicitEnv: Record | undefined -): void { - for (const key of PANE_IDENTITY_ENV_KEYS) { - if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { - delete env[key] - } - } -} - -/** - * Promotes the agent-teams shim path ahead of inherited PATH entries. - */ -function promoteAgentTeamsShimPath( - env: Record, - requestedPath: string | undefined -): void { - if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { - return - } - const normalizedRequestedPath = - process.platform === 'win32' - ? expandWindowsEnvironmentVariables(requestedPath, env) - : requestedPath - const pathDelimiter = process.platform === 'win32' ? ';' : delimiter - const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] - if (!shimDir) { - return - } - const pathKey = resolvePathEnvKey(env, process.platform) - const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] - env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) -} - /** * Disposes native node-pty listeners registered for a PTY id. */ @@ -254,30 +209,6 @@ function runPtyCleanup(id: string): void { cleanup() } -/** - * Resolves a WSL context from a worktree id whose path is already a WSL path. - */ -function getWslContextFromWorktreeId( - worktreeId: string | undefined -): { distro: string; treatPosixCwdAsWsl: true } | undefined { - // Why: strip any synthetic `::workspace:` suffix so WSL detection parses the real path, not a nonexistent identifier. - const worktreePath = worktreeId - ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - : undefined - const wslInfo = worktreePath ? parseWslPath(worktreePath) : null - return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined -} - -/** - * Resolves a WSL launch context from a user-selected distro name. - */ -function getWslContextFromPreferredDistro( - distro: string | null | undefined -): { distro: string; treatPosixCwdAsWsl: true } | undefined { - const trimmed = distro?.trim() - return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined -} - /** * Removes all local tracking state for a PTY id after teardown. */ @@ -413,20 +344,6 @@ function cancelAllPendingLocalPtySpawns(): void { } } -/** - * Normalizes renderer session ids that should be reused for local PTY reattach. - */ -function normalizeLocalCallerSessionId( - sessionId: string | undefined, - allowNumeric = false -): string | null { - const requested = sessionId?.trim() - if (!requested || (!allowNumeric && /^\d+$/.test(requested))) { - return null - } - return requested -} - function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResult | null { const existing = ptyProcesses.get(id) if (!existing) { @@ -445,37 +362,6 @@ function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResul } } -/** - * Normalizes node-pty foreground process strings to executable basenames. - */ -function normalizeForegroundProcessName(processName: string | null | undefined): string | null { - const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' - if (!trimmed || trimmed === 'xterm-256color') { - return null - } - return trimmed.split(/[\\/]/).pop() || null -} - -/** - * Falls back to the spawned Windows shell when node-pty reports a terminal name. - */ -function resolveForegroundFallbackProcess( - processName: string | null | undefined, - shellName: string | undefined -): string | null { - if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) { - return processName || null - } - // Why: Windows node-pty may expose only the terminal name (`xterm-256color`); the spawned shell is the best foreground fallback. - return shellName ?? processName ?? null -} - -/** Basename of the spawned shell path, parsed for the *target* platform. - * Why: POSIX `basename` won't split a Windows `\` path (non-Windows host/CI), so it'd break the foreground comparison. */ -function getSpawnedShellName(shellPath: string): string { - return process.platform === 'win32' ? pathWin32.basename(shellPath) : basename(shellPath) -} - /** * Disposes the native PTY handle while avoiding recycled-pid signals on POSIX. */ From bdabb09a07af4eca7d50314e9dcbe25e77017fa6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:50:11 -0700 Subject: [PATCH 02/37] Extract mobile host and browser helpers (#16779) --- mobile/app/h/[hostId]/index.tsx | 312 +------------- mobile/src/browser/MobileBrowserPane.tsx | 395 +----------------- .../src/browser/mobile-browser-frame-state.ts | 210 ++++++++++ .../src/browser/mobile-browser-pane-styles.ts | 171 ++++++++ .../host-screen/host-screen-primary-styles.ts | 175 ++++++++ .../host-screen-secondary-styles.ts | 139 ++++++ mobile/src/host-screen/host-screen-styles.ts | 7 + 7 files changed, 728 insertions(+), 681 deletions(-) create mode 100644 mobile/src/browser/mobile-browser-frame-state.ts create mode 100644 mobile/src/browser/mobile-browser-pane-styles.ts create mode 100644 mobile/src/host-screen/host-screen-primary-styles.ts create mode 100644 mobile/src/host-screen/host-screen-secondary-styles.ts create mode 100644 mobile/src/host-screen/host-screen-styles.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 3400fd3694b..b211f5cc92f 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native' +import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router' import { @@ -69,8 +69,9 @@ import { MobileSearchField } from '../../../src/components/MobileSearchField' import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder' import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache' import { setCachedRepos } from '../../../src/cache/repo-cache' -import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { colors, spacing } from '../../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' +import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles' import { leaveHostRoute } from '../../../src/host-route-exit' import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' import { @@ -1410,310 +1411,3 @@ export default function HostWorktreeRoute() { function ListSeparator() { return } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase - }, - topChrome: { - backgroundColor: colors.bgPanel, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle - }, - statusBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - minHeight: 34, - paddingTop: spacing.xs, - paddingHorizontal: spacing.lg - }, - backButton: { - width: 32, - height: 32, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.xs - }, - sidebarCollapseButton: { - width: 24, - height: 24, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button, - marginLeft: spacing.xs - }, - hostIdentity: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - minWidth: 0, - marginRight: spacing.md - }, - hostNameText: { - flex: 1, - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary - }, - reconnectButton: { - paddingVertical: 4, - paddingHorizontal: spacing.sm, - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - reconnectButtonText: { - color: colors.textPrimary, - fontSize: typography.metaSize, - fontWeight: '600' - }, - toolbar: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.xs + 2, - paddingHorizontal: spacing.md, - gap: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle - }, - embeddedToolbar: { - paddingVertical: spacing.xs + 2, - paddingHorizontal: spacing.sm, - gap: spacing.xs, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle - }, - embeddedToolbarRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - embeddedFilterChip: { - flex: 1, - minWidth: 0, - height: 30, - justifyContent: 'center', - paddingHorizontal: spacing.xs, - paddingVertical: 0 - }, - embeddedModeButton: { - flex: 1, - minWidth: 0, - height: 30, - justifyContent: 'center', - paddingHorizontal: spacing.xs, - paddingVertical: 0 - }, - filterChip: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: spacing.sm + 2, - paddingVertical: spacing.xs, - borderRadius: 12, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - filterChipActive: { - borderColor: colors.textSecondary, - backgroundColor: colors.bgRaised - }, - filterChipText: { - fontSize: 12, - color: colors.textSecondary - }, - filterChipTextActive: { - color: colors.textPrimary - }, - modeButton: { - flexDirection: 'row', - alignItems: 'center', - flexShrink: 1, - minWidth: 0, - gap: 4, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs - }, - sortLabel: { - flexShrink: 1, - minWidth: 0, - fontSize: 12, - color: colors.textSecondary - }, - toolbarSpacer: { - flex: 1 - }, - floatingWorkspaceHeaderButton: { - width: 32, - height: 32, - alignItems: 'center', - justifyContent: 'center', - marginLeft: spacing.xs - }, - embeddedToolbarIconButton: { - flex: 1, - height: 28, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - toolbarIconDisabled: { - opacity: 0.6 - }, - searchToggle: { - padding: spacing.xs - }, - searchBar: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - centered: { - flex: 1, - alignItems: 'center', - justifyContent: 'center' - }, - emptyText: { - color: colors.textSecondary, - fontSize: typography.bodySize - }, - errorText: { - color: colors.statusRed, - fontSize: typography.bodySize - }, - list: { - paddingBottom: spacing.lg - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.lg, - paddingTop: spacing.md, - paddingBottom: spacing.xs - }, - sectionIcon: { - marginRight: spacing.xs - }, - sectionRepoIcon: { - marginRight: spacing.xs - }, - sectionTitle: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.5 - }, - sectionCount: { - fontSize: 11, - color: colors.textMuted, - marginLeft: spacing.xs - }, - separator: { - height: 1, - backgroundColor: colors.borderSubtle, - marginLeft: spacing.lg + 24, - marginRight: spacing.lg - }, - filterModalHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - filterModalTitle: { - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary - }, - clearFiltersText: { - fontSize: 13, - color: colors.textSecondary - }, - filterSectionLabel: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - filterGroup: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden', - marginBottom: spacing.md - }, - filterRow: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2, - gap: spacing.sm - }, - filterRowText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.textPrimary - }, - filterSeparator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - filterRepoDot: { - width: 8, - height: 8, - borderRadius: 4 - }, - confirmContent: { - paddingBottom: spacing.lg - }, - confirmTitle: { - fontSize: 16, - fontWeight: '700', - color: colors.textPrimary - }, - confirmMessage: { - fontSize: typography.bodySize, - color: colors.textSecondary, - marginTop: spacing.xs, - lineHeight: 20 - }, - confirmButtons: { - flexDirection: 'row', - gap: spacing.sm - }, - confirmBtn: { - flex: 1, - paddingVertical: spacing.sm + 2, - borderRadius: 10, - alignItems: 'center' - }, - confirmBtnCancel: { - backgroundColor: colors.bgPanel - }, - confirmBtnDestructive: { - backgroundColor: colors.statusRed - }, - confirmBtnPressed: { - opacity: 0.7 - }, - confirmBtnCancelText: { - fontSize: typography.bodySize, - fontWeight: '600', - color: colors.textSecondary - }, - confirmBtnDestructiveText: { - fontSize: typography.bodySize, - fontWeight: '600', - color: '#fff' - } -}) diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 6397f69c549..f823f309630 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -1,7 +1,4 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */ -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, @@ -10,7 +7,6 @@ import { PanResponder, PixelRatio, Pressable, - StyleSheet, Text, TextInput, View, @@ -24,7 +20,7 @@ import type { BrowserScreencastFrame, BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { colors } from '../theme/mobile-theme' import { MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS, buildMobileBrowserScreencastRequest, @@ -47,11 +43,32 @@ import { computeBrowserTouchClickRadiusCss, mapScreenToBrowserPoint, readLocalTouchPoint, - type BrowserFrameGeometry, type BrowserPoint, type BrowserTouchLayout, type BrowserZoomState } from './browser-touch-geometry' +import { + MAX_ZOOM, + MIN_ZOOM, + assertRpcOk, + browserErrorMessage, + browserFrameMetadataEqual, + buttonColor, + cacheBrowserFrame, + clearCachedBrowserFramesForWorktree, + createBrowserFrameDataUri, + createPinchGesture, + getCachedBrowserFrame, + makeBrowserFrameCacheKey, + peekCachedBrowserFrame, + shouldSurfaceBrowserError, + updateBrowserImageSource, + updateBrowserLayerVisibility, + updatePinchZoom, + type FrameLayer, + type PinchGesture +} from './mobile-browser-frame-state' +import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles' import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url' import { MobileBrowserAddressField } from './MobileBrowserAddressField' import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync' @@ -79,15 +96,6 @@ type MobileBrowserPaneProps = { onToast: (message: string, durationMs?: number) => void } -type FrameLayer = 0 | 1 - -type PinchGesture = { - distance: number - scale: number - anchorX: number - anchorY: number -} - type PanGesture = { x: number y: number @@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22 const LONG_PRESS_MS = 550 const WHEEL_INTERVAL_MS = 70 const TOUCH_CLICK_RADIUS_DIP = 14 -const MIN_ZOOM = 1 -const MAX_ZOOM = 3.5 const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 } -const BROWSER_FRAME_CACHE_LIMIT = 4 - -type BrowserFrameCacheEntry = { - uri: string - metadata: BrowserScreencastFrameMetadata -} - -const browserFrameCache = new Map() type BrowserPageParams = { worktree: string @@ -1268,350 +1266,3 @@ export function MobileBrowserPane({ ) } - -function buttonColor(enabled: boolean): string { - return enabled ? colors.textSecondary : colors.textMuted -} - -function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string { - return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` -} - -function makeBrowserFrameCacheKey( - worktreeId: string, - browserPageId: string | null, - viewMode: MobileBrowserViewMode -): string | null { - return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null -} - -function clearCachedBrowserFramesForWorktree(worktreeId: string): void { - const prefix = `${worktreeId}:` - for (const key of browserFrameCache.keys()) { - if (key.startsWith(prefix)) { - browserFrameCache.delete(key) - } - } -} - -function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { - if (!cacheKey) { - return null - } - const cached = browserFrameCache.get(cacheKey) - if (!cached) { - return null - } - browserFrameCache.delete(cacheKey) - browserFrameCache.set(cacheKey, cached) - return cached -} - -function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { - return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null -} - -function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void { - if (!cacheKey) { - return - } - browserFrameCache.delete(cacheKey) - browserFrameCache.set(cacheKey, entry) - while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) { - const oldestKey = browserFrameCache.keys().next().value - if (typeof oldestKey !== 'string') { - break - } - browserFrameCache.delete(oldestKey) - } -} - -function updateBrowserLayerVisibility( - layers: [View | null, View | null], - visible: FrameLayer -): void { - for (const [index, layer] of layers.entries()) { - layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } }) - } -} - -function updateBrowserImageSource(image: Image | null, uri: string): void { - // Why: browser frames are large strings; mutating only the native Image - // source avoids re-rendering the whole tab view for every streamed frame. - const source = [{ uri }] - image?.setNativeProps({ source, src: source }) -} - -function assertRpcOk( - response: RpcSuccess | RpcFailure, - fallbackMessage: string -): asserts response is RpcSuccess { - if (!response.ok) { - throw new Error(response.error.message || fallbackMessage) - } -} - -function browserFrameMetadataEqual( - a: BrowserScreencastFrameMetadata | null, - b: BrowserScreencastFrameMetadata -): boolean { - return ( - a?.deviceWidth === b.deviceWidth && - a?.deviceHeight === b.deviceHeight && - a?.pageScaleFactor === b.pageScaleFactor - ) -} - -function browserErrorMessage(error: unknown, fallback: string): string { - return error instanceof Error && error.message ? error.message : fallback -} - -function shouldSurfaceBrowserError(message: string): boolean { - const normalized = message.toLowerCase() - // Why: selector_not_found can be emitted by in-flight page automation while - // the browser is still usable; replacing the frame with it feels like a crash. - return !normalized.includes('selector_not_found') && !normalized.includes('selector not found') -} - -function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null { - const touches = event.nativeEvent.touches - if (!touches || touches.length < 2) { - return null - } - const a = readLocalTouchPoint(touches[0]) - const b = readLocalTouchPoint(touches[1]) - return a && b ? { a, b } : null -} - -function pointDistance(a: BrowserPoint, b: BrowserPoint): number { - return Math.hypot(a.x - b.x, a.y - b.y) -} - -function createPinchGesture( - event: GestureResponderEvent, - geometry: BrowserFrameGeometry | null, - zoom: BrowserZoomState -): PinchGesture | null { - if (!geometry) { - return null - } - const pair = touchPair(event) - if (!pair) { - return null - } - const distance = pointDistance(pair.a, pair.b) - if (distance < 8) { - return null - } - const centerX = (pair.a.x + pair.b.x) / 2 - const centerY = (pair.a.y + pair.b.y) / 2 - const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX - const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY - return { - distance, - scale: zoom.scale, - anchorX: (centerX - frameCenterX) / zoom.scale, - anchorY: (centerY - frameCenterY) / zoom.scale - } -} - -function updatePinchZoom( - event: GestureResponderEvent, - geometry: BrowserFrameGeometry | null, - pinch: PinchGesture -): BrowserZoomState | null { - if (!geometry) { - return null - } - const pair = touchPair(event) - if (!pair) { - return null - } - const nextScale = Math.min( - MAX_ZOOM, - Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance) - ) - const centerX = (pair.a.x + pair.b.x) / 2 - const centerY = (pair.a.y + pair.b.y) / 2 - const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2 - const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2 - return clampBrowserZoomState( - { - scale: nextScale, - offsetX: centerX - baseCenterX - pinch.anchorX * nextScale, - offsetY: centerY - baseCenterY - pinch.anchorY * nextScale - }, - geometry, - MIN_ZOOM, - MAX_ZOOM - ) -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - minHeight: 0, - backgroundColor: colors.bgBase - }, - toolbar: { - minHeight: 32, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - paddingHorizontal: spacing.sm, - paddingVertical: 2, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - viewport: { - flex: 1, - minHeight: 0, - overflow: 'hidden', - backgroundColor: colors.bgBase - }, - browserImageHost: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden' - }, - browserImageFill: { - width: '100%', - height: '100%' - }, - browserImageLayer: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center' - }, - browserImageLayerHidden: { - opacity: 0 - }, - browserZoomOffset: { - alignItems: 'center', - justifyContent: 'center' - }, - browserFrameBox: { - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden' - }, - browserImage: { - backgroundColor: colors.bgBase - }, - overlay: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, - gap: spacing.sm, - backgroundColor: 'rgba(13, 15, 24, 0.2)' - }, - errorText: { - color: colors.textPrimary, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.button, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - fontSize: 13, - textAlign: 'center', - overflow: 'hidden' - }, - dialogOverlay: { - ...StyleSheet.absoluteFillObject, - zIndex: 30, - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, - backgroundColor: 'rgba(13, 15, 24, 0.5)' - }, - dialogCard: { - width: '100%', - maxWidth: 360, - borderRadius: radii.card, - borderWidth: 1, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgPanel, - padding: spacing.lg - }, - dialogTitle: { - color: colors.textPrimary, - fontSize: 16, - fontWeight: '600' - }, - dialogMessage: { - color: colors.textSecondary, - fontSize: typography.bodySize, - lineHeight: 20, - marginTop: spacing.sm - }, - dialogActions: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: spacing.sm, - marginTop: spacing.lg - }, - dialogButton: { - minHeight: 34, - borderRadius: radii.button, - backgroundColor: colors.bgRaised, - paddingHorizontal: spacing.md, - alignItems: 'center', - justifyContent: 'center' - }, - dialogButtonPrimary: { - backgroundColor: colors.textPrimary - }, - dialogButtonPressed: { - opacity: 0.75 - }, - dialogButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '600' - }, - dialogButtonPrimaryText: { - color: colors.bgBase - }, - keyboardDock: { - zIndex: 20, - borderTopWidth: 1, - borderTopColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - inputRow: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingTop: spacing.xs, - paddingBottom: spacing.xs + 2 - }, - keyboardInput: { - flex: 1, - height: 34, - backgroundColor: colors.bgRaised, - color: colors.textPrimary, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - fontSize: 14, - fontFamily: typography.monoFamily, - marginRight: spacing.sm - }, - sendButton: { - width: 34, - height: 34, - borderRadius: 17, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised - }, - disabled: { - opacity: 0.35 - }, - disabledText: { - color: colors.textMuted - } -}) diff --git a/mobile/src/browser/mobile-browser-frame-state.ts b/mobile/src/browser/mobile-browser-frame-state.ts new file mode 100644 index 00000000000..9609c2ffda4 --- /dev/null +++ b/mobile/src/browser/mobile-browser-frame-state.ts @@ -0,0 +1,210 @@ +import { Buffer } from 'buffer' +import type { GestureResponderEvent, Image, View } from 'react-native' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import type { + BrowserScreencastFrame, + BrowserScreencastFrameMetadata +} from '../transport/browser-screencast-protocol' +import { colors } from '../theme/mobile-theme' +import { + clampBrowserZoomState, + readLocalTouchPoint, + type BrowserFrameGeometry, + type BrowserPoint, + type BrowserZoomState +} from './browser-touch-geometry' +import type { MobileBrowserViewMode } from './browser-screencast-request' + +export type FrameLayer = 0 | 1 +export type PinchGesture = { + distance: number + scale: number + anchorX: number + anchorY: number +} +export type BrowserFrameCacheEntry = { + uri: string + metadata: BrowserScreencastFrameMetadata +} +export const MIN_ZOOM = 1 +export const MAX_ZOOM = 3.5 +const BROWSER_FRAME_CACHE_LIMIT = 4 +const browserFrameCache = new Map() + +export function buttonColor(enabled: boolean): string { + return enabled ? colors.textSecondary : colors.textMuted +} + +export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string { + return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` +} + +export function makeBrowserFrameCacheKey( + worktreeId: string, + browserPageId: string | null, + viewMode: MobileBrowserViewMode +): string | null { + return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null +} + +export function clearCachedBrowserFramesForWorktree(worktreeId: string): void { + const prefix = `${worktreeId}:` + for (const key of browserFrameCache.keys()) { + if (key.startsWith(prefix)) { + browserFrameCache.delete(key) + } + } +} + +export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { + if (!cacheKey) { + return null + } + const cached = browserFrameCache.get(cacheKey) + if (!cached) { + return null + } + browserFrameCache.delete(cacheKey) + browserFrameCache.set(cacheKey, cached) + return cached +} + +export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { + return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null +} + +export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void { + if (!cacheKey) { + return + } + browserFrameCache.delete(cacheKey) + browserFrameCache.set(cacheKey, entry) + while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) { + const oldestKey = browserFrameCache.keys().next().value + if (typeof oldestKey !== 'string') { + break + } + browserFrameCache.delete(oldestKey) + } +} + +export function updateBrowserLayerVisibility( + layers: [View | null, View | null], + visible: FrameLayer +): void { + for (const [index, layer] of layers.entries()) { + layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } }) + } +} + +export function updateBrowserImageSource(image: Image | null, uri: string): void { + // Why: browser frames are large strings; mutating only the native Image + // source avoids re-rendering the whole tab view for every streamed frame. + const source = [{ uri }] + image?.setNativeProps({ source, src: source }) +} + +export function assertRpcOk( + response: RpcSuccess | RpcFailure, + fallbackMessage: string +): asserts response is RpcSuccess { + if (!response.ok) { + throw new Error(response.error.message || fallbackMessage) + } +} + +export function browserFrameMetadataEqual( + a: BrowserScreencastFrameMetadata | null, + b: BrowserScreencastFrameMetadata +): boolean { + return ( + a?.deviceWidth === b.deviceWidth && + a?.deviceHeight === b.deviceHeight && + a?.pageScaleFactor === b.pageScaleFactor + ) +} + +export function browserErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +export function shouldSurfaceBrowserError(message: string): boolean { + const normalized = message.toLowerCase() + // Why: selector_not_found can be emitted by in-flight page automation while + // the browser is still usable; replacing the frame with it feels like a crash. + return !normalized.includes('selector_not_found') && !normalized.includes('selector not found') +} + +function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null { + const touches = event.nativeEvent.touches + if (!touches || touches.length < 2) { + return null + } + const a = readLocalTouchPoint(touches[0]) + const b = readLocalTouchPoint(touches[1]) + return a && b ? { a, b } : null +} + +function pointDistance(a: BrowserPoint, b: BrowserPoint): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +export function createPinchGesture( + event: GestureResponderEvent, + geometry: BrowserFrameGeometry | null, + zoom: BrowserZoomState +): PinchGesture | null { + if (!geometry) { + return null + } + const pair = touchPair(event) + if (!pair) { + return null + } + const distance = pointDistance(pair.a, pair.b) + if (distance < 8) { + return null + } + const centerX = (pair.a.x + pair.b.x) / 2 + const centerY = (pair.a.y + pair.b.y) / 2 + const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX + const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY + return { + distance, + scale: zoom.scale, + anchorX: (centerX - frameCenterX) / zoom.scale, + anchorY: (centerY - frameCenterY) / zoom.scale + } +} + +export function updatePinchZoom( + event: GestureResponderEvent, + geometry: BrowserFrameGeometry | null, + pinch: PinchGesture +): BrowserZoomState | null { + if (!geometry) { + return null + } + const pair = touchPair(event) + if (!pair) { + return null + } + const nextScale = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance) + ) + const centerX = (pair.a.x + pair.b.x) / 2 + const centerY = (pair.a.y + pair.b.y) / 2 + const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2 + const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2 + return clampBrowserZoomState( + { + scale: nextScale, + offsetX: centerX - baseCenterX - pinch.anchorX * nextScale, + offsetY: centerY - baseCenterY - pinch.anchorY * nextScale + }, + geometry, + MIN_ZOOM, + MAX_ZOOM + ) +} diff --git a/mobile/src/browser/mobile-browser-pane-styles.ts b/mobile/src/browser/mobile-browser-pane-styles.ts new file mode 100644 index 00000000000..b41428516ba --- /dev/null +++ b/mobile/src/browser/mobile-browser-pane-styles.ts @@ -0,0 +1,171 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const mobileBrowserPaneStyles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + backgroundColor: colors.bgBase + }, + toolbar: { + minHeight: 32, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + viewport: { + flex: 1, + minHeight: 0, + overflow: 'hidden', + backgroundColor: colors.bgBase + }, + browserImageHost: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden' + }, + browserImageFill: { + width: '100%', + height: '100%' + }, + browserImageLayer: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center' + }, + browserImageLayerHidden: { + opacity: 0 + }, + browserZoomOffset: { + alignItems: 'center', + justifyContent: 'center' + }, + browserFrameBox: { + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden' + }, + browserImage: { + backgroundColor: colors.bgBase + }, + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + gap: spacing.sm, + backgroundColor: 'rgba(13, 15, 24, 0.2)' + }, + errorText: { + color: colors.textPrimary, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 13, + textAlign: 'center', + overflow: 'hidden' + }, + dialogOverlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 30, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + backgroundColor: 'rgba(13, 15, 24, 0.5)' + }, + dialogCard: { + width: '100%', + maxWidth: 360, + borderRadius: radii.card, + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + padding: spacing.lg + }, + dialogTitle: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '600' + }, + dialogMessage: { + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 20, + marginTop: spacing.sm + }, + dialogActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg + }, + dialogButton: { + minHeight: 34, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.md, + alignItems: 'center', + justifyContent: 'center' + }, + dialogButtonPrimary: { + backgroundColor: colors.textPrimary + }, + dialogButtonPressed: { + opacity: 0.75 + }, + dialogButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + dialogButtonPrimaryText: { + color: colors.bgBase + }, + keyboardDock: { + zIndex: 20, + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingTop: spacing.xs, + paddingBottom: spacing.xs + 2 + }, + keyboardInput: { + flex: 1, + height: 34, + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + fontSize: 14, + fontFamily: typography.monoFamily, + marginRight: spacing.sm + }, + sendButton: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + disabled: { + opacity: 0.35 + }, + disabledText: { + color: colors.textMuted + } +}) diff --git a/mobile/src/host-screen/host-screen-primary-styles.ts b/mobile/src/host-screen/host-screen-primary-styles.ts new file mode 100644 index 00000000000..c499ad2afe6 --- /dev/null +++ b/mobile/src/host-screen/host-screen-primary-styles.ts @@ -0,0 +1,175 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const hostScreenPrimaryStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topChrome: { + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + statusBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 34, + paddingTop: spacing.xs, + paddingHorizontal: spacing.lg + }, + backButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, + sidebarCollapseButton: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + marginLeft: spacing.xs + }, + hostIdentity: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + minWidth: 0, + marginRight: spacing.md + }, + hostNameText: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + reconnectButton: { + paddingVertical: 4, + paddingHorizontal: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + reconnectButtonText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + gap: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + embeddedToolbar: { + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.sm, + gap: spacing.xs, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + embeddedToolbarRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + embeddedFilterChip: { + flex: 1, + minWidth: 0, + height: 30, + justifyContent: 'center', + paddingHorizontal: spacing.xs, + paddingVertical: 0 + }, + embeddedModeButton: { + flex: 1, + minWidth: 0, + height: 30, + justifyContent: 'center', + paddingHorizontal: spacing.xs, + paddingVertical: 0 + }, + filterChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + filterChipActive: { + borderColor: colors.textSecondary, + backgroundColor: colors.bgRaised + }, + filterChipText: { + fontSize: 12, + color: colors.textSecondary + }, + filterChipTextActive: { + color: colors.textPrimary + }, + modeButton: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 1, + minWidth: 0, + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + sortLabel: { + flexShrink: 1, + minWidth: 0, + fontSize: 12, + color: colors.textSecondary + }, + toolbarSpacer: { + flex: 1 + }, + floatingWorkspaceHeaderButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.xs + }, + embeddedToolbarIconButton: { + flex: 1, + height: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button + }, + toolbarIconDisabled: { + opacity: 0.6 + }, + searchToggle: { + padding: spacing.xs + }, + searchBar: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + } +}) diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts new file mode 100644 index 00000000000..af14aa2b97a --- /dev/null +++ b/mobile/src/host-screen/host-screen-secondary-styles.ts @@ -0,0 +1,139 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const hostScreenSecondaryStyles = StyleSheet.create({ + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize + }, + list: { + paddingBottom: spacing.lg + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xs + }, + sectionIcon: { + marginRight: spacing.xs + }, + sectionRepoIcon: { + marginRight: spacing.xs + }, + sectionTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + sectionCount: { + fontSize: 11, + color: colors.textMuted, + marginLeft: spacing.xs + }, + separator: { + height: 1, + backgroundColor: colors.borderSubtle, + marginLeft: spacing.lg + 24, + marginRight: spacing.lg + }, + filterModalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + filterModalTitle: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + clearFiltersText: { + fontSize: 13, + color: colors.textSecondary + }, + filterSectionLabel: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + filterGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden', + marginBottom: spacing.md + }, + filterRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2, + gap: spacing.sm + }, + filterRowText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + filterSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + filterRepoDot: { + width: 8, + height: 8, + borderRadius: 4 + }, + confirmContent: { + paddingBottom: spacing.lg + }, + confirmTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + confirmMessage: { + fontSize: typography.bodySize, + color: colors.textSecondary, + marginTop: spacing.xs, + lineHeight: 20 + }, + confirmButtons: { + flexDirection: 'row', + gap: spacing.sm + }, + confirmBtn: { + flex: 1, + paddingVertical: spacing.sm + 2, + borderRadius: 10, + alignItems: 'center' + }, + confirmBtnCancel: { + backgroundColor: colors.bgPanel + }, + confirmBtnDestructive: { + backgroundColor: colors.statusRed + }, + confirmBtnPressed: { + opacity: 0.7 + }, + confirmBtnCancelText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textSecondary + }, + confirmBtnDestructiveText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: '#fff' + } +}) diff --git a/mobile/src/host-screen/host-screen-styles.ts b/mobile/src/host-screen/host-screen-styles.ts new file mode 100644 index 00000000000..f4fdb7bed5d --- /dev/null +++ b/mobile/src/host-screen/host-screen-styles.ts @@ -0,0 +1,7 @@ +import { hostScreenPrimaryStyles } from './host-screen-primary-styles' +import { hostScreenSecondaryStyles } from './host-screen-secondary-styles' + +export const hostScreenStyles = { + ...hostScreenPrimaryStyles, + ...hostScreenSecondaryStyles +} From c90d9c4780b7323b3c413b2b3a18fcdcccb42d26 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:57:29 -0700 Subject: [PATCH 03/37] Extract GitLab dialog presentation parts (#16780) --- .../src/components/GitLabItemDialog.tsx | 288 +----------------- .../components/gitlab-item-dialog-parts.tsx | 285 +++++++++++++++++ 2 files changed, 299 insertions(+), 274 deletions(-) create mode 100644 src/renderer/src/components/gitlab-item-dialog-parts.tsx diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index aeddf718975..1d32d254084 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -47,11 +47,23 @@ import type { GitLabMRUpdate, GitLabPipelineJob, GitLabWorkItem, - GitLabWorkItemDetails, - MRComment + GitLabWorkItemDetails } from '../../../shared/gitlab-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' +import { + CommentCard, + PipelineJobRow, + StateBadge, + dedupeGitLabUsers, + formatGitLabLabelDraft, + gitLabUserKey, + normalizeGitLabLabels, + parseGitLabLabelDraft, + showGitLabMutationError, + toggleGitLabLabelDraft, + type JobTraceState +} from './gitlab-item-dialog-parts' type Props = { item: GitLabWorkItem | null @@ -68,278 +80,6 @@ type GitLabDialogRepoSelector = { sourceContext?: TaskSourceContext | null } -type JobTraceState = { - loading: boolean - trace?: string - error?: string -} - -// Why: GitLab MR / issue states map onto a coarser palette than GitHub. -const STATE_TONE: Record = { - opened: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300', - closed: 'bg-rose-500/15 text-rose-700 dark:text-rose-300', - merged: 'bg-violet-500/15 text-violet-700 dark:text-violet-300', - locked: 'bg-rose-500/15 text-rose-700 dark:text-rose-300', - draft: 'bg-amber-500/15 text-amber-700 dark:text-amber-300' -} - -// Why: pipeline job statuses map to one of four visual buckets — keep -// the mapping local so the renderer doesn't depend on the backend's -// shared mapper module (which is main-process only). -function jobStatusTone(status: string): string { - switch (status) { - case 'success': - return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300' - case 'failed': - return 'bg-rose-500/15 text-rose-700 dark:text-rose-300' - case 'running': - case 'pending': - case 'created': - case 'preparing': - case 'waiting_for_resource': - case 'scheduled': - return 'bg-sky-500/15 text-sky-700 dark:text-sky-300' - case 'manual': - return 'bg-amber-500/15 text-amber-700 dark:text-amber-300' - case 'canceled': - case 'skipped': - default: - return 'bg-muted text-muted-foreground' - } -} - -function showGitLabMutationError(error: unknown): void { - const message = error instanceof Error && error.message ? error.message : String(error) - toast.error( - message === 'undefined' || message === 'null' - ? translate('auto.components.GitLabItemDialog.gitlabActionFailed', 'GitLab action failed.') - : message - ) -} - -function StateBadge({ state }: { state: GitLabWorkItem['state'] }): React.JSX.Element { - return ( - - {state} - - ) -} - -function normalizeGitLabLabels(labels: readonly string[]): string[] { - const seen = new Set() - const normalized: string[] = [] - for (const label of labels) { - const trimmed = label.trim() - const key = trimmed.toLowerCase() - if (!trimmed || seen.has(key)) { - continue - } - seen.add(key) - normalized.push(trimmed) - } - return normalized -} - -function parseGitLabLabelDraft(value: string): string[] { - return normalizeGitLabLabels(value.split(',')) -} - -function formatGitLabLabelDraft(labels: readonly string[]): string { - return normalizeGitLabLabels(labels).join(', ') -} - -function toggleGitLabLabelDraft(value: string, label: string): string { - const labels = parseGitLabLabelDraft(value) - const key = label.trim().toLowerCase() - const next = labels.some((item) => item.toLowerCase() === key) - ? labels.filter((item) => item.toLowerCase() !== key) - : [...labels, label] - return formatGitLabLabelDraft(next) -} - -function gitLabUserKey(user: GitLabAssignableUser): string { - return typeof user.id === 'number' ? `id:${user.id}` : `username:${user.username.toLowerCase()}` -} - -function dedupeGitLabUsers(users: readonly GitLabAssignableUser[]): GitLabAssignableUser[] { - const byKey = new Map() - for (const user of users) { - byKey.set(gitLabUserKey(user), user) - } - return Array.from(byKey.values()).sort((a, b) => a.username.localeCompare(b.username)) -} - -function CommentCard({ - comment, - canResolve, - resolving, - onResolve -}: { - comment: MRComment - canResolve?: boolean - resolving?: boolean - onResolve?: (threadId: string, resolved: boolean) => void -}): React.JSX.Element { - const hasThread = Boolean(comment.threadId) - return ( -
-
-
- {comment.authorAvatarUrl ? ( - { - e.currentTarget.style.display = 'none' - }} - /> - ) : null} - {comment.author} - {comment.isResolved ? ( - - {translate('auto.components.GitLabItemDialog.f23ea85341', 'resolved')} - - ) : null} -
-
- {canResolve && hasThread && onResolve ? ( - - ) : null} - {comment.createdAt ? new Date(comment.createdAt).toLocaleDateString() : ''} -
-
- {comment.path ? ( -
- {comment.path} - {comment.line ? `:${comment.line}` : ''} -
- ) : null} - -
- ) -} - -function PipelineJobRow({ - job, - expanded, - traceState, - retrying, - onToggleTrace, - onRetry -}: { - job: GitLabPipelineJob - expanded: boolean - traceState?: JobTraceState - retrying: boolean - onToggleTrace: (job: GitLabPipelineJob) => void - onRetry: (job: GitLabPipelineJob) => void -}): React.JSX.Element { - const canRetry = ['failed', 'canceled', 'cancelled'].includes(job.status) - return ( -
-
- - {job.stage} - - {job.status} - - - {/* Why: durations come back as seconds; show "Nm Ns" for >60s - and "Ns" otherwise. null = job hasn't finished. */} - {typeof job.duration === 'number' - ? job.duration >= 60 - ? `${Math.floor(job.duration / 60)}m ${Math.floor(job.duration % 60)}s` - : `${Math.floor(job.duration)}s` - : '—'} - -
- {canRetry ? ( - - ) : null} - {job.webUrl ? ( - - ) : null} -
-
- {expanded ? ( -
-
- {translate('auto.components.GitLabItemDialog.2f9b27f838', 'Job log')} - -
- {traceState?.loading ? ( -
- - {translate('auto.components.GitLabItemDialog.d600c2619a', 'Loading log')} -
- ) : traceState?.error ? ( -
{traceState.error}
- ) : ( -
-              {traceState?.trace?.trim()
-                ? traceState.trace
-                : translate('auto.components.GitLabItemDialog.32f8bef818', 'No log output.')}
-            
- )} -
- ) : null} -
- ) -} - export default function GitLabItemDialog({ item, repoPath, diff --git a/src/renderer/src/components/gitlab-item-dialog-parts.tsx b/src/renderer/src/components/gitlab-item-dialog-parts.tsx new file mode 100644 index 00000000000..fe16ff10e39 --- /dev/null +++ b/src/renderer/src/components/gitlab-item-dialog-parts.tsx @@ -0,0 +1,285 @@ +import React from 'react' +import { ExternalLink, LoaderCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { + GitLabAssignableUser, + GitLabPipelineJob, + GitLabWorkItem, + MRComment +} from '../../../shared/gitlab-types' + +export type JobTraceState = { + loading: boolean + trace?: string + error?: string +} + +// Why: GitLab MR / issue states map onto a coarser palette than GitHub. +export const STATE_TONE: Record = { + opened: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300', + closed: 'bg-rose-500/15 text-rose-700 dark:text-rose-300', + merged: 'bg-violet-500/15 text-violet-700 dark:text-violet-300', + locked: 'bg-rose-500/15 text-rose-700 dark:text-rose-300', + draft: 'bg-amber-500/15 text-amber-700 dark:text-amber-300' +} + +// Why: pipeline job statuses map to one of four visual buckets — keep +// the mapping local so the renderer doesn't depend on the backend's +// shared mapper module (which is main-process only). +export function jobStatusTone(status: string): string { + switch (status) { + case 'success': + return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300' + case 'failed': + return 'bg-rose-500/15 text-rose-700 dark:text-rose-300' + case 'running': + case 'pending': + case 'created': + case 'preparing': + case 'waiting_for_resource': + case 'scheduled': + return 'bg-sky-500/15 text-sky-700 dark:text-sky-300' + case 'manual': + return 'bg-amber-500/15 text-amber-700 dark:text-amber-300' + case 'canceled': + case 'skipped': + default: + return 'bg-muted text-muted-foreground' + } +} + +export function showGitLabMutationError(error: unknown): void { + const message = error instanceof Error && error.message ? error.message : String(error) + toast.error( + message === 'undefined' || message === 'null' + ? translate('auto.components.GitLabItemDialog.gitlabActionFailed', 'GitLab action failed.') + : message + ) +} + +export function StateBadge({ state }: { state: GitLabWorkItem['state'] }): React.JSX.Element { + return ( + + {state} + + ) +} + +export function normalizeGitLabLabels(labels: readonly string[]): string[] { + const seen = new Set() + const normalized: string[] = [] + for (const label of labels) { + const trimmed = label.trim() + const key = trimmed.toLowerCase() + if (!trimmed || seen.has(key)) { + continue + } + seen.add(key) + normalized.push(trimmed) + } + return normalized +} + +export function parseGitLabLabelDraft(value: string): string[] { + return normalizeGitLabLabels(value.split(',')) +} + +export function formatGitLabLabelDraft(labels: readonly string[]): string { + return normalizeGitLabLabels(labels).join(', ') +} + +export function toggleGitLabLabelDraft(value: string, label: string): string { + const labels = parseGitLabLabelDraft(value) + const key = label.trim().toLowerCase() + const next = labels.some((item) => item.toLowerCase() === key) + ? labels.filter((item) => item.toLowerCase() !== key) + : [...labels, label] + return formatGitLabLabelDraft(next) +} + +export function gitLabUserKey(user: GitLabAssignableUser): string { + return typeof user.id === 'number' ? `id:${user.id}` : `username:${user.username.toLowerCase()}` +} + +export function dedupeGitLabUsers(users: readonly GitLabAssignableUser[]): GitLabAssignableUser[] { + const byKey = new Map() + for (const user of users) { + byKey.set(gitLabUserKey(user), user) + } + return Array.from(byKey.values()).sort((a, b) => a.username.localeCompare(b.username)) +} + +export function CommentCard({ + comment, + canResolve, + resolving, + onResolve +}: { + comment: MRComment + canResolve?: boolean + resolving?: boolean + onResolve?: (threadId: string, resolved: boolean) => void +}): React.JSX.Element { + const hasThread = Boolean(comment.threadId) + return ( +
+
+
+ {comment.authorAvatarUrl ? ( + { + e.currentTarget.style.display = 'none' + }} + /> + ) : null} + {comment.author} + {comment.isResolved ? ( + + {translate('auto.components.GitLabItemDialog.f23ea85341', 'resolved')} + + ) : null} +
+
+ {canResolve && hasThread && onResolve ? ( + + ) : null} + {comment.createdAt ? new Date(comment.createdAt).toLocaleDateString() : ''} +
+
+ {comment.path ? ( +
+ {comment.path} + {comment.line ? `:${comment.line}` : ''} +
+ ) : null} + +
+ ) +} + +export function PipelineJobRow({ + job, + expanded, + traceState, + retrying, + onToggleTrace, + onRetry +}: { + job: GitLabPipelineJob + expanded: boolean + traceState?: JobTraceState + retrying: boolean + onToggleTrace: (job: GitLabPipelineJob) => void + onRetry: (job: GitLabPipelineJob) => void +}): React.JSX.Element { + const canRetry = ['failed', 'canceled', 'cancelled'].includes(job.status) + return ( +
+
+ + {job.stage} + + {job.status} + + + {/* Why: durations come back as seconds; show "Nm Ns" for >60s + and "Ns" otherwise. null = job hasn't finished. */} + {typeof job.duration === 'number' + ? job.duration >= 60 + ? `${Math.floor(job.duration / 60)}m ${Math.floor(job.duration % 60)}s` + : `${Math.floor(job.duration)}s` + : '—'} + +
+ {canRetry ? ( + + ) : null} + {job.webUrl ? ( + + ) : null} +
+
+ {expanded ? ( +
+
+ {translate('auto.components.GitLabItemDialog.2f9b27f838', 'Job log')} + +
+ {traceState?.loading ? ( +
+ + {translate('auto.components.GitLabItemDialog.d600c2619a', 'Loading log')} +
+ ) : traceState?.error ? ( +
{traceState.error}
+ ) : ( +
+              {traceState?.trace?.trim()
+                ? traceState.trace
+                : translate('auto.components.GitLabItemDialog.32f8bef818', 'No log output.')}
+            
+ )} +
+ ) : null} +
+ ) +} From 3ed7796624c4d8ffaf82bb8e13d562c3119e00a7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:16:06 -0700 Subject: [PATCH 04/37] fix(fork-sync): address runtime repos by main worktree id, not repo id (#16876) Repo-level fork sync (Safe Auto and the Sync Now button) passed `repo.id` as the runtime worktree selector, so runtime-hosted repos always failed with `worktree_id_requires_full_path`. Compose the repo's main worktree id (`::`) via a new shared `getRepoMainWorktreeId`. Fixes #16447 --- .../RepositoryForkSyncSection.test.tsx | 54 ++++++++++ .../settings/RepositoryForkSyncSection.tsx | 3 +- .../store/repos/safe-auto-fork-sync.test.ts | 98 +++++++++++++++++++ .../src/store/repos/safe-auto-fork-sync.ts | 3 +- src/shared/worktree/id.test.ts | 14 +++ src/shared/worktree/id.ts | 9 ++ 6 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx create mode 100644 src/renderer/src/store/repos/safe-auto-fork-sync.test.ts diff --git a/src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx b/src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx new file mode 100644 index 00000000000..53545eaf740 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryForkSyncSection.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { RepositoryForkSyncSection } from './RepositoryForkSyncSection' + +const syncRuntimeGitForkDefaultBranch = vi.fn() + +vi.mock('../../runtime/runtime-git-client', () => ({ + syncRuntimeGitForkDefaultBranch: (...args: unknown[]) => syncRuntimeGitForkDefaultBranch(...args) +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ settingsSearchQuery: '', settings: { activeRuntimeEnvironmentId: 'env-1' } }) +})) + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { message: vi.fn(), success: vi.fn(), error: vi.fn() }) +})) + +const RUNTIME_REPO: Repo = { + id: 'repo-1', + path: '/srv/repo-1', + displayName: 'repo-1', + badgeColor: '#000000', + addedAt: 0, + kind: 'git', + executionHostId: 'runtime:env-1', + forkSyncMode: 'ask', + upstream: { owner: 'up', repo: 'r' } +} + +describe('RepositoryForkSyncSection', () => { + afterEach(() => { + cleanup() + syncRuntimeGitForkDefaultBranch.mockReset() + }) + + it('syncs a runtime-hosted repo by its main worktree id, not the bare repo id', () => { + // Why: the runtime rejects `id:` with worktree_id_requires_full_path (#16447). + syncRuntimeGitForkDefaultBranch.mockResolvedValue({ status: 'up-to-date', behind: 0 }) + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: /sync now/i })) + + expect(syncRuntimeGitForkDefaultBranch).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'repo-1::/srv/repo-1', worktreePath: '/srv/repo-1' }), + { owner: 'up', repo: 'r' } + ) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx b/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx index 3a399f16e38..a541a82dd24 100644 --- a/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx +++ b/src/renderer/src/components/settings/RepositoryForkSyncSection.tsx @@ -3,6 +3,7 @@ import { RefreshCw } from 'lucide-react' import { toast } from 'sonner' import type { ForkSyncMode, GitForkSyncResult } from '../../../../shared/git-fork-sync' import type { Repo } from '../../../../shared/repo-types' +import { getRepoMainWorktreeId } from '../../../../shared/worktree/id' import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { SettingsSegmentedControl } from './SettingsFormControls' @@ -128,7 +129,7 @@ export function RepositoryForkSyncSection({ const result = await syncRuntimeGitForkDefaultBranch( { settings: getRepoOwnerRoutedSettings(settings, repo), - worktreeId: repo.id, + worktreeId: getRepoMainWorktreeId(repo), worktreePath: repo.path, connectionId: repo.connectionId ?? undefined }, diff --git a/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts b/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts new file mode 100644 index 00000000000..ec30e09944f --- /dev/null +++ b/src/renderer/src/store/repos/safe-auto-fork-sync.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { safeAutoForkSyncAttempts, scheduleSafeAutoForkSync } from './safe-auto-fork-sync' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const gitSyncFork = vi.fn() + +const RUNTIME_REPO: Repo = { + id: 'repo-1', + path: '/srv/repo-1', + displayName: 'repo-1', + badgeColor: '#000000', + addedAt: 0, + kind: 'git', + executionHostId: 'runtime:env-1', + forkSyncMode: 'safe-auto', + upstream: { owner: 'up', repo: 'r' } +} + +function stateWith(repo: Repo): AppState { + return { + repos: [repo], + settings: { activeRuntimeEnvironmentId: 'env-1' } + } as unknown as AppState +} + +async function flushScheduledSyncs(): Promise { + await Promise.all([...safeAutoForkSyncAttempts.values()].map((attempt) => attempt.promise)) +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + safeAutoForkSyncAttempts.clear() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + gitSyncFork.mockReset() + gitSyncFork.mockResolvedValue({ status: 'up-to-date', behind: 0 }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { status: 'up-to-date', behind: 0 }, + _meta: { runtimeId: 'remote-runtime' } + }) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + git: { syncFork: gitSyncFork }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('scheduleSafeAutoForkSync', () => { + it('addresses a runtime-hosted repo by its main worktree id, not the bare repo id', async () => { + // Why: the runtime rejects `id:` with worktree_id_requires_full_path (#16447). + scheduleSafeAutoForkSync(() => stateWith(RUNTIME_REPO), [RUNTIME_REPO]) + await flushScheduledSyncs() + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'git.forkSync', + params: expect.objectContaining({ worktree: 'id:repo-1::/srv/repo-1' }) + }) + ) + }) + + it('still runs local repos against the repo path over local git IPC', async () => { + const localRepo: Repo = { + ...RUNTIME_REPO, + id: 'repo-2', + path: '/home/me/repo-2', + executionHostId: 'local' + } + + scheduleSafeAutoForkSync( + () => + ({ ...stateWith(localRepo), settings: { activeRuntimeEnvironmentId: null } }) as AppState, + [localRepo] + ) + await flushScheduledSyncs() + + expect(gitSyncFork).toHaveBeenCalledWith({ + worktreePath: '/home/me/repo-2', + connectionId: undefined, + expectedUpstream: { owner: 'up', repo: 'r' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/repos/safe-auto-fork-sync.ts b/src/renderer/src/store/repos/safe-auto-fork-sync.ts index d2167a9296a..3db51383ee5 100644 --- a/src/renderer/src/store/repos/safe-auto-fork-sync.ts +++ b/src/renderer/src/store/repos/safe-auto-fork-sync.ts @@ -2,6 +2,7 @@ import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { getRepoMainWorktreeId } from '../../../../shared/worktree/id' import { settingsForRepoOwner } from './owner-routing' export const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000 @@ -32,7 +33,7 @@ export function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Re const promise = syncRuntimeGitForkDefaultBranch( { settings: settingsForRepoOwner(get(), repo.id), - worktreeId: repo.id, + worktreeId: getRepoMainWorktreeId(repo), worktreePath: repo.path, connectionId: repo.connectionId ?? undefined }, diff --git a/src/shared/worktree/id.test.ts b/src/shared/worktree/id.test.ts index 726e53abe5a..156c78e9959 100644 --- a/src/shared/worktree/id.test.ts +++ b/src/shared/worktree/id.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { WORKTREE_ID_SEPARATOR, getRepoIdFromWorktreeId, + getRepoMainWorktreeId, getWorktreePathBasenameFromId, splitWorktreeId, splitWorktreeIdForFilesystem, @@ -14,6 +15,19 @@ describe('WORKTREE_ID_SEPARATOR', () => { }) }) +describe('getRepoMainWorktreeId', () => { + it('round-trips through the id parsers on posix and Windows paths', () => { + for (const repo of [ + { id: 'repo-123', path: '/abs/path' }, + { id: 'repo-123', path: 'C:\\Users\\me\\repo' } + ]) { + const worktreeId = getRepoMainWorktreeId(repo) + expect(worktreeId).toBe(`${repo.id}${WORKTREE_ID_SEPARATOR}${repo.path}`) + expect(splitWorktreeId(worktreeId)).toEqual({ repoId: repo.id, worktreePath: repo.path }) + } + }) +}) + describe('getRepoIdFromWorktreeId', () => { it('returns the repo id for a canonical worktree id', () => { expect(getRepoIdFromWorktreeId('repo-123::/abs/path')).toBe('repo-123') diff --git a/src/shared/worktree/id.ts b/src/shared/worktree/id.ts index 478ac6bfa76..82220328eb3 100644 --- a/src/shared/worktree/id.ts +++ b/src/shared/worktree/id.ts @@ -1,5 +1,6 @@ import { normalizeRuntimePathForComparison } from '../cross-platform-path' import { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' +import type { Repo } from '../repo-types' export { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format' @@ -13,6 +14,14 @@ const FOLDER_WORKSPACE_INSTANCE_SUFFIX = new RegExp( `${FOLDER_WORKSPACE_INSTANCE_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9a-f-]{36}$` ) +/** + * Worktree id of the repo's own checkout. A bare repo id is never a valid worktree id — + * runtimes reject it with `worktree_id_requires_full_path` (#16447). + */ +export function getRepoMainWorktreeId(repo: Pick): string { + return `${repo.id}${WORKTREE_ID_SEPARATOR}${repo.path}` +} + export function getRepoIdFromWorktreeId(worktreeId: string): string { const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR) return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx) From f96dfd58103cd822a7d399a377421591094dc8d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:18:50 -0700 Subject: [PATCH 05/37] Extract CDP auxiliary commands (#16781) --- src/main/browser/cdp-auxiliary-commands.ts | 213 ++++++++++++++++ src/main/browser/cdp-bridge.ts | 268 +++++---------------- 2 files changed, 271 insertions(+), 210 deletions(-) create mode 100644 src/main/browser/cdp-auxiliary-commands.ts diff --git a/src/main/browser/cdp-auxiliary-commands.ts b/src/main/browser/cdp-auxiliary-commands.ts new file mode 100644 index 00000000000..88a02d3f43c --- /dev/null +++ b/src/main/browser/cdp-auxiliary-commands.ts @@ -0,0 +1,213 @@ +import type { WebContents } from 'electron' +import type { + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleEntry, + BrowserConsoleResult, + BrowserCookie, + BrowserCookieDeleteResult, + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserGeolocationResult, + BrowserInterceptDisableResult, + BrowserInterceptEnableResult, + BrowserInterceptedRequest, + BrowserNetworkEntry, + BrowserNetworkLogResult, + BrowserViewportResult +} from '../../shared/runtime-types' +import type { CdpCommandSender, SnapshotResult } from './snapshot-engine' + +export type CdpTabState = { + navigationId: string | null + snapshotResult: SnapshotResult | null + debuggerAttached: boolean + debuggerDetachListener: (() => void) | null + debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null + iframeSessions: Map + capturing: boolean + consoleLog: BrowserConsoleEntry[] + networkLog: BrowserNetworkEntry[] + intercepting: boolean + interceptPatterns: string[] + pausedRequests: Map + networkRequestMap: Map +} + +type ActiveCdpContext = { + guest: WebContents + sender: CdpCommandSender + state: CdpTabState +} + +export type CdpAuxiliaryCommandHost = { + run: (operation: (context: ActiveCdpContext) => Promise) => Promise + // Why: captureStop must not attach the debugger — stopping a capture on a tab whose + // debugger detached (DevTools opened) would otherwise throw before clearing state. + runOnState: ( + operation: (context: Pick) => Promise + ) => Promise + current: () => Pick +} + +export class CdpAuxiliaryCommands { + constructor(private readonly host: CdpAuxiliaryCommandHost) {} + + cookieGet(url?: string): Promise { + return this.host.run(async ({ sender }) => { + const params: Record = {} + if (url) { + params.urls = [url] + } + const { cookies } = (await sender('Network.getCookies', params)) as { + cookies: BrowserCookie[] + } + return { cookies } + }) + } + + cookieSet(cookie: { + name: string + value: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: string + expires?: number + }): Promise { + return this.host.run(async ({ sender }) => { + let domain = cookie.domain + if (!domain) { + const { result } = (await sender('Runtime.evaluate', { + expression: 'location.hostname', + returnByValue: true + })) as { result: { value: string } } + domain = result.value + } + const params: Record = { + name: cookie.name, + value: cookie.value, + domain, + path: cookie.path ?? '/', + secure: cookie.secure ?? false, + httpOnly: cookie.httpOnly ?? false, + sameSite: cookie.sameSite ?? 'Lax' + } + if (cookie.expires !== undefined) { + params.expires = cookie.expires + } + const { success } = (await sender('Network.setCookie', params)) as { success: boolean } + return { success } + }) + } + + cookieDelete(name: string, domain?: string, url?: string): Promise { + return this.host.run(async ({ sender }) => { + const params: Record = { name } + if (domain) { + params.domain = domain + } + if (url) { + params.url = url + } + if (!domain && !url) { + const { result } = (await sender('Runtime.evaluate', { + expression: 'location.href', + returnByValue: true + })) as { result: { value: string } } + params.url = result.value + } + await sender('Network.deleteCookies', params) + return { deleted: true } + }) + } + + setViewport( + width: number, + height: number, + deviceScaleFactor = 1, + mobile = false + ): Promise { + return this.host.run(async ({ sender }) => { + await sender('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor, + mobile + }) + await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {}) + return { width, height, deviceScaleFactor, mobile } + }) + } + + setGeolocation( + latitude: number, + longitude: number, + accuracy = 1 + ): Promise { + return this.host.run(async ({ sender }) => { + await sender('Emulation.setGeolocationOverride', { latitude, longitude, accuracy }) + return { latitude, longitude, accuracy } + }) + } + + interceptEnable(patterns: string[] = ['*']): Promise { + return this.host.run(async ({ sender, state }) => { + await sender('Fetch.enable', { patterns: patterns.map((urlPattern) => ({ urlPattern })) }) + state.intercepting = true + state.interceptPatterns = patterns + return { enabled: true, patterns } + }) + } + + interceptDisable(): Promise { + return this.host.run(async ({ sender, state }) => { + await sender('Fetch.disable') + state.intercepting = false + state.interceptPatterns = [] + state.pausedRequests.clear() + return { disabled: true } + }) + } + + interceptList(): { requests: BrowserInterceptedRequest[] } { + const { state } = this.host.current() + return { requests: [...state.pausedRequests.values()] } + } + + captureStart(): Promise { + return this.host.run(async ({ sender, state }) => { + await sender('Runtime.enable') + state.capturing = true + state.consoleLog = [] + state.networkLog = [] + state.networkRequestMap.clear() + return { capturing: true } + }) + } + + captureStop(): Promise { + return this.host.runOnState(async ({ state }) => { + state.capturing = false + state.networkRequestMap.clear() + return { stopped: true } + }) + } + + consoleLog(limit = 100): BrowserConsoleResult { + const { state } = this.host.current() + return { + entries: state.consoleLog.slice(-limit), + truncated: state.consoleLog.length > limit + } + } + + networkLog(limit = 100): BrowserNetworkLogResult { + const { state } = this.host.current() + return { + entries: state.networkLog.slice(-limit), + truncated: state.networkLog.length > limit + } + } +} diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index fc5991924a9..73dec07f2d2 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -6,9 +6,7 @@ import type { BrowserCheckResult, BrowserClearResult, BrowserClickResult, - BrowserConsoleEntry, BrowserConsoleResult, - BrowserCookie, BrowserCookieDeleteResult, BrowserCookieGetResult, BrowserCookieSetResult, @@ -39,16 +37,12 @@ import type { BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' -import { - buildSnapshot, - type CdpCommandSender, - type RefEntry, - type SnapshotResult -} from './snapshot-engine' +import { buildSnapshot, type CdpCommandSender, type RefEntry } from './snapshot-engine' import { insertTextThroughCdp } from './browser-text-insertion' import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { BrowserError } from './browser-error' +import { CdpAuxiliaryCommands, type CdpTabState } from './cdp-auxiliary-commands' const CAPTURE_LOG_LIMIT = 1000 @@ -56,25 +50,6 @@ const CAPTURE_LOG_LIMIT = 1000 // pulling Chromium in. Existing importers of this path keep working. export { BrowserError } from './browser-error' -type TabState = { - navigationId: string | null - snapshotResult: SnapshotResult | null - debuggerAttached: boolean - debuggerDetachListener: (() => void) | null - debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null - iframeSessions: Map - // Why: capture state is per-tab so one tab's console/network events don't pollute another's buffer. - capturing: boolean - consoleLog: BrowserConsoleEntry[] - networkLog: BrowserNetworkEntry[] - // Why: interception state lets the agent selectively continue or block individual requests. - intercepting: boolean - interceptPatterns: string[] - pausedRequests: Map - // Why: maps CDP requestId → networkLog entry so loadingFinished attributes size to the right overlapping response. - networkRequestMap: Map -} - type QueuedCommand = { execute: () => Promise resolve: (value: unknown) => void @@ -83,13 +58,45 @@ type QueuedCommand = { export class CdpBridge { private activeWebContentsId: number | null = null - private readonly tabState = new Map() + private readonly tabState = new Map() private readonly commandQueues = new Map() private readonly processingQueues = new Set() private readonly browserManager: BrowserManager + private readonly auxiliaryCommands: CdpAuxiliaryCommands constructor(browserManager: BrowserManager) { this.browserManager = browserManager + this.auxiliaryCommands = new CdpAuxiliaryCommands({ + run: ( + operation: (context: { + guest: Electron.WebContents + sender: CdpCommandSender + state: CdpTabState + }) => Promise + ): Promise => + this.enqueueCommand(async () => { + const guest = this.getActiveGuest() + const sender = this.makeCdpSender(guest) + await this.ensureDebuggerAttached(guest) + const state = this.getOrCreateTabState(this.resolveTabId(guest.id)) + return operation({ guest, sender, state }) + }), + runOnState: ( + operation: (context: { guest: Electron.WebContents; state: CdpTabState }) => Promise + ): Promise => + this.enqueueCommand(async () => { + const guest = this.getActiveGuest() + const state = this.getOrCreateTabState(this.resolveTabId(guest.id)) + return operation({ guest, state }) + }), + current: () => { + const guest = this.getActiveGuest() + return { + guest, + state: this.getOrCreateTabState(this.resolveTabId(guest.id)) + } + } + }) } setActiveTab(webContentsId: number): void { @@ -636,25 +643,11 @@ export class CdpBridge { // ── Cookie management ── - async cookieGet(url?: string): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const params: Record = {} - if (url) { - params.urls = [url] - } - const { cookies } = (await sender('Network.getCookies', params)) as { - cookies: BrowserCookie[] - } - - return { cookies } - }) + cookieGet(url?: string): Promise { + return this.auxiliaryCommands.cookieGet(url) } - async cookieSet(cookie: { + cookieSet(cookie: { name: string value: string domain?: string @@ -664,211 +657,66 @@ export class CdpBridge { sameSite?: string expires?: number }): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - // Why: Network.setCookie needs a domain or url to scope the cookie; infer the domain from the current page when omitted. - let domain = cookie.domain - if (!domain) { - const { result: urlResult } = (await sender('Runtime.evaluate', { - expression: 'location.hostname', - returnByValue: true - })) as { result: { value: string } } - domain = urlResult.value - } - - const params: Record = { - name: cookie.name, - value: cookie.value, - domain, - path: cookie.path ?? '/', - secure: cookie.secure ?? false, - httpOnly: cookie.httpOnly ?? false, - sameSite: cookie.sameSite ?? 'Lax' - } - if (cookie.expires !== undefined) { - params.expires = cookie.expires - } - - const { success } = (await sender('Network.setCookie', params)) as { success: boolean } - return { success } - }) + return this.auxiliaryCommands.cookieSet(cookie) } - async cookieDelete( - name: string, - domain?: string, - url?: string - ): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const params: Record = { name } - if (domain) { - params.domain = domain - } - if (url) { - params.url = url - } - // Why: Network.deleteCookies needs a domain or url; infer from the current page if neither was given. - if (!domain && !url) { - const { result: urlResult } = (await sender('Runtime.evaluate', { - expression: 'location.href', - returnByValue: true - })) as { result: { value: string } } - params.url = urlResult.value - } - - await sender('Network.deleteCookies', params) - return { deleted: true } - }) + cookieDelete(name: string, domain?: string, url?: string): Promise { + return this.auxiliaryCommands.cookieDelete(name, domain, url) } // ── Viewport emulation ── - async setViewport( + setViewport( width: number, height: number, deviceScaleFactor = 1, mobile = false ): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - await sender('Emulation.setDeviceMetricsOverride', { - width, - height, - deviceScaleFactor, - mobile - }) - // Why: metrics-only resize can leave the compositor surface at the old size, cropping remote screencast clients. - await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {}) - - return { width, height, deviceScaleFactor, mobile } - }) + return this.auxiliaryCommands.setViewport(width, height, deviceScaleFactor, mobile) } // ── Geolocation ── - async setGeolocation( + setGeolocation( latitude: number, longitude: number, accuracy = 1 ): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - await sender('Emulation.setGeolocationOverride', { latitude, longitude, accuracy }) - return { latitude, longitude, accuracy } - }) + return this.auxiliaryCommands.setGeolocation(latitude, longitude, accuracy) } // ── Request interception ── - async interceptEnable(patterns: string[] = ['*']): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - const requestPatterns = patterns.map((p) => ({ urlPattern: p })) - await sender('Fetch.enable', { patterns: requestPatterns }) - - state.intercepting = true - state.interceptPatterns = patterns - - return { enabled: true, patterns } - }) + interceptEnable(patterns: string[] = ['*']): Promise { + return this.auxiliaryCommands.interceptEnable(patterns) } - async interceptDisable(): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - await sender('Fetch.disable') - state.intercepting = false - state.interceptPatterns = [] - state.pausedRequests.clear() - - return { disabled: true } - }) + interceptDisable(): Promise { + return this.auxiliaryCommands.interceptDisable() } interceptList(): { requests: BrowserInterceptedRequest[] } { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - return { requests: [...state.pausedRequests.values()] } + return this.auxiliaryCommands.interceptList() } // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions (CLI is URL-pattern-only). // ── Console/network capture ── - async captureStart(): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - await sender('Runtime.enable') - state.capturing = true - state.consoleLog = [] - state.networkLog = [] - state.networkRequestMap.clear() - - return { capturing: true } - }) + captureStart(): Promise { + return this.auxiliaryCommands.captureStart() } - async captureStop(): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - state.capturing = false - state.networkRequestMap.clear() - - return { stopped: true } - }) + captureStop(): Promise { + return this.auxiliaryCommands.captureStop() } consoleLog(limit = 100): BrowserConsoleResult { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - const entries = state.consoleLog.slice(-limit) - return { entries, truncated: state.consoleLog.length > limit } + return this.auxiliaryCommands.consoleLog(limit) } networkLog(limit = 100): BrowserNetworkLogResult { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - const entries = state.networkLog.slice(-limit) - return { entries, truncated: state.networkLog.length > limit } + return this.auxiliaryCommands.networkLog(limit) } async back(): Promise<{ url: string; title: string }> { @@ -1077,7 +925,7 @@ export class CdpBridge { return null } - private getOrCreateTabState(tabId: string): TabState { + private getOrCreateTabState(tabId: string): CdpTabState { let state = this.tabState.get(tabId) if (!state) { state = { @@ -1100,7 +948,7 @@ export class CdpBridge { return state } - private removeDebuggerListeners(guest: Electron.WebContents, state: TabState): void { + private removeDebuggerListeners(guest: Electron.WebContents, state: CdpTabState): void { const detachListener = state.debuggerDetachListener const messageListener = state.debuggerMessageListener state.debuggerDetachListener = null From ebe6b559eff3385db7e456a0c76c02ea1ecca3d2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:34:03 -0700 Subject: [PATCH 06/37] Prime changed native caches before test fanout (#16918) --- .../install-node-dependencies/action.yml | 5 +-- .github/workflows/pr.yml | 35 ++++++++++++++++--- config/scripts/pr-code-change-scope.mjs | 23 +++++++++++- config/scripts/pr-code-change-scope.test.mjs | 31 ++++++++++++++-- .../scripts/pr-workflow-parallelism.test.mjs | 12 +++++++ ...ws-pty-native-capability-workflow.test.mjs | 5 +++ 6 files changed, 101 insertions(+), 10 deletions(-) diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index e6d6f8f15e3..b67ce13cc1b 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -30,6 +30,7 @@ runs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: + version: 10.24.0 run_install: false - name: Setup Node.js @@ -117,7 +118,7 @@ runs: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} - name: Restore compiled native modules without saving if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false' @@ -127,7 +128,7 @@ runs: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} - name: Prepare native runtime if: inputs.native-runtime != 'none' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5eadeb9be81..9fbe35a173f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,6 +27,7 @@ jobs: runs-on: ubuntu-latest outputs: should_run: ${{ steps.filter.outputs.should_run }} + native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -442,10 +443,36 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts + # Cache-key input changes would otherwise make every shard compile the same + # native addon concurrently. Prime each Node ABI once before the matrix fans out. + test_native_cache: + name: prepare test native cache node ${{ matrix.node }} + needs: [code_paths] + if: needs.code_paths.outputs.native_cache_changed == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['24', '26'] + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: ${{ matrix.node }} + test: name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} - needs: [code_paths] - if: needs.code_paths.outputs.test == 'true' + needs: [code_paths, test_native_cache] + if: >- + always() && + needs.code_paths.outputs.test == 'true' && + (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') runs-on: ubuntu-latest strategy: fail-fast: false @@ -695,7 +722,7 @@ jobs: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} - name: Test Windows-specific boundaries run: >- @@ -748,7 +775,7 @@ jobs: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} + key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }} - name: Prepare Electron native runtime run: node config/scripts/ensure-native-runtime.mjs --runtime=electron diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 8ccc864aabb..c51b1c18a5c 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -116,6 +116,19 @@ const NATIVE_RUNTIME_PREFIXES = [ 'config/patches/@vscode__windows-process-tree' ] +const NATIVE_CACHE_FILES = new Set([ + 'package.json', + 'pnpm-lock.yaml', + '.github/actions/install-node-dependencies/action.yml', + 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/rebuild-native-deps.mjs' +]) + +const NATIVE_CACHE_PREFIXES = [ + 'config/patches/node-pty@', + 'config/patches/@vscode__windows-process-tree' +] + const SHARED_PACKAGE_PREFIXES = [ 'electron.vite.config.ts', 'config/electron-builder', @@ -222,7 +235,11 @@ export function classifyPrJobs(changedFiles) { shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles)) ]) ) - return { should_run: shouldRun, ...jobs } + return { + should_run: shouldRun, + native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), + ...jobs + } } function jobDetector(job) { @@ -274,6 +291,10 @@ function isDesktopIrrelevantPath(file) { return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES) } +function isNativeCacheInputPath(file) { + return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES) +} + function isGlobalForcePath(file) { return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES) } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 0ce697d1511..223929fc213 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -207,6 +207,22 @@ describe('per-job path classification', () => { expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true) }) + it('primes native caches only when their immutable inputs change', () => { + expect(classifyPrJobs([]).native_cache_changed).toBe(true) + expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false) + expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false) + for (const file of [ + 'package.json', + 'pnpm-lock.yaml', + '.github/actions/install-node-dependencies/action.yml', + 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/rebuild-native-deps.mjs', + 'config/patches/node-pty@1.1.0.patch' + ]) { + expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true) + } + }) + it('keeps unit-test-only diffs out of packaging', () => { expectClassification(['src/main/git/git-status.test.ts'], { git_compatibility: true @@ -238,7 +254,7 @@ describe('PR Checks skip wiring', () => { expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs') expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"') - for (const jobName of ['should_run', ...expensiveJobs]) { + for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) { expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe( `\${{ steps.filter.outputs.${jobName} }}` ) @@ -250,13 +266,22 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() }) - it('gates each expensive job on its own classifier output', () => { - for (const jobName of expensiveJobs) { + it('gates each expensive job on its classifier and cache prerequisite', () => { + for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths']) expect(prWorkflow.jobs[jobName].if, jobName).toBe( `needs.code_paths.outputs.${jobName} == 'true'` ) } + expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache']) + expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") + expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'") + expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths']) + expect(prWorkflow.jobs.test_native_cache.if).toBe( + "needs.code_paths.outputs.native_cache_changed == 'true'" + ) + expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26']) }) it('skips e2e detection on docs-only PRs without dropping the draft gate', () => { diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index a61244d42d3..88bf4165d63 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -63,6 +63,13 @@ describe('PR workflow parallelism', () => { for (const testFile of nativeShellContractFiles) { expect(testStep.run).toContain(`--exclude=${testFile}`) } + const primerInstall = workflow.jobs.test_native_cache.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26']) + expect(primerInstall.with['native-runtime']).toBe('node') + expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}') + expect(workflow.jobs.test.needs).toContain('test_native_cache') }) it('runs real-shell coverage once outside the general shards', () => { @@ -208,6 +215,8 @@ describe('PR workflow parallelism', () => { expect(pnpmIndex).toBeLessThan(nodeIndex) expect(pnpmIndex).toBeLessThan(requestedNodeIndex) + const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1] + expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion) expect(steps[nodeIndex].with.cache).toBe('pnpm') expect(steps[nodeIndex].if).toBe("inputs.node-version == ''") expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''") @@ -316,6 +325,9 @@ describe('PR workflow parallelism', () => { expect(cacheStep.with.key).toContain( 'config/patches/@vscode__windows-process-tree@0.8.0.patch' ) + expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml') + expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs') + expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs') expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build') expect(cacheStep.with.path).toContain('windows-native-registry@') expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@') diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs index 8d4b7d568fd..e1742a43bb0 100644 --- a/config/scripts/windows-pty-native-capability-workflow.test.mjs +++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs @@ -50,6 +50,11 @@ describe('packaged Windows PTY native capability routing', () => { expect(nodeCacheSave.uses).toBe('actions/cache/save@v5') expect(nodeCacheSave.with.key).toContain('-node-node') expect(electronCache.with.key).toContain('-electron-node') + for (const cache of [nodeCacheSave, electronCache]) { + expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml') + expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs') + expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs') + } expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']") expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')") expect(build.run).toBe('pnpm run build:release:parallel') From 3b4a15e609dd7dcb294795d0cc25751c16c187e8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:44:26 -0700 Subject: [PATCH 07/37] test(editor): add copy button tests for check run details (#16925) Add tests verifying that copy buttons render and correctly copy content to clipboard for the output, annotations, and jobs sections of the check run details panel. --- .../editor/CheckRunDetailsPanel.copy.test.tsx | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx new file mode 100644 index 00000000000..23bc3d51c59 --- /dev/null +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.copy.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' +import { CheckRunDetailsPanel } from './CheckRunDetailsPanel' + +const writeClipboardText = vi.fn<(text: string) => Promise>() + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/sidebar/CommentMarkdown', () => ({ + default: ({ content }: { content: string }) => content +})) + +vi.mock('@/components/right-sidebar/source-control-fix-split-button', () => ({ + SourceControlFixSplitButton: () => null +})) + +vi.mock('./check-run-details-fix-with-ai', () => ({ + useCheckRunDetailsFixWithAI: () => ({ + canFixWithAI: false, + disabledReason: null, + isFixing: false, + fixPrompt: '', + repoId: null, + connectionId: null, + launchPlatform: null, + savedAgentId: null, + savedCommandInputTemplate: null, + savedAgentArgs: [], + saveLaunchActionDefault: vi.fn(), + openSourceControlAiSettings: vi.fn(), + fixWithAI: vi.fn() + }) +})) + +afterEach(cleanup) + +beforeEach(() => { + writeClipboardText.mockReset().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText } } }) +}) + +const check: PRCheckDetail = { + name: 'package', + status: 'completed', + conclusion: 'failure', + url: null, + checkRunId: 42 +} + +const details: PRCheckRunDetails = { + name: 'package', + status: 'completed', + conclusion: 'failure', + url: null, + detailsUrl: null, + startedAt: null, + completedAt: null, + title: 'Build failed', + summary: 'One test failed.', + text: 'Run the suite locally.', + annotations: [ + { + path: 'src/main.ts', + startLine: 10, + endLine: 10, + annotationLevel: 'failure', + title: 'Type error', + message: 'Expected string', + rawDetails: 'Actual: number' + } + ], + jobs: [ + { + id: 1, + name: 'unit', + status: 'completed', + conclusion: 'failure', + startedAt: null, + completedAt: null, + url: null, + logTail: 'AssertionError: expected true', + steps: [ + { + name: 'test', + status: 'completed', + conclusion: 'failure', + startedAt: null, + completedAt: null + } + ] + } + ] +} + +describe('CheckRunDetailsPanel card copy actions', () => { + it('renders a copy action for every card and copies each card payload', async () => { + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Copy output' })) + fireEvent.click(screen.getByRole('button', { name: 'Copy annotations' })) + fireEvent.click(screen.getByRole('button', { name: 'Copy jobs' })) + + await waitFor(() => expect(writeClipboardText).toHaveBeenCalledTimes(3)) + expect(writeClipboardText).toHaveBeenNthCalledWith( + 1, + 'Build failed\n\nOne test failed.\n\nRun the suite locally.' + ) + expect(writeClipboardText).toHaveBeenNthCalledWith( + 2, + 'src/main.ts:10\nfailure\nType error\nExpected string\nActual: number' + ) + expect(writeClipboardText).toHaveBeenNthCalledWith( + 3, + 'unit: failure\ntest: failure\nAssertionError: expected true' + ) + }) +}) From 4f14d6d757c0d3eee7bf52f6704682f5c02be270 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:06:08 -0700 Subject: [PATCH 08/37] fix(mobile): stop held accessory keys after release under latency (#14219) * fix(mobile): backpressure accessory key repeats * fix(mobile): keep accessory repeats on the pressed terminal * fix(mobile): serialize accessory key presses * fix(mobile): preserve queued accessory taps * fix(mobile): fence queued taps across reconnects * fix(mobile): recheck queued tap delivery context * fix(mobile): stop accessory repeats after IME send failure * fix(mobile): pace repeated live-input edits * fix(mobile): dispatch accessory taps without ack delay --- .../app/h/[hostId]/session/[worktreeId].tsx | 73 ++++--- .../terminal-accessory-repeat.test.ts | 192 ++++++++++++++++++ .../src/terminal/terminal-accessory-repeat.ts | 76 +++++++ .../terminal-live-accessory-raw-send.test.ts | 38 +++- .../terminal-live-accessory-raw-send.ts | 12 +- ...rminal-live-accessory-input-commit.test.ts | 16 +- ...se-terminal-live-accessory-input-commit.ts | 21 +- .../use-terminal-live-input-commit.test.ts | 14 ++ .../use-terminal-live-input-commit.ts | 2 +- .../use-terminal-live-pending-input-flush.ts | 11 +- 10 files changed, 398 insertions(+), 57 deletions(-) create mode 100644 mobile/src/terminal/terminal-accessory-repeat.test.ts create mode 100644 mobile/src/terminal/terminal-accessory-repeat.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 14a94513507..0d36fa96020 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -117,6 +117,10 @@ import { } from '../../../../src/terminal/terminal-accessory-layout' import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' +import { + createTerminalAccessoryRepeatController, + createTerminalAccessoryRepeatSender +} from '../../../../src/terminal/terminal-accessory-repeat' import { clearTerminalLiveInputFocusTimer, isTerminalLiveInputWithinByteLimit, @@ -2993,16 +2997,22 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(input: ReturnType) { - if (!client || !activeHandle || !canSend) { - return + async function handleAccessoryKey( + input: ReturnType, + targetHandle = activeHandleRef.current, + isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current + ) { + if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) { + return false } - const targetHandle = activeHandle const accessoryCommit = await handleLiveInputAccessoryBytes(input) - if (accessoryCommit.kind !== 'allow-raw') { - return + if (!isDeliveryTargetCurrent()) { + return false } - await sendTerminalLiveAccessoryRawBytes({ + if (accessoryCommit.kind !== 'allow-raw') { + return accessoryCommit.kind === 'handled' + } + return sendTerminalLiveAccessoryRawBytes({ client: clientRef.current, targetHandle, activeHandle: activeHandleRef.current, @@ -3399,32 +3409,37 @@ export default function SessionScreen() { } } - // Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive). - const repeatTimeoutRef = useRef | null>(null) - const repeatIntervalRef = useRef | null>(null) - // Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal. + const accessoryRepeatRef = useRef( + createTerminalAccessoryRepeatController>() + ) + // Why: the current callback observes reconnect state while the sender pins the press to its original terminal. const handleAccessoryKeyRef = useRef(handleAccessoryKey) handleAccessoryKeyRef.current = handleAccessoryKey const stopAccessoryRepeat = useCallback(() => { - if (repeatTimeoutRef.current) { - clearTimeout(repeatTimeoutRef.current) - repeatTimeoutRef.current = null - } - if (repeatIntervalRef.current) { - clearInterval(repeatIntervalRef.current) - repeatIntervalRef.current = null - } + accessoryRepeatRef.current.stop() }, []) const startAccessoryRepeat = useCallback( (input: ReturnType) => { - stopAccessoryRepeat() - repeatTimeoutRef.current = setTimeout(() => { - repeatIntervalRef.current = setInterval(() => { - void handleAccessoryKeyRef.current(input) - }, 45) - }, 400) + // Why: a held repeat must not resume through a replacement client or connection. + const targetClient = clientRef.current + const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null + const isDeliveryTargetCurrent = (targetHandle: string) => + activeHandleRef.current === targetHandle && + targetClient !== null && + clientRef.current === targetClient && + connStateRef.current === 'connected' && + targetClient.getLastConnectedAt() === targetConnectedAt + accessoryRepeatRef.current.start( + input, + createTerminalAccessoryRepeatSender( + activeHandleRef.current, + isDeliveryTargetCurrent, + (nextInput, targetHandle, isTargetCurrent) => + handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent) + ) + ) }, - [stopAccessoryRepeat] + [] ) const setMobileSessionRootRef = useCallback( (node: View | null): void => { @@ -3440,15 +3455,14 @@ export default function SessionScreen() { clearPendingLiveInputCommit() sessionTabActionSheetRequestSeqRef.current += 1 clearSessionTabActionSheetKeyboardListener() - stopAccessoryRepeat() + accessoryRepeatRef.current.cancel() }, [ clearPendingLiveInputCommit, clearDelayedActionTimers, clearSessionTabActionSheetKeyboardListener, clearTerminalCache, - clearToastHideTimer, - stopAccessoryRepeat + clearToastHideTimer ] ) @@ -4848,7 +4862,6 @@ export default function SessionScreen() { return } const input = createTerminalLiveAccessoryInput(key) - void handleAccessoryKey(input) startAccessoryRepeat(input) }} onPressOut={() => { diff --git a/mobile/src/terminal/terminal-accessory-repeat.test.ts b/mobile/src/terminal/terminal-accessory-repeat.test.ts new file mode 100644 index 00000000000..eab103eac15 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-repeat.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createTerminalAccessoryRepeatController, + createTerminalAccessoryRepeatSender, + TERMINAL_ACCESSORY_REPEAT_DELAY_MS, + TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS +} from './terminal-accessory-repeat' + +function deferred() { + let resolve: () => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('terminal accessory repeat', () => { + afterEach(() => vi.useRealTimers()) + + it('keeps one send in flight and preserves repeat order under host latency', async () => { + vi.useFakeTimers() + const first = deferred() + const second = deferred() + const sent: string[] = [] + const send = vi.fn((input: string) => { + sent.push(input) + return (sent.length === 1 ? first.promise : second.promise).then(() => true) + }) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + expect(sent).toEqual(['down']) + + await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500) + expect(sent).toEqual(['down']) + + first.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(sent).toEqual(['down', 'down']) + + await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500) + expect(sent).toEqual(['down', 'down']) + + second.resolve() + await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS) + expect(sent).toEqual(['down', 'down', 'down']) + }) + + it('does not schedule another repeat after release while a send is pending', async () => { + vi.useFakeTimers() + const pending = deferred() + const send = vi.fn(() => pending.promise.then(() => true)) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + repeat.stop() + pending.resolve() + await vi.runAllTimersAsync() + + expect(send).toHaveBeenCalledTimes(1) + }) + + it('dispatches distinct taps without waiting for prior acknowledgements', async () => { + vi.useFakeTimers() + const pending = Array.from({ length: 5 }, () => deferred()) + const sent: string[] = [] + const send = vi.fn((input: string) => { + sent.push(input) + return pending[sent.length - 1]!.promise.then(() => true) + }) + const repeat = createTerminalAccessoryRepeatController() + + for (let index = 0; index < pending.length; index += 1) { + repeat.start(`tap-${index}`, send) + repeat.stop() + } + + expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4']) + + pending.forEach(({ resolve }) => resolve()) + await vi.runAllTimersAsync() + expect(send).toHaveBeenCalledTimes(5) + }) + + it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => { + vi.useFakeTimers() + const first = deferred() + const sent: string[] = [] + const send = vi.fn((input: string) => { + sent.push(input) + return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true) + }) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + repeat.stop() + repeat.start('up', send) + repeat.cancel() + + first.resolve() + await vi.runAllTimersAsync() + + expect(sent).toEqual(['down', 'up']) + expect(send).toHaveBeenCalledTimes(2) + }) + + it('stops repeating when the transport rejects a send', async () => { + vi.useFakeTimers() + const send = vi.fn(() => Promise.reject(new Error('disconnected'))) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + await vi.runAllTimersAsync() + + expect(send).toHaveBeenCalledTimes(1) + }) + + it('stops repeating when the send is dropped before reaching the transport', async () => { + vi.useFakeTimers() + const send = vi.fn(async () => false) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + await vi.runAllTimersAsync() + + expect(send).toHaveBeenCalledTimes(1) + }) + + it('stops sending when the terminal active at press time is no longer active', async () => { + let activeTerminal = 'terminal-a' + const sendToTerminal = vi.fn(async () => true) + const send = createTerminalAccessoryRepeatSender( + activeTerminal, + (targetHandle) => activeTerminal === targetHandle, + sendToTerminal + ) + + await expect(send('down')).resolves.toBe(true) + activeTerminal = 'terminal-b' + await expect(send('down')).resolves.toBe(false) + + expect(sendToTerminal).toHaveBeenCalledTimes(1) + expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function)) + }) + + it('provides a live target guard for async send preflight', async () => { + let targetCurrent = true + const preflight = deferred() + const send = createTerminalAccessoryRepeatSender( + 'terminal-a', + () => targetCurrent, + async (_input, _targetHandle, isDeliveryTargetCurrent) => { + await preflight.promise + return isDeliveryTargetCurrent() + } + ) + + const result = send('down') + targetCurrent = false + preflight.resolve() + + await expect(result).resolves.toBe(false) + }) + + it('stops held repeats when the press-time connection is no longer current', async () => { + vi.useFakeTimers() + let connectionGeneration = 1 + const first = deferred() + const sent: string[] = [] + const sendToTerminal = vi.fn((input: string) => { + sent.push(input) + return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true) + }) + const createSender = () => { + const pressedConnectionGeneration = connectionGeneration + return createTerminalAccessoryRepeatSender( + 'terminal-a', + () => connectionGeneration === pressedConnectionGeneration, + sendToTerminal + ) + } + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', createSender()) + + connectionGeneration = 2 + first.resolve() + await vi.runAllTimersAsync() + + expect(sent).toEqual(['down']) + }) +}) diff --git a/mobile/src/terminal/terminal-accessory-repeat.ts b/mobile/src/terminal/terminal-accessory-repeat.ts new file mode 100644 index 00000000000..2b354b42d59 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-repeat.ts @@ -0,0 +1,76 @@ +export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400 +export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45 + +type TerminalAccessoryRepeatSender = (input: TInput) => Promise +type TerminalAccessoryRepeatSendToTerminal = ( + input: TInput, + targetHandle: string, + isDeliveryTargetCurrent: () => boolean +) => Promise + +export function createTerminalAccessoryRepeatSender( + targetHandle: string | null, + isDeliveryTargetCurrent: (targetHandle: string) => boolean, + sendToTerminal: TerminalAccessoryRepeatSendToTerminal +): TerminalAccessoryRepeatSender { + return (input) => { + if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) { + return Promise.resolve(false) + } + return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle)) + } +} + +export function createTerminalAccessoryRepeatController() { + let generation = 0 + let timer: ReturnType | null = null + + const stop = () => { + generation += 1 + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + const cancel = stop + + const start = (input: TInput, send: TerminalAccessoryRepeatSender) => { + stop() + const activeGeneration = generation + const pressedAt = Date.now() + + const schedule = (delayMs: number) => { + timer = setTimeout(() => { + timer = null + if (generation !== activeGeneration) { + return + } + void send(input).then( + (sent) => { + if (sent && generation === activeGeneration) { + schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS) + } + }, + () => undefined + ) + }, delayMs) + } + + void send(input).then( + (sent) => { + if (sent && generation === activeGeneration) { + schedule( + Math.min( + TERMINAL_ACCESSORY_REPEAT_DELAY_MS, + Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt)) + ) + ) + } + }, + () => undefined + ) + } + + return { cancel, start, stop } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts index 7a1110b407b..4ecab7dd10c 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send' import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' -function captureClient(result: Promise = Promise.resolve({ ok: true })) { +function captureClient( + result: Promise = Promise.resolve({ + id: 'send', + ok: true, + result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } }, + _meta: { runtimeId: 'test-runtime' } + }) +) { const sendRequest = vi.fn(() => result) return { client: { sendRequest } as unknown as Pick, sendRequest } } @@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => { it('sends raw bytes now-or-never with the device presence tag', async () => { const { client, sendRequest } = captureClient() - await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true) expect(sendRequest).toHaveBeenCalledWith( 'terminal.send', @@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => { it('drops the bytes instead of sending while disconnected', async () => { const { client, sendRequest } = captureClient() - await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' }) + await expect( + sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' }) + ).resolves.toBe(false) expect(sendRequest).not.toHaveBeenCalled() }) @@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => { it('drops the bytes when the terminal selection went stale mid-flush', async () => { const { client, sendRequest } = captureClient() - await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' }) + await expect( + sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' }) + ).resolves.toBe(false) expect(sendRequest).not.toHaveBeenCalled() }) - it('swallows a rejected send so accessory taps never surface transport errors', async () => { + it('reports a rejected send without surfacing transport errors', async () => { const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send'))) - await expect( - sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) - ).resolves.toBeUndefined() + await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false) + }) + + it('reports a fulfilled RPC failure as a failed send', async () => { + const response: RpcResponse = { + id: 'send', + ok: false, + error: { code: 'terminal_error', message: 'failed' }, + _meta: { runtimeId: 'test-runtime' } + } + const { client } = captureClient(Promise.resolve(response)) + + await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false) }) }) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts index 792491f6c63..3824d750792 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts @@ -1,4 +1,5 @@ import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = { export async function sendTerminalLiveAccessoryRawBytes( args: TerminalLiveAccessoryRawSendArgs -): Promise { +): Promise { // Why: async IME flushing can outlive the original terminal selection. const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ targetHandle: args.targetHandle, @@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes( activeSessionTabType: args.activeSessionTabType }) if (!args.client || !rawSendTarget || args.connState !== 'connected') { - return + return false } - await args.client + return args.client .sendRequest( 'terminal.send', buildTerminalSendParams({ @@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes( }), TERMINAL_INPUT_SEND_OPTIONS ) - .then( - () => undefined, - () => undefined - ) + .then(isTerminalSendRpcAccepted, () => false) } diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index 4474f043ebe..ad1d1726798 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -70,7 +70,7 @@ function createAccessoryInputCommitHarness({ } } const applyLiveInputMirror = vi.fn( - (_handle: string, _fieldText: string, _composing?: boolean) => {} + async (_handle: string, _fieldText: string, _composing?: boolean) => true ) const clearPendingLiveInputCommit = vi.fn(() => {}) const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult) @@ -171,6 +171,20 @@ describe('terminal live accessory input commit hook', () => { expect(result).toEqual({ kind: 'handled' }) }) + it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => { + const harness = createAccessoryInputCommitHarness({ + heldText: '한', + pendingHandle: 'terminal-a', + sendResult: false + }) + + const result = await harness.commit({ bytes: '\x1b' }) + + expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a') + expect(harness.sent).toEqual(['\x1b']) + expect(result).toEqual({ kind: 'suppress-raw' }) + }) + it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => { // Given const harness = createAccessoryInputCommitHarness({ pendingHandle: null }) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index ef6a2339822..80a8422d085 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -21,7 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult( type TerminalLiveAccessoryInputCommitOptions = { readonly activeHandle: string | null - readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void + readonly applyLiveInputMirror: ( + handle: string, + fieldText: string, + composing?: boolean + ) => Promise readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject @@ -84,15 +88,20 @@ export function useTerminalLiveAccessoryInputCommit({ setLiveInputCapture(editedText) liveInputRef.current?.setNativeProps({ text: editedText }) // Preserve undefined so Android's heuristic hold still settles on its timer. - applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current) - return { kind: 'handled' } + const sent = await applyLiveInputMirror( + activeHandle, + editedText, + liveInputComposingRef.current + ) + return sent ? { kind: 'handled' } : { kind: 'suppress-raw' } } - case 'commit-held-then-send': - await sendTerminalLiveControlAfterPendingFlush( + case 'commit-held-then-send': { + const sent = await sendTerminalLiveControlAfterPendingFlush( () => flushPendingLiveInputText(activeHandle), () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) ) - return { kind: 'handled' } + return sent ? { kind: 'handled' } : { kind: 'suppress-raw' } + } default: decision satisfies never return { kind: 'handled' } diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index b24636114a9..6077ccb4033 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -190,6 +190,20 @@ describe('terminal live input commit hook', () => { await vi.waitFor(() => expect(sent).toEqual(['한'])) }) + it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => { + const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) + changeLiveInput(handlers, 'a', false) + await vi.waitFor(() => expect(sent).toEqual(['a'])) + + const result = await handlers.handleLiveInputAccessoryBytes({ + bytes: '\x7f', + localEdit: 'backspace' + }) + + expect(sent).toEqual(['a', '\x7f']) + expect(result).toEqual({ kind: 'suppress-raw' }) + }) + it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => { // Given vi.useFakeTimers() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 25bbf1ae002..952612f0a87 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -133,7 +133,7 @@ export function useTerminalLiveInputCommit({ // that differs from the native field text, so the controlled capture must // echo the field verbatim; only the PTY mirror sees normalized text. setLiveInputCapture(nativeEvent.text) - applyLiveInputMirror( + void applyLiveInputMirror( activeHandle, normalizeTerminalTextInput(nativeEvent.text), nativeEvent.isComposing diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index c01aa5af213..ff0c09c1c51 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -30,7 +30,11 @@ type RunTerminalLiveMirrorStep = ( ) => Promise type TerminalLivePendingInputFlush = { - readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void + readonly applyLiveInputMirror: ( + handle: string, + fieldText: string, + composing?: boolean + ) => Promise readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject @@ -153,9 +157,8 @@ export function useTerminalLivePendingInputFlush({ }, [runMirrorStep]) const applyLiveInputMirror = useCallback( - (handle: string, fieldText: string, composing?: boolean): void => { - void runMirrorStep(handle, fieldText, false, composing) - }, + (handle: string, fieldText: string, composing?: boolean): Promise => + runMirrorStep(handle, fieldText, false, composing), [runMirrorStep] ) From 2ac23c29be2a00c55263ead03f249e936e52e319 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:38:01 -0700 Subject: [PATCH 09/37] Add parent worktree selection when creating workspace (#15420) * feat: select parent worktree for nesting when creating workspace Enable users to pick a parent workspace in the composer's Advanced drawer, organizing newly created worktrees hierarchically in the sidebar. The backend validates lineage relationships and gracefully retries without the parent if it becomes unavailable during creation. * feat: select parent worktree for nesting when creating workspace - Record app-picked parents as manual actions, not CLI-flag equivalents, ensuring the same user action carries consistent cleanup semantics across hosts - Gracefully retry without parent if the selection goes stale, warn the user instead of failing - Refactor create into modules: parent resolution, payload building, state merge * Allow selecting parent worktree when creating workspace - New parent worktree picker filtered by execution host and project - Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership * Allow selecting parent worktree when creating workspace - Rename "Parent workspace" label to "Parent worktree" - Filter candidates by execution host and project to prevent nesting across hosts - Wire parentWorktreeId through composer state and creation request pipeline - Update translations and add new copy for nesting-related messages --- src/main/ipc/worktree-create-lineage.test.ts | 265 +++++++++ src/main/ipc/worktree-remote.ts | 134 ++++- src/main/runtime/orca-runtime.test.ts | 46 ++ src/main/runtime/orca-runtime.ts | 17 +- .../rpc/methods/worktree-create-args.test.ts | 15 + .../rpc/methods/worktree-create-args.ts | 1 + .../rpc/methods/worktree-create-schemas.ts | 3 + src/renderer/src/assets/main.css | 11 +- ...orkspaceComposerCard.set-location.test.tsx | 2 + .../NewWorkspaceComposerCard.test.tsx | 35 +- .../components/NewWorkspaceComposerCard.tsx | 10 + .../ComposerParentWorktreePicker.test.tsx | 540 ++++++++++++++++++ .../ComposerParentWorktreePicker.tsx | 429 ++++++++++++++ .../NewWorkspaceComposerAdvancedSection.tsx | 23 + .../new-workspace-composer-card-props.ts | 6 + .../folder-workspace-attached-worktrees.ts | 3 +- .../composer-state/composer-card-contract.ts | 4 + .../composer-state/composer-card-props.ts | 8 + .../composer-state/composer-store-actions.ts | 1 + .../composer-submit-orchestration.ts | 2 + .../full-creation-execution.test.ts | 1 + .../composer-state/full-creation-execution.ts | 6 +- .../hooks/composer-state/identity-model.ts | 2 + .../quick-creation-execution.ts | 4 + .../quick-creation-request.test.ts | 3 + .../composer-state/quick-creation-request.ts | 2 + .../workspace-identity-state.ts | 4 + .../useComposerState.integration.test.ts | 8 +- src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 89 ++- src/renderer/src/i18n/locales/zh.json | 97 +++- .../src/lib/pending-worktree-creation.ts | 2 + ...reation-flow-agent-trust-preflight.test.ts | 2 +- .../src/lib/worktree-creation-flow-execute.ts | 240 ++++++++ .../src/lib/worktree-creation-flow.ts | 235 +------- .../runtime-compatibility-test-fixture.ts | 1 + .../src/store/slices/worktree-helpers.ts | 19 +- .../worktrees-create-parent-pick.test.ts | 388 +++++++++++++ .../worktrees-fetch-remote-lineage.test.ts | 217 +++++++ .../worktrees/create/create-worktree.ts | 337 +++++------ .../create/created-worktree-state-merge.ts | 63 ++ .../create/worktree-create-parent-pick.ts | 94 +++ .../create/worktree-create-payload.ts | 132 +++++ .../worktrees/listing/fetch-worktrees.ts | 2 +- .../listing/runtime-worktree-rpc-errors.ts | 6 + .../metadata/worktree-lineage-actions.ts | 11 +- .../metadata/worktree-lineage-host-merge.ts | 30 +- .../metadata/worktree-lineage-refresh.ts | 48 +- .../src/web/preload-api/web-runtime-calls.ts | 4 +- .../src/web/preload-api/web-worktrees-api.ts | 2 + .../web-preload-api-workspace-catalog.test.ts | 43 ++ src/shared/resolved-worktree-lineage.test.ts | 41 +- src/shared/resolved-worktree-lineage.ts | 13 +- 55 files changed, 3224 insertions(+), 513 deletions(-) create mode 100644 src/main/ipc/worktree-create-lineage.test.ts create mode 100644 src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.test.tsx create mode 100644 src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx create mode 100644 src/renderer/src/lib/worktree-creation-flow-execute.ts create mode 100644 src/renderer/src/store/slices/worktrees-create-parent-pick.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/created-worktree-state-merge.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/worktree-create-parent-pick.ts create mode 100644 src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts diff --git a/src/main/ipc/worktree-create-lineage.test.ts b/src/main/ipc/worktree-create-lineage.test.ts new file mode 100644 index 00000000000..fa7e101ac3f --- /dev/null +++ b/src/main/ipc/worktree-create-lineage.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CreateWorktreeArgs } from '../../shared/worktree/create-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Worktree } from '../../shared/worktree/types' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { + assertAttachableParentWorkspace, + recordWorkspaceLineageForCreatedWorktree +} from './worktree-remote' + +const CHILD_ID = 'repo-1::/repos/child' +const PARENT_ID = 'repo-1::/repos/parent' +const CREATED_AT = 1_700_000_000_000 + +function createdWorktree(overrides: Partial = {}): Worktree { + return { + id: CHILD_ID, + instanceId: 'child-instance', + repoId: 'repo-1', + hostId: 'local', + projectId: 'project-1', + path: '/repos/child', + head: 'abc123', + branch: 'refs/heads/child', + isBare: false, + isMainWorktree: false, + displayName: 'child', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function parentMeta(overrides: Partial = {}): WorktreeMeta { + return { + instanceId: 'parent-instance', + hostId: 'local', + projectId: 'project-1', + displayName: 'parent', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function createStore(options: { + metaById?: Record + folderWorkspaceIds?: string[] +}) { + const metaById = options.metaById ?? {} + const folderWorkspaceIds = new Set(options.folderWorkspaceIds ?? []) + return { + getWorktreeMeta: vi.fn((id: string) => metaById[id]), + getFolderWorkspace: vi.fn((id: string) => + folderWorkspaceIds.has(id) ? { id, path: `/folders/${id}` } : undefined + ), + setWorktreeLineage: vi.fn((_id: string, lineage: unknown) => lineage), + setWorkspaceLineage: vi.fn((lineage: unknown) => lineage) + } +} + +function record( + store: ReturnType, + args: Partial, + worktree = createdWorktree() +) { + return recordWorkspaceLineageForCreatedWorktree( + store as never, + args as CreateWorktreeArgs, + worktree, + CREATED_AT + ) +} + +describe('recordWorkspaceLineageForCreatedWorktree', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('writes both lineage rows for a worktree parent inside the same repo/host/project', () => { + const store = createStore({ metaById: { [PARENT_ID]: parentMeta() } }) + + const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) }) + + expect(store.setWorktreeLineage).toHaveBeenCalledWith(CHILD_ID, { + worktreeId: CHILD_ID, + worktreeInstanceId: 'child-instance', + parentWorktreeId: PARENT_ID, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: CREATED_AT + }) + expect(result.lineage).toMatchObject({ + parentWorktreeId: PARENT_ID, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' } + }) + expect(result.workspaceLineage).toEqual({ + childWorkspaceKey: worktreeWorkspaceKey(CHILD_ID), + childInstanceId: 'child-instance', + parentWorkspaceKey: worktreeWorkspaceKey(PARENT_ID), + parentInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: CREATED_AT + }) + }) + + it('skips both lineage records when the parent belongs to a different repo', () => { + const foreignParentId = 'repo-2::/repos/parent' + const store = createStore({ metaById: { [foreignParentId]: parentMeta() } }) + + const result = record(store, { parentWorkspace: worktreeWorkspaceKey(foreignParentId) }) + + expect(store.setWorktreeLineage).not.toHaveBeenCalled() + expect(result.lineage).toBeNull() + expect(store.setWorkspaceLineage).not.toHaveBeenCalled() + expect(result.workspaceLineage).toBeNull() + }) + + it.each([ + ['host', parentMeta({ hostId: 'ssh:other-host' })], + ['project', parentMeta({ projectId: 'project-2' })] + ])( + 'skips both lineage records when the parent %s conflicts, so no cross-host row is persisted', + (_label, meta) => { + const store = createStore({ metaById: { [PARENT_ID]: meta } }) + + const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) }) + + expect(store.setWorktreeLineage).not.toHaveBeenCalled() + expect(result.lineage).toBeNull() + // A persisted cross-host row makes filterLineageForHost return null for the entire host. + expect(store.setWorkspaceLineage).not.toHaveBeenCalled() + expect(result.workspaceLineage).toBeNull() + } + ) + + it('skips worktree lineage when the parent has no instance identity', () => { + const store = createStore({ metaById: { [PARENT_ID]: parentMeta({ instanceId: undefined }) } }) + + const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) }) + + expect(store.setWorktreeLineage).not.toHaveBeenCalled() + expect(result.lineage).toBeNull() + expect(result.workspaceLineage).toMatchObject({ + parentWorkspaceKey: worktreeWorkspaceKey(PARENT_ID), + parentInstanceId: null + }) + }) + + it('records only workspace lineage for a folder-workspace parent', () => { + const store = createStore({ folderWorkspaceIds: ['folder-1'] }) + + const result = record(store, { parentWorkspace: folderWorkspaceKey('folder-1') }) + + expect(store.setWorktreeLineage).not.toHaveBeenCalled() + expect(result.lineage).toBeNull() + expect(result.workspaceLineage).toMatchObject({ + parentWorkspaceKey: folderWorkspaceKey('folder-1'), + parentInstanceId: null, + capture: { source: 'active-workspace', confidence: 'explicit' } + }) + }) + + it('records nothing without a parent workspace', () => { + const store = createStore({}) + + const result = record(store, {}) + + expect(store.setWorktreeLineage).not.toHaveBeenCalled() + expect(store.setWorkspaceLineage).not.toHaveBeenCalled() + expect(result).toEqual({ lineage: null, workspaceLineage: null }) + }) + + it('records nothing when the parent workspace is the created worktree itself', () => { + const store = createStore({ metaById: { [CHILD_ID]: parentMeta() } }) + + const result = record(store, { parentWorkspace: worktreeWorkspaceKey(CHILD_ID) }) + + expect(store.setWorktreeLineage).not.toHaveBeenCalled() + expect(store.setWorkspaceLineage).not.toHaveBeenCalled() + expect(result).toEqual({ lineage: null, workspaceLineage: null }) + }) + + it('records nothing when the parent worktree disappeared before the write', () => { + const store = createStore({}) + + const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) }) + + expect(store.setWorkspaceLineage).not.toHaveBeenCalled() + expect(result).toEqual({ lineage: null, workspaceLineage: null }) + }) +}) + +describe('assertAttachableParentWorkspace', () => { + const childKey = worktreeWorkspaceKey(CHILD_ID) + + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function assertParent( + store: ReturnType, + parentWorkspace: CreateWorktreeArgs['parentWorkspace'] + ) { + assertAttachableParentWorkspace(store as never, parentWorkspace, childKey) + } + + // Why: the pick can go stale between the composer and the create; nesting is decoration, not the job. + it.each([ + ['worktree', () => worktreeWorkspaceKey(PARENT_ID)], + ['folder', () => folderWorkspaceKey('folder-gone')] + ])('degrades instead of failing when the %s parent disappeared', (_label, key) => { + const store = createStore({}) + + expect(() => assertParent(store, key())).not.toThrow() + expect(console.warn).toHaveBeenCalled() + }) + + it('accepts a parent that still exists', () => { + const store = createStore({ metaById: { [PARENT_ID]: parentMeta() } }) + + expect(() => assertParent(store, worktreeWorkspaceKey(PARENT_ID))).not.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }) + + it('rejects a self-referential parent', () => { + const store = createStore({ metaById: { [CHILD_ID]: parentMeta() } }) + + expect(() => assertParent(store, childKey)).toThrow(/cannot be attached to itself/) + }) + + it('rejects a malformed parent key', () => { + const store = createStore({}) + + expect(() => assertParent(store, 'not-a-workspace-key' as never)).toThrow( + /Invalid parent workspace/ + ) + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index c20899ea9cf..977c29d8792 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -91,6 +91,7 @@ import { findCreatedWorktree } from './created-worktree-reconciliation' import type { BranchPrefixSettings } from '../../shared/branch-prefix' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { sharesWorktreeLineageBoundary } from '../../shared/resolved-worktree-lineage' import { cleanupUnusedWorktreePushTargetRemoteWithExec, sameGitHubRemoteUrl, @@ -197,7 +198,11 @@ function getSetupRunnerCommandPlatformForLaunch( return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) } -function validateWorkspaceLineageParentBeforeCreate( +/** Why not throw on a missing parent: nesting is optional decoration, and the pick can go stale + * between the composer and the create. A malformed or self-referential key is a bad request and + * still fails; a parent that simply disappeared degrades to an unattached workspace, matching the + * runtime path and `recordWorkspaceLineageForCreatedWorktree`. */ +export function assertAttachableParentWorkspace( store: Store, parentWorkspace: CreateWorktreeArgs['parentWorkspace'], childWorkspaceKey: ReturnType @@ -213,51 +218,120 @@ function validateWorkspaceLineageParentBeforeCreate( throw new Error(`Invalid parent workspace: ${parentWorkspace}`) } if (parentScope.type === 'folder' && !store.getFolderWorkspace(parentScope.folderWorkspaceId)) { - throw new Error(`Parent folder workspace not found: ${parentWorkspace}`) + console.warn(`[worktree-create] parent folder workspace not found: ${parentWorkspace}`) + return } if (parentScope.type === 'worktree' && !store.getWorktreeMeta(parentScope.worktreeId)) { - throw new Error(`Parent worktree workspace not found: ${parentWorkspace}`) + console.warn(`[worktree-create] parent worktree workspace not found: ${parentWorkspace}`) } } -function recordWorkspaceLineageForCreatedWorktree( +type CreatedWorktreeLineageRecords = { + lineage: CreateWorktreeResult['lineage'] + workspaceLineage: CreateWorktreeResult['workspaceLineage'] +} + +const NO_CREATED_WORKTREE_LINEAGE: CreatedWorktreeLineageRecords = { + lineage: null, + workspaceLineage: null +} + +/** Mirrors the projection's edge rule so we never persist a row the sidebar would silently drop. + * WorktreeMeta has no repoId, so the parent's comes from its `::` id. */ +function createdWorktreeSharesParentLineageBoundary( + worktree: Worktree, + parentWorktreeId: string, + parentMeta: WorktreeMeta +): boolean { + return sharesWorktreeLineageBoundary(worktree, { + repoId: getRepoIdFromWorktreeId(parentWorktreeId), + hostId: parentMeta.hostId, + projectId: parentMeta.projectId + }) +} + +export function recordWorkspaceLineageForCreatedWorktree( store: Store, args: CreateWorktreeArgs, worktree: Worktree, createdAt: number -): CreateWorktreeResult['workspaceLineage'] { +): CreatedWorktreeLineageRecords { if (!args.parentWorkspace || !worktree.instanceId) { - return null + return NO_CREATED_WORKTREE_LINEAGE } const childWorkspaceKey = worktreeWorkspaceKey(worktree.id) if (args.parentWorkspace === childWorkspaceKey) { console.warn(`[worktree-create] refusing to attach ${worktree.id} to itself`) - return null + return NO_CREATED_WORKTREE_LINEAGE } const parentScope = parseWorkspaceKey(args.parentWorkspace) if (!parentScope) { console.warn(`[worktree-create] ignoring invalid parent workspace ${args.parentWorkspace}`) - return null + return NO_CREATED_WORKTREE_LINEAGE } if (parentScope.type === 'folder' && !store.getFolderWorkspace(parentScope.folderWorkspaceId)) { console.warn(`[worktree-create] parent folder workspace disappeared: ${args.parentWorkspace}`) - return null + return NO_CREATED_WORKTREE_LINEAGE } const parentWorktreeMeta = parentScope.type === 'worktree' ? store.getWorktreeMeta(parentScope.worktreeId) : null if (parentScope.type === 'worktree' && !parentWorktreeMeta) { console.warn(`[worktree-create] parent worktree workspace disappeared: ${args.parentWorkspace}`) - return null + return NO_CREATED_WORKTREE_LINEAGE } - return store.setWorkspaceLineage({ + + // Why: only a worktree parent produces sidebar nesting; a folder parent has no WorktreeLineage row. + let lineage: CreateWorktreeResult['lineage'] = null + let parentOutsideLineageBoundary = false + if (parentScope.type === 'worktree' && parentWorktreeMeta) { + if (!parentWorktreeMeta.instanceId) { + console.warn( + `[worktree-create] parent ${parentScope.worktreeId} has no instance identity; skipping lineage` + ) + } else if ( + !createdWorktreeSharesParentLineageBoundary( + worktree, + parentScope.worktreeId, + parentWorktreeMeta + ) + ) { + parentOutsideLineageBoundary = true + console.warn( + `[worktree-create] parent ${parentScope.worktreeId} is outside ${worktree.id}'s repo/host/project boundary; skipping lineage` + ) + } else { + lineage = store.setWorktreeLineage(worktree.id, { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId, + parentWorktreeId: parentScope.worktreeId, + parentWorktreeInstanceId: parentWorktreeMeta.instanceId, + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt + }) + } + } + + // Why: persisting a workspace row for an out-of-boundary worktree parent poisons the whole host — + // `filterLineageForHost` returns null for any owned row whose child and parent hosts differ, so + // every nesting on that host stops hydrating, and the row survives restarts. + if (parentOutsideLineageBoundary) { + return NO_CREATED_WORKTREE_LINEAGE + } + + const workspaceLineage = store.setWorkspaceLineage({ childWorkspaceKey, childInstanceId: worktree.instanceId, parentWorkspaceKey: args.parentWorkspace, parentInstanceId: parentWorktreeMeta?.instanceId ?? null, origin: 'manual', - capture: { source: 'active-workspace', confidence: 'explicit' }, + capture: { + source: parentScope.type === 'worktree' ? 'manual-action' : 'active-workspace', + confidence: 'explicit' + }, createdAt }) + return { lineage, workspaceLineage } } function countNonEmptyGitOutputLines(output: string): number { @@ -1677,7 +1751,7 @@ export async function createRemoteWorktree( ) } - validateWorkspaceLineageParentBeforeCreate( + assertAttachableParentWorkspace( store, args.parentWorkspace, worktreeWorkspaceKey(`${repo.id}::${remotePath}`) @@ -1915,7 +1989,12 @@ export async function createRemoteWorktree( const meta = store.setWorktreeMeta(worktreeId, metaUpdates) return { worktree: mergeWorktree(repo.id, created, meta) } }) - const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) + const { lineage: worktreeLineage, workspaceLineage } = recordWorkspaceLineageForCreatedWorktree( + store, + args, + worktree, + now + ) // Why: shared/symlink paths, `orca.yaml` shared directories, and `.worktreeinclude` copies are local-only; remote (SSH) support needs a new relay method + auth surface, so all are skipped here. @@ -1962,7 +2041,14 @@ export async function createRemoteWorktree( notifyWorktreesChanged(mainWindow, repo.id) return { - worktree: { ...worktree, workspaceLineage }, + worktree: { + ...worktree, + workspaceLineage, + ...(worktreeLineage + ? { lineage: worktreeLineage, parentWorktreeId: worktreeLineage.parentWorktreeId } + : {}) + }, + ...(worktreeLineage ? { lineage: worktreeLineage } : {}), ...(workspaceLineage ? { workspaceLineage } : {}), ...(setup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), @@ -2308,7 +2394,7 @@ export async function createLocalWorktree( ) } - validateWorkspaceLineageParentBeforeCreate( + assertAttachableParentWorkspace( store, args.parentWorkspace, worktreeWorkspaceKey(`${repo.id}::${worktreePath}`) @@ -2555,7 +2641,12 @@ export async function createLocalWorktree( const meta = store.setWorktreeMeta(worktreeId, metaUpdates) return { worktree: mergeWorktree(repo.id, created, meta) } }) - const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) + const { lineage: worktreeLineage, workspaceLineage } = recordWorkspaceLineageForCreatedWorktree( + store, + args, + worktree, + now + ) // Why: reuse the roots creation already paid for via `git worktree list` so later IPC doesn't lazily rescan and trip macOS privacy prompts. registerWorktreeRootsForRepo(store, repo.id, [ repo.path, @@ -2658,7 +2749,14 @@ export async function createLocalWorktree( notifyWorktreesChanged(mainWindow, repo.id) return { - worktree: { ...worktree, workspaceLineage }, + worktree: { + ...worktree, + workspaceLineage, + ...(worktreeLineage + ? { lineage: worktreeLineage, parentWorktreeId: worktreeLineage.parentWorktreeId } + : {}) + }, + ...(worktreeLineage ? { lineage: worktreeLineage } : {}), ...(workspaceLineage ? { workspaceLineage } : {}), ...(stagedStartup.activationSetup ? { setup: stagedStartup.activationSetup } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7747797de4f..4f02816f1c5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -6469,6 +6469,52 @@ describe('OrcaRuntimeService', () => { } }) + // Why: the desktop composer sends `parentWorkspace` too, and a bare selector defaults to CLI + // provenance — the same user action must not carry different cleanup semantics per host. + it('records an app-selected parent workspace as a manual action', async () => { + vi.mocked(addWorktree).mockClear() + const created = { + path: '/tmp/workspaces/manual-child', + head: 'def', + branch: 'refs/heads/manual-child', + isBare: false, + isMainWorktree: false + } + const childId = `${TEST_REPO_ID}::${created.path}` + const metaById: Record = {} + const runtimeStore = { + ...createFolderWorkspaceRuntimeStore(), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + setWorkspaceLineage: vi.fn((lineage: WorkspaceLineage) => lineage) + } + computeWorktreePathMock.mockReturnValue(created.path) + ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue) + vi.mocked(listWorktrees).mockResolvedValueOnce([created]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const result = await runtime.createManagedWorktree({ + repoSelector: TEST_REPO_ID, + name: 'manual-child', + baseBranch: 'origin/main', + lineage: { + parentWorkspace: TEST_FOLDER_WORKSPACE_KEY, + parentWorkspaceOrigin: 'manual' + } + }) + + expect(result.workspaceLineage).toMatchObject({ + childWorkspaceKey: `worktree:${childId}`, + parentWorkspaceKey: TEST_FOLDER_WORKSPACE_KEY, + origin: 'manual', + capture: { source: 'active-workspace', confidence: 'explicit' } + }) + }) + it('records folder workspace lineage inferred from environment context', async () => { vi.mocked(addWorktree).mockClear() const created = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 02ea1ba1afc..4e94b6d6872 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2901,6 +2901,8 @@ type ResolvedTerminalWorkspaceLaunchTarget = { type WorktreeLineageInput = { parentWorkspace?: string + /** Set by in-app parent pickers so the row is not recorded as a CLI flag. */ + parentWorkspaceOrigin?: 'manual' envParentWorkspace?: string parentWorktree?: string cwdParentWorktree?: string @@ -32573,11 +32575,20 @@ export class OrcaRuntimeService { if (input.parentWorkspace) { try { + const parent = await this.resolveWorkspaceParentSelector(input.parentWorkspace) + // Why: a picker in the app must record the same provenance as a local create, or the same + // user action would carry different cleanup semantics depending on where the repo lives. return { kind: 'lineage', - parent: await this.resolveWorkspaceParentSelector(input.parentWorkspace), - origin: 'cli', - capture: { source: 'explicit-cli-flag', confidence: 'explicit' } + parent, + origin: input.parentWorkspaceOrigin === 'manual' ? 'manual' : 'cli', + capture: + input.parentWorkspaceOrigin === 'manual' + ? { + source: parent.type === 'worktree' ? 'manual-action' : 'active-workspace', + confidence: 'explicit' + } + : { source: 'explicit-cli-flag', confidence: 'explicit' } } } catch (err) { throw new RuntimeLineageError( diff --git a/src/main/runtime/rpc/methods/worktree-create-args.test.ts b/src/main/runtime/rpc/methods/worktree-create-args.test.ts index fa2b773560b..5a55d54460f 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.test.ts @@ -26,4 +26,19 @@ describe('buildManagedWorktreeCreateArgs', () => { nameWasGenerated: true }) }) + + it('carries the parent-pick provenance only when the client marked it manual', () => { + // Why: older clients never send it, and those creates really are CLI-flag equivalents. + expect( + build({ repo: 'id:repo-1', name: 'child', parentWorkspace: 'folder:f1' }).lineage + ).not.toHaveProperty('parentWorkspaceOrigin') + expect( + build({ + repo: 'id:repo-1', + name: 'child', + parentWorkspace: 'folder:f1', + parentWorkspaceOrigin: 'manual' + }).lineage + ).toMatchObject({ parentWorkspaceOrigin: 'manual' }) + }) }) diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 2b86b12ee05..ac674f08d61 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -80,6 +80,7 @@ export function buildManagedWorktreeCreateArgs( startupDraft: params.startupDraft, lineage: { parentWorkspace: params.parentWorkspace, + ...(params.parentWorkspaceOrigin ? { parentWorkspaceOrigin: 'manual' as const } : {}), envParentWorkspace: params.envParentWorkspace, parentWorktree: params.parentWorktree, ...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}), diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts index 507f44b325b..e296b9c3ace 100644 --- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts @@ -75,6 +75,9 @@ export const WorktreeCreate = z // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal. navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(), parentWorkspace: OptionalString, + // Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag. + // Absent keeps the CLI provenance older clients rely on. + parentWorkspaceOrigin: z.literal('manual').optional(), envParentWorkspace: OptionalString, parentWorktree: OptionalString, cwdParentWorktree: OptionalString, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9d75836cd11..cd556d6f486 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1145,9 +1145,11 @@ html.native-shell .app-layout { /* ── Jump palette (Cmd+J) ─────────────────────────────── */ -/* Why the data-slot qualifier: CommandItem also sets data-[selected=true]:bg-accent; - this selector must beat that utility so light-mode selection is actually visible. */ -[data-slot='command-item'].jump-palette-item[data-selected='true'] { +/* Why the extra qualifier: CommandItem also sets data-[selected=true]:bg-accent; + this selector must beat that utility so light-mode selection is actually visible. + The role='option' arm covers virtualized rows that render outside cmdk. */ +[data-slot='command-item'].jump-palette-item[data-selected='true'], +[role='option'].jump-palette-item[data-selected='true'] { /* Why: flat --accent (#f5f5f5) on the palette surface (#fff) is nearly invisible in light mode — the keyboard cursor has to read as a solid selected row. Mix foreground into background and draw an inset ring; expose the surface @@ -1159,7 +1161,8 @@ html.native-shell .app-layout { color: var(--foreground); } -.dark [data-slot='command-item'].jump-palette-item[data-selected='true'] { +.dark [data-slot='command-item'].jump-palette-item[data-selected='true'], +.dark [role='option'].jump-palette-item[data-selected='true'] { /* Why accent, not the light-mode mix: --accent (#404040) already reads clearly on dark surfaces; a low-%% foreground mix was a visible step down from the old row. */ --jump-palette-selection-surface: var(--accent); diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx index fe68dc4f53c..9cdea1d7d54 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -171,6 +171,8 @@ function renderCard( onOpenAgentSettings={() => {}} advancedOpen={false} onToggleAdvanced={() => {}} + parentWorktreeId={null} + onParentWorktreeIdChange={() => {}} createDisabled={false} projectError={null} creating={false} diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 9775cd06dae..8206354f916 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -230,6 +230,8 @@ function renderCard( onReuseSelectedBranchChange={() => {}} branchNameOverride="" onBranchNameOverrideChange={() => {}} + parentWorktreeId={null} + onParentWorktreeIdChange={() => {}} forkPushWarning={null} detectedAgentIds={null} onOpenAgentSettings={() => {}} @@ -306,6 +308,11 @@ function findRunTargetItem(label: string): HTMLElement | undefined { let current: { container: HTMLDivElement; root: Root } | null = null +function unmountCurrent(): void { + act(() => current?.root.unmount()) + current?.container.remove() +} + describe('NewWorkspaceComposerCard folder task source mode', () => { beforeEach(() => { ;(window as unknown as { api: unknown }).api = { @@ -333,8 +340,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { }) afterEach(() => { - act(() => current?.root.unmount()) - current?.container.remove() + unmountCurrent() current = null vi.clearAllMocks() }) @@ -395,8 +401,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { ) expect(collapsedReuse).toBeTruthy() - act(() => current?.root.unmount()) - current?.container.remove() + unmountCurrent() current = renderCard({ canReuseSelectedBranch: true, reuseSelectedBranch: true }) const reuseLabel = [...current.container.querySelectorAll('label')].find((label) => @@ -430,8 +435,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { clickReuseCheckbox() expect(offChanges).toEqual([false]) - act(() => current?.root.unmount()) - current?.container.remove() + unmountCurrent() // Unchecked -> checked (opting into reuse — the action that pins the branch). const onChanges: boolean[] = [] @@ -458,8 +462,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { 'Wait for setup to complete before starting agent' ) - act(() => current?.root.unmount()) - current?.container.remove() + unmountCurrent() current = renderCard({ advancedOpen: true, @@ -579,6 +582,19 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(findInputByLabel(current.container, 'Branch name')).toBeTruthy() }) + it('places the parent workspace picker immediately after the branch name field', () => { + current = renderCard({ + advancedOpen: true, + branchesEnabled: true + }) + + const nextField = findInputByLabel(current.container, 'Branch name')?.closest( + 'div.space-y-1' + )?.nextElementSibling + expect(nextField?.textContent).toMatch(/Parent worktree(?!.*Note)/) + expect(nextField?.nextElementSibling?.textContent).toContain('Note') + }) + it('does not disable folder workspace creation when only source lookup needs SSH', () => { current = renderCard({ eligibleRepos: [ @@ -886,8 +902,7 @@ describe('NewWorkspaceComposerCard note sizing', () => { // Sizing is layout-driven (field-sizing) rather than a JS measure pass, and happy-dom // has no layout engine, so these assert the class contract that produces the growth. afterEach(() => { - act(() => current?.root.unmount()) - current?.container.remove() + unmountCurrent() current = null }) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index b0a711cb0eb..c6cca5192ea 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -55,6 +55,11 @@ export default function NewWorkspaceComposerCard( setupConfig, setupControlsEnabled = true, selectedProjectId = null, + parentWorktreeId, + onParentWorktreeIdChange, + selectedRepoExecutionHostId, + selectedRepoProjectId, + activeFolderWorkspaceId, onAddProjectOverride, onNestedDialogOpenChange } = props @@ -302,6 +307,11 @@ export default function NewWorkspaceComposerCard( setupRunButtonLabel={setupRunButtonLabel} setupSkipButtonLabel={setupSkipButtonLabel} showSetupAgentStartupPolicy={showSetupAgentStartupPolicy} + parentWorktreeId={parentWorktreeId} + onParentWorktreeIdChange={onParentWorktreeIdChange} + selectedRepoExecutionHostId={selectedRepoExecutionHostId} + selectedRepoProjectId={selectedRepoProjectId} + activeFolderWorkspaceId={activeFolderWorkspaceId} /> + repos: Repo[] + projectHostSetups: never[] + worktreeLineageById: Record + workspaceLineageByChildKey: Record +} + +const storeState = vi.hoisted( + () => + ({ + worktreesByRepo: {}, + repos: [], + projectHostSetups: [], + worktreeLineageById: {}, + workspaceLineageByChildKey: {} + }) as FakeStoreState +) + +const popoverMock = vi.hoisted(() => ({ + open: false, + onOpenChange: undefined as ((open: boolean) => void) | undefined +})) + +const indexSpies = vi.hoisted(() => ({ + getIndexedAllWorktrees: vi.fn(), + getIndexedWorktreeMap: vi.fn(), + getIndexedWorktreeById: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }) +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: FakeStoreState) => unknown) => selector(storeState) +})) + +// Spies wrap the real projections, so exclusion rules stay under test while call +// counts prove when enumeration happens. +vi.mock('@/store/worktree-repo-index', async (importOriginal) => { + const actual = await importOriginal() + indexSpies.getIndexedAllWorktrees.mockImplementation(actual.getIndexedAllWorktrees) + indexSpies.getIndexedWorktreeMap.mockImplementation(actual.getIndexedWorktreeMap) + indexSpies.getIndexedWorktreeById.mockImplementation(actual.getIndexedWorktreeById) + return { ...actual, ...indexSpies } +}) + +vi.mock('@/components/ui/popover', async () => { + const { cloneElement } = await import('react') + return { + Popover: ({ + children, + open, + onOpenChange + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => { + popoverMock.open = open ?? false + popoverMock.onOpenChange = onOpenChange + return
{children}
+ }, + // Radix `asChild` renders the child itself and owns its click; mirror that. + PopoverTrigger: ({ children }: { children: React.ReactElement }) => + cloneElement(children as React.ReactElement<{ onClick?: () => void }>, { + onClick: () => popoverMock.onOpenChange?.(!popoverMock.open) + }), + // Radix mounts content only while open — the property the lazy list relies on. + PopoverContent: ({ children }: { children: React.ReactNode }) => + popoverMock.open ?
{children}
: null + } +}) + +vi.mock('@/components/ui/command', () => ({ + Command: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+ ), + CommandInput: ({ + ref, + value, + placeholder, + onValueChange, + onKeyDown, + onKeyUp, + onCompositionStart, + onCompositionEnd + }: { + ref?: React.Ref + value?: string + placeholder?: string + onValueChange?: (value: string) => void + onKeyDown?: React.KeyboardEventHandler + onKeyUp?: React.KeyboardEventHandler + onCompositionStart?: React.CompositionEventHandler + onCompositionEnd?: React.CompositionEventHandler + }) => ( + onValueChange?.(event.target.value)} + onKeyDown={onKeyDown} + onKeyUp={onKeyUp} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} + /> + ), + CommandList: ({ + ref, + children, + className + }: { + ref?: React.Ref + children: React.ReactNode + className?: string + }) => ( +
+ {children} +
+ ) +})) + +// The virtual window needs real layout the test DOM has none of; render every row. +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 44, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 44, + size: 44 + })), + scrollToIndex: vi.fn(), + scrollToOffset: vi.fn() + }) +})) + +const { ComposerParentWorktreePicker } = await import('./ComposerParentWorktreePicker') + +const REPO_ID = 'repo1' + +function makeRepo(id: string): Repo { + return { + id, + path: `/src/${id}`, + displayName: id, + badgeColor: '#111111', + addedAt: 0 + } as Repo +} + +function makeWorktree(overrides: Partial & { id: string }): Worktree { + return { + repoId: REPO_ID, + path: `/work/${overrides.id}`, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: overrides.id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function seed(worktrees: Worktree[], repoIds: string[] = [REPO_ID]): void { + const byRepo: Record = {} + for (const worktree of worktrees) { + byRepo[worktree.repoId] = [...(byRepo[worktree.repoId] ?? []), worktree] + } + storeState.worktreesByRepo = byRepo + storeState.repos = repoIds.map(makeRepo) +} + +function trigger(): HTMLButtonElement { + const node = document.querySelector('button[role="combobox"]') + if (!node) { + throw new Error('parent picker trigger not found') + } + return node +} + +function rows(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="option"]')) +} + +function candidateLabels(): string[] { + return rows() + .slice(1) + .map((row) => row.textContent ?? '') +} + +function searchInput(): HTMLInputElement { + const node = document.querySelector('[data-slot="command-input"]') + if (!node) { + throw new Error('parent picker search input not found') + } + return node +} + +function rowFor(label: string): HTMLElement { + const row = rows().find((node) => node.textContent?.includes(label)) + if (!row) { + throw new Error(`row not found for ${label}`) + } + return row +} + +beforeEach(() => { + popoverMock.open = false + popoverMock.onOpenChange = undefined + storeState.worktreesByRepo = {} + storeState.repos = [] + storeState.worktreeLineageById = {} + storeState.workspaceLineageByChildKey = {} + indexSpies.getIndexedAllWorktrees.mockClear() + indexSpies.getIndexedWorktreeMap.mockClear() + indexSpies.getIndexedWorktreeById.mockClear() +}) + +afterEach(cleanup) + +describe('ComposerParentWorktreePicker', () => { + it('enumerates candidate worktrees only once the popover opens', () => { + seed([makeWorktree({ id: 'alpha' }), makeWorktree({ id: 'beta' })]) + + render() + + // The closed trigger resolves one name from the shared index; it never enumerates. + expect(indexSpies.getIndexedWorktreeById).toHaveBeenCalled() + expect(indexSpies.getIndexedAllWorktrees).not.toHaveBeenCalled() + expect(indexSpies.getIndexedWorktreeMap).not.toHaveBeenCalled() + + fireEvent.click(trigger()) + + expect(indexSpies.getIndexedAllWorktrees).toHaveBeenCalled() + expect(indexSpies.getIndexedWorktreeMap).toHaveBeenCalled() + }) + + it('shows "No parent" on the closed trigger when nothing is picked', () => { + seed([makeWorktree({ id: 'alpha' })]) + + render() + + expect(trigger().textContent).toContain('No parent') + }) + + it('shows the picked worktree display name on the closed trigger', () => { + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha workspace' })]) + + render() + + expect(trigger().textContent).toContain('Alpha workspace') + expect(trigger().textContent).not.toContain('No parent') + }) + + it('reports the picked worktree id and closes when a candidate row is clicked', () => { + const onChange = vi.fn() + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })]) + + render() + fireEvent.click(trigger()) + fireEvent.click(rowFor('Alpha')) + + expect(onChange).toHaveBeenCalledWith('alpha') + expect(rows()).toHaveLength(0) + }) + + it('clears the pick when the pinned "No parent" row is clicked', () => { + const onChange = vi.fn() + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })]) + + render() + fireEvent.click(trigger()) + fireEvent.click(rows()[0]) + + expect(onChange).toHaveBeenCalledWith(null) + expect(rows()).toHaveLength(0) + }) + + it('excludes archived worktrees and worktrees from another repo', () => { + seed( + [ + makeWorktree({ id: 'alpha', displayName: 'Alpha' }), + makeWorktree({ id: 'archived', displayName: 'Archived', isArchived: true }), + makeWorktree({ id: 'other', displayName: 'Other repo', repoId: 'repo2' }) + ], + [REPO_ID, 'repo2'] + ) + + render() + fireEvent.click(trigger()) + + expect(candidateLabels().join(' ')).toContain('Alpha') + expect(candidateLabels().join(' ')).not.toContain('Archived') + expect(candidateLabels().join(' ')).not.toContain('Other repo') + }) + + it('excludes candidates on another execution host or project', () => { + seed([ + makeWorktree({ id: 'same', displayName: 'Same host', hostId: 'local', projectId: 'proj1' }), + makeWorktree({ + id: 'otherHost', + displayName: 'Other host', + hostId: 'ssh:box', + projectId: 'proj1' + }), + makeWorktree({ + id: 'otherProject', + displayName: 'Other project', + hostId: 'local', + projectId: 'proj2' + }) + ]) + + render( + + ) + fireEvent.click(trigger()) + + expect(candidateLabels().join(' ')).toContain('Same host') + expect(candidateLabels().join(' ')).not.toContain('Other host') + expect(candidateLabels().join(' ')).not.toContain('Other project') + }) + + // A worktree with no recorded hostId inherits its repo's host, which is the child's host too. + it('keeps candidates whose host or project is unrecorded', () => { + seed([makeWorktree({ id: 'unscoped', displayName: 'Unscoped' })]) + + render( + + ) + fireEvent.click(trigger()) + + expect(candidateLabels().join(' ')).toContain('Unscoped') + }) + + it('restricts candidates to the active folder workspace subtree', () => { + const attached = makeWorktree({ + id: 'attached', + displayName: 'Attached', + instanceId: 'attached-instance' + }) + const nested = makeWorktree({ + id: 'nested', + displayName: 'Nested', + instanceId: 'nested-instance' + }) + const outside = makeWorktree({ id: 'outside', displayName: 'Outside' }) + seed([attached, nested, outside]) + storeState.workspaceLineageByChildKey = { + [worktreeWorkspaceKey(attached.id)]: { + childWorkspaceKey: worktreeWorkspaceKey(attached.id), + childInstanceId: attached.instanceId ?? null, + parentWorkspaceKey: folderWorkspaceKey('folder-1'), + parentInstanceId: null, + origin: 'manual', + capture: { source: 'active-workspace', confidence: 'explicit' }, + createdAt: 1 + } + } + storeState.worktreeLineageById = { + [nested.id]: { + worktreeId: nested.id, + worktreeInstanceId: 'nested-instance', + parentWorktreeId: attached.id, + parentWorktreeInstanceId: 'attached-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 2 + } + } + + render( + + ) + fireEvent.click(trigger()) + + const labels = candidateLabels().join(' ') + expect(labels).toContain('Attached') + expect(labels).toContain('Nested') + expect(labels).not.toContain('Outside') + }) + + it('keeps the trigger inert while disabled', () => { + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })]) + + render( + + ) + fireEvent.click(trigger()) + + expect(trigger().disabled).toBe(true) + expect(rows()).toHaveLength(0) + }) + + it('picks the top match when Enter follows a search query', () => { + const onChange = vi.fn() + seed([ + makeWorktree({ id: 'alpha', displayName: 'Alpha' }), + makeWorktree({ id: 'beta', displayName: 'Beta' }) + ]) + + render() + fireEvent.click(trigger()) + fireEvent.change(searchInput(), { target: { value: 'Beta' } }) + fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 13 }) + + // Regression: the highlight used to reset onto the pinned "No parent" row, so the + // standard type-then-Enter flow cleared the parent instead of picking the match. + expect(onChange).toHaveBeenCalledWith('beta') + }) + + it('clears the parent when Enter is pressed with no search query', () => { + const onChange = vi.fn() + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })]) + + render() + fireEvent.click(trigger()) + fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 13 }) + + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('keeps the parent when Enter only confirms a CJK composition', () => { + const onChange = vi.fn() + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })]) + + render() + fireEvent.click(trigger()) + fireEvent.compositionStart(searchInput()) + // The confirming Enter arrives twice: the marked keydown, then an unmarked redispatch. + fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 229 }) + fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 13 }) + + expect(onChange).not.toHaveBeenCalled() + expect(rows().length).toBeGreaterThan(0) + }) + + it('falls back to "No parent" on the trigger when the picked parent is archived', () => { + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha workspace', isArchived: true })]) + + render() + + expect(trigger().textContent).not.toContain('Alpha workspace') + expect(trigger().textContent).toContain('No parent') + }) + + it('excludes a folder root whose lineage row is instance-stale', () => { + const attached = makeWorktree({ + id: 'attached', + displayName: 'Attached', + instanceId: 'current-instance' + }) + seed([attached]) + storeState.workspaceLineageByChildKey = { + [worktreeWorkspaceKey(attached.id)]: { + childWorkspaceKey: worktreeWorkspaceKey(attached.id), + childInstanceId: 'deleted-and-recreated', + parentWorkspaceKey: folderWorkspaceKey('folder-1'), + parentInstanceId: null, + origin: 'manual', + capture: { source: 'active-workspace', confidence: 'explicit' }, + createdAt: 1 + } + } + + render( + + ) + fireEvent.click(trigger()) + + expect(candidateLabels().join(' ')).not.toContain('Attached') + }) + + it('closes an open popover when the drawer collapses the trigger', () => { + seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })]) + + const view = render( + + ) + fireEvent.click(trigger()) + expect(rows().length).toBeGreaterThan(0) + + view.rerender( + + ) + + expect(rows()).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx new file mode 100644 index 00000000000..41c22978f14 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ComposerParentWorktreePicker.tsx @@ -0,0 +1,429 @@ +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Check, ChevronsUpDown, GitBranch } from 'lucide-react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { Button } from '@/components/ui/button' +import { Command, CommandInput, CommandList } from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { + isImeCompositionKeyDown, + useImeEnterGestureOwnership +} from '@/lib/ime-composition-keyboard-event' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getIndexedAllWorktrees, + getIndexedWorktreeById, + getIndexedWorktreeMap +} from '@/store/worktree-repo-index' +import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' +import { branchDisplayName } from '@/components/sidebar/WorktreeCardHelpers' +import { getCyclicProjectedWorktreeLineageIds } from '@/components/sidebar/worktree-lineage-projection' +import { + clampWorktreeParentPickerIndex, + filterWorktreeParentCandidates +} from '@/components/sidebar/worktree-parent-picker-filtering' +import { + PICKER_LIST_MAX_HEIGHT, + PICKER_ROW_HEIGHT, + PICKER_ROW_OVERSCAN +} from '@/components/sidebar/worktree-parent-picker-placement' +import { + getLineageChildrenByParentId, + getLineageChildWorktree +} from '@/components/right-sidebar/folder-workspace-attached-worktrees' +import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' +import { + sharesWorktreeLineageBoundary, + type WorktreeLineageBoundary +} from '../../../../shared/resolved-worktree-lineage' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +/** Single-line row: text-sm leading (20px) over py-2. Set as the row's explicit height. */ +const NO_PARENT_ROW_HEIGHT = 36 +const NO_PARENT_ROW_KEY = 'no-parent' + +type ComposerParentWorktreePickerProps = { + repoId: string + /** Parent must belong to the same execution host that will create the child. */ + executionHostId?: ExecutionHostId | null + projectId?: string | null + value: string | null + onChange: (id: string | null) => void + disabled?: boolean + /** When the active workspace is a folder, restrict candidates to that folder's subtree. */ + activeFolderWorkspaceId?: string | null +} + +type ParentWorktreeCandidateListProps = { + repoId: string + executionHostId?: ExecutionHostId | null + projectId?: string | null + value: string | null + activeFolderWorkspaceId: string | null + onSelect: (id: string | null) => void +} + +/** + * Parent picker for the composer's Advanced drawer. Nesting only — the base + * branch is unaffected. + * + * Everything that enumerates worktrees lives in the inner list, which Radix + * mounts on open, so a closed picker costs one string selector and nothing else. + */ +function ComposerParentWorktreePickerImpl({ + repoId, + executionHostId, + projectId, + value, + onChange, + disabled = false, + activeFolderWorkspaceId = null +}: ComposerParentWorktreePickerProps): React.JSX.Element { + // Why: subscribe so translate() copy refreshes on language change without a remount. + useTranslation() + const [open, setOpen] = useState(false) + const labelId = `${useId()}label` + + // Why: an archived parent is already gone from the candidate list and the sidebar would + // drop the edge, so showing its name would promise nesting the create can't deliver. + const parentName = useAppStore((s) => { + if (!value) { + return null + } + const parent = getIndexedWorktreeById(s.worktreesByRepo, value) + return parent && !parent.isArchived ? parent.displayName : null + }) + + // Why: the trigger goes inert when the Advanced drawer collapses, but the portaled + // popover does not — it would linger detached from a control the user can't reach. + // Adjusted during render (not in an effect) so the stale-open frame never paints. + if (open && disabled) { + setOpen(false) + } + + const handleSelect = useCallback( + (nextId: string | null) => { + onChange(nextId) + setOpen(false) + }, + [onChange] + ) + + return ( +
+ + {translate('auto.components.ComposerParentWorktreePicker.label', 'Parent worktree')} + + + + + + { + event.preventDefault() + const content = event.currentTarget + if (content instanceof HTMLElement) { + content.querySelector('[data-slot="command-input"]')?.focus() + } + }} + > + + + +

+ {translate( + 'auto.components.ComposerParentWorktreePicker.description', + 'Nests this workspace under another in the sidebar. Does not change the base branch.' + )} +

+
+ ) +} + +/** Worktrees reachable from the folder workspace, so a pick can't drop the new one out of it. */ +function getFolderWorkspaceSubtreeIds( + folderWorkspaceId: string, + workspaceLineageByChildKey: Readonly>, + worktreeLineageById: Record, + worktreeById: Map +): Set { + const folderKey = folderWorkspaceKey(folderWorkspaceId) + const rootIds = new Set() + for (const lineage of Object.values(workspaceLineageByChildKey)) { + if (lineage.parentWorkspaceKey !== folderKey) { + continue + } + // Why: the folder view drops archived and instance-stale rows, so accepting them here + // would offer a parent whose whole branch the folder never actually shows. + const rootWorktree = getLineageChildWorktree(lineage, worktreeById) + if (rootWorktree) { + rootIds.add(rootWorktree.id) + } + } + + const subtreeIds = new Set(rootIds) + for (const children of getLineageChildrenByParentId( + worktreeLineageById, + worktreeById, + rootIds + ).values()) { + for (const child of children) { + subtreeIds.add(child.id) + } + } + return subtreeIds +} + +function ParentWorktreeCandidateList({ + repoId, + executionHostId, + projectId, + value, + activeFolderWorkspaceId, + onSelect +}: ParentWorktreeCandidateListProps): React.JSX.Element { + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) + const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) + const listRef = useRef(null) + const inputRef = useRef(null) + const imeEnter = useImeEnterGestureOwnership() + const optionIdPrefix = `${useId()}option` + const [search, setSearch] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + + const candidates = useMemo(() => { + // Why `?? undefined`: an unresolved host or project is "not known yet", not "no host", and + // the boundary check reads undefined on either side as a wildcard. A candidate in this repo + // with no recorded hostId inherits that repo's host, so it is on the child's host too. + const childBoundary: WorktreeLineageBoundary = { + repoId, + hostId: executionHostId ?? undefined, + projectId: projectId ?? undefined + } + const worktreeMap = getIndexedWorktreeMap(worktreesByRepo) + const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap) + const folderSubtreeIds = activeFolderWorkspaceId + ? getFolderWorkspaceSubtreeIds( + activeFolderWorkspaceId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreeMap + ) + : null + return getIndexedAllWorktrees(worktreesByRepo) + .filter( + (candidate) => + candidate.repoId === repoId && + !candidate.isArchived && + sharesWorktreeLineageBoundary(childBoundary, candidate) && + !cyclicLineageIds.has(candidate.id) && + (folderSubtreeIds === null || folderSubtreeIds.has(candidate.id)) + ) + .sort(compareWorktreeDisplayName) + }, [ + activeFolderWorkspaceId, + executionHostId, + projectId, + repoId, + workspaceLineageByChildKey, + worktreeLineageById, + worktreesByRepo + ]) + + const filtered = useMemo( + () => filterWorktreeParentCandidates(candidates, search), + [candidates, search] + ) + // Index 0 is the pinned "No parent" row; candidates start at 1. + const activeIndex = clampWorktreeParentPickerIndex(highlightedIndex, filtered.length + 1) + + // Why: the "No parent" row is virtualized as index 0 rather than pinned above the + // scroller. A row outside the virtual container shifts every item's true position + // without the virtualizer knowing, so scrollToIndex would land short by that height. + const virtualizer = useVirtualizer({ + count: filtered.length + 1, + getScrollElement: () => listRef.current, + estimateSize: (index) => (index === 0 ? NO_PARENT_ROW_HEIGHT : PICKER_ROW_HEIGHT), + overscan: PICKER_ROW_OVERSCAN, + getItemKey: (index) => (index === 0 ? NO_PARENT_ROW_KEY : (filtered[index - 1]?.id ?? index)), + // Why: the list mounts inside a popover that measures on the next frame, so + // seed the viewport to avoid a blank first paint. + initialRect: { width: 0, height: PICKER_LIST_MAX_HEIGHT } + }) + + const moveHighlight = useCallback( + (nextIndex: number) => { + setHighlightedIndex(nextIndex) + virtualizer.scrollToIndex(nextIndex, { align: 'auto' }) + }, + [virtualizer] + ) + + const handleSearchChange = useCallback( + (nextSearch: string) => { + // Why: re-ranking on each keystroke makes any prior highlight meaningless. A query + // highlights the top match so Enter picks it; index 0 clears the parent, which is + // never what typing a name means. The clamp drops it back to 0 when nothing matches. + setSearch(nextSearch) + setHighlightedIndex(nextSearch.trim().length > 0 ? 1 : 0) + virtualizer.scrollToOffset(0) + }, + [virtualizer] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Why: cmdk's root handler only knows the mounted virtual window, so own navigation here. + // A CJK confirm sends two Enter keydowns and only the first is marked, so the marked-only + // check must be paired with the gesture carry or the unmarked one commits a selection. + if (imeEnter.ownsKeyDown(event) || isImeCompositionKeyDown(event)) { + return + } + const navigate = (nextIndex: number): void => { + event.preventDefault() + event.stopPropagation() + moveHighlight(clampWorktreeParentPickerIndex(nextIndex, filtered.length + 1)) + } + if (event.key === 'ArrowDown') { + navigate(activeIndex + 1) + } else if (event.key === 'ArrowUp') { + navigate(activeIndex - 1) + } else if (event.key === 'Home') { + navigate(0) + } else if (event.key === 'End') { + navigate(filtered.length) + } else if (event.key === 'Enter') { + const candidate = activeIndex === 0 ? null : filtered[activeIndex - 1] + if (activeIndex === 0 || candidate) { + event.preventDefault() + event.stopPropagation() + onSelect(candidate?.id ?? null) + } + } + }, + [activeIndex, filtered, imeEnter, moveHighlight, onSelect] + ) + + // Why: cmdk's Input owns aria-activedescendant and points it at its own empty + // item registry while we drive selection, so re-point it after every commit. + useEffect(() => { + inputRef.current?.setAttribute('aria-activedescendant', `${optionIdPrefix}-${activeIndex}`) + }) + + const virtualRows = virtualizer.getVirtualItems() + + return ( + + imeEnter.setComposing(true)} + onCompositionEnd={() => imeEnter.setComposing(false)} + wrapperClassName="shrink-0" + placeholder={translate( + 'auto.components.ComposerParentWorktreePicker.searchPlaceholder', + 'Search workspaces...' + )} + /> + +
+ {virtualRows.map((virtualRow) => { + const rowIndex = virtualRow.index + const candidate = rowIndex === 0 ? null : filtered[rowIndex - 1] + if (rowIndex !== 0 && !candidate) { + return null + } + const isHighlighted = rowIndex === activeIndex + return ( +
setHighlightedIndex(rowIndex)} + onClick={() => onSelect(candidate?.id ?? null)} + > + + {candidate ? ( +
+
{candidate.displayName}
+
+ + {branchDisplayName(candidate.branch)} +
+
+ ) : ( + + {translate( + 'auto.components.ComposerParentWorktreePicker.noParent', + 'No parent' + )} + + )} +
+ ) + })} +
+ {filtered.length === 0 && search.trim().length > 0 ? ( +
+ {translate('auto.components.ComposerParentWorktreePicker.noMatches', 'No matches.')} +
+ ) : null} +
+
+ ) +} + +/** Why: every prop is a primitive or a stable callback, so the composer's per-keystroke + * renders stop here instead of re-rendering the popover subtree. */ +export const ComposerParentWorktreePicker = React.memo(ComposerParentWorktreePickerImpl) + +export default ComposerParentWorktreePicker diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx index 549370ee029..f7b18f6ebaa 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAdvancedSection.tsx @@ -11,6 +11,7 @@ import { shouldHandleTextControlPaste } from '@/lib/text-control-paste' import { translate } from '@/i18n/i18n' +import { ComposerParentWorktreePicker } from './ComposerParentWorktreePicker' import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props' function SetupCommandPreview({ @@ -37,6 +38,11 @@ type NewWorkspaceComposerAdvancedSectionProps = Pick< | 'branchesEnabled' | 'branchNameOverride' | 'onBranchNameOverrideChange' + | 'parentWorktreeId' + | 'onParentWorktreeIdChange' + | 'selectedRepoExecutionHostId' + | 'selectedRepoProjectId' + | 'activeFolderWorkspaceId' | 'note' | 'onNoteChange' | 'setupControlsEnabled' @@ -74,6 +80,11 @@ export function NewWorkspaceComposerAdvancedSection({ branchNameInputId, branchNameOverride, onBranchNameOverrideChange, + parentWorktreeId = null, + onParentWorktreeIdChange, + selectedRepoExecutionHostId, + selectedRepoProjectId, + activeFolderWorkspaceId = null, note, onNoteChange, setupControlsEnabled = true, @@ -191,6 +202,18 @@ export function NewWorkspaceComposerAdvancedSection({ ) : null} + {selectedRepoIsGit && branchesEnabled && onParentWorktreeIdChange ? ( + + ) : null} +
- {createDestination && isCreateMode && !isHermesTarget ? ( + {destination && !isHermesTarget ? (
-
diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index f596c21c36b..7eb2ff13c1d 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -36,6 +36,7 @@ import { import { getAutomationRunContent } from './automation-run-content' import type { AutomationActionNotice } from './automation-row-action-dispatch' import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import type { AutomationTargetAvailability } from './automation-target-availability' import type { AutomationRunViewState } from './automation-run-view-state' import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' @@ -56,6 +57,8 @@ type AutomationsDetailPaneProps = { selectedRepoDisplayName: string selectedRepoDefaultBaseRef: string | null selectedWorkspaceName: string + /** Catalog entry the selected row was listed from; absent for legacy unscoped rows. */ + selectedHostEntry: AutomationHostCatalogEntry | null hostLabelById: ReadonlyMap selectedRunNowAvailability: AutomationTargetAvailability | null selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null @@ -107,6 +110,7 @@ export function AutomationsDetailPane({ selectedRepoDisplayName, selectedRepoDefaultBaseRef, selectedWorkspaceName, + selectedHostEntry, hostLabelById, selectedRunNowAvailability, selectedAutomationRunPageWorkspaceDisplay, @@ -221,6 +225,7 @@ export function AutomationsDetailPane({ projectName={selectedRepoDisplayName} projectDefaultBaseRef={selectedRepoDefaultBaseRef} workspaceName={selectedWorkspaceName} + hostEntry={selectedHostEntry} hostLabelById={hostLabelById} runNowAvailability={selectedRunNowAvailability} now={relativeNow} diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index da04a8cd58f..a0778029ef9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -32,6 +32,7 @@ import { } from './automations-page-test-harness' import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' import type { Repo } from '../../../../shared/repo-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' installAutomationsPageHarness() @@ -71,6 +72,52 @@ function addSshProject(): void { } as Repo mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] mocks.repoMap.set(SSH_REPO_ID, repo) + // Without a ready setup on that host the project has no run context, and a + // save against it is refused before any destination is stated. + mocks.state.projectHostSetups = [ + ...(mocks.state.projectHostSetups as ProjectHostSetup[]), + { + id: 'setup-ssh', + projectId: 'project-ssh', + hostId: `ssh:${SSH_TARGET_ID}`, + repoId: SSH_REPO_ID, + path: '/repos/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] +} + +/** A runtime project with the same host-local id as the desktop project. */ +function addCollidingRuntimeProject(): void { + const repo = { + id: REPO_ID, + displayName: 'orca on m4-air', + path: '/repos/orca', + badgeColor: '#111111', + addedAt: 1, + worktreeBaseRef: 'main', + executionHostId: `runtime:${RUNTIME_ID}` + } as Repo + mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] + mocks.state.projectHostSetups = [ + ...(mocks.state.projectHostSetups as ProjectHostSetup[]), + { + id: 'setup-runtime-collision', + projectId: 'project-runtime-collision', + hostId: `runtime:${RUNTIME_ID}`, + repoId: REPO_ID, + path: '/repos/orca', + displayName: 'orca on m4-air', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] } /** The runtime answers a create; without this the RPC double returns an empty result. */ @@ -395,6 +442,227 @@ describe('AutomationsPage edit dialog projects', () => { }) }) +describe('AutomationsPage edit destination', () => { + it('names the edited row’s own host, and offers every host it could move to', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + addSshHost() + addSshProject() + runtimeHost([], []) + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + const resolution = mocks.editorDialog?.editDestination?.resolution + expect(resolution?.status === 'ready' && resolution.entry.stableKey).toBe(DESKTOP_SELF_KEY) + // Another authority is a legal destination — reached by a create there and a + // delete here — so restricting the list to this one would hide a real move. + expect(mocks.editorDialog?.editDestination?.entries.map((entry) => entry.stableKey)).toContain( + RUNTIME_SELF_KEY + ) + expect(mocks.editorDialog?.editDestination?.moveWarning).toBeFalsy() + }) + + it('moves the record across authorities by creating there and deleting here', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + runtimeHost([], []) + addRuntimeProject() + runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID })) + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY) + }) + + expect(mocks.editorDialog?.editDestination?.moveWarning).toBeTruthy() + + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + projectId: RUNTIME_REPO_ID, + workspaceMode: 'existing', + workspaceId: RUNTIME_WORKSPACE_ID + })) + }) + await save() + + expect(api.automations.update).not.toHaveBeenCalled() + expect(runtimeCreateCalls()).toHaveLength(1) + expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ + repo: `id:${RUNTIME_REPO_ID}`, + dtstart: automation.dtstart + }) + expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' })) + }) + + it('moves when the destination project has the same host-local id', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + runtimeHost([], []) + addCollidingRuntimeProject() + runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: REPO_ID })) + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY) + }) + await save() + + expect(runtimeCreateCalls()).toHaveLength(1) + expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ repo: `id:${REPO_ID}` }) + expect(api.automations.delete).toHaveBeenCalled() + }) + + it('reuses the move creation key on retry even when the schedule changed', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + runtimeHost([], []) + addRuntimeProject() + // The first create dies in transport after the server may have committed; + // only an identical creationKey on the retry keeps that copy singular. + let failCreate = true + const previous = mocks.callRuntimeRpc.getMockImplementation() + mocks.callRuntimeRpc.mockImplementation( + async (target: unknown, method: string, params: unknown, options: unknown) => { + if (method === 'automation.create') { + if (failCreate) { + failCreate = false + throw new Error('socket hang up') + } + return { automation: makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }) } + } + return await previous?.(target, method, params, options) + } + ) + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + projectId: RUNTIME_REPO_ID, + workspaceMode: 'existing', + workspaceId: RUNTIME_WORKSPACE_ID, + // A changed schedule mints dtstart per attempt — the volatile input that + // must not re-key the move. + preset: 'custom', + customSchedule: 'FREQ=DAILY;BYHOUR=10;BYMINUTE=0' + })) + }) + + const realNow = Date.now.bind(Date) + let clockSkew = 0 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => realNow() + clockSkew) + try { + await save() + clockSkew = 5000 + await save() + } finally { + nowSpy.mockRestore() + } + + const creationKeys = runtimeCreateCalls().map( + (call) => (call[2] as { creationKey?: string }).creationKey + ) + expect(creationKeys).toHaveLength(2) + expect(creationKeys[0]).toBeTruthy() + expect(creationKeys[1]).toBe(creationKeys[0]) + expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' })) + }) + + it('reports a kept original as two live copies, never as a completed move', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + runtimeHost([], []) + addRuntimeProject() + runtimeCreateReturns(makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID })) + api.automations.delete.mockRejectedValue(new Error('gone away')) + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + projectId: RUNTIME_REPO_ID, + workspaceMode: 'existing', + workspaceId: RUNTIME_WORKSPACE_ID + })) + }) + await save() + + expect(runtimeCreateCalls()).toHaveLength(1) + expect(api.automations.delete).toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('could not be deleted')) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + }) + + it('moves the record to the host the user picks, clearing the stranded project', async () => { + const automation = makeAutomation({ id: 'a-1' }) + api.automations.list.mockResolvedValue([]) + scopedList([automation]) + addSshHost() + addSshProject() + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + await act(async () => { + mocks.editorDialog?.editDestination?.onSelect(SSH_HOST_KEY) + }) + + // Keeping the local project selected only defers the same refusal to submit. + expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([SSH_REPO_ID]) + expect(mocks.editorDialog?.draft?.projectId).toBe('') + + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + projectId: SSH_REPO_ID, + workspaceMode: 'new_per_run', + workspaceId: '' + })) + }) + await save() + + expect(api.automations.update).toHaveBeenCalledWith( + expect.objectContaining({ + destination: { selector: { kind: 'ssh', targetId: SSH_TARGET_ID, targetGeneration: 1 } } + }) + ) + }) +}) + describe('AutomationsPage create admission', () => { it('keeps the create button available while every offered host is ineligible', async () => { // No host has answered, so nothing resolves ready — but the dialog is where diff --git a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx index 5c7225a9bd9..618c092b45a 100644 --- a/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.cross-authority-actions.test.tsx @@ -53,6 +53,24 @@ function runtimeAutomationCalls(): string[] { } describe('AutomationsPage row actions under a colliding automation id', () => { + it('names the selected row’s own host, not the desktop that listed it', async () => { + await collidingHosts() + await renderPage() + await settleHostQueries() + + const remote = mocks.listPanel?.filteredRows.find( + (candidate) => candidate.automation.name === 'Remote nightly' + ) + await act(async () => { + mocks.listPanel?.selectAutomationRow(remote?.key ?? '') + }) + + // The runtime copy's own execution target reads `local` — local to that + // server — so only the row's catalog entry can name the host on the detail view. + expect(mocks.detailPane?.selected?.name).toBe('Remote nightly') + expect(mocks.detailPane?.selectedHostEntry?.authorityLabel).toBe('GPU box') + }) + it('runs the selected desktop row on the desktop, not on the runtime holding the same id', async () => { await collidingHosts() const { container } = await renderPage() diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 7eef4ac6f57..1ff12cf347a 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -26,7 +26,9 @@ import { getLocalExecutionHostLabel, getRepoExecutionHostId, getWorktreeExecutionHostId, - parseExecutionHostId + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId } from '../../../../shared/execution-host' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' @@ -75,6 +77,7 @@ import { import { deleteAutomationForTarget, type AutomationHostTarget, + getAutomationAuthorityTarget, getAutomationHostTargetFromKey, getAutomationHostTargetKey, getAutomationListTarget, @@ -139,13 +142,18 @@ import { groupReposByAutomationAuthority } from './automation-authority-identity' import { + automationCreateEligibleProjects, automationCreateHostOffered, automationCreateHostStableKey, + automationCreateProjectMismatch, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, type AutomationCreateDestination } from './automation-create-destination' -import { useAutomationCreateDestination } from './use-automation-create-destination' +import { + useAutomationCreateDestination, + type AutomationCreateDestinationControl +} from './use-automation-create-destination' import { persistSkipDeleteAutomationConfirm } from './automation-delete-confirm-preference' import { buildAutomationEditDraft, buildExternalAutomationEditDraft } from './automation-edit-draft' import { createAutomationAtDestination } from './automation-owner-action-runner' @@ -193,6 +201,7 @@ export default function AutomationsPage(): React.JSX.Element { const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const fetchRuntimeEnvironmentRepos = useAppStore((s) => s.fetchRuntimeEnvironmentRepos) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted) const updateSettings = useAppStore((s) => s.updateSettings) @@ -257,6 +266,7 @@ export default function AutomationsPage(): React.JSX.Element { // Separate from the page notice: while the editor is open it covers the page, // so a refusal posted there is a save that visibly did nothing at all. const [editorNotice, setEditorNotice] = useState(null) + const [editorNoticeHost, setEditorNoticeHost] = useState(null) const [externalActionKey, setExternalActionKey] = useState(null) const [rerunRunIdsInFlight, setRerunRunIdsInFlight] = useState>( () => new Set() @@ -277,6 +287,12 @@ export default function AutomationsPage(): React.JSX.Element { projectId: string destination: AutomationCreateDestination } | null>(null) + // The host the edit form names. It filters the project list; the destination a + // save commits still comes from the chosen project, which is what moves the record. + const [editingHostStableKey, setEditingHostStableKey] = useState(null) + // Reuse a move's create key after an ambiguous transport failure so retrying + // cannot schedule a second copy on the destination authority. + const moveCreationKeysRef = useRef(new Map()) const [relativeNow, setRelativeNow] = useState(Date.now()) const [activePaneTab, setActivePaneTab] = useState('overview') const [selectedAutomationRunPageId, setSelectedAutomationRunPageId] = useState( @@ -503,32 +519,70 @@ export default function AutomationsPage(): React.JSX.Element { ( candidate: Pick ): AutomationDraft['setupDecision'] => { - const settingsForRepo = getSettingsForRepoRuntimeOwner( - { repos, settings }, - candidate.projectId - ) - const hookKey = `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}` + // During an edit the selected destination, not the ambient repo-id lookup, + // owns setup policy. Repo ids can collide across runtime authorities. + const selectedEditEntry = + editingAutomationId !== null && editingHostStableKey + ? hostCatalog.entries.find((entry) => entry.stableKey === editingHostStableKey) + : null + const selectedEditAuthority = selectedEditEntry?.stableRef.authority + const setupHostId = + selectedEditAuthority?.kind === 'runtime' + ? toRuntimeExecutionHostId(selectedEditAuthority.environmentId) + : undefined + const setupRepos = + selectedEditAuthority?.kind === 'runtime' + ? getAutomationCreateRepos(repos, { + kind: 'environment', + environmentId: selectedEditAuthority.environmentId + }) + : repos + const setupProjectHostSetups = setupHostId + ? projectHostSetups.filter( + (setup) => setup.repoId !== candidate.projectId || setup.hostId === setupHostId + ) + : projectHostSetups + const settingsForRepo = setupHostId + ? { + ...settings, + activeRuntimeEnvironmentId: + selectedEditAuthority?.kind === 'runtime' ? selectedEditAuthority.environmentId : null + } + : getSettingsForRepoRuntimeOwner({ repos, settings }, candidate.projectId) + const hookKey = `${setupHostId ?? settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}` return getVisibleAutomationSetupDecision({ createTarget, workspaceMode: candidate.workspaceMode, repoId: candidate.projectId, - repos, - projectHostSetups, + repos: setupRepos, + projectHostSetups: setupProjectHostSetups, yamlHooks: automationYamlHooksByRepoKey[hookKey] }) }, - [automationYamlHooksByRepoKey, createTarget, projectHostSetups, repos, settings] + [ + automationYamlHooksByRepoKey, + createTarget, + editingAutomationId, + editingHostStableKey, + hostCatalog.entries, + projectHostSetups, + repos, + settings + ] ) const getAutomationHooksCacheKey = useCallback( - (repoId: string): string => { + (repoId: string, hostId?: ExecutionHostId): string => { + if (hostId) { + return `${hostId}:${repoId}` + } const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}` }, [repos, settings] ) const loadAutomationYamlHooksForRepo = useCallback( - async (repoId: string): Promise => { - const key = getAutomationHooksCacheKey(repoId) + async (repoId: string, hostId?: ExecutionHostId): Promise => { + const key = getAutomationHooksCacheKey(repoId, hostId) if (Object.hasOwn(automationYamlHooksByRepoKey, key)) { return automationYamlHooksByRepoKey[key] ?? null } @@ -537,7 +591,7 @@ export default function AutomationsPage(): React.JSX.Element { return (await existingPromise).hooks } const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) - const promise = checkRuntimeHooks(settingsForRepo, repoId) + const promise = checkRuntimeHooks(settingsForRepo, repoId, hostId) .then((result) => ({ hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null), ok: result.status !== 'error' @@ -604,10 +658,6 @@ export default function AutomationsPage(): React.JSX.Element { }) ) }, [repoForRow, selectedRow, selectedRuns, worktreeForRow, worktreeMap]) - const worktrees = useMemo( - () => worktreesByRepo[draft.projectId] ?? [], - [draft.projectId, worktreesByRepo] - ) const automationHostTarget = useMemo( () => getAutomationHostTargetFromKey(automationHostTargetKey), [automationHostTargetKey] @@ -658,6 +708,9 @@ export default function AutomationsPage(): React.JSX.Element { createDestination.control.resolution.authority.kind === 'runtime' ? createDestination.control.resolution.authority.environmentId : null + const createDestinationHostId = createDestinationRuntimeEnvironmentId + ? toRuntimeExecutionHostId(createDestinationRuntimeEnvironmentId) + : undefined useEffect(() => { if (createDestinationRuntimeEnvironmentId) { void useAppStore @@ -923,7 +976,9 @@ export default function AutomationsPage(): React.JSX.Element { const canSaveDraft = editingAutomationId === null || !draftAtOpen || - JSON.stringify(draft) !== JSON.stringify(draftAtOpen) + JSON.stringify(draft) !== JSON.stringify(draftAtOpen) || + (editingAutomationId !== null && + editingHostStableKey !== rowRecoveryHost(editingRowKey)?.stableKey) const getAutomationRepoHostLabel = useCallback( (repo: Repo): string => { const hostId = getRepoExecutionHostId(repo) @@ -1015,14 +1070,127 @@ export default function AutomationsPage(): React.JSX.Element { ) })() const isOrcaForm = createTarget === 'orca' && editingExternalTarget === null + const dialogAuthorityRepos = getAutomationCreateRepos(repos, automationDialogTarget) + // The authority storing the edited record; a save landing on another one is a move. + const dialogAuthorityKey = automationAuthorityCatalogKey( + automationDialogTarget.kind === 'environment' + ? { kind: 'runtime', environmentId: automationDialogTarget.environmentId } + : { kind: 'desktop' } + ) + const editHostEntries = hostCatalog.entries + const editHostResolution = resolveAutomationCreateDestination( + editingHostStableKey + ? editHostEntries.find((entry) => entry.stableKey === editingHostStableKey) + : null + ) + const editHostProjects = + editHostResolution.status === 'ready' + ? automationCreateEligibleProjects( + repoTables, + editHostResolution, + getAutomationCreateRepos( + repos, + getAutomationAuthorityTarget(editHostResolution.authority) + ) + ) + : dialogAuthorityRepos + const editMoveTargetEntry = + editHostResolution.status === 'ready' && + automationAuthorityCatalogKey(editHostResolution.authority) !== dialogAuthorityKey + ? editHostResolution.entry + : null + const editDestinationRuntimeEnvironmentId = + editHostResolution.status === 'ready' && editHostResolution.authority.kind === 'runtime' + ? editHostResolution.authority.environmentId + : null + useEffect(() => { + if (editDestinationRuntimeEnvironmentId) { + void fetchRuntimeEnvironmentRepos(editDestinationRuntimeEnvironmentId) + } + }, [editDestinationRuntimeEnvironmentId, fetchRuntimeEnvironmentRepos]) + // Picking a host strands a project that lives on another one; clearing it here + // states the move instead of deferring the same refusal to submit. + const handleEditHostChange = (stableKey: string): void => { + setEditingHostStableKey(stableKey) + const resolved = resolveAutomationCreateDestination( + editHostEntries.find((entry) => entry.stableKey === stableKey) + ) + const projectId = draftRef.current.projectId + if ( + projectId && + resolved.status === 'ready' && + !automationCreateProjectMismatch(repoTables, resolved, projectId) + ) { + setEditingDestination({ projectId, destination: resolved }) + // Workspace ids are host-local. Never carry the source host's workspace + // into a move, even when the destination happens to reuse the project id. + if (editingHostStableKey !== stableKey) { + setDraft((current) => ({ ...current, workspaceId: '', baseBranch: '' })) + if (resolved.authority.kind === 'runtime') { + void fetchWorktrees(projectId, { + executionHostId: toRuntimeExecutionHostId(resolved.authority.environmentId) + }) + } + } + return + } + setEditingDestination(null) + setDraft((current) => ({ ...current, projectId: '', workspaceId: '', baseBranch: '' })) + } + const editDestinationControl: AutomationCreateDestinationControl = { + entries: editHostEntries, + resolution: editHostResolution, + onSelect: handleEditHostChange, + projects: editHostProjects, + // Replacer fn: a literal replacement would expand `$` patterns in host labels. + moveWarning: editMoveTargetEntry + ? translate( + 'auto.components.automations.createDestination.move', + 'Saving creates this automation on {host} and deletes the original and its run history.' + ).replace('{host}', () => editMoveTargetEntry.authorityLabel) + : null + } const dialogRepos = isOrcaForm ? editingAutomationId !== null - ? getAutomationCreateRepos(repos, automationDialogTarget) + ? editHostProjects : editorProjects : getAutomationCreateRepos(repos, { kind: 'local' }) + // Worktree caches are merged by project id across hosts. The dialog's repo + // list is already authority-scoped, so use that repo instance to keep + // host-local workspace ids from crossing an authority boundary. + const dialogWorktrees = useMemo(() => { + const candidates = worktreesByRepo[draft.projectId] ?? [] + const project = dialogRepos.find((repo) => repo.id === draft.projectId) + if (!project) { + return candidates + } + const hostId = getRepoExecutionHostId(project) + const parsedHost = parseExecutionHostId(hostId) + return candidates.filter((worktree) => { + if (getWorktreeExecutionHostId(worktree, project) === hostId) { + return true + } + // A paired runtime can own an SSH execution host; nested worktrees keep + // hostId=ssh:* while runtimeOwnerEnvironmentId names the routing owner. + return ( + parsedHost?.kind === 'runtime' && + worktree.runtimeOwnerEnvironmentId === parsedHost.environmentId + ) + }) + }, [dialogRepos, draft.projectId, worktreesByRepo]) const destinationForProject = useCallback( - (projectId: string): AutomationCreateDestination | null => { + (projectId: string, hostStableKey?: string | null): AutomationCreateDestination | null => { + const selectedEntry = hostStableKey + ? hostCatalog.entries.find((candidate) => candidate.stableKey === hostStableKey) + : null + const selected = resolveAutomationCreateDestination(selectedEntry) + if (hostStableKey) { + return selected.status === 'ready' && + !automationCreateProjectMismatch(repoTables, selected, projectId) + ? selected + : null + } const runContext = buildAutomationRunContextForRepo({ repoId: projectId, repos, @@ -1038,7 +1206,7 @@ export default function AutomationsPage(): React.JSX.Element { const resolved = resolveAutomationCreateDestination(entry) return resolved.status === 'ready' ? resolved : null }, - [hostCatalog.entries, projectHostSetups, repos] + [hostCatalog.entries, projectHostSetups, repoTables, repos] ) const reloadExternalManagers = scopedExternal.reload @@ -1158,7 +1326,9 @@ export default function AutomationsPage(): React.JSX.Element { }, [refresh]) useEffect(() => { - if (!draft.projectId) { + // Creates only: an edit whose project was cleared is waiting for one on the + // chosen host, and this default would reinstate a project that host lacks. + if (!draft.projectId && editingAutomationId === null) { const target = getDefaultTarget() if (!target.projectId) { return @@ -1169,18 +1339,19 @@ export default function AutomationsPage(): React.JSX.Element { workspaceId: target.workspaceId })) } - }, [draft.projectId, getDefaultTarget]) + }, [draft.projectId, editingAutomationId, getDefaultTarget]) useEffect(() => { if (!draft.projectId) { return } - const available = worktreesByRepo[draft.projectId] ?? [] - const defaultWorktree = getDefaultWorktree(available) + // Authority-scoped: the merged cache would restore a source-host workspace + // after a host change clears it, which save validation then rejects. + const defaultWorktree = getDefaultWorktree(dialogWorktrees) if (!draft.workspaceId && defaultWorktree) { setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id })) } - }, [draft.projectId, draft.workspaceId, worktreesByRepo]) + }, [dialogWorktrees, draft.projectId, draft.workspaceId]) useEffect(() => { if ( @@ -1191,10 +1362,11 @@ export default function AutomationsPage(): React.JSX.Element { ) { return } - void loadAutomationYamlHooksForRepo(draft.projectId) + void loadAutomationYamlHooksForRepo(draft.projectId, createDestinationHostId) }, [ createOpen, createTarget, + createDestinationHostId, draft.projectId, draft.workspaceMode, loadAutomationYamlHooksForRepo @@ -1280,6 +1452,7 @@ export default function AutomationsPage(): React.JSX.Element { setEditingAutomationId(null) setEditingExternalTarget(null) setEditingDestination(null) + setEditingHostStableKey(null) setCreateTarget('orca') const baseDraft: AutomationDraft = { name: '', @@ -1344,10 +1517,12 @@ export default function AutomationsPage(): React.JSX.Element { } setEditingAutomationId(latest.id) setEditingRowKey(row.key) - const initialDestination = destinationForProject(latest.projectId) + const initialHostStableKey = rowRecoveryHost(row.key)?.stableKey ?? null + const initialDestination = destinationForProject(latest.projectId, initialHostStableKey) setEditingDestination( initialDestination ? { projectId: latest.projectId, destination: initialDestination } : null ) + setEditingHostStableKey(initialDestination?.entry.stableKey ?? null) const nextDraft = buildAutomationEditDraft(latest) setDraft(nextDraft) setDraftAtOpen(nextDraft) @@ -1383,6 +1558,7 @@ export default function AutomationsPage(): React.JSX.Element { setEditingAutomationId(null) setEditingRowKey(null) setEditingDestination(null) + setEditingHostStableKey(null) setEditingExternalTarget({ manager, job, scope }) setCreateTarget('hermes') setDraft(nextDraft) @@ -1394,9 +1570,27 @@ export default function AutomationsPage(): React.JSX.Element { (projectId: string): void => { const currentWorktrees = worktreesByRepo[projectId] ?? [] const currentDefaultWorktree = getDefaultWorktree(currentWorktrees) + const selectedEditDestination = + editingAutomationId !== null && editingHostStableKey + ? resolveAutomationCreateDestination( + hostCatalog.entries.find((entry) => entry.stableKey === editingHostStableKey) + ) + : null + const worktreeFetchOptions = + selectedEditDestination?.status === 'ready' && + selectedEditDestination.authority.kind === 'runtime' + ? { + executionHostId: toRuntimeExecutionHostId( + selectedEditDestination.authority.environmentId + ) + } + : undefined if (editingAutomationId !== null) { - const destination = destinationForProject(projectId) + const destination = destinationForProject(projectId, editingHostStableKey) setEditingDestination(destination ? { projectId, destination } : null) + if (destination) { + setEditingHostStableKey(destination.entry.stableKey) + } } setDraft((current) => ({ ...current, @@ -1405,7 +1599,7 @@ export default function AutomationsPage(): React.JSX.Element { baseBranch: '' })) - void fetchWorktrees(projectId).then(() => { + void fetchWorktrees(projectId, worktreeFetchOptions).then(() => { const latestWorktrees = useAppStore.getState().worktreesByRepo[projectId] ?? [] const latestWorktree = getDefaultWorktree(latestWorktrees) if (!latestWorktree) { @@ -1420,7 +1614,14 @@ export default function AutomationsPage(): React.JSX.Element { ) }) }, - [destinationForProject, editingAutomationId, fetchWorktrees, worktreesByRepo] + [ + destinationForProject, + editingAutomationId, + editingHostStableKey, + fetchWorktrees, + hostCatalog.entries, + worktreesByRepo + ] ) const handleDraftChange = useCallback( @@ -1433,11 +1634,14 @@ export default function AutomationsPage(): React.JSX.Element { editingAutomationId !== null && (next.projectId !== current.projectId || next.workspaceId !== current.workspaceId) ) { - const destination = destinationForProject(next.projectId) + const destination = destinationForProject(next.projectId, editingHostStableKey) setEditingDestination(destination ? { projectId: next.projectId, destination } : null) + if (destination) { + setEditingHostStableKey(destination.entry.stableKey) + } } }, - [destinationForProject, editingAutomationId] + [destinationForProject, editingAutomationId, editingHostStableKey] ) const saveAutomation = async (): Promise => { @@ -1496,7 +1700,7 @@ export default function AutomationsPage(): React.JSX.Element { try { const selectedWorkspaceExists = draft.workspaceMode !== 'existing' || - worktrees.some((worktree) => worktree.id === draft.workspaceId) + dialogWorktrees.some((worktree) => worktree.id === draft.workspaceId) if (!selectedWorkspaceExists) { toast.error( translate( @@ -1614,18 +1818,29 @@ export default function AutomationsPage(): React.JSX.Element { const precheck = buildDraftPrecheck(draft) const runContext = buildAutomationRunContextForRepo({ repoId: draft.projectId, - repos, + repos: editingAutomationId !== null ? dialogRepos : repos, projectHostSetups }) + const setupDestination = + editingAutomationId !== null ? editHostResolution : createDestination.control.resolution + const setupHostId: ExecutionHostId | undefined = + setupDestination.status === 'ready' && setupDestination.authority.kind === 'runtime' + ? toRuntimeExecutionHostId(setupDestination.authority.environmentId) + : undefined + const setupProjectHostSetups = setupHostId + ? projectHostSetups.filter( + (setup) => setup.repoId !== draft.projectId || setup.hostId === setupHostId + ) + : projectHostSetups let setupDecision = resolveAutomationSetupDecisionForSave({ createTarget, workspaceMode: draft.workspaceMode, repoId: draft.projectId, - repos, - projectHostSetups, + repos: editingAutomationId !== null ? dialogRepos : repos, + projectHostSetups: setupProjectHostSetups, yamlHooks: createTarget === 'orca' && draft.workspaceMode === 'new_per_run' - ? await loadAutomationYamlHooksForRepo(draft.projectId) + ? await loadAutomationYamlHooksForRepo(draft.projectId, setupHostId) : null, draftSetupDecision: draft.setupDecision }) @@ -1633,7 +1848,8 @@ export default function AutomationsPage(): React.JSX.Element { const trustDecision = await ensureHooksConfirmed( useAppStore.getState(), draft.projectId, - 'setup' + 'setup', + setupHostId ) if (trustDecision === 'skip') { setupDecision = 'skip' @@ -1669,9 +1885,14 @@ export default function AutomationsPage(): React.JSX.Element { currentAutomation = (reread.ok ? reread.value : null) ?? currentAutomation } let editDestination: AutomationDestination | undefined + let moveTarget: AutomationCreateDestination | null = null + const destinationHostChanged = + editingDestination !== null && + editingDestination.destination.entry.stableKey !== rowRecoveryHost(editingRowKey)?.stableKey const destinationChanged = currentAutomation && - (currentAutomation.projectId !== draft.projectId || + (destinationHostChanged || + currentAutomation.projectId !== draft.projectId || currentAutomation.workspaceId !== (draft.workspaceId || null) || currentAutomation.workspaceMode !== draft.workspaceMode) if (editingAutomationId && currentAutomation && destinationChanged) { @@ -1713,6 +1934,11 @@ export default function AutomationsPage(): React.JSX.Element { return } editDestination = revalidated.destination + // `update` reaches only the authority already holding the record, so a + // save landing on another one is a create there plus a delete here. + if (automationAuthorityCatalogKey(revalidated.authority) !== dialogAuthorityKey) { + moveTarget = revalidated + } } const updates: AutomationUpdateInput = { name: draft.name, @@ -1734,44 +1960,57 @@ export default function AutomationsPage(): React.JSX.Element { updates.rrule = rrule updates.dtstart = now } + const createInput: AutomationCreateInput = { + name: draft.name, + prompt: draft.prompt, + precheck, + agentId: draft.agentId, + runContext, + projectId: draft.projectId, + workspaceMode: draft.workspaceMode, + workspaceId: draft.workspaceId, + baseBranch: draft.baseBranch.trim() || null, + setupDecision, + reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, + timezone, + rrule, + dtstart: now, + missedRunGraceMinutes + } const editSource = currentAutomation - const saved = editingAutomationId - ? await dispatchAutomationUpdate( - automationDispatchContext, - { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, - updates, - () => { - // Nothing names a host: no captured owner and no record to read one - // from, so the save is refused rather than sent unfenced. - if (!editSource) { - throw new Error( - automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) - ) - } - return updateAutomationForTarget(editSource, updates, automationHostTarget) - }, - 'save', - editDestination - ) - : await createDraftAutomation({ - name: draft.name, - prompt: draft.prompt, - precheck, - agentId: draft.agentId, - runContext, - projectId: draft.projectId, - workspaceMode: draft.workspaceMode, - workspaceId: draft.workspaceId, - baseBranch: draft.baseBranch.trim() || null, - setupDecision, - reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, - timezone, - rrule, - dtstart: now, - missedRunGraceMinutes + const move = moveTarget + ? await moveAutomationToDestination(editSource, moveTarget, { + ...createInput, + // A move keeps the existing schedule anchor and paused state. + dtstart: updates.dtstart ?? editSource?.dtstart ?? createInput.dtstart, + enabled: editSource?.enabled ?? true, + sourceContext: editSource?.sourceContext ?? null }) + : null + const saved = move + ? move.saved + : editingAutomationId + ? await dispatchAutomationUpdate( + automationDispatchContext, + { rowKey: editingRowKey ?? '', automationId: editingAutomationId }, + updates, + () => { + // Nothing names a host: no captured owner and no record to read one + // from, so the save is refused rather than sent unfenced. + if (!editSource) { + throw new Error( + automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged) + ) + } + return updateAutomationForTarget(editSource, updates, automationHostTarget) + }, + 'save', + editDestination + ) + : await createDraftAutomation(createInput) if (!saved.ok) { setEditorNotice(saved.notice) + setEditorNoticeHost(moveTarget?.entry ?? null) return } const automation = saved.value @@ -1788,7 +2027,9 @@ export default function AutomationsPage(): React.JSX.Element { }) setDraft((current) => ({ ...current, name: '', prompt: '' })) await refresh() - if (editingAutomationId && editingRowKey) { + // A move retires the edited row key; the else branch selects the new copy + // by id alone and lets the refreshed list resolve its row. + if (editingAutomationId && editingRowKey && !moveTarget) { setSelectedAutomationRunPageId(null) setSelectedRowKey(editingRowKey) setSelectedId(automation.id) @@ -1799,13 +2040,25 @@ export default function AutomationsPage(): React.JSX.Element { if (!editingAutomationId) { useAppStore.getState().recordFeatureInteraction('automation-created') } + // A kept original already raised its own toast; "moved" would contradict it. + if (move && !move.originalRemoved) { + return + } toast.success( - editingAutomationId + moveTarget ? translate( - 'auto.components.automations.AutomationsPage.244727e655', - 'Automation updated.' - ) - : translate('auto.components.automations.AutomationsPage.2a20596d6b', 'Automation saved.') + 'auto.components.automations.AutomationsPage.moved', + 'Automation moved to {host}.' + ).replace('{host}', () => moveTarget.entry.authorityLabel) + : editingAutomationId + ? translate( + 'auto.components.automations.AutomationsPage.244727e655', + 'Automation updated.' + ) + : translate( + 'auto.components.automations.AutomationsPage.2a20596d6b', + 'Automation saved.' + ) ) } catch (error) { if (isHermesSave) { @@ -1824,6 +2077,79 @@ export default function AutomationsPage(): React.JSX.Element { } } + /** Creates on the new authority before removing the old copy. */ + const moveAutomationToDestination = async ( + source: Automation | null, + target: AutomationCreateDestination, + input: AutomationCreateInput + ): Promise<{ saved: AutomationDispatchResult; originalRemoved: boolean }> => { + if (!source) { + return { + saved: { + ok: false, + notice: { + message: automationOwnerConflictMessage(AUTOMATION_OWNER_CONFLICT_CODES.ownerChanged), + recovery: 'retry', + severity: 'owner' + } + }, + originalRemoved: false + } + } + // Keyed by the move, not its payload: dtstart is minted per attempt, so a + // payload-keyed retry would mint a fresh creationKey and let an ambiguous + // create failure schedule a second copy on the destination. + const operationKey = `${source.id}:${target.entry.stableKey}` + const creationKey = moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID() + moveCreationKeysRef.current.set(operationKey, creationKey) + const created = toDispatchResult( + await createAutomationAtDestination( + target.authority, + { ...input, creationKey }, + target.destination + ) + ) + if (!created.ok) { + return { saved: created, originalRemoved: false } + } + invalidateWrittenHost(target.entry.stableRef, 'definition') + const removed = await dispatchAutomationDelete( + automationDispatchContext, + { rowKey: editingRowKey ?? '', automationId: source.id }, + () => deleteAutomationForTarget(source, automationDialogTarget) + ) + if (!removed.ok) { + // A transport error is not proof that deletion failed. Re-read the source + // under its captured owner before telling the user a duplicate remains. + const reread = await dispatchAutomationReread( + automationDispatchContext, + { rowKey: editingRowKey ?? '', automationId: source.id }, + async () => + (await listAutomationsForTarget(automationDialogTarget)).find( + (automation) => automation.id === source.id + ) ?? null + ) + if (reread.ok && reread.value === null) { + moveCreationKeysRef.current.delete(operationKey) + return { saved: created, originalRemoved: true } + } + const messageKey = + reread.ok && reread.value + ? 'auto.components.automations.AutomationsPage.moveOriginalKept' + : 'auto.components.automations.AutomationsPage.moveOriginalUnverified' + const message = + reread.ok && reread.value + ? 'Created on {host}, but the original could not be deleted. Remove it on the old host.' + : 'Created on {host}, but the original deletion could not be verified. Check the old host before retrying.' + toast.error( + translate(messageKey, message).replace('{host}', () => target.entry.authorityLabel) + ) + } else { + moveCreationKeysRef.current.delete(operationKey) + } + return { saved: created, originalRemoved: removed.ok } + } + /** Creation states its destination and re-checks it at submit; it never infers one. */ const createDraftAutomation = async ( input: AutomationCreateInput @@ -2279,27 +2605,41 @@ export default function AutomationsPage(): React.JSX.Element { automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} getAutomationHooksCacheKey={getAutomationHooksCacheKey} repoMap={repoMap} - worktrees={worktrees} + worktrees={dialogWorktrees} settings={settings} draft={draft} createDestination={createDestination.control} + editDestination={isOrcaForm ? editDestinationControl : undefined} notice={editorNotice} onNoticeRecover={(action) => { + const recoveryHost = editorNoticeHost ?? editorRecoveryHost setEditorNotice(null) - hostCatalog.recover(action, editorRecoveryHost) + setEditorNoticeHost(null) + hostCatalog.recover(action, recoveryHost) if (action === 'retry') { void refresh() } }} - onNoticeDismiss={() => setEditorNotice(null)} + onNoticeDismiss={() => { + setEditorNotice(null) + setEditorNoticeHost(null) + }} onProjectChange={handleProjectChange} getRepoHostLabel={getAutomationRepoHostLabel} - allowAddProject={!isOrcaForm || automationDialogTarget.kind === 'local'} + allowAddProject={ + !isOrcaForm || + (editingAutomationId !== null + ? editHostResolution.status === 'ready' + ? getAutomationAuthorityTarget(editHostResolution.authority).kind === 'local' + : automationDialogTarget.kind === 'local' + : automationDialogTarget.kind === 'local') + } onCreateTargetChange={handleCreateTargetChange} onOpenChange={(open) => { setCreateOpen(open) if (!open) { setEditorNotice(null) + setEditorNoticeHost(null) } }} onDraftChange={handleDraftChange} @@ -2350,10 +2690,11 @@ export default function AutomationsPage(): React.JSX.Element { selectedAutomationRunPage={selectedAutomationRunPage} selectedRuns={selectedRuns} selectedRunsNotice={selectedRunsNotice} + selectedHostEntry={rowRecoveryHost(selectedRow?.key ?? null)} recoverSelectedRuns={(action) => { // Reconnect/Update server act on the selected row's own host; the // re-ask is what brings this automation's history back either way. - hostCatalog.recover(action, rowRecoveryHost(selectedRowKey)) + hostCatalog.recover(action, rowRecoveryHost(selectedRow?.key ?? null)) setSelectedAutomationRuns((current) => ({ ...current, notice: null })) setRunHistoryReloadToken((token) => token + 1) }} diff --git a/src/renderer/src/components/automations/automation-capability-probe.ts b/src/renderer/src/components/automations/automation-capability-probe.ts index 38cf3c69326..258fb4eb292 100644 --- a/src/renderer/src/components/automations/automation-capability-probe.ts +++ b/src/renderer/src/components/automations/automation-capability-probe.ts @@ -5,6 +5,7 @@ import { AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import { automationAuthorityCatalogKey } from './automation-host-catalog-types' @@ -129,7 +130,18 @@ export async function assertOwnerFencingSupported( ) } +export async function assertAutomationCreateIdempotencySupported( + authority: AutomationAuthorityRef +): Promise { + await assertAuthorityCapability( + authority, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + 'Moving automations to this host requires a newer Orca server. Update the HUB and try again.' + ) +} + export { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 2b7c3b7c102..e7ef5a44770 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -5,6 +5,7 @@ import type { AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import { parseExecutionHostId } from '../../../../shared/execution-host' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -49,6 +50,14 @@ export function getAutomationTargetFromHostId( : { kind: 'local' } } +export function getAutomationAuthorityTarget( + authority: AutomationAuthorityRef +): AutomationHostTarget { + return authority.kind === 'runtime' + ? { kind: 'environment', environmentId: authority.environmentId } + : { kind: 'local' } +} + export function getAutomationListTarget( settings: Pick | null | undefined ): AutomationHostTarget { diff --git a/src/renderer/src/components/automations/automation-host-detail-display.test.ts b/src/renderer/src/components/automations/automation-host-detail-display.test.ts new file mode 100644 index 00000000000..a2b2817194b --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-detail-display.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' +import { getAutomationHostDetailDisplay } from './automation-host-detail-display' + +const RUNTIME_SELF: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'runtime', environmentId: 'r1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'r1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:r1:self', + label: 'GPU box', + authorityLabel: 'GPU box', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'scoped' +} + +const DESKTOP_SSH: AutomationHostCatalogEntry = { + ...RUNTIME_SELF, + stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } }, + owner: { + authority: { kind: 'desktop' }, + selector: { kind: 'ssh', targetId: 't1', targetGeneration: 1 } + }, + stableKey: 'host:desktop:ssh:t1', + label: 'openclaw', + authorityLabel: 'Local Mac', + kind: 'ssh' +} + +function automation(overrides: Partial): Automation { + return { executionTargetType: 'local', executionTargetId: 'local', ...overrides } as Automation +} + +describe('getAutomationHostDetailDisplay', () => { + it('names the runtime that stores the record, not the “local” its target reads', () => { + // The record's own target is local *to that server*; rendering it verbatim + // would claim a remote automation runs on this Mac. + const display = getAutomationHostDetailDisplay({ + automation: automation({ executionTargetType: 'local' }), + entry: RUNTIME_SELF + }) + + expect(display).toEqual({ label: 'GPU box', title: 'GPU box' }) + }) + + it('qualifies a host with the authority that stores it when the two differ', () => { + const display = getAutomationHostDetailDisplay({ + automation: automation({ executionTargetType: 'ssh', executionTargetId: 't1' }), + entry: DESKTOP_SSH + }) + + expect(display).toEqual({ label: 'openclaw', title: 'Local Mac · openclaw' }) + }) + + it('falls back to the record’s own target for a row no host answered for', () => { + const display = getAutomationHostDetailDisplay({ + automation: automation({ executionTargetType: 'ssh', executionTargetId: 't1' }), + entry: null, + hostLabelById: new Map([['ssh:t1', 'openclaw']]) + }) + + expect(display).toEqual({ label: 'openclaw', title: 'openclaw' }) + }) +}) diff --git a/src/renderer/src/components/automations/automation-host-detail-display.ts b/src/renderer/src/components/automations/automation-host-detail-display.ts new file mode 100644 index 00000000000..2d8bdc3ee65 --- /dev/null +++ b/src/renderer/src/components/automations/automation-host-detail-display.ts @@ -0,0 +1,36 @@ +// The row's catalog entry names the storing authority; the record's own target +// cannot — a runtime-stored automation reads `local`, meaning local to that +// server. That target is only the fallback for a legacy row no host answered for. + +import type { Automation } from '../../../../shared/automations-types' +import { getExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' + +export type AutomationHostDetailDisplay = { + label: string + /** Authority-qualified when the two differ; a self host is its own authority. */ + title: string +} + +export function getAutomationHostDetailDisplay(input: { + automation: Pick + entry?: AutomationHostCatalogEntry | null + hostLabelById?: ReadonlyMap +}): AutomationHostDetailDisplay { + const { automation, entry, hostLabelById } = input + if (entry) { + return { + label: entry.label, + title: + entry.authorityLabel === entry.label + ? entry.label + : `${entry.authorityLabel} · ${entry.label}` + } + } + const hostId = + automation.executionTargetType === 'ssh' + ? toSshExecutionHostId(automation.executionTargetId) + : 'local' + const label = hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId) + return { label, title: label } +} diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index e1bf8873e97..e07399319fb 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../../shared/automation-owner-conflict' import { AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' const callRuntimeRpc = vi.fn() @@ -27,7 +28,8 @@ const SSH_OWNER = { const ALL_CAPABILITIES = { capabilities: [ AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY ] } diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts index ec44ac74ece..825329a1812 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts @@ -38,6 +38,7 @@ import { import { assertAuthorityCapability, assertOwnerFencingSupported, + assertAutomationCreateIdempotencySupported, AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, AutomationHostScopeUnsupportedError, @@ -286,6 +287,9 @@ export async function createAutomationForDestination( input: AutomationCreateInput, destination: AutomationDestination ): Promise { + if (input.creationKey) { + await assertAutomationCreateIdempotencySupported(authority) + } await assertOwnerFencingSupported(authority) const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', { ...toRuntimeAutomationCreateInput(input), diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index 815338927c6..ae01653093c 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -17,10 +17,12 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, type Mock, vi } from 'vitest' import type { Automation, AutomationRun } from '../../../../shared/automations-types' import { - AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY as LIST_HOST_SCOPE, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY as OWNER_FENCING, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY as CREATE_IDEMPOTENCY } from '../../../../shared/protocol-version' import type { AppState } from '@/store' +import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import type { AutomationHostCatalogView } from './use-automation-host-catalog' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import type { ExternalAutomationListEntry } from './external-automation-list-entries' @@ -76,6 +78,7 @@ export type ListPanelProps = { export type DetailPaneProps = { selected: Automation | null + selectedHostEntry: AutomationHostCatalogEntry | null selectedRuns: AutomationRun[] selectedRunsNotice: { message: string } | null runNow: (automation: Automation) => void @@ -95,6 +98,7 @@ export type EditorDialogProps = { open: boolean isEditing: boolean createDestination?: AutomationCreateDestinationControl + editDestination?: AutomationCreateDestinationControl notice?: { message: string; recovery: string | null } | null onNoticeRecover?: (action: string) => void repos?: { id: string }[] @@ -300,10 +304,7 @@ export const DESKTOP_SELF_OWNER = { authority: { kind: 'desktop' }, selector: { export const RUNTIME_ID = 'gpu' /** A runtime that advertises both automation capabilities, so its rows carry owners. */ -const RUNTIME_CAPABILITIES = [ - AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY -] +const RUNTIME_CAPABILITIES = [LIST_HOST_SCOPE, OWNER_FENCING, CREATE_IDEMPOTENCY] export const RUNTIME_SELF_FILTER = { kind: 'host' as const, host: { diff --git a/src/renderer/src/components/automations/use-automation-create-destination.ts b/src/renderer/src/components/automations/use-automation-create-destination.ts index e6d83e9491a..a47faa37c71 100644 --- a/src/renderer/src/components/automations/use-automation-create-destination.ts +++ b/src/renderer/src/components/automations/use-automation-create-destination.ts @@ -35,6 +35,8 @@ export type AutomationCreateDestinationControl = { onSelect: (stableKey: string) => void /** Projects the resolved destination can hold; the whole list until one resolves. */ projects: readonly Repo[] + /** Set when saving would move the record to another host; replaces the plain line. */ + moveWarning?: string | null } export type AutomationCreateDestinationCheck = @@ -73,7 +75,7 @@ function choiceMessage(reason: AutomationCreateDestinationChoiceReason): string } return translate( 'auto.components.automations.createDestination.unselected', - 'Choose the host this automation will be created on.' + 'Choose the host that stores and schedules this automation.' ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 06989bb3f9e..115cfb5ae1f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15220,6 +15220,7 @@ "showMore": "Show more" }, "AutomationDetail": { + "host": "Host", "007c8ad874": "Prompt", "a1d52c2189": "Usage coverage", "449fc83bf7": "Tokens", @@ -15388,6 +15389,9 @@ "b11170a008": "Failed to save automation.", "2a20596d6b": "Automation saved.", "244727e655": "Automation updated.", + "moved": "Automation moved to {host}.", + "moveOriginalKept": "Created on {host}, but the original could not be deleted. Remove it on the old host.", + "moveOriginalUnverified": "Created on {host}, but the original deletion could not be verified. Check the old host before retrying.", "77b81bc4ac": "Hermes automation created.", "08efc3ae12": "Hermes automation updated.", "e431bb85d4": "Choose a workspace on the same host as this Hermes automation.", @@ -15693,16 +15697,17 @@ "occurrences": "{{times}} times" }, "createDestination": { - "label": "Create on", + "label": "Host", "placeholder": "Select a host", "storedOn": "Stored and scheduled by {authority}.", - "unselected": "Choose the host this automation will be created on.", + "unselected": "Choose the host that stores and schedules this automation.", "orphan": "Automations with no host cannot hold new ones. Choose a host to create this automation on.", "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", "stale": "{host} changed while this form was open. Choose it again before saving.", "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.", "noProjects": "No projects are set up on {host}. Add one there, or choose another host.", - "updateRequired": "Update the Orca server on {hosts} to create automations there." + "updateRequired": "Update the Orca server on {hosts} to store automations there.", + "move": "Saving creates this automation on {host} and deletes the original and its run history." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 69e7c383533..8b2717c3385 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13727,6 +13727,7 @@ "showMore": "Mostrar más" }, "AutomationDetail": { + "host": "Host", "007c8ad874": "Inmediato", "a1d52c2189": "Cobertura de uso", "449fc83bf7": "Fichas", @@ -13890,6 +13891,9 @@ "b11170a008": "No se pudo guardar la automatización.", "2a20596d6b": "Automatización guardada.", "244727e655": "Automatización actualizada.", + "moved": "Automatización movida a {host}.", + "moveOriginalKept": "Se creó en {host}, pero no se pudo eliminar la original. Elimínela en el host anterior.", + "moveOriginalUnverified": "Se creó en {host}, pero no se pudo verificar la eliminación de la original. Compruebe el host anterior antes de volver a intentarlo.", "77b81bc4ac": "Se crea la automatización de Hermes.", "08efc3ae12": "Automatización Hermes actualizada.", "e431bb85d4": "Elija un espacio de trabajo en el mismo host que esta automatización de Hermes.", @@ -14086,6 +14090,19 @@ "filters": "Filtros", "clear": "Limpiar filtros", "removeFilter": "Eliminar filtro de {{value0}}" + }, + "createDestination": { + "label": "Host", + "placeholder": "Seleccionar un host", + "storedOn": "Almacenada y programada por {authority}.", + "unselected": "Elija el host que almacena y programa esta automatización.", + "orphan": "Las automatizaciones sin host no pueden alojar otras nuevas. Elija un host en el que crear esta automatización.", + "unavailable": "Ese host todavía no puede alojar una nueva automatización. Elija otro host para crear esta.", + "stale": "{host} cambió mientras este formulario estaba abierto. Elíjalo de nuevo antes de guardar.", + "projectMismatch": "Ese proyecto no está en {host}. Elija un proyecto en ese host, u otro host.", + "noProjects": "No hay proyectos configurados en {host}. Agregue uno allí o elija otro host.", + "updateRequired": "Actualice el servidor de Orca en {hosts} para almacenar automatizaciones allí.", + "move": "Al guardar se crea esta automatización en {host} y se eliminan la original y su historial de ejecuciones." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 43129d595af..a105e956622 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13718,6 +13718,7 @@ "showMore": "さらに表示" }, "AutomationDetail": { + "host": "ホスト", "007c8ad874": "プロンプト", "a1d52c2189": "使用範囲", "449fc83bf7": "トークン", @@ -13881,6 +13882,9 @@ "b11170a008": "オートメーションを保存できませんでした。", "2a20596d6b": "オートメーションが保存されました。", "244727e655": "自動化が更新されました。", + "moved": "自動化を {host} に移動しました。", + "moveOriginalKept": "{host} に作成しましたが、元の自動化を削除できませんでした。元のホストで削除してください。", + "moveOriginalUnverified": "{host} に作成しましたが、元の自動化の削除を確認できませんでした。再試行する前に元のホストを確認してください。", "77b81bc4ac": "Hermes オートメーションが作成されました。", "08efc3ae12": "Hermes オートメーションが更新されました。", "e431bb85d4": "この Hermes オートメーションと同じホスト上のワークスペースを選択します。", @@ -14077,6 +14081,19 @@ "filters": "フィルター", "clear": "フィルターをクリア", "removeFilter": "{{value0}} フィルターを解除" + }, + "createDestination": { + "label": "ホスト", + "placeholder": "ホストを選択", + "storedOn": "{authority} が保存してスケジュールします。", + "unselected": "この自動化を保存してスケジュールするホストを選択します。", + "orphan": "ホストのない自動化は新しい自動化を保持できません。この自動化を作成するホストを選択してください。", + "unavailable": "そのホストはまだ新しい自動化を保持できません。これを作成する別のホストを選択してください。", + "stale": "このフォームを開いている間に {host} が変更されました。保存する前にもう一度選択してください。", + "projectMismatch": "そのプロジェクトは {host} にありません。そのホスト上のプロジェクト、または別のホストを選択してください。", + "noProjects": "{host} にはプロジェクトが設定されていません。そこに追加するか、別のホストを選択してください。", + "updateRequired": "{hosts} の Orca サーバーを更新すると、そこに自動化を保存できます。", + "move": "保存すると、この自動化は {host} に作成され、元の自動化と実行履歴は削除されます。" } }, "agent": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e88670171ac..e09d641641a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13750,6 +13750,7 @@ "showMore": "더 보기" }, "AutomationDetail": { + "host": "호스트", "007c8ad874": "프롬프트", "a1d52c2189": "사용 범위", "449fc83bf7": "토큰", @@ -13913,6 +13914,9 @@ "b11170a008": "자동화를 저장하지 못했습니다.", "2a20596d6b": "자동화가 저장되었습니다.", "244727e655": "자동화가 업데이트되었습니다.", + "moved": "자동화를 {host}(으)로 이동했습니다.", + "moveOriginalKept": "{host}에 생성했지만 원본을 삭제하지 못했습니다. 이전 호스트에서 삭제하세요.", + "moveOriginalUnverified": "{host}에 생성했지만 원본 삭제를 확인할 수 없습니다. 다시 시도하기 전에 이전 호스트를 확인하세요.", "77b81bc4ac": "Hermes 자동화가 생성되었습니다.", "08efc3ae12": "Hermes 자동화가 업데이트되었습니다.", "e431bb85d4": "이 Hermes 자동화와 동일한 호스트에서 워크스페이스를 선택하세요.", @@ -14109,6 +14113,19 @@ "filters": "필터", "clear": "필터 지우기", "removeFilter": "{{value0}} 필터 제거" + }, + "createDestination": { + "label": "호스트", + "placeholder": "호스트 선택", + "storedOn": "{authority}에서 저장하고 예약합니다.", + "unselected": "이 자동화를 저장하고 예약할 호스트를 선택하세요.", + "orphan": "호스트가 없는 자동화는 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 호스트를 선택하세요.", + "unavailable": "해당 호스트는 아직 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 다른 호스트를 선택하세요.", + "stale": "이 양식이 열려 있는 동안 {host}이(가) 변경되었습니다. 저장하기 전에 다시 선택하세요.", + "projectMismatch": "해당 프로젝트는 {host}에 없습니다. 그 호스트의 프로젝트를 선택하거나 다른 호스트를 선택하세요.", + "noProjects": "{host}에 설정된 프로젝트가 없습니다. 거기에 추가하거나 다른 호스트를 선택하세요.", + "updateRequired": "{hosts}의 Orca 서버를 업데이트하면 거기에 자동화를 저장할 수 있습니다.", + "move": "저장하면 이 자동화가 {host}에 생성되고 원본과 실행 기록은 삭제됩니다." } }, "agent": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 00f5e077a07..4d4a7010809 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13774,6 +13774,7 @@ "showMore": "显示更多" }, "AutomationDetail": { + "host": "主机", "007c8ad874": "提示词", "a1d52c2189": "使用范围", "449fc83bf7": "token", @@ -13937,6 +13938,9 @@ "b11170a008": "无法保存自动化。", "2a20596d6b": "自动化已保存。", "244727e655": "自动化已更新。", + "moved": "自动化已移动到 {host}。", + "moveOriginalKept": "已在 {host} 上创建,但无法删除原有自动化。请在原主机上删除它。", + "moveOriginalUnverified": "已在 {host} 上创建,但无法确认是否删除了原有自动化。重试前请检查原主机。", "77b81bc4ac": "Hermes 自动化创建。", "08efc3ae12": "Hermes 自动化已更新。", "e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。", @@ -14133,6 +14137,19 @@ "filters": "筛选器", "clear": "清除筛选器", "removeFilter": "移除 {{value0}} 筛选器" + }, + "createDestination": { + "label": "主机", + "placeholder": "选择主机", + "storedOn": "由 {authority} 存储和调度。", + "unselected": "选择存储和调度此自动化的主机。", + "orphan": "没有主机的自动化无法容纳新的自动化。请选择要在其上创建此自动化的主机。", + "unavailable": "该主机尚无法容纳新的自动化。请选择另一台主机来创建它。", + "stale": "此表单打开期间 {host} 已更改。保存前请重新选择。", + "projectMismatch": "该项目不在 {host} 上。请选择该主机上的项目,或选择另一台主机。", + "noProjects": "{host} 上未设置任何项目。请在该主机上添加一个,或选择另一台主机。", + "updateRequired": "更新 {hosts} 上的 Orca 服务器即可在其中存储自动化。", + "move": "保存后会在 {host} 上创建此自动化,并删除原有自动化及其运行历史。" } }, "agent": { diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index e3c661f976e..24d9345242d 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -90,6 +90,8 @@ export type AutomationPrecheckResult = { export type Automation = { id: string + /** Optional client request key used to make cross-authority creates retry-safe. */ + creationKey?: string name: string prompt: string precheck: AutomationPrecheck | null @@ -169,6 +171,8 @@ export type AutomationRun = { } export type AutomationCreateInput = { + /** Optional idempotency key; repeated creates return the original record. */ + creationKey?: string name: string prompt: string precheck?: AutomationPrecheck | null diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 8f10558d7e5..9a262cafd7e 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -137,6 +137,8 @@ export const AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE = export const AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY = 'automation.owner-fencing.v1' as const export const AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE = 'Editing automations on this host requires a newer Orca server. Update the HUB and try again.' +export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = + 'automation.create-idempotency.v1' as const // Generic native clients include the CLI and must not claim Electron-only page // placement support. @@ -145,7 +147,8 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY ] as const // Electron clients can decode client-hosted page placement; becoming a page @@ -213,7 +216,8 @@ export const RUNTIME_CAPABILITIES = [ SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_DELETE_CAPABILITY, AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) From 3d796b82cba90ffec6018410931bfd40e2287318 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:13:14 -0700 Subject: [PATCH 20/37] fix(claude-usage): bill 1-hour cache writes at 2x base input (#16878) --- .../claude-usage/claude-model-pricing.test.ts | 77 +++++++++++++++ src/main/claude-usage/claude-model-pricing.ts | 72 ++++++++++---- .../claude-usage-automation-attribution.ts | 7 +- .../claude-usage-report-aggregation.test.ts | 71 ++++++++++++++ .../claude-usage-report-aggregation.ts | 12 ++- src/main/claude-usage/scanner.test.ts | 69 ++++++++++++-- src/main/claude-usage/store.test.ts | 95 +++++++++++++++---- src/main/claude-usage/store.ts | 5 +- .../claude-usage/transcript-record-parser.ts | 17 +++- src/main/claude-usage/types.ts | 5 + src/main/claude-usage/usage-aggregation.ts | 13 ++- 11 files changed, 386 insertions(+), 57 deletions(-) create mode 100644 src/main/claude-usage/claude-model-pricing.test.ts create mode 100644 src/main/claude-usage/claude-usage-report-aggregation.test.ts diff --git a/src/main/claude-usage/claude-model-pricing.test.ts b/src/main/claude-usage/claude-model-pricing.test.ts new file mode 100644 index 00000000000..8c5f317d875 --- /dev/null +++ b/src/main/claude-usage/claude-model-pricing.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { estimateCostUsd } from './claude-model-pricing' + +describe('estimateCostUsd cache-write TTL rates', () => { + it('bills 5-minute cache writes at 1.25x base input', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25) + }) + + it('keeps the legacy five-argument call billing every write at the 5-minute rate', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000)).toBeCloseTo(6.25) + }) + + it('bills 1-hour cache writes at 2x base input', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(10) + }) + + it('splits a mixed write bucket without double-billing the 1-hour share', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75) + }) + + it('clamps a 1-hour count that exceeds the reported write total', () => { + expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01) + }) + + it('applies the long-context tier to 1-hour writes', () => { + expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(3.6) + }) + + it('shares one long-context allowance across both TTL buckets', () => { + // 400k writes split evenly: 200k @ (3.75/7.5) and 200k @ (6/12), each tier + // getting half of the 200k allowance. + expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 200_000)).toBeCloseTo(2.925) + }) + + it('never lowers a long-context estimate as writes shift from 5-minute to 1-hour', () => { + const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map( + (write1h) => estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)! + ) + for (let index = 1; index < costs.length; index++) { + expect(costs[index]).toBeGreaterThan(costs[index - 1]) + } + }) + + it('still returns null for unknown models', () => { + expect(estimateCostUsd('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull() + }) +}) + +// Published Claude Sonnet 5 rates (platform.claude.com/docs/en/about-claude/pricing): +// $2 base input, $10 output, $0.20 cache hit, $2.50 5m write, $4 1h write per MTok. +// The $2/$10 launch price, announced as introductory through 2026-08-31, is now the +// standard price — the scheduled 2026-09-01 rise to $3/$15 was cancelled — so the rate +// is date-independent. +describe('estimateCostUsd Claude Sonnet 5 rates', () => { + it('bills base input at $2/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(2) + }) + + it('bills output at $10/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(10) + }) + + it('bills cache hits at $0.20/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 0, 0, 1_000_000, 0, 0)).toBeCloseTo(0.2) + }) + + it('bills 5-minute cache writes at $2.50/MTok and 1-hour writes at $4/MTok', () => { + expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(2.5) + expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4) + }) + + it('keeps the full 1M window at flat rates with no long-context tier', () => { + expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo( + estimateCostUsd('claude-sonnet-5', 500_000, 0, 0, 0, 0)! * 2 + ) + }) +}) diff --git a/src/main/claude-usage/claude-model-pricing.ts b/src/main/claude-usage/claude-model-pricing.ts index 6d28b3deace..eace643eb3b 100644 --- a/src/main/claude-usage/claude-model-pricing.ts +++ b/src/main/claude-usage/claude-model-pricing.ts @@ -2,12 +2,16 @@ type ClaudeModelPricing = { input: number output: number cacheRead: number + /** 5-minute TTL cache write (1.25x base input). */ cacheWrite: number + /** 1-hour TTL cache write (2x base input). */ + cacheWrite1h: number thresholdTokens?: number inputAboveThreshold?: number outputAboveThreshold?: number cacheReadAboveThreshold?: number cacheWriteAboveThreshold?: number + cacheWrite1hAboveThreshold?: number } const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 @@ -16,26 +20,29 @@ const SONNET_LONG_CONTEXT_PRICING = { inputAboveThreshold: 6, outputAboveThreshold: 22.5, cacheReadAboveThreshold: 0.6, - cacheWriteAboveThreshold: 7.5 + cacheWriteAboveThreshold: 7.5, + cacheWrite1hAboveThreshold: 12 } satisfies Partial const MODEL_PRICING: Record = { - 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5, cacheWrite1h: 20 }, + 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, // Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here. - // Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension. - 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + // Why: $2/$10 needs no date dimension — it launched as introductory pricing through + // 2026-08-31 but is now the standard price; the 2026-09-01 rise to $3/$15 was cancelled. + 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 }, + 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 }, + 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 }, + 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 }, 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, + cacheWrite1h: 6, ...SONNET_LONG_CONTEXT_PRICING }, 'claude-sonnet-4-5': { @@ -43,6 +50,7 @@ const MODEL_PRICING: Record = { output: 15, cacheRead: 0.3, cacheWrite: 3.75, + cacheWrite1h: 6, ...SONNET_LONG_CONTEXT_PRICING }, 'claude-sonnet-4': { @@ -50,13 +58,20 @@ const MODEL_PRICING: Record = { output: 15, cacheRead: 0.3, cacheWrite: 3.75, + cacheWrite1h: 6, ...SONNET_LONG_CONTEXT_PRICING }, - 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }, - 'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 } + 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, + 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 }, + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 }, + 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, cacheWrite1h: 1.6 }, + 'claude-haiku-3': { + input: 0.25, + output: 1.25, + cacheRead: 0.03, + cacheWrite: 0.3, + cacheWrite1h: 0.5 + } } const MODEL_ALIASES: Record = { @@ -179,18 +194,31 @@ function calculateTieredCost( return belowTokens * basePrice + aboveTokens * abovePrice } +/** + * @param cacheWriteTokens every cache write, both TTLs + * @param cacheWrite1hTokens the 1-hour-TTL subset of `cacheWriteTokens` + */ export function estimateCostUsd( model: string | null, inputTokens: number, outputTokens: number, cacheReadTokens: number, - cacheWriteTokens: number + cacheWriteTokens: number, + cacheWrite1hTokens = 0 ): number | null { const normalized = normalizeModelForPricing(model) if (!normalized) { return null } const pricing = MODEL_PRICING[normalized] + const write1hTokens = Math.min(Math.max(cacheWrite1hTokens, 0), cacheWriteTokens) + // Why: both TTL buckets share one long-context allowance. Giving each its own + // would make a split bucket cheaper than the same tokens billed entirely at 5m. + const write1hShare = cacheWriteTokens > 0 ? write1hTokens / cacheWriteTokens : 0 + const write5mThreshold = + pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * (1 - write1hShare) + const write1hThreshold = + pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * write1hShare return ( (calculateTieredCost( inputTokens, @@ -211,10 +239,16 @@ export function estimateCostUsd( pricing.thresholdTokens ) + calculateTieredCost( - cacheWriteTokens, + cacheWriteTokens - write1hTokens, pricing.cacheWrite, pricing.cacheWriteAboveThreshold, - pricing.thresholdTokens + write5mThreshold + ) + + calculateTieredCost( + write1hTokens, + pricing.cacheWrite1h, + pricing.cacheWrite1hAboveThreshold, + write1hThreshold )) / 1_000_000 ) diff --git a/src/main/claude-usage/claude-usage-automation-attribution.ts b/src/main/claude-usage/claude-usage-automation-attribution.ts index adda1ad394e..0b4a8a9844f 100644 --- a/src/main/claude-usage/claude-usage-automation-attribution.ts +++ b/src/main/claude-usage/claude-usage-automation-attribution.ts @@ -106,6 +106,7 @@ export async function resolveAutomationRunUsage( acc.outputTokens += entry.outputTokens acc.cacheReadTokens += entry.cacheReadTokens acc.cacheWriteTokens += entry.cacheWriteTokens + acc.cacheWrite1hTokens += entry.cacheWrite1hTokens return acc }, { @@ -113,7 +114,8 @@ export async function resolveAutomationRunUsage( inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, - cacheWriteTokens: 0 + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 } ) const estimatedCostUsd = estimateCostUsd( @@ -121,7 +123,8 @@ export async function resolveAutomationRunUsage( totals.inputTokens, totals.outputTokens, totals.cacheReadTokens, - totals.cacheWriteTokens + totals.cacheWriteTokens, + totals.cacheWrite1hTokens ) return { diff --git a/src/main/claude-usage/claude-usage-report-aggregation.test.ts b/src/main/claude-usage/claude-usage-report-aggregation.test.ts new file mode 100644 index 00000000000..7d09c3074f2 --- /dev/null +++ b/src/main/claude-usage/claude-usage-report-aggregation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { ClaudeUsageDailyAggregate, ClaudeUsagePersistedState } from './types' +import { buildSummary } from './claude-usage-report-aggregation' + +function dailyRow(day: string, model: string): ClaudeUsageDailyAggregate { + return { + day, + model, + projectKey: 'project', + projectLabel: 'project', + repoId: null, + worktreeId: 'worktree-1', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 + } +} + +function stateWithDaily(dailyAggregates: ClaudeUsageDailyAggregate[]): ClaudeUsagePersistedState { + return { + schemaVersion: 6, + worktreeFingerprint: null, + processedFiles: [], + sessions: [], + dailyAggregates, + scanState: { + enabled: true, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +describe('buildSummary Claude Sonnet 5 pricing over time', () => { + // Why: the "All" range re-prices historical days on every render, so a day inside the + // announced 2026-08-31 introductory window has to price the same as a later day — + // the scheduled $3/$15 increase was cancelled and never took effect. + it('prices an introductory-window day and a post-window day at the same $2/$10 rate', () => { + const inWindow = buildSummary( + stateWithDaily([dailyRow('2026-08-15', 'claude-sonnet-5')]), + 'all', + 'all' + ) + const afterWindow = buildSummary( + stateWithDaily([dailyRow('2026-09-15', 'claude-sonnet-5')]), + 'all', + 'all' + ) + + expect(inWindow.estimatedCostUsd).toBeCloseTo(12) + expect(afterWindow.estimatedCostUsd).toBeCloseTo(12) + }) + + it('sums both days in the All range without re-pricing history upward', () => { + const summary = buildSummary( + stateWithDaily([ + dailyRow('2026-08-15', 'claude-sonnet-5'), + dailyRow('2026-09-15', 'claude-sonnet-5') + ]), + 'all', + 'all' + ) + + expect(summary.estimatedCostUsd).toBeCloseTo(24) + }) +}) diff --git a/src/main/claude-usage/claude-usage-report-aggregation.ts b/src/main/claude-usage/claude-usage-report-aggregation.ts index f9bdd863c36..9231af85cfa 100644 --- a/src/main/claude-usage/claude-usage-report-aggregation.ts +++ b/src/main/claude-usage/claude-usage-report-aggregation.ts @@ -47,7 +47,8 @@ export function buildSummary( row.inputTokens, row.outputTokens, row.cacheReadTokens, - row.cacheWriteTokens + row.cacheWriteTokens, + row.cacheWrite1hTokens ) if (cost !== null) { hasAnyBillableCost = true @@ -111,6 +112,8 @@ export function buildBreakdown( kind: ClaudeUsageBreakdownKind ): ClaudeUsageBreakdownRow[] { const rows = new Map() + // Why: the 1h cache-write split only feeds pricing, so it stays off the renderer row type. + const cacheWrite1hTokensByKey = new Map() const filteredDaily = getFilteredDaily(state, scope, range) const filteredSessions = getFilteredSessions(state, scope, range) @@ -133,6 +136,10 @@ export function buildBreakdown( existing.outputTokens += daily.outputTokens existing.cacheReadTokens += daily.cacheReadTokens existing.cacheWriteTokens += daily.cacheWriteTokens + cacheWrite1hTokensByKey.set( + key, + (cacheWrite1hTokensByKey.get(key) ?? 0) + daily.cacheWrite1hTokens + ) rows.set(key, existing) } @@ -168,7 +175,8 @@ export function buildBreakdown( row.inputTokens, row.outputTokens, row.cacheReadTokens, - row.cacheWriteTokens + row.cacheWriteTokens, + cacheWrite1hTokensByKey.get(row.key) ?? 0 ) } } diff --git a/src/main/claude-usage/scanner.test.ts b/src/main/claude-usage/scanner.test.ts index dc4d6085a5a..cf5b4cc99a3 100644 --- a/src/main/claude-usage/scanner.test.ts +++ b/src/main/claude-usage/scanner.test.ts @@ -36,10 +36,59 @@ describe('parseClaudeUsageRecord', () => { inputTokens: 100, outputTokens: 25, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 }) }) + it('splits the cache-write total by TTL when the breakdown is present', () => { + const parsed = parseClaudeUsageRecord( + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 100, + output_tokens: 25, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 1000, + cache_creation: { + ephemeral_5m_input_tokens: 600, + ephemeral_1h_input_tokens: 400 + } + } + } + }) + ) + + expect(parsed?.cacheWriteTokens).toBe(1000) + expect(parsed?.cacheWrite1hTokens).toBe(400) + }) + + it('clamps a 1-hour breakdown that exceeds the reported cache-write total', () => { + const parsed = parseClaudeUsageRecord( + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 100, + output_tokens: 25, + cache_creation_input_tokens: 100, + cache_creation: { ephemeral_1h_input_tokens: 400 } + } + } + }) + ) + + expect(parsed?.cacheWriteTokens).toBe(100) + expect(parsed?.cacheWrite1hTokens).toBe(100) + }) + it('merges duplicate streamed assistant usage by message and request id', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-claude-dedupe-')) const filePath = join(root, 'session.jsonl') @@ -92,7 +141,8 @@ describe('parseClaudeUsageRecord', () => { inputTokens: 120, outputTokens: 30, cacheReadTokens: 12, - cacheWriteTokens: 7 + cacheWriteTokens: 7, + cacheWrite1hTokens: 0 } ]) } finally { @@ -114,7 +164,8 @@ describe('Claude usage aggregation', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 }, { sessionId: 'session-1', @@ -125,7 +176,8 @@ describe('Claude usage aggregation', () => { inputTokens: 50, outputTokens: 10, cacheReadTokens: 0, - cacheWriteTokens: 0 + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 } ], new Map([ @@ -154,7 +206,8 @@ describe('Claude usage aggregation', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 }, { locationKey: 'cwd:/outside/repo-b', @@ -165,7 +218,8 @@ describe('Claude usage aggregation', () => { inputTokens: 50, outputTokens: 10, cacheReadTokens: 0, - cacheWriteTokens: 0 + cacheWriteTokens: 0, + cacheWrite1hTokens: 0 } ]) }) @@ -182,7 +236,8 @@ describe('Claude usage aggregation', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ], new Map([ diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 3609399ac33..650976a2455 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -101,6 +101,7 @@ describe('ClaudeUsageStore', () => { totalOutputTokens: 20, totalCacheReadTokens: 10, totalCacheWriteTokens: 5, + totalCacheWrite1hTokens: 0, locationBreakdown: [ { locationKey: 'cwd:/outside/repo', @@ -111,7 +112,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] } @@ -129,7 +131,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] }) @@ -159,6 +162,7 @@ describe('ClaudeUsageStore', () => { totalOutputTokens: 20, totalCacheReadTokens: 10, totalCacheWriteTokens: 5, + totalCacheWrite1hTokens: 0, locationBreakdown: [ { locationKey: 'worktree:repo-1::/workspace/repo-a', @@ -169,7 +173,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] } @@ -187,7 +192,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] }) @@ -213,7 +219,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 100, outputTokens: 20, cacheReadTokens: 10, - cacheWriteTokens: 5 + cacheWriteTokens: 5, + cacheWrite1hTokens: 0 } ] }) @@ -239,7 +246,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -253,6 +261,37 @@ describe('ClaudeUsageStore', () => { ).toBeCloseTo(36.75) }) + it('prices the 1-hour cache-write share above the 5-minute rate', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'claude-opus-4-7-20260416', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 400_000 + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + // 5 + 25 + 0.5 + (0.6 * 6.25 + 0.4 * 10); the flat 5m rate would give 36.75. + expect(summary.estimatedCostUsd).toBeCloseTo(38.25) + expect( + breakdown.find((row) => row.key === 'claude-opus-4-7-20260416')?.estimatedCostUsd + ).toBeCloseTo(38.25) + }) + it('prices Claude Opus 4.8 with current Anthropic rates', async () => { const store = createStoreWithState({ dailyAggregates: [ @@ -268,7 +307,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -282,7 +322,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -314,7 +355,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -328,7 +370,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -342,7 +385,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -357,7 +401,7 @@ describe('ClaudeUsageStore', () => { ).toBeCloseTo(73.5) expect( breakdown.find((row) => row.key === 'claude-sonnet-5-thinking')?.estimatedCostUsd - ).toBeCloseTo(22.05) + ).toBeCloseTo(14.7) }) it('prices Sonnet 5 long-context usage at flat rates', async () => { @@ -375,7 +419,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 300_000, outputTokens: 300_000, cacheReadTokens: 300_000, - cacheWriteTokens: 300_000 + cacheWriteTokens: 300_000, + cacheWrite1hTokens: 0 } ] }) @@ -383,7 +428,7 @@ describe('ClaudeUsageStore', () => { const summary = await store.getSummary('orca', '30d') // Why: Sonnet 4.6 and earlier bill above 200k at a premium; Sonnet 5 does not. - expect(summary.estimatedCostUsd).toBeCloseTo(6.615) + expect(summary.estimatedCostUsd).toBeCloseTo(4.41) }) it('does not collapse Opus 4.5 or Sonnet 4.5 usage into Claude 5 pricing', async () => { @@ -400,7 +445,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 300_000, outputTokens: 300_000, cacheReadTokens: 300_000, - cacheWriteTokens: 300_000 + cacheWriteTokens: 300_000, + cacheWrite1hTokens: 0 })) }) @@ -433,7 +479,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -447,7 +494,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -472,7 +520,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 }, { day: '2026-04-09', @@ -486,7 +535,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheReadTokens: 1_000_000, - cacheWriteTokens: 1_000_000 + cacheWriteTokens: 1_000_000, + cacheWrite1hTokens: 0 } ] }) @@ -511,7 +561,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 300_000, outputTokens: 300_000, cacheReadTokens: 300_000, - cacheWriteTokens: 300_000 + cacheWriteTokens: 300_000, + cacheWrite1hTokens: 0 } ] }) @@ -546,6 +597,7 @@ describe('ClaudeUsageStore', () => { totalOutputTokens: 500, totalCacheReadTokens: 200, totalCacheWriteTokens: 100, + totalCacheWrite1hTokens: 0, locationBreakdown: [ { locationKey: `worktree:${worktreeId}`, @@ -556,7 +608,8 @@ describe('ClaudeUsageStore', () => { inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, - cacheWriteTokens: 100 + cacheWriteTokens: 100, + cacheWrite1hTokens: 0 } ] } diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 466b30571cb..921c09c8732 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -22,8 +22,9 @@ import { resolveAutomationRunUsage } from './claude-usage-automation-attribution // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count -// after fork reclaim (#8006). -const SCHEMA_VERSION = 5 +// after fork reclaim (#8006). v6 adds the 1-hour cache-write split, which older +// caches never recorded, so their cost estimates stay stuck at the 5m rate (#15993). +const SCHEMA_VERSION = 6 // Why: capture the path after configureDevUserDataPath() but before app.setName() // mutates Electron's derived userData location, matching the persistence/store pattern. diff --git a/src/main/claude-usage/transcript-record-parser.ts b/src/main/claude-usage/transcript-record-parser.ts index 50623fa295f..59b0e75be39 100644 --- a/src/main/claude-usage/transcript-record-parser.ts +++ b/src/main/claude-usage/transcript-record-parser.ts @@ -23,6 +23,11 @@ type ClaudeUsageSourceRecord = { output_tokens?: number cache_read_input_tokens?: number cache_creation_input_tokens?: number + /** TTL split of `cache_creation_input_tokens`; 1h writes bill at 2x base input. */ + cache_creation?: { + ephemeral_5m_input_tokens?: number + ephemeral_1h_input_tokens?: number + } } } } @@ -43,7 +48,8 @@ export function stripClaudeSourceMetadata( inputTokens: turn.inputTokens, outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens + cacheWriteTokens: turn.cacheWriteTokens, + cacheWrite1hTokens: turn.cacheWrite1hTokens } } @@ -64,6 +70,7 @@ function dedupeClaudeUsageTurns( existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens) existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens) existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens) + existing.cacheWrite1hTokens = Math.max(existing.cacheWrite1hTokens, turn.cacheWrite1hTokens) continue } } @@ -101,6 +108,11 @@ function parseClaudeUsageSourceRecord( const outputTokens = usage?.output_tokens ?? 0 const cacheReadTokens = usage?.cache_read_input_tokens ?? 0 const cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0 + // Why: clamp so the implied 5m remainder can never go negative on a partial row. + const cacheWrite1hTokens = Math.min( + usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0, + cacheWriteTokens + ) if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens <= 0) { return null @@ -119,7 +131,8 @@ function parseClaudeUsageSourceRecord( inputTokens, outputTokens, cacheReadTokens, - cacheWriteTokens + cacheWriteTokens, + cacheWrite1hTokens } } diff --git a/src/main/claude-usage/types.ts b/src/main/claude-usage/types.ts index ec497e5e098..0a4d061c332 100644 --- a/src/main/claude-usage/types.ts +++ b/src/main/claude-usage/types.ts @@ -15,6 +15,7 @@ export type ClaudeUsageLocationBreakdown = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + cacheWrite1hTokens: number } export type ClaudeUsageSession = { @@ -31,6 +32,7 @@ export type ClaudeUsageSession = { totalOutputTokens: number totalCacheReadTokens: number totalCacheWriteTokens: number + totalCacheWrite1hTokens: number locationBreakdown: ClaudeUsageLocationBreakdown[] } @@ -47,6 +49,7 @@ export type ClaudeUsageDailyAggregate = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + cacheWrite1hTokens: number } export type ClaudeUsagePersistedState = { @@ -86,6 +89,8 @@ export type ClaudeUsageParsedTurn = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number + /** 1-hour-TTL subset of `cacheWriteTokens`; billed at 2x base input. */ + cacheWrite1hTokens: number } export type ClaudeUsageAttributedTurn = ClaudeUsageParsedTurn & { diff --git a/src/main/claude-usage/usage-aggregation.ts b/src/main/claude-usage/usage-aggregation.ts index 5ead6af0177..7626845827a 100644 --- a/src/main/claude-usage/usage-aggregation.ts +++ b/src/main/claude-usage/usage-aggregation.ts @@ -30,6 +30,7 @@ export function mergeClaudeSessions( existing.totalOutputTokens += session.totalOutputTokens existing.totalCacheReadTokens += session.totalCacheReadTokens existing.totalCacheWriteTokens += session.totalCacheWriteTokens + existing.totalCacheWrite1hTokens += session.totalCacheWrite1hTokens for (const location of session.locationBreakdown) { const existingLocation = @@ -41,6 +42,7 @@ export function mergeClaudeSessions( existingLocation.outputTokens += location.outputTokens existingLocation.cacheReadTokens += location.cacheReadTokens existingLocation.cacheWriteTokens += location.cacheWriteTokens + existingLocation.cacheWrite1hTokens += location.cacheWrite1hTokens } else { existing.locationBreakdown.push({ ...location }) } @@ -65,6 +67,7 @@ export function mergeClaudeDailyAggregates( existing.outputTokens += aggregate.outputTokens existing.cacheReadTokens += aggregate.cacheReadTokens existing.cacheWriteTokens += aggregate.cacheWriteTokens + existing.cacheWrite1hTokens += aggregate.cacheWrite1hTokens } } @@ -113,6 +116,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + totalCacheWrite1hTokens: 0, locationBreakdown: [] }) } @@ -132,6 +136,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { session.totalOutputTokens += turn.outputTokens session.totalCacheReadTokens += turn.cacheReadTokens session.totalCacheWriteTokens += turn.cacheWriteTokens + session.totalCacheWrite1hTokens += turn.cacheWrite1hTokens const location = session.locationBreakdown.find((entry) => entry.locationKey === turn.projectKey) ?? null @@ -141,6 +146,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { location.outputTokens += turn.outputTokens location.cacheReadTokens += turn.cacheReadTokens location.cacheWriteTokens += turn.cacheWriteTokens + location.cacheWrite1hTokens += turn.cacheWrite1hTokens } else { session.locationBreakdown.push({ locationKey: turn.projectKey, @@ -151,7 +157,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { inputTokens: turn.inputTokens, outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens + cacheWriteTokens: turn.cacheWriteTokens, + cacheWrite1hTokens: turn.cacheWrite1hTokens }) } @@ -166,6 +173,7 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { existingDaily.outputTokens += turn.outputTokens existingDaily.cacheReadTokens += turn.cacheReadTokens existingDaily.cacheWriteTokens += turn.cacheWriteTokens + existingDaily.cacheWrite1hTokens += turn.cacheWrite1hTokens } else { dailyByKey.set(dailyKey, { day: turn.day, @@ -179,7 +187,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { inputTokens: turn.inputTokens, outputTokens: turn.outputTokens, cacheReadTokens: turn.cacheReadTokens, - cacheWriteTokens: turn.cacheWriteTokens + cacheWriteTokens: turn.cacheWriteTokens, + cacheWrite1hTokens: turn.cacheWrite1hTokens }) } } From 6f8c5888b3acc4011c09498cda27cf3906edd91a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:17:25 -0700 Subject: [PATCH 21/37] Run Node 26 compatibility daily instead of per PR (#16946) * Run Node 26 compatibility daily * Update relocated unit workflow contracts --- .github/workflows/node-next-compat.yml | 20 +++++++ .github/workflows/pr.yml | 57 ++---------------- .github/workflows/unit-tests.yml | 59 +++++++++++++++++++ ...package-electron-runtime-contract.test.mjs | 9 +-- config/scripts/pr-code-change-scope.test.mjs | 6 +- .../scripts/pr-workflow-parallelism.test.mjs | 37 ++++++++---- .../web/web-preload-api-runtime-calls.test.ts | 8 ++- 7 files changed, 124 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/node-next-compat.yml create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml new file mode 100644 index 00000000000..eb9de70dab7 --- /dev/null +++ b/.github/workflows/node-next-compat.yml @@ -0,0 +1,20 @@ +name: Node next compatibility + +on: + schedule: + # Full future-runtime coverage is useful, but not worth doubling every PR matrix. + - cron: '0 10 * * *' + workflow_dispatch: + +concurrency: + group: node-next-compat + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + uses: ./.github/workflows/unit-tests.yml + with: + node_versions: '["26"]' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9f3b14f5903..338bcda7dbd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -444,16 +444,12 @@ jobs: src/shared/posix-command-path-lookup.test.ts # Cache-key input changes would otherwise make every shard compile the same - # native addon concurrently. Prime each Node ABI once before the matrix fans out. + # native addon concurrently. Prime the supported Node ABI before the matrix fans out. test_native_cache: - name: prepare test native cache node ${{ matrix.node }} + name: prepare test native cache node 24 needs: [code_paths] if: needs.code_paths.outputs.native_cache_changed == 'true' runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: ['24', '26'] steps: - name: Checkout @@ -464,58 +460,17 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - node-version: ${{ matrix.node }} + node-version: '24' test: - name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} needs: [code_paths, test_native_cache] if: >- always() && needs.code_paths.outputs.test == 'true' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: ['24', '26'] - shard: [1, 2, 3, 4, 5, 6, 7, 8] - shard_total: [8] - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - with: - native-runtime: node - node-version: ${{ matrix.node }} - - - name: Install Electron package binary for tests - run: node config/scripts/install-electron-package-binary.mjs - - - name: Test shard - run: | - pnpm exec vitest run --config config/vitest.config.ts \ - --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ - --exclude=src/main/daemon/shell-ready.test.ts \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ - --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ - --exclude=src/shared/posix-command-path-lookup.test.ts \ - --exclude=tests/e2e/cross-version-wire/** \ - --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + uses: ./.github/workflows/unit-tests.yml + with: + node_versions: '["24"]' # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000000..9906b569324 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,59 @@ +name: Unit tests + +on: + workflow_call: + inputs: + node_versions: + description: JSON array of Node.js major versions to test. + required: true + type: string + +permissions: + contents: read + +jobs: + test: + name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.node_versions) }} + shard: [1, 2, 3, 4, 5, 6, 7, 8] + shard_total: [8] + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: ${{ matrix.node }} + + - name: Install Electron package binary for tests + run: node config/scripts/install-electron-package-binary.mjs + + - name: Test shard + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ + --exclude=src/main/daemon/shell-ready.test.ts \ + --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ + --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ + --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/main/shell-startup-feature-channel.test.ts \ + --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ + --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ + --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ + --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ + --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ + --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ + --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ + --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ + --exclude=src/shared/posix-command-path-lookup.test.ts \ + --exclude=tests/e2e/cross-version-wire/** \ + --shard=${{ matrix.shard }}/${{ matrix.shard_total }} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index ef9745e476c..9c440280098 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => { expect(copyStep.run).toContain('git add "$CASK_PATH"') }) - it('installs the Electron package binary in PR checks without changing native module ABI', () => { - const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') - const parsedWorkflow = parse(prWorkflow) - const installStep = parsedWorkflow.jobs.test.steps.find( + it('installs the Electron package binary in the shared unit-test workflow', () => { + const unitTestWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8') + ) + const installStep = unitTestWorkflow.jobs.test.steps.find( (step) => step.name === 'Install Electron package binary for tests' ) diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 223929fc213..ea83796e6cb 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -281,7 +281,11 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.test_native_cache.if).toBe( "needs.code_paths.outputs.native_cache_changed == 'true'" ) - expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26']) + expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined() + const primerInstall = prWorkflow.jobs.test_native_cache.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + expect(primerInstall.with['node-version']).toBe('24') }) it('skips e2e detection on docs-only PRs without dropping the draft gate', () => { diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 88bf4165d63..29f532b9436 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -3,6 +3,8 @@ import { parse } from 'yaml' import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) +const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8')) const dependencyAction = parse( readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') ) @@ -47,28 +49,34 @@ describe('PR workflow parallelism', () => { expect(workflow.permissions).toEqual({ contents: 'read' }) }) - it('shards the general test suite across Node 24 and Node 26', () => { - expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26']) - expect(workflow.jobs.test.strategy.matrix.shard).toEqual( - Array.from({ length: 8 }, (_, index) => index + 1) + it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => { + const sharedTest = unitTestWorkflow.jobs.test + const testStep = sharedTest.steps.find((step) => step.name === 'Test shard') + const installStep = sharedTest.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' ) - expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8]) - const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard') - const installStep = workflow.jobs.test.steps.find( + const primerInstall = workflow.jobs.test_native_cache.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) + expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') + expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24']) + expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') + expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26']) + expect(nodeNextWorkflow.on.schedule).toHaveLength(1) + expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() + expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') + expect(sharedTest.strategy.matrix.shard).toEqual( + Array.from({ length: 8 }, (_, index) => index + 1) + ) + expect(sharedTest.strategy.matrix.shard_total).toEqual([8]) expect(installStep.with['node-version']).toBe('${{ matrix.node }}') expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}') for (const testFile of nativeShellContractFiles) { expect(testStep.run).toContain(`--exclude=${testFile}`) } - const primerInstall = workflow.jobs.test_native_cache.steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26']) expect(primerInstall.with['native-runtime']).toBe('node') - expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}') + expect(primerInstall.with['node-version']).toBe('24') expect(workflow.jobs.test.needs).toContain('test_native_cache') }) @@ -241,13 +249,16 @@ describe('PR workflow parallelism', () => { workflow.jobs[jobName].steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) + const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) { expect(installFor(jobName).with, jobName).toBeUndefined() } expect(installFor('static_analysis').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') - expect(installFor('test').with['native-runtime']).toBe('node') + expect(sharedTestInstall.with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') expect(installFor('package_windows').with['native-runtime']).toBe('node') expect(installFor('package_windows').with['persist-native-cache']).toBe('false') diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts index 6960ba5b83f..fcf24437595 100644 --- a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -65,7 +65,7 @@ describe('web preload runtime calls', () => { ).toMatchObject({ runtimeId: 'runtime-failure' }) }) - it('unwraps domain failures to their message after persisting failure metadata', async () => { + it('unwraps domain failures with their code after persisting failure metadata', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { call(method: string): Promise> { @@ -96,11 +96,13 @@ describe('web preload runtime calls', () => { rejection = error } - expect(rejection).toEqual(new Error('Repository catalog is unavailable')) + expect(rejection).toEqual( + Object.assign(new Error('Repository catalog is unavailable'), { code: 'repo_unavailable' }) + ) if (!(rejection instanceof Error)) { throw new Error('Expected a domain Error rejection') } - expect(Reflect.get(rejection, 'code')).toBeUndefined() + expect(Reflect.get(rejection, 'code')).toBe('repo_unavailable') expect( JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') ).toMatchObject({ runtimeId: 'runtime-domain-failure' }) From 6f3c5cc03995ceeb7181ba421dfa58a50ea77f71 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:18:04 -0700 Subject: [PATCH 22/37] =?UTF-8?q?fix(i18n):=20use=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=A0=91=20consistently=20for=20parent=20worktree=20strings=20?= =?UTF-8?q?in=20zh=20locale=20(#16954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: select parent worktree for nesting when creating workspace Enable users to pick a parent workspace in the composer's Advanced drawer, organizing newly created worktrees hierarchically in the sidebar. The backend validates lineage relationships and gracefully retries without the parent if it becomes unavailable during creation. * feat: select parent worktree for nesting when creating workspace - Record app-picked parents as manual actions, not CLI-flag equivalents, ensuring the same user action carries consistent cleanup semantics across hosts - Gracefully retry without parent if the selection goes stale, warn the user instead of failing - Refactor create into modules: parent resolution, payload building, state merge * Allow selecting parent worktree when creating workspace - New parent worktree picker filtered by execution host and project - Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership * Allow selecting parent worktree when creating workspace - Rename "Parent workspace" label to "Parent worktree" - Filter candidates by execution host and project to prevent nesting across hosts - Wire parentWorktreeId through composer state and creation request pipeline - Update translations and add new copy for nesting-related messages * fix(i18n): use 工作树 consistently for parent worktree strings in zh locale Addresses PR #15420 review feedback: parent worktree strings mixed 工作区 (workspace) and 工作树 (worktree) terminology. --- ...ados-hardware-2set-hangul-batchim-migration-trace.json | 8 ++------ src/renderer/src/i18n/locales/zh.json | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json index 832e08ed997..c208ba06ff1 100644 --- a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json +++ b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json @@ -4,11 +4,7 @@ "maxTouchPoints": 5, "typed": "gksrmfRownla", "expected": "한글깨쥠", - "observedSent": [ - "한", - "글", - "깨쥠" - ], + "observedSent": ["한", "글", "깨쥠"], "events": [ { "type": "keydown", @@ -283,4 +279,4 @@ "value": "한글깨쥠" } ] -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4d4a7010809..3e81e69b8b3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -269,7 +269,7 @@ "runtimeScopeForbiddenDescription": "移动端范围的配对无法使用工作区。请通过设置 → 远程 Orca 服务器 → 分享此 Orca 服务器中的浏览器访问链接重新连接。", "createdWithoutParentNesting": "已创建,但未嵌套在“{{value0}}”下", "createdWithoutParentNestingUnnamed": "已创建,但未嵌套在所选父工作树下", - "createdWithoutParentNestingDetail": "父工作区已不可用。您可以从工作区菜单中重新设置。" + "createdWithoutParentNestingDetail": "父工作树已不可用。您可以从工作区菜单中重新设置。" }, "repos": { "2975400634": "请更新 Orca 服务器,以便在此运行时打开非 Git 文件夹。", @@ -14504,8 +14504,8 @@ "ComposerParentWorktreePicker": { "label": "父工作树", "noParent": "无父工作树", - "description": "在侧边栏中将此工作区嵌套到另一个工作区下。不会更改基础分支。", - "searchPlaceholder": "搜索工作区...", + "description": "在侧边栏中将此工作区嵌套到另一个工作树下。不会更改基础分支。", + "searchPlaceholder": "搜索工作树...", "noMatches": "无匹配项。" } }, From c54bf9218117e01d2ff1492b2b1a87830e309cd3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:29:42 -0700 Subject: [PATCH 23/37] fix(window): stop will-navigate from blocking the lazy-chunk recovery reload (#16944) * wip: lazy-chunk-reload * fix(window): reject non-document navigation schemes --- .../privileged-window-navigation.test.ts | 96 +++++++++++++++++++ .../window/privileged-window-navigation.ts | 18 ++-- .../window/renderer-document-navigation.ts | 9 +- 3 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 src/main/window/privileged-window-navigation.test.ts diff --git a/src/main/window/privileged-window-navigation.test.ts b/src/main/window/privileged-window-navigation.test.ts new file mode 100644 index 00000000000..579d830f244 --- /dev/null +++ b/src/main/window/privileged-window-navigation.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const openExternal = vi.fn() +vi.mock('electron', () => ({ + shell: { openExternal: (url: string) => openExternal(url) } +})) + +import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' + +describe('privileged window navigation policy', () => { + function createFixture(currentUrl: string) { + const handlers = new Map void>() + const contents = { + getURL: () => currentUrl, + setWindowOpenHandler: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler) + }) + } + installPrivilegedWindowNavigationPolicy(contents as never) + return { + willNavigate(url: string) { + const event = { preventDefault: vi.fn() } + const handler = handlers.get('will-navigate') + // Why: the allow-cases assert preventDefault was *not* called, so a missing + // handler would pass them vacuously. + if (!handler) { + throw new Error('no will-navigate handler was registered') + } + handler(event, url) + return event + } + } + } + + beforeEach(() => { + openExternal.mockClear() + }) + + it('lets the packaged renderer document reload itself', () => { + const appUrl = + 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html' + const event = createFixture(appUrl).willNavigate(appUrl) + + expect(event.preventDefault).not.toHaveBeenCalled() + expect(openExternal).not.toHaveBeenCalled() + }) + + it('still blocks and hands off an external http target', () => { + const appUrl = + 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html' + const event = createFixture(appUrl).willNavigate('https://example.com/') + + expect(event.preventDefault).toHaveBeenCalled() + expect(openExternal).toHaveBeenCalledWith('https://example.com/') + }) + + it('lets the dev renderer origin navigate but blocks foreign documents', () => { + const fixture = createFixture('http://localhost:5173/') + + expect( + fixture.willNavigate('http://localhost:5173/index.html').preventDefault + ).not.toHaveBeenCalled() + expect(fixture.willNavigate('https://example.com/').preventDefault).toHaveBeenCalled() + expect( + fixture.willNavigate('blob:http://localhost:5173/attacker-document').preventDefault + ).toHaveBeenCalled() + }) + + // Why: the packaged file: path is the whole privilege boundary, so a foreign host or a + // non-file scheme that reuses it must not read as "our own document". + it('blocks a foreign file host and a data: URL that reuse the renderer path', () => { + const appUrl = + 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html' + const fixture = createFixture(appUrl) + + expect( + fixture.willNavigate( + 'file://evil.example/Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html' + ).preventDefault + ).toHaveBeenCalled() + expect( + fixture.willNavigate('data:text/html,').preventDefault + ).toHaveBeenCalled() + expect(openExternal).not.toHaveBeenCalled() + }) + + it('still blocks navigation to an unrelated local file', () => { + const appUrl = + 'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html' + const event = createFixture(appUrl).willNavigate('file:///Users/someone/.ssh/id_rsa') + + expect(event.preventDefault).toHaveBeenCalled() + expect(openExternal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/privileged-window-navigation.ts b/src/main/window/privileged-window-navigation.ts index a5afbbe0a98..d11268f0340 100644 --- a/src/main/window/privileged-window-navigation.ts +++ b/src/main/window/privileged-window-navigation.ts @@ -1,6 +1,6 @@ import { shell, type WebContents } from 'electron' -import { is } from '@electron-toolkit/utils' import { normalizeExternalBrowserUrl } from '../../shared/browser-url' +import { isRendererDocumentNavigation } from './renderer-document-navigation' /** Keep remote documents from inheriting an Orca window's privileged preload. */ export function installPrivilegedWindowNavigationPolicy(contents: WebContents): void { @@ -13,19 +13,13 @@ export function installPrivilegedWindowNavigationPolicy(contents: WebContents): }) contents.on('will-navigate', (event, url) => { + // Why: location.reload() is a renderer-initiated navigation, so blocking it here + // silently kills the lazy-chunk recovery reload with no unload-prevented signal. + if (isRendererDocumentNavigation(contents.getURL(), url)) { + return + } const externalUrl = normalizeExternalBrowserUrl(url) if (externalUrl) { - if (is.dev && process.env.ELECTRON_RENDERER_URL) { - try { - const target = new URL(externalUrl) - const allowed = new URL(process.env.ELECTRON_RENDERER_URL) - if (target.origin === allowed.origin) { - return - } - } catch { - // Fall through and block malformed navigation targets. - } - } void shell.openExternal(externalUrl) } event.preventDefault() diff --git a/src/main/window/renderer-document-navigation.ts b/src/main/window/renderer-document-navigation.ts index 4179e24a511..50fb128b633 100644 --- a/src/main/window/renderer-document-navigation.ts +++ b/src/main/window/renderer-document-navigation.ts @@ -1,6 +1,12 @@ import type { WebContents } from 'electron' -function isRendererDocumentNavigation(currentUrl: string, nextUrl: string): boolean { +/** + * True when the target stays inside the window's own privileged document: for `file:` + * the exact same host+path (query/hash may differ, as a reload keeps them), for http(s) + * the same origin. Also gates preload privilege in installPrivilegedWindowNavigationPolicy, + * so loosening it past a same-origin document hands a foreign page the Orca bridge. + */ +export function isRendererDocumentNavigation(currentUrl: string, nextUrl: string): boolean { try { const current = new URL(currentUrl) const next = new URL(nextUrl) @@ -13,6 +19,7 @@ function isRendererDocumentNavigation(currentUrl: string, nextUrl: string): bool } return ( (current.protocol === 'http:' || current.protocol === 'https:') && + (next.protocol === 'http:' || next.protocol === 'https:') && next.origin === current.origin ) } catch { From c72afda498d4d29b69ebad2b5aa6bfea86e199f2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:56:00 -0700 Subject: [PATCH 24/37] wip: react185-cascade (#16940) --- .../activity/ActivityPrototypePage.tsx | 11 + ...vity-auto-mark-read-loop.react185.test.tsx | 224 +++++++++++++++++ ...reeContextMenu.react185-bystander.test.tsx | 234 ++++++++++++++++++ .../SortableTab.update-depth-probe.test.tsx | 212 ++++++++++++++++ ...-park-tab-model-identity.react185.test.tsx | 233 +++++++++++++++++ .../use-terminal-tab-cold-parking.ts | 15 +- .../useAutoAckViewedAgent.clock-skew.test.ts | 112 +++++++++ ...ab-agent-observed-signal-dispatch.test.tsx | 148 +++++++++++ src/renderer/src/lib/use-tab-agent.ts | 8 +- .../ui-acknowledge-agents-clock-skew.test.ts | 144 +++++++++++ src/renderer/src/store/slices/ui.ts | 36 ++- 11 files changed, 1370 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx create mode 100644 src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx create mode 100644 src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts create mode 100644 src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx create mode 100644 src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index a6a1df6dc9f..623e41d4c89 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -1426,6 +1426,8 @@ export default function ActivityPrototypePage(): React.JSX.Element { const [groupBy, setGroupBy] = useState('status') const [query, setQuery] = useState('') const activityFilterInputRef = useRef(null) + // Why: bounds auto mark-read to one acknowledgement per selected thread turn. + const autoAcknowledgedTurnRef = useRef(null) const [compactMode, setCompactMode] = useState(false) const [selectedPaneKey, setSelectedPaneKey] = useState(null) const [displayedPaneKey, setDisplayedPaneKey] = useState(null) @@ -1731,11 +1733,20 @@ export default function ActivityPrototypePage(): React.JSX.Element { ) { return } + // Why (React #185): a turn stamped ahead of this clock (SSH/remote execution host) can never + // have its unread cleared, and each retry lands on a later millisecond, so acknowledgeAgents' + // `prev < now` guard rewrites the ack map every time and re-enters here forever through + // storeData. Auto-read is once per turn, not a retry. + const autoAcknowledgeKey = `${selectedThread.paneKey}:${selectedThread.latestTimestamp}` + if (autoAcknowledgedTurnRef.current === autoAcknowledgeKey) { + return + } const selectedThreadHasDetailOnlyView = !selectedHasLiveTab || selectedThread.migrationUnsupportedPtyId !== undefined const selectedThreadIsVisibleTerminal = visibleThread?.paneKey === effectiveSelectedPaneKey && visiblePortalReady if (selectedThreadHasDetailOnlyView || selectedThreadIsVisibleTerminal) { + autoAcknowledgedTurnRef.current = autoAcknowledgeKey storeData.acknowledgeAgents([selectedThread.paneKey]) } }, [ diff --git a/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx new file mode 100644 index 00000000000..9fdc4c0ecef --- /dev/null +++ b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx @@ -0,0 +1,224 @@ +/** @vitest-environment happy-dom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Repo } from '../../../../shared/repo-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Worktree } from '../../../../shared/worktree/types' +import ActivityPrototypePage from './ActivityPrototypePage' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1' +const TAB_A = 'tab-a' +const PANE_A = makePaneKey(TAB_A, LEAF_A) +const PROMPT = 'turn stamped by the execution host' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 +} + +const worktree: Worktree = { + id: 'wt-1', + repoId: repo.id, + path: '/repo/wt-1', + head: 'abc123', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 +} + +const tab: TerminalTab = { + id: TAB_A, + ptyId: 'pty-a', + worktreeId: worktree.id, + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +const initialState = useAppStore.getInitialState() +let root: Root +let seededContainer: HTMLElement + +beforeEach(() => { + useAppStore.setState(initialState, true) +}) + +afterEach(() => { + act(() => root?.unmount()) + useAppStore.setState(initialState, true) + document.body.replaceChildren() +}) + +function seedRetainedThread(stampedAt: number, lastAssistantMessage: string): void { + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + tabsByWorktree: { [worktree.id]: [] }, + retainedAgentsByPaneKey: { + [PANE_A]: { + entry: { + state: 'done', + prompt: PROMPT, + updatedAt: stampedAt, + stateStartedAt: stampedAt, + paneKey: PANE_A, + terminalTitle: 'Claude', + stateHistory: [], + agentType: 'claude', + lastAssistantMessage + }, + worktreeId: worktree.id, + tab, + agentType: 'claude', + startedAt: stampedAt + } + }, + activeRepoId: repo.id, + activeWorktreeId: worktree.id + }) +} + +function seedThreadStampedByLocalClock(): void { + seedRetainedThread(Date.now() - 60_000, 'finished locally') +} + +// Why: an SSH/remote execution host stamps the turn with its own clock, so the renderer can +// acknowledge a thread whose event still sorts as newer than the acknowledgement. +function seedThreadStampedAheadOfLocalClock(): void { + seedRetainedThread(Date.now() + 60_000, 'finished on the execution host') +} + +async function mountActivityPage(): Promise { + const container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => { + root.render( + + + + ) + await new Promise((resolve) => setTimeout(resolve, 30)) + }) + seededContainer = container +} + +async function selectSeededThread(): Promise { + const row = Array.from(seededContainer.querySelectorAll('[role="button"]')).find( + (element) => element.textContent?.includes(PROMPT) + ) + expect(row).toBeDefined() + await act(async () => { + row?.click() + await new Promise((resolve) => setTimeout(resolve, 30)) + }) +} + +function countAcknowledgeWrites(): { readonly count: () => number; stop: () => void } { + let writes = 0 + const unsubscribe = useAppStore.subscribe((next, prev) => { + if (next.acknowledgedAgentsByPaneKey !== prev.acknowledgedAgentsByPaneKey) { + writes += 1 + } + }) + return { count: () => writes, stop: unsubscribe } +} + +describe('Activity auto mark-read loop (React #185)', () => { + it('acknowledges a selected thread once even when its unread flag cannot clear', async () => { + seedThreadStampedAheadOfLocalClock() + await mountActivityPage() + const acknowledgeWrites = countAcknowledgeWrites() + + try { + await selectSeededThread() + } finally { + acknowledgeWrites.stop() + } + + expect(acknowledgeWrites.count()).toBe(1) + }) + + it('re-acknowledges when a new turn lands on the still-selected thread', async () => { + seedThreadStampedAheadOfLocalClock() + await mountActivityPage() + const acknowledgeWrites = countAcknowledgeWrites() + + try { + await selectSeededThread() + expect(acknowledgeWrites.count()).toBe(1) + + // Why: the per-turn guard must not swallow the next turn's auto mark-read. + const nextTurnAt = Date.now() + 120_000 + await act(async () => { + useAppStore.setState((s) => { + const previous = s.retainedAgentsByPaneKey[PANE_A] + return { + retainedAgentsByPaneKey: { + [PANE_A]: { + ...previous, + startedAt: nextTurnAt, + entry: { + ...previous.entry, + updatedAt: nextTurnAt, + stateStartedAt: nextTurnAt, + lastAssistantMessage: 'second turn on the execution host' + } + } + } + } + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + } finally { + acknowledgeWrites.stop() + } + + expect(acknowledgeWrites.count()).toBe(2) + }) + + it('still marks a locally stamped thread read on selection', async () => { + seedThreadStampedByLocalClock() + await mountActivityPage() + await selectSeededThread() + + expect(useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_A]).toBeGreaterThan(0) + }) + + it('leaves the selected thread unread after the user marks it unread', async () => { + seedThreadStampedByLocalClock() + await mountActivityPage() + await selectSeededThread() + + // Why pinned: auto mark-read is once per turn, so an explicit mark-unread on the + // still-selected thread must survive — a guard keyed on unread instead of the turn + // would silently re-acknowledge it and make the menu action a no-op. + await act(async () => { + useAppStore.getState().unacknowledgeAgents([PANE_A]) + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + expect(useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_A]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx new file mode 100644 index 00000000000..da48eb26ae0 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx @@ -0,0 +1,234 @@ +/** + * @vitest-environment happy-dom + * + * Reproduces the shape of the production React #185 cluster filed under + * boundary_id `sidebar.worktrees` (component stack: RovingFocusGroupItem inside + * DropdownMenuItem inside the worktree row's context menu). + * + * The point of the test is that the menu is a BYSTANDER: it contains no loop of + * its own (first case), and it only takes the blame because Radix's roving-focus + * item runs `onFocusableItemAdd()` — a setState — from a mount LAYOUT effect, + * which is the next dispatch after an unrelated driver has already pushed + * React's root-global nested-update counter past its limit (second case). + */ +import React, { act, useLayoutEffect, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/worktree/types' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution' +import { TooltipProvider } from '@/components/ui/tooltip' +import WorktreeContextMenu from './WorktreeContextMenu' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const state = { + updateWorktreeMeta: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + workspaceStatuses: [ + { id: 'todo', label: 'Todo' }, + { id: 'doing', label: 'Doing' } + ], + openModal: vi.fn(), + projectGroups: [{ id: 'g1', name: 'Group 1' }], + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + deleteStateByWorktreeId: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + updateWorktreeLineage: vi.fn(), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + keybindings: {}, + settings: { activeRuntimeEnvironmentId: null, openInApplications: [] }, + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (s: typeof state) => unknown) => selector(state), { + getState: () => state + }) +})) + +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => [], + useRepoById: (repoId?: string) => + repoId ? { id: repoId, name: repoId, displayName: repoId, projectGroupId: null } : undefined, + useRepoMap: () => new Map(), + useWorktreeMap: () => new Map() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback, + i18n: { language: 'en', on: () => {}, off: () => {} } +})) + +vi.mock('./ProjectGroupNameDialog', () => ({ ProjectGroupNameDialog: () => null })) +vi.mock('./WorktreeParentPickerPopover', () => ({ WorktreeParentPickerPopover: () => null })) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +afterEach(() => { + for (const { root, container } of mounted) { + act(() => root.unmount()) + container.remove() + } + mounted.length = 0 +}) + +function worktreeFixture(): Worktree { + return { + id: 'repo::wt-1', + repoId: 'repo', + name: 'wt-1', + displayName: 'wt-1', + path: '/path/to/wt-1', + isMainWorktree: false + } as unknown as Worktree +} + +type Capture = { errors: Error[]; componentStacks: string[] } + +class SidebarBoundary extends React.Component< + { children: React.ReactNode; capture: Capture }, + { failed: boolean } +> { + state = { failed: false } + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true } + } + componentDidCatch(error: Error, info: React.ErrorInfo): void { + this.props.capture.errors.push(error) + this.props.capture.componentStacks.push(info.componentStack ?? '') + } + render(): React.ReactNode { + return this.state.failed ? null : this.props.children + } +} + +function mount(node: React.ReactNode, capture: Capture): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container, { + onUncaughtError: (error) => capture.errors.push(error as Error), + onCaughtError: () => undefined + }) + mounted.push({ container, root }) + act(() => { + root.render( + + {node} + + ) + }) + return container +} + +/** + * Stands in for whatever unrelated sidebar/app code kept sync lanes pending + * commit after commit in the field report. It is the driver, not the menu. + */ +function CommitCascadeDriver({ + ticks, + onTick +}: { + ticks: number + onTick: () => void +}): React.JSX.Element { + useLayoutEffect(() => { + if (ticks < REACT_NESTED_UPDATE_LIMIT + 4) { + onTick() + } + }) + return
{ticks}
+} + +describe('WorktreeContextMenu and React #185', () => { + it('opening the row context menu on its own does not trip the nested-update limit', () => { + const capture: Capture = { errors: [], componentStacks: [] } + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const container = mount( + +
Card
+
, + capture + ) + + const scope = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement + act(() => { + scope.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }) + ) + }) + consoleError.mockRestore() + + expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeTruthy() + expect(capture.errors.map((error) => error.message)).toEqual([]) + }) + + // Reproduces the field report: the driver is elsewhere, the menu is blamed. + it('is blamed for a driver-owned cascade when its items mount past the limit', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const blamedFrames: string[] = [] + + // Which commit the right-click lands on decides which mounting fiber + // dispatches first past the limit; sweep the window around it rather than + // pinning one React-version-specific offset. + for ( + let openAt = REACT_NESTED_UPDATE_LIMIT - 6; + openAt <= REACT_NESTED_UPDATE_LIMIT; + openAt++ + ) { + const capture: Capture = { errors: [], componentStacks: [] } + + function Harness(): React.JSX.Element { + const [ticks, setTicks] = useState(0) + const hostRef = React.useRef(null) + // Right-click lands mid-cascade, so the menu content — and every + // RovingFocusGroupItem inside it — mounts while the counter is already deep. + useLayoutEffect(() => { + if (ticks !== openAt) { + return + } + hostRef.current + ?.querySelector('[data-worktree-context-menu-scope]') + ?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) + }, [ticks]) + return ( + <> +
+ +
Card
+
+
+ setTicks((value) => value + 1)} /> + + ) + } + + mount(, capture) + + const depthErrors = capture.errors.filter((error) => + error.message.includes('Maximum update depth exceeded') + ) + expect(depthErrors.length).toBeGreaterThan(0) + blamedFrames.push(capture.componentStacks[0]?.split('\n')[1]?.trim() ?? '') + } + consoleError.mockRestore() + + // Carried as the assertion message so a React/Radix version bump reports the + // frames it blamed instead of a bare `false`. + const blamed = blamedFrames.map((frame) => frame.split(' (')[0]).join(', ') + // The production report's innermost component_stack frame. + expect( + blamedFrames.some((frame) => frame.startsWith('at RovingFocusGroupItem')), + blamed + ).toBe(true) + // ...and the loop is never in menu code: only the driver owns a setState loop. + expect( + blamedFrames.some((frame) => frame.startsWith('at CommitCascadeDriver')), + blamed + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx new file mode 100644 index 00000000000..acbd7ce38bc --- /dev/null +++ b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx @@ -0,0 +1,212 @@ +// @vitest-environment happy-dom + +/** + * Pins the settling guarantee for every setState-in-effect path SortableTab owns + * (rename shortcut, title churn mid-rename, store write storms, StrictMode double + * invoke) under a real React root, where "Maximum update depth exceeded" throws. + * + * Written while triaging the React #185 crash cluster whose component_stack names + * SortableTab: these paths settle, which matches shared/react-update-depth-attribution.ts + * — #185 lands on whichever fiber dispatched next after a root-global counter tripped, + * so that stack names a bystander. Keep this green so the tab stays exonerated. + */ +import type { ReactElement, ReactNode } from 'react' +import { cloneElement, isValidElement, StrictMode, useEffect, useState } from 'react' +import { act, render, cleanup } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TabDragItemData } from '../tab-group/useTabDragSplit' +import { useAppStore } from '../../store' +import SortableTab from './SortableTab' + +type ProbeState = { + unreadTerminalTabs: Record + unreadAgentCompletionPanes: Record + agentStatusByPaneKey: Record + agentStatusEpoch: number + runtimePaneTitlesByTabId: Record> + ptyIdsByTabId: Record + terminalLayoutsByTabId: Record + renamingTabId: string | null + keybindings: Record + setRenamingTabId: (tabId: string | null) => void +} + +type StoreApiWithHook = { + (selector: (state: ProbeState) => unknown): unknown + getState: () => ProbeState + setState: (partial: Partial) => void +} + +// Both specifiers resolve to the same store module; memoize so they share one instance. +async function createProbeStore(): Promise { + const globalKey = '__sortableTabProbeStore' + const globals = globalThis as Record + if (!globals[globalKey]) { + const { create } = await import('zustand') + globals[globalKey] = create((set) => ({ + unreadTerminalTabs: {}, + unreadAgentCompletionPanes: {}, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + renamingTabId: null, + keybindings: {}, + setRenamingTabId: (tabId) => set({ renamingTabId: tabId }) + })) + } + return globals[globalKey] as StoreApiWithHook +} + +vi.mock('@/store', async () => ({ useAppStore: await createProbeStore() })) +vi.mock('../../store', async () => ({ useAppStore: await createProbeStore() })) + +vi.mock('@dnd-kit/sortable', () => ({ + useSortable: ({ id }: { id: string }) => ({ + attributes: { role: 'tab', 'data-sortable-id': id }, + listeners: { onPointerDown: vi.fn() }, + setNodeRef: vi.fn() + }) +})) + +vi.mock('@/lib/use-tab-agent', () => ({ useTabAgent: () => null })) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) => + asChild && isValidElement(children) ? ( + cloneElement(children as ReactElement>) + ) : ( + {children} + ) +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: () => null, + DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSeparator: () => null, + DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSub: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/input', () => ({ + Input: (props: Record) => +})) + +vi.mock('./shell-icons', () => ({ ShellIcon: () => })) +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => })) +vi.mock('../sidebar/WorktreeCardHelpers', () => ({ FilledBellIcon: () => })) + +function makeTab(overrides: Partial = {}): TerminalTab { + return { + id: 'terminal-tab-1', + title: 'Terminal 1', + worktreeId: 'wt-1', + ...overrides + } as TerminalTab +} + +const dragData: TabDragItemData = { + kind: 'tab', + worktreeId: 'wt-1', + groupId: 'group-1', + unifiedTabId: 'unified-1', + visibleTabId: 'terminal-tab-1', + tabType: 'terminal', + label: 'Terminal 1' +} + +const probeStore = useAppStore as unknown as StoreApiWithHook + +let renderCount = 0 + +function Harness({ tab }: { tab: TerminalTab }): ReactElement { + renderCount += 1 + return ( + + ) +} + +/** Re-renders the tab with a new tab object on every store write, like the tab strip does. */ +function ChurningHarness({ titles }: { titles: string[] }): ReactElement { + const [index, setIndex] = useState(0) + useEffect(() => { + if (index < titles.length - 1) { + setIndex(index + 1) + } + }, [index, titles.length]) + return +} + +afterEach(() => { + cleanup() + probeStore.setState({ renamingTabId: null, unreadTerminalTabs: {}, agentStatusEpoch: 0 }) + renderCount = 0 +}) + +describe('SortableTab update-depth probe', () => { + it('settles when the rename shortcut arms renamingTabId', () => { + probeStore.setState({ renamingTabId: 'terminal-tab-1' }) + const { container } = render() + expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull() + expect(probeStore.getState().renamingTabId).toBeNull() + expect(renderCount).toBeLessThan(20) + }) + + it('settles under title churn while the rename editor is open', () => { + probeStore.setState({ renamingTabId: 'terminal-tab-1' }) + render() + expect(renderCount).toBeLessThan(40) + }) + + it('settles under a store write storm', () => { + render() + const before = renderCount + act(() => { + for (let i = 0; i < 60; i += 1) { + probeStore.setState({ agentStatusEpoch: i, agentStatusByPaneKey: {} }) + } + }) + expect(renderCount - before).toBeLessThan(200) + }) + + it('settles in StrictMode double-invoked effects', () => { + probeStore.setState({ renamingTabId: 'terminal-tab-1' }) + render( + + + + ) + expect(probeStore.getState().renamingTabId).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx new file mode 100644 index 00000000000..87973f0d47a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-tab-model-identity.react185.test.tsx @@ -0,0 +1,233 @@ +/** @vitest-environment happy-dom */ +/** + * Crash cluster "React #185 in TerminalPaneOverlayLayer" (boundary terminal.workbench). + * + * The overlay's own park verdict does not oscillate (terminal-cold-park-verdict-loop + * covers that). What used to put it in the stack: its cold-park effect re-runs on + * every tab-model write — a runtime title publication, an unread bump re-mints + * tabsByWorktree — and each run dispatched setColdParkedTerminalTabIds even when + * the verdict was unchanged. React only bails on such a dispatch while the fiber + * has no pending lanes, so inside a cascade some other component drives, this hook + * is the one that trips the root-global nested-update counter and gets blamed (see + * src/shared/react-update-depth-attribution.ts). + */ +import { act, useEffect, useState, StrictMode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { REACT_NESTED_UPDATE_LIMIT } from '../../../../shared/react-update-depth-attribution' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const harness = vi.hoisted(() => ({ + worktreeId: 'repo::/cold-park-tab-model-identity', + parkEffectRuns: 0, + renderedParkedSets: [] as string[][] +})) + +vi.mock('../../store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + activeGroupIdByWorktree: {} as Record, + groupsByWorktree: {} as Record, + pendingStartupByTabId: {} as Record, + ptyIdsByTabId: {} as Record, + runtimeStatusByEnvironmentId: new Map(), + runtimePaneTitlesByTabId: {} as Record>, + settings: {} as Record, + sleepingAgentSessionsByPaneKey: {} as Record, + tabsByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record, + unifiedTabsByWorktree: {} as Record, + consumeSuppressedPtyExit: () => false, + focusGroup: () => {}, + reconcileWorktreeTabModel: () => ({ renderableTabCount: 2 }), + setActiveWorktree: () => {} + })) + return { useAppStore } +}) + +vi.mock('../native-chat/use-native-chat-toggle-shortcut', () => ({ + useNativeChatToggleShortcut: () => {} +})) + +vi.mock('./TerminalOverlaySlot', () => ({ TerminalOverlaySlot: () => null })) + +vi.mock('./terminal-parked-tab-watchers', () => ({ + canWatcherCoverParkedTerminalTab: () => true, + disposeParkedTerminalWatchersForWorktree: () => {}, + syncParkedTerminalTabWatchers: (args: { parkedTabIds: ReadonlySet }) => { + harness.renderedParkedSets.push([...args.parkedTabIds].sort()) + } +})) + +// Counts cold-park effect executions: the effect clears and re-arms its recheck +// timers on every run, so this is also the per-commit work the identity dep costs. +vi.mock('./terminal-parking-e2e-overrides', () => ({ + getTerminalParkingPolicyOverrides: () => { + harness.parkEffectRuns += 1 + return { coldParkDelayMs: 0, hotRetainMs: 0 } + } +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ recordRendererCrashBreadcrumb: vi.fn() })) + +import { useAppStore } from '../../store' +import TerminalPaneOverlayLayer from './TerminalPaneOverlayLayer' + +const TAB_IDS = ['tab-a', 'tab-b'] as const +const GROUP_ID = 'group-a' +/** Above React's nested-update limit so the cascade actually reaches the bail. */ +const CASCADE_COMMITS = REACT_NESTED_UPDATE_LIMIT * 2 + +type ParkingStoreState = { + groupsByWorktree: Record + tabsByWorktree: Record + unifiedTabsByWorktree: Record +} + +const parkingStore = useAppStore as unknown as { + getState: () => ParkingStoreState + setState: (partial: unknown) => void +} + +function terminalTab(id: string): TerminalTab { + return { + id, + worktreeId: harness.worktreeId, + ptyId: `${harness.worktreeId}@@session-${id}`, + title: id, + generation: 0 + } as TerminalTab +} + +function unifiedTerminalTab(id: string): Tab { + return { + id: `unified-${id}`, + entityId: id, + worktreeId: harness.worktreeId, + groupId: GROUP_ID, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } as Tab +} + +/** An ordinary runtime title publication: re-mints tabsByWorktree, changes no park input. */ +function publishRuntimeTitle(revision: number): void { + parkingStore.setState((state: ParkingStoreState) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [harness.worktreeId]: state.tabsByWorktree[harness.worktreeId].map((tab) => ({ + ...tab, + title: `${tab.id}-${revision}` + })) + } + })) +} + +/** + * The cascade driver: a component with its own runaway passive effect, in no way + * related to terminal parking. It is the bug; the overlay is the bystander. + */ +function UnrelatedCascadeDriver(): null { + const [tick, setTick] = useState(0) + useEffect(() => { + if (tick >= CASCADE_COMMITS) { + return + } + publishRuntimeTitle(tick) + setTick((current) => current + 1) + }, [tick]) + return null +} + +function renderTree(root: Root): unknown { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let error: unknown = null + try { + act(() => { + root.render( + + + + + ) + }) + } catch (thrown) { + error = thrown + } + consoleError.mockRestore() + return error +} + +describe('cold-park effect vs. unrelated commit cascade', () => { + let container: HTMLDivElement + let root: Root | undefined + beforeEach(() => { + harness.parkEffectRuns = 0 + harness.renderedParkedSets.length = 0 + const tabs = TAB_IDS.map(terminalTab) + const unifiedTabs = TAB_IDS.map(unifiedTerminalTab) + parkingStore.setState({ + activeGroupIdByWorktree: { [harness.worktreeId]: GROUP_ID }, + groupsByWorktree: { + [harness.worktreeId]: [ + { + id: GROUP_ID, + worktreeId: harness.worktreeId, + activeTabId: unifiedTabs[0].id, + tabOrder: unifiedTabs.map((tab) => tab.id), + recentTabIds: [unifiedTabs[0].id] + } as TabGroup + ] + }, + tabsByWorktree: { [harness.worktreeId]: tabs }, + unifiedTabsByWorktree: { [harness.worktreeId]: unifiedTabs } + }) + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + try { + act(() => root?.unmount()) + } catch { + /* a failed commit leaves no mounted tree */ + } + root = undefined + container.remove() + }) + + it('dispatches no park-state update for tab-model writes that change no park verdict', () => { + root = createRoot(container) + const error = renderTree(root) + + // The park verdict itself never churns: one empty -> parked transition, + // matching the field bundle, which carries no terminal_park_verdict_churn crumb. + expect(harness.renderedParkedSets.at(-1)).toEqual(['tab-b']) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set(['', 'tab-b']) + ) + + // React #185 still fires — the driver is the bug — but it must land on the + // driver's own dispatch, not on the overlay's park state. Asserting the throw + // happened keeps the "not blamed" check below from passing on an empty stack. + expect(error instanceof Error ? error.message : '').toContain('Maximum update depth exceeded') + const stack = error instanceof Error ? (error.stack ?? '') : String(error ?? '') + expect(stack).not.toContain('use-terminal-tab-cold-parking') + expect(stack).toContain('terminal-cold-park-tab-model-identity') + // The effect itself still re-runs — coverage is re-derived from store state + // the park key cannot encode — it just stops dispatching an unchanged verdict. + expect(harness.parkEffectRuns).toBeGreaterThan(REACT_NESTED_UPDATE_LIMIT) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 76434e024c1..0f25b61830e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -138,6 +138,8 @@ export function useTerminalTabColdParking(args: { const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( () => new Set() ) + // Mirrors the committed park set; written only from the post-commit effect below. + const coldParkedTerminalTabIdsRef = useRef(coldParkedTerminalTabIds) useEffect(() => { const timers = terminalTabParkingTimersRef.current @@ -224,9 +226,16 @@ export function useTerminalTabColdParking(args: { parkVerdictRecords: parkVerdictRecordsRef.current, nowMs }) - setColdParkedTerminalTabIds((current) => - haveSameTerminalTabIds(current, parkedTabIds) ? current : parkedTabIds - ) + // Why the ref and not the updater form: returning `current` still dispatches, + // and React only bails eagerly while the fiber has no pending lanes. This + // effect re-runs on every tab-model write (runtime titles, unread bumps), + // so inside any commit cascade the no-op dispatch was what tripped React's + // root-global nested-update counter — naming this hook in a #185 whose real + // driver is elsewhere (see src/shared/react-update-depth-attribution.ts). + if (!haveSameTerminalTabIds(coldParkedTerminalTabIdsRef.current, parkedTabIds)) { + coldParkedTerminalTabIdsRef.current = parkedTabIds + setColdParkedTerminalTabIds(parkedTabIds) + } for (const candidate of candidates) { if ( diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts new file mode 100644 index 00000000000..d81c0c49ffa --- /dev/null +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.clock-skew.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAutoAckViewedAgent } from './useAutoAckViewedAgent' +import { useAppStore } from '../store' +import { makeTab } from '../store/slices/store-test-helpers' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' + +// Why: the hook re-scans on every store write and advances its diff refs to the PRE-write snapshot, +// so its ref guard never suppresses the rescan an ack triggers. It only terminated because +// acknowledgeAgents returned the same object within one millisecond — a scan costing >=1ms with a +// turn stamped ahead of the local clock (SSH/remote host) re-acked forever (React #185). + +const TAB_ID = 'tab-main' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const NOW = new Date('2026-06-02T12:00:00Z').getTime() +const SKEW_MS = 90_000 +const ACK_CALL_CEILING = 20 + +function seedFutureStampedTurn(stateStartedAt: number): void { + const entry: AgentStatusEntry = { + state: 'done', + prompt: 'remote turn', + updatedAt: stateStartedAt, + stateStartedAt, + agentType: 'codex', + paneKey: PANE_KEY, + stateHistory: [] + } + useAppStore.setState({ + activeView: 'terminal', + activeTabId: TAB_ID, + activeWorktreeId: 'wt-1', + activeTabIdByWorktree: {}, + tabsByWorktree: { 'wt-1': [makeTab({ id: TAB_ID, worktreeId: 'wt-1' })] }, + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + }, + agentStatusByPaneKey: { [PANE_KEY]: entry }, + retainedAgentsByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + unreadAgentCompletionPanes: {}, + unreadTerminalTabs: {} + }) +} + +/** Wraps the real action, then circuit-breaks so a regression fails the assertion instead of hanging. */ +function instrumentAcknowledgeAgents(): string[][] { + const real = useAppStore.getState().acknowledgeAgents + const calls: string[][] = [] + useAppStore.setState({ + acknowledgeAgents: (paneKeys: string[]) => { + calls.push(paneKeys) + if (calls.length > ACK_CALL_CEILING) { + return + } + real(paneKeys) + } + }) + return calls +} + +describe('useAutoAckViewedAgent — clock-skewed execution host', () => { + let realAcknowledgeAgents: (paneKeys: string[]) => void + + beforeEach(() => { + realAcknowledgeAgents = useAppStore.getState().acknowledgeAgents + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + // Why a ticking clock: the loop self-terminated only while Date.now() stayed on one millisecond. + let clock = NOW + vi.spyOn(Date, 'now').mockImplementation(() => clock++) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + useAppStore.setState({ acknowledgeAgents: realAcknowledgeAgents }) + }) + + it('acks a turn stamped ahead of the local clock exactly once', () => { + seedFutureStampedTurn(NOW + SKEW_MS) + const calls = instrumentAcknowledgeAgents() + + renderHook(() => useAutoAckViewedAgent(false)) + + expect(calls).toEqual([[PANE_KEY]]) + const ackAt = useAppStore.getState().acknowledgedAgentsByPaneKey[PANE_KEY] ?? 0 + expect(ackAt).toBeGreaterThanOrEqual(NOW + SKEW_MS) + }) + + it('leaves nothing to re-scan for a future-stamped turn on the next store write', () => { + seedFutureStampedTurn(NOW + SKEW_MS) + renderHook(() => useAutoAckViewedAgent(false)) + + const calls = instrumentAcknowledgeAgents() + useAppStore.getState().markTerminalTabUnread('tab-unrelated') + + expect(calls).toEqual([]) + }) + + it('still acks a normally stamped turn once', () => { + seedFutureStampedTurn(NOW - 5_000) + const calls = instrumentAcknowledgeAgents() + + renderHook(() => useAutoAckViewedAgent(false)) + + expect(calls).toEqual([[PANE_KEY]]) + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx b/src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx new file mode 100644 index 00000000000..e7e6ec0012d --- /dev/null +++ b/src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx @@ -0,0 +1,148 @@ +/** @vitest-environment happy-dom */ +/** + * Crash cluster "React #185 whose component_stack names SortableTab" + * (boundary terminal.workbench, app 1.4.190). + * + * useTabAgent's signal effect re-runs on every tab-model write that touches the + * title — a working agent republishes an OSC spinner frame many times a second, + * re-minting tabsByWorktree — and it used to dispatch setHasObservedAgentSignal(true) + * on every run, including the overwhelming majority where the flag was already true. + * That dispatch costs a second render+commit of SortableTab per title frame, and it + * puts a dispatchSetState on SortableTab's fiber inside commitHookEffectListMount — + * the exact frame the #185 reports blame (see shared/react-update-depth-attribution.ts: + * the throw lands on whoever dispatches next after a root-global counter trips). + * + * Same defect and same remedy as the cold-park fix in + * terminal-cold-park-tab-model-identity.react185.test.tsx. + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import { useTabAgent } from './use-tab-agent' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const initialAppState = useAppStore.getInitialState() +const WORKTREE_ID = 'repo::/tab-agent-observed-signal' +const TAB_ID = 'terminal-tab-observed-signal' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const TITLE_PUBLICATIONS = 10 + +function paneLayout(ptyId: string): TerminalLayoutSnapshot { + return { + root: null, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + } +} + +function workingAgentStatus(): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'codex', + paneKey: PANE_KEY, + stateHistory: [] + } as AgentStatusEntry +} + +function terminalTab(title: string): TerminalTab { + return { + id: TAB_ID, + worktreeId: WORKTREE_ID, + ptyId: 'pty-a', + title, + generation: 0 + } as TerminalTab +} + +/** An ordinary agent spinner frame: re-mints tabsByWorktree, changes no agent signal. */ +function publishRuntimeTitle(revision: number): void { + useAppStore.setState( + (state) => + ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: [terminalTab(`⠋ codex ${revision}`)] + } + }) as never + ) +} + +let probeRenders = 0 +let latestAgent: TuiAgent | null = null + +/** Stands in for SortableTab, useTabAgent's only production caller. */ +function TabAgentProbe(): null { + probeRenders += 1 + const tab = useAppStore((state) => state.tabsByWorktree[WORKTREE_ID]?.[0]) as TerminalTab + latestAgent = useTabAgent(tab) + return null +} + +describe('useTabAgent observed-signal dispatch', () => { + let container: HTMLDivElement + let root: Root | undefined + + beforeEach(() => { + useAppStore.setState({ + agentStatusByPaneKey: { [PANE_KEY]: workingAgentStatus() }, + terminalLayoutsByTabId: { [TAB_ID]: paneLayout('pty-a') }, + ptyIdsByTabId: { [TAB_ID]: ['pty-a'] }, + tabsByWorktree: { [WORKTREE_ID]: [terminalTab('⠋ codex 0')] } + } as never) + container = document.createElement('div') + document.body.appendChild(container) + probeRenders = 0 + latestAgent = null + root = createRoot(container) + act(() => root!.render()) + }) + + afterEach(() => { + act(() => root?.unmount()) + root = undefined + container.remove() + useAppStore.setState(initialAppState, true) + }) + + it('schedules no extra commit for a title publication that changes no agent signal', () => { + probeRenders = 0 + for (let revision = 1; revision <= TITLE_PUBLICATIONS; revision += 1) { + act(() => publishRuntimeTitle(revision)) + } + // One commit per publication. Re-dispatching the unchanged observed-signal + // flag doubled this, and put SortableTab's fiber in every #185 stack. + expect(probeRenders).toBe(TITLE_PUBLICATIONS) + expect(latestAgent).toBe('codex') + }) + + it('still re-arms the observed signal after a pty respawn', () => { + act(() => { + useAppStore.setState({ + agentStatusByPaneKey: {}, + terminalLayoutsByTabId: { [TAB_ID]: paneLayout('pty-b') }, + ptyIdsByTabId: { [TAB_ID]: ['pty-b'] }, + tabsByWorktree: { [WORKTREE_ID]: [terminalTab('zsh')] } + } as never) + }) + expect(latestAgent).toBeNull() + + act(() => { + useAppStore.setState({ + agentStatusByPaneKey: { [PANE_KEY]: workingAgentStatus() }, + tabsByWorktree: { [WORKTREE_ID]: [terminalTab('⠋ codex 1')] } + } as never) + }) + expect(latestAgent).toBe('codex') + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index b6c88400632..fef059ad616 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -284,7 +284,13 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { ? explicitTitleAgent === tab.launchAgent : Boolean(explicitTitleAgent || siblingHookAgent) // Why: a recognized foreground process arms exit clearing even for agents with no hook or title integration. - if (focusedHookAgent || completedHookEvidence || processAgent || fallbackAgentSignal) { + // Why the ref gate: this effect re-runs on every title frame, and re-dispatching an + // already-true flag costs SortableTab a second commit each time — and names its fiber + // in #185 stacks driven elsewhere (see shared/react-update-depth-attribution.ts). + if ( + !hasObservedAgentSignalRef.current && + (focusedHookAgent || completedHookEvidence || processAgent || fallbackAgentSignal) + ) { hasObservedAgentSignalRef.current = true setHasObservedAgentSignal(true) } diff --git a/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts new file mode 100644 index 00000000000..669ebceb53e --- /dev/null +++ b/src/renderer/src/store/slices/ui-acknowledge-agents-clock-skew.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createUIStore } from './ui-slice-test-harness' +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +// Why: an SSH/remote execution host stamps turns with ITS clock. When that clock runs ahead, every +// unread rule (`ackAt < turnTimestamp`) stayed true after an ack, so the row could never be marked +// read and its auto-ack effect re-fired on each new millisecond — the React #185 update loop. + +const NOW = new Date('2026-06-02T12:00:00Z').getTime() +const SKEW_MS = 90_000 +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) + +function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'done', + prompt: 'Review complete', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'codex', + paneKey: PANE_KEY, + stateHistory: [], + ...overrides + } +} + +function makeTerminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + worktreeId, + ptyId: null, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } +} + +describe('acknowledgeAgents with a clock-skewed execution host', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('clears unread for a live turn stamped ahead of the local clock in one acknowledge', () => { + const store = createUIStore() + const futureStartedAt = NOW + SKEW_MS + store.setState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + agentStatusByPaneKey: { + [PANE_KEY]: makeAgentEntry({ stateStartedAt: futureStartedAt, updatedAt: futureStartedAt }) + } + } as Partial) + + store.getState().acknowledgeAgents([PANE_KEY]) + + const ackAt = store.getState().acknowledgedAgentsByPaneKey[PANE_KEY] + expect(ackAt).toBe(futureStartedAt) + expect(ackAt < futureStartedAt).toBe(false) + }) + + it('stops rewriting the ack map once a future-stamped turn is acknowledged', () => { + const store = createUIStore() + const futureStartedAt = NOW + SKEW_MS + store.setState({ + agentStatusByPaneKey: { + [PANE_KEY]: makeAgentEntry({ stateStartedAt: futureStartedAt, updatedAt: futureStartedAt }) + } + } as Partial) + + store.getState().acknowledgeAgents([PANE_KEY]) + const firstMap = store.getState().acknowledgedAgentsByPaneKey + + // Later millisecond, same turn: the retry that used to allocate a new map (and re-render forever). + vi.setSystemTime(NOW + 1_000) + store.getState().acknowledgeAgents([PANE_KEY]) + + expect(store.getState().acknowledgedAgentsByPaneKey).toBe(firstMap) + }) + + it('covers a retained row and a future history event on the same pane', () => { + const store = createUIStore() + const futureHistoryAt = NOW + SKEW_MS + store.setState({ + retainedAgentsByPaneKey: { + [PANE_KEY]: { + entry: makeAgentEntry({ + stateStartedAt: NOW + 1_000, + stateHistory: [{ state: 'blocked', prompt: 'p', startedAt: futureHistoryAt }] + }), + worktreeId: 'wt-1', + tab: makeTerminalTab('tab-1', 'wt-1'), + agentType: 'codex', + startedAt: NOW + 1_000 + } + } + } as Partial) + + store.getState().acknowledgeAgents([PANE_KEY]) + + expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(futureHistoryAt) + }) + + it('covers a future-stamped migration-unsupported row, which Activity renders as a blocked event', () => { + const store = createUIStore() + const futureUpdatedAt = NOW + SKEW_MS + store.setState({ + migrationUnsupportedByPtyId: { + 'pty-1': { + ptyId: 'pty-1', + paneKey: PANE_KEY, + reason: 'legacy-numeric-pane-key', + source: 'ssh', + updatedAt: futureUpdatedAt + } + } + } as Partial) + + store.getState().acknowledgeAgents([PANE_KEY]) + + expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(futureUpdatedAt) + }) + + it('still stamps the local clock when the turn is not ahead of it', () => { + const store = createUIStore() + store.setState({ + agentStatusByPaneKey: { + [PANE_KEY]: makeAgentEntry({ stateStartedAt: NOW - 5_000, updatedAt: NOW - 5_000 }) + } + } as Partial) + + store.getState().acknowledgeAgents([PANE_KEY]) + + expect(store.getState().acknowledgedAgentsByPaneKey[PANE_KEY]).toBe(NOW) + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 466e3db9724..9c4c550194f 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -372,6 +372,23 @@ function collectAcknowledgedAgentNotificationId({ } } +function usableTimestamp(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +/** Newest turn timestamp an unread check can compare against for one agent row. */ +function latestAgentTurnTimestamp(entry: { + stateStartedAt?: number + stateHistory?: { startedAt?: number }[] +}): number { + let latest = usableTimestamp(entry.stateStartedAt) + // Why history too: Activity renders one event per stateHistory entry, each with its own unread check. + for (const history of entry.stateHistory ?? []) { + latest = Math.max(latest, usableTimestamp(history.startedAt)) + } + return latest +} + function isPlainPersistedRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } @@ -1215,10 +1232,15 @@ export const createUISlice: StateCreator = (set, get) return s } const now = Date.now() - // Why: only reallocate if an ack advances; compare prev | null = null for (const key of paneKeys) { const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 + // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock, + // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the + // row, so its auto-ack effect re-fires on each new millisecond forever (React #185). + let stamp = now const liveEntry = s.agentStatusByPaneKey?.[key] if (liveEntry) { collectAcknowledgedAgentNotificationId({ @@ -1228,6 +1250,7 @@ export const createUISlice: StateCreator = (set, get) stateStartedAt: liveEntry.stateStartedAt, previousAckAt: prev }) + stamp = Math.max(stamp, latestAgentTurnTimestamp(liveEntry)) } const retained = s.retainedAgentsByPaneKey?.[key] if (retained) { @@ -1238,12 +1261,19 @@ export const createUISlice: StateCreator = (set, get) stateStartedAt: retained.entry.stateStartedAt, previousAckAt: prev }) + stamp = Math.max(stamp, latestAgentTurnTimestamp(retained.entry)) } - if (prev < now) { + for (const unsupported of migrationUnsupported) { + // Why: Activity synthesizes a blocked row from this entry, stamped by the pane's host like any turn. + if (unsupported.paneKey === key) { + stamp = Math.max(stamp, usableTimestamp(unsupported.updatedAt)) + } + } + if (prev < stamp) { if (next === null) { next = { ...s.acknowledgedAgentsByPaneKey } } - next[key] = now + next[key] = stamp } } return next ? { acknowledgedAgentsByPaneKey: next } : s From b19a397d3e7fe7a65dc4271379ed8b67fb14a58b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:27:01 -0700 Subject: [PATCH 25/37] feat(browser-preview): reland remote HTML document previews (STA-5758) (#16920) Reapply the reverted remote HTML document preview implementation so remote workspace files render locally over the orca-preview scheme. --- config/reliability-gates.jsonc | 615 ++++++++++++--- electron.vite.config.ts | 1 + .../browser-manager-annotation-bridge.test.ts | 210 +++++ .../browser-manager-guest-lifecycle.test.ts | 98 +++ ...owser-manager-guest-policy-profile.test.ts | 250 ++++++ .../browser-manager-viewport-override.test.ts | 29 - src/main/browser/browser-manager.ts | 106 ++- ...browser-session-partition-policies.test.ts | 185 +++++ .../browser-session-partition-policies.ts | 33 +- .../doc-preview-download-block-notice.test.ts | 107 +++ .../doc-preview-download-block-notice.ts | 34 + .../doc-preview-failure-notice.test.ts | 70 ++ .../browser/doc-preview-failure-notice.ts | 34 + .../browser/doc-preview-file-reader.test.ts | 333 ++++++++ src/main/browser/doc-preview-file-reader.ts | 198 +++++ .../doc-preview-grant-registry.test.ts | 164 ++++ .../browser/doc-preview-grant-registry.ts | 208 +++++ .../browser/doc-preview-guest-policy.test.ts | 550 +++++++++++++ src/main/browser/doc-preview-guest-policy.ts | 247 ++++++ src/main/browser/doc-preview-protocol.test.ts | 336 ++++++++ src/main/browser/doc-preview-protocol.ts | 144 ++++ ...ffscreen-browser-backend-lifecycle.test.ts | 85 +- src/main/browser/offscreen-browser-backend.ts | 44 +- src/main/index.ts | 11 + src/main/ipc/browser-grab-ipc.ts | 19 + src/main/ipc/browser-guest-view-ipc.ts | 26 +- ...browser-preview-tool-authorization.test.ts | 510 ++++++++++++ .../ipc/browser-tab-registration-wait.test.ts | 108 +++ src/main/ipc/browser-tab-registration-wait.ts | 13 + src/main/ipc/browser.test.ts | 54 +- src/main/ipc/browser.ts | 8 + src/main/ipc/doc-preview-grant-ipc.test.ts | 159 ++++ src/main/ipc/doc-preview-grant-ipc.ts | 95 +++ .../main-window-webview-security.test.ts | 261 ++++++- .../window/main-window-webview-security.ts | 61 +- src/preload/api-types.ts | 2 + src/preload/api/doc-preview-api.ts | 29 + .../doc-preview-link-interception.test.ts | 269 +++++++ src/preload/doc-preview-link-interception.ts | 152 ++++ src/preload/doc-preview-link.ts | 15 + src/preload/index.ts | 27 + .../ClientHostedBrowserPagePane.tsx | 28 +- .../browser-guest-annotate-overlays.tsx | 189 +++++ .../annotate/browser-page-grab-action.ts | 8 +- .../guest-annotation-viewport-bridge.ts | 43 ++ .../use-browser-page-grab-annotations.ts | 16 +- .../assemble-chrome/BrowserAddressBar.tsx | 4 +- .../browser-chrome-address-slot.ts | 13 + .../browser-chrome-toolbar.tsx | 206 +++++ .../browser-navigation-control-row.test.tsx | 66 +- .../browser-navigation-control-row.tsx | 61 +- .../assemble-chrome/browser-page-toolbar.tsx | 282 ++----- .../browser-page-viewport-overlays.tsx | 163 +--- .../browser-reload-control.tsx | 95 +++ .../browser-workspace-pane.tsx | 49 +- .../use-browser-page-webview-lifecycle.ts | 29 +- .../host-guest/use-guest-drag-passthrough.ts | 32 + .../browser-address-bar-navigation.test.ts | 27 + .../browser-address-bar-navigation.ts | 24 +- .../remote-browser-page-toolbar.tsx | 24 +- .../use-remote-browser-page-navigation.ts | 2 +- .../HtmlDocPreview.failure-message.test.tsx | 342 ++++++++ .../HtmlDocPreview.toolbar.test.tsx | 582 ++++++++++++++ .../workspace-doc/HtmlDocPreview.tsx | 413 ++++++++++ .../doc-preview-document-actions.test.ts | 206 +++++ .../doc-preview-document-actions.ts | 97 +++ .../doc-preview-document-chip.tsx | 68 ++ .../doc-preview-document-identity.test.ts | 67 ++ .../doc-preview-document-identity.ts | 38 + .../doc-preview-failure-messages.ts | 70 ++ .../doc-preview-overflow-menu.tsx | 76 ++ .../workspace-doc/doc-preview-toolbar.tsx | 118 +++ .../doc-preview-webview-history.ts | 80 ++ .../use-doc-preview-guest-tools.test.ts | 134 ++++ .../use-doc-preview-guest-tools.ts | 103 +++ .../workspace-doc-page-pane.test.tsx | 109 +++ .../workspace-doc/workspace-doc-page-pane.tsx | 60 ++ .../editor/EditorPanelShell.header.test.tsx | 124 +++ .../components/editor/EditorPanelShell.tsx | 3 +- .../src/components/editor/editor-header.ts | 5 + .../tab-strip-pointer-activation.test.tsx | 38 + .../tab-bar/tab-strip-pointer-activation.ts | 20 +- .../TerminalLinkActionPopover.tsx | 6 +- .../terminal-file-link-actions.test.ts | 62 ++ .../terminal-file-link-actions.ts | 66 +- .../terminal-file-open-routing.ts | 48 +- ...minal-link-remote-runtime-ssh-open.test.ts | 64 +- .../terminal-remote-file-download-open.ts | 37 + .../ipc-events/browser-state-ipc-bridge.ts | 29 + .../doc-preview-external-link-bridge.test.ts | 92 +++ .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 39 +- .../lib/browser-page-palette-activation.ts | 21 +- .../lib/browser-workspace-tab-activation.ts | 30 + .../src/lib/doc-preview-grants.test.ts | 169 ++++ src/renderer/src/lib/doc-preview-grants.ts | 86 +++ .../src/lib/execution-host-display-label.ts | 61 ++ .../file-preview-action-visibility.test.tsx | 85 ++ .../file-preview-capability-cleanup.test.ts | 91 --- src/renderer/src/lib/file-preview.test.ts | 409 ++++++---- src/renderer/src/lib/file-preview.ts | 255 +++--- .../src/lib/workspace-file-host-routing.ts | 50 ++ ...ync-runtime-graph-editor-diff-tabs.test.ts | 180 +++++ .../src/runtime/sync-runtime-graph.ts | 18 +- .../browser-workspace-doc-location.test.ts | 412 ++++++++++ src/renderer/src/store/slices/browser.ts | 125 ++- .../src/store/slices/tabs-hydration.test.ts | 63 ++ .../src/store/slices/tabs-hydration.ts | 9 + src/shared/browser-page-doc-location.ts | 27 + src/shared/browser-workspace-types.ts | 17 + src/shared/doc-preview-scheme.test.ts | 45 ++ src/shared/doc-preview-scheme.ts | 105 +++ .../workspace-session-browser-schema.ts | 20 +- .../helpers/paired-client-window-reveal.ts | 31 + .../helpers/paired-html-preview-inventory.ts | 341 +++++--- .../paired-remote-html-browser-focus.spec.ts | 459 ----------- ...d-remote-html-preview-local-render.spec.ts | 731 ++++++++++++++++++ 117 files changed, 12585 insertions(+), 1714 deletions(-) create mode 100644 src/main/browser/browser-manager-annotation-bridge.test.ts create mode 100644 src/main/browser/browser-manager-guest-policy-profile.test.ts create mode 100644 src/main/browser/browser-session-partition-policies.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.ts create mode 100644 src/main/browser/doc-preview-failure-notice.test.ts create mode 100644 src/main/browser/doc-preview-failure-notice.ts create mode 100644 src/main/browser/doc-preview-file-reader.test.ts create mode 100644 src/main/browser/doc-preview-file-reader.ts create mode 100644 src/main/browser/doc-preview-grant-registry.test.ts create mode 100644 src/main/browser/doc-preview-grant-registry.ts create mode 100644 src/main/browser/doc-preview-guest-policy.test.ts create mode 100644 src/main/browser/doc-preview-guest-policy.ts create mode 100644 src/main/browser/doc-preview-protocol.test.ts create mode 100644 src/main/browser/doc-preview-protocol.ts create mode 100644 src/main/ipc/browser-preview-tool-authorization.test.ts create mode 100644 src/main/ipc/browser-tab-registration-wait.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.ts create mode 100644 src/preload/api/doc-preview-api.ts create mode 100644 src/preload/doc-preview-link-interception.test.ts create mode 100644 src/preload/doc-preview-link-interception.ts create mode 100644 src/preload/doc-preview-link.ts create mode 100644 src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx create mode 100644 src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-address-slot.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx create mode 100644 src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-failure-messages.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-history.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx create mode 100644 src/renderer/src/components/editor/EditorPanelShell.header.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts create mode 100644 src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts create mode 100644 src/renderer/src/lib/browser-workspace-tab-activation.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.test.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.ts create mode 100644 src/renderer/src/lib/execution-host-display-label.ts create mode 100644 src/renderer/src/lib/file-preview-action-visibility.test.tsx delete mode 100644 src/renderer/src/lib/file-preview-capability-cleanup.test.ts create mode 100644 src/renderer/src/lib/workspace-file-host-routing.ts create mode 100644 src/renderer/src/store/slices/browser-workspace-doc-location.test.ts create mode 100644 src/shared/browser-page-doc-location.ts create mode 100644 src/shared/doc-preview-scheme.test.ts create mode 100644 src/shared/doc-preview-scheme.ts delete mode 100644 tests/e2e/paired-remote-html-browser-focus.spec.ts create mode 100644 tests/e2e/paired-remote-html-preview-local-render.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0fc9a8645ed..4d75e89a918 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2817,16 +2817,16 @@ }, { "id": "browser-session.remote-html-preview-ownership", - "title": "Paired HTML previews keep one host page and one stable client split", + "title": "Paired HTML previews render locally as document browser tabs and create no host browser page", "maturity": "experimental", "protection": "partial", "owner": "browser-runtime", "layer": "renderer-paired-runtime-reconciliation", "surfaces": [ - "paired remote HTML file preview", - "background preview creation and explicit browser activation", - "client-only split placement", - "capability-unknown and mixed-version preview rejection cleanup", + "paired HTML file preview served locally over the orca-preview scheme", + "preview split placement that leaves editor focus alone", + "grant-scoped document reads, including truncation and out-of-worktree refusal", + "capability-unknown and mixed-version browser-creation rejection cleanup", "post-create reconciliation failure and compensating rollback", "remote browser close convergence" ], @@ -2834,8 +2834,9 @@ "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], - "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, a forced post-create reconciliation timeout, and navigation held beyond the client create deadline. The same reconciliation fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, canonical preallocated page identity, navigation-independent acknowledgement, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", + "coverageNotes": "A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it — registered true, grant live, focused false — after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page — the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry — 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural — a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns — capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence — keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists — the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it — dropping the page-activity check, or either half of the reader-surface check — survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one.", "motivatingLinks": [ + "https://linear.app/stably/issue/STA-5557/remote-html-preview-render-workspace-files-locally-over-the-file", "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", @@ -2843,19 +2844,35 @@ "https://github.com/stablyai/orca/pull/14363", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", - "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Hold a real navigation response beyond the 15-second client deadline after host creation; require the first RPC to return the exact host inventory page ID, one host page, and no retry. Repeat reconciliation faults against headless serve. Retain the separate exact-page reconciliation rollback oracle.", + "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest that holds focus and is still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", + "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for an unfocused guest, a sender that is no preview guest, a guest with no bound or a revoked grant, and non-web URLs, and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + // The hardening round: the third url door, the group projection, and both directions of the focus handoff. + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + // The changed-spec e2e job's own command, which is the only Linux run this gate has. + "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], @@ -2863,7 +2880,6 @@ "src/main/runtime/orca-runtime-browser.test.ts", "src/main/runtime/rpc/methods/browser.test.ts", "src/renderer/src/lib/file-preview.test.ts", - "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "src/renderer/src/runtime/web-session-browser-placement.test.ts", "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", @@ -2874,7 +2890,38 @@ "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts", "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts", "src/renderer/src/store/slices/browser.test.ts", - "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "src/main/browser/doc-preview-grant-registry.test.ts", + "src/main/browser/doc-preview-file-reader.test.ts", + "src/main/browser/doc-preview-protocol.test.ts", + "src/main/browser/browser-session-partition-policies.test.ts", + "src/main/browser/doc-preview-download-block-notice.test.ts", + "src/main/browser/browser-manager-guest-policy-profile.test.ts", + "src/main/browser/doc-preview-guest-policy.test.ts", + "src/main/ipc/browser-tab-registration-wait.test.ts", + "src/main/browser/doc-preview-failure-notice.test.ts", + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "src/preload/doc-preview-link-interception.test.ts", + "src/main/window/main-window-webview-security.test.ts", + "src/main/ipc/browser-preview-tool-authorization.test.ts", + "src/main/browser/browser-manager-annotation-bridge.test.ts", + "src/main/browser/browser-manager-guest-lifecycle.test.ts", + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts", + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "src/shared/doc-preview-scheme.test.ts", + "src/renderer/src/lib/doc-preview-grants.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "src/main/ipc/doc-preview-grant-ipc.test.ts", + "src/renderer/src/store/slices/tabs-hydration.test.ts", + "src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx", + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], @@ -2886,18 +2933,13 @@ "direct creation removes the caller-declared new split without issuing an RPC" ] }, - { - "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", - "assertions": [ - "known unavailability reports visibly without creating a split", - "capability loss after caller preflight reports visibly without duplicating transaction cleanup" - ] - }, { "file": "src/renderer/src/lib/file-preview.test.ts", "assertions": [ - "a failed side preview reports visibly without caller-owned split cleanup", - "overlapping side previews reuse the split without the first failure deleting it" + "a paired-runtime HTML preview opens a local doc preview instead of a runtime browser tab", + "a local workspace still opens the embedded browser tab at a file URL", + "a paired document outside the worktree names that boundary and creates no split", + "an SSH document outside the worktree root still previews" ] }, { @@ -2973,11 +3015,152 @@ ] }, { - "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "file": "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "assertions": [ - "one HTML open produces one client workspace, one client unified tab, and one host page", - "the source editor and remote frame remain visible in separate groups", - "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" + "the host's HTML document renders inside the client-local orca-preview guest", + "neither the client browser-workspace count nor the host browser-tab count moves off baseline", + "the preview opens in its own split with mode html-preview and leaves the source editor active", + "an explicit click reactivates the preview, and closing it preserves the source editor, its group, and the terminal", + "the document reports having attempted both its unattended and its post-press egress, so neither absence assertion can pass on a document that never tried", + "a scripted window.open and location.href driven by a real press elsewhere in the document route nothing and move no browser count", + "a real target=_blank press in the rendered document routes out as an Orca browser tab while the preview stays open" + ] + }, + { + "file": "src/main/browser/doc-preview-file-reader.test.ts", + "assertions": [ + "a host-reported truncated read is refused instead of served as partial bytes", + "an over-cap binary and an empty binary body are reported as too-large and unsupported rather than a bare read failure", + "a document outside the paired worktree is refused without issuing an RPC", + "an SSH path that canonicalizes outside the grant root is refused before any read", + "a 0-byte file the host still typed is served as 0 bytes rather than reported as a refused format" + ] + }, + { + "file": "src/main/browser/doc-preview-grant-registry.test.ts", + "assertions": [ + "a path escaping the grant root resolves to nothing, including sibling-prefix roots", + "a revoked grant stops resolving" + ] + }, + { + "file": "src/main/browser/doc-preview-protocol.test.ts", + "assertions": [ + "an unknown or revoked grant is answered exactly like a missing file, with no read attempted", + "a failed read pushes its reason for the requested path while a served document pushes nothing", + "every served document carries the self-only content security policy", + "the preview session cancels any request that is not in-document", + "the scheme is handled on the preview session and never on the default one", + "the scheme claims exactly its five privileges, so a widened one fails" + ] + }, + { + "file": "src/main/browser/doc-preview-failure-notice.test.ts", + "assertions": ["413 and 415 map to distinct reasons and everything else stays generic"] + }, + { + "file": "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "assertions": [ + "the Explorer row predicate and the preview plan return one answer for the same file" + ] + }, + { + "file": "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "assertions": [ + "an external preview link becomes a browser tab, and a tab that is refused or that fails outright surfaces instead of being dropped" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "assertions": [ + "each entry-document failure reason surfaces its own sentence instead of one generic unavailable panel", + "a refused subresource names itself in a notice, deduplicated per path, while the document stays rendered", + "an entry-document failure replaces the asset notice with the panel", + "a failure for another preview's grant leaves the rendered document alone" + ] + }, + { + "file": "src/main/browser/doc-preview-guest-policy.test.ts", + "assertions": [ + "the guest may only navigate within its own live grant, and not at all until a document has bound it to one", + "every navigation and popup sink refuses without routing, whenever genuine input happened", + "a subframe cannot navigate outside the grant, and only the main frame binds the guest to one", + "a reported click routes exactly once, and only from a focused preview guest with a live bound grant", + "a reported click on a non-web URL, or from a destroyed guest, routes nothing", + "the guest is denied non-proxied WebRTC UDP when its policy is installed" + ] + }, + { + "file": "src/preload/doc-preview-link-interception.test.ts", + "assertions": [ + "a click the document dispatched itself is not trusted and routes nothing", + "a trusted press on an external anchor reports it once and stops the navigation, including through what the anchor wraps and an SVG animated href", + "a fragment link scrolls in the document, raw or percent-encoded, and a bare hash goes to the top", + "a sibling preview document is left to the guest's own navigation, and a middle click opens nothing whether or not it is trusted", + "an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor", + "the preload reports on exactly the channel main listens on" + ] + }, + { + "file": "src/main/window/main-window-webview-security.test.ts", + "assertions": [ + "an orca-preview src is admitted only on the preview partition with a live grant", + "preview guests receive their own policy instead of the browser guest policies", + "a preview attach is pinned to the preview preload over any renderer-supplied value, and a browsing attach never receives it", + "grants a renderer can no longer release are cleared at window creation and on reload, and survive an in-document navigation" + ] + }, + { + "file": "src/shared/doc-preview-scheme.test.ts", + "assertions": ["preview URLs round-trip per segment and reject a malformed grant id"] + }, + { + "file": "src/renderer/src/lib/doc-preview-grants.test.ts", + "assertions": [ + "grants are deduped per preview tab so a StrictMode double-mount cannot revoke the surviving webview", + "a mint that rejects after its tab was released and reopened leaves the reopened tab's grant revocable" + ] + }, + { + "file": "src/main/ipc/doc-preview-grant-ipc.test.ts", + "assertions": [ + "a sender that is not the trusted renderer mints no grant and revokes none", + "a reported link click reaches the guest policy with its own sender, and a non-string report reaches nothing" + ] + }, + { + "file": "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "assertions": [ + "closing a document browser tab revokes its grant, and closing a URL tab beside it revokes nothing", + "a document page's url is blank in the store, in the written session and after restore", + "a navigation committing a url onto a document page leaves the url blank, the document title standing and nothing loading, while an ordinary page beside it takes the url it is given" + ] + }, + { + "file": "src/renderer/src/store/slices/tabs-hydration.test.ts", + "assertions": [ + "chrome for a preview tab written before previews were browser tabs is dropped, and the ordinary editor tab for the same document is kept" + ] + }, + { + "file": "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "assertions": [ + "a browser tab located by a workspace document is not published to mobile clients, while an ordinary browser tab beside it is", + "with tab groups in play the document is kept out of the published groups too: no group's order, recency or active tab names a tab the snapshot does not carry, the held-back group leaves the published layout tree with it, and the grouped URL tab beside it still publishes" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "assertions": [ + "the pane offers its guest focus when the reader is in this very preview", + "it refuses while the reader is in a terminal in front of it, in a different browser tab, or while its own page is not the active one" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "assertions": [ + "a preview that is the reader's surface hands its guest focus, and one that is not leaves focus alone", + "the window taking focus back re-offers it to the guest, and re-offers nothing for a preview that was refusing" ] }, { @@ -2999,22 +3182,301 @@ ], "evidenceRuns": [ { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", "result": "failed", - "durationSeconds": 0.72, - "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases." + "durationSeconds": 7, + "summary": "The adversarial pass answered the request for its own group-projection probe after this round landed, and it had measured a third observable this test was missing: the held-back group also has to leave the published layout tree, or the phone renders a split whose second pane can never hold anything. Adopted as a fourth assertion and re-run against the same sole delta, which now fails on the dangling-reference check and, with that check blinded, on the layout alone — group-left and group-right against the group-right the clean tree publishes. The two probes agreed on everything else, including that the tab list is unchanged under this mutant, which is why an assertion reading only the tab list cannot reach the site at all." }, { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 3, + "summary": "The adversarial pass's four coverage findings, landed and pinned: 58/58. The third url door now keeps a document page blank and names it by its document, the group projection holds a document back from published groups as well as from the tab list, the pane's focus handoff is pinned in the widening direction as well as the narrowing one, and the handoff is re-offered when the window takes focus back." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants on the third url door, each failing only the new store test: committing the reported url onto a document page, dropping the document from the title normalizer there (which renames the tab New Tab, since a blank url is what that fallback reads), and marking a blank document page loading. The ordinary-page half of the same test passes under all three, so the door is pinned as a document-only refusal rather than as a door that writes nothing." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Two sole-delta mutants on the mobile publish boundary. Dropping the document from the group projection's browser ids fails only the new grouped test — the tab list stays correct because the per-tab guard still refuses the tab, and what breaks is a published group whose order and active tab name a tab the phone is never sent. This is the survivor the adversarial pass reported: it survives the whole suite without this test, because no other test in it has tab groups. Dropping the per-tab guard instead fails only the ungrouped test, so the two halves of the boundary are pinned separately." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants widening the focus handoff, the direction the adversarial pass found uncovered: dropping the page-activity half, dropping the active-tab-type half, and dropping the active-browser-tab half each fail exactly one of the pane's refusals — the inactive page, the terminal in front, and the other browser tab respectively. The presence half of that test passes under all three, so a pane that had stopped offering focus at all would still fail rather than pass the refusals vacuously." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "One sole-delta mutant dropping the window-focus re-offer: the guest is focused once at mount and never again after the window comes back, which is the desktop gap behind it — another app taking the front leaves the preview's only route out shut until the pane remounts. It fails only the new re-offer test, and the refusing-preview case beside it stays green, so the re-offer is pinned as gated rather than unconditional." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 186, + "summary": "The rewritten journey passed 2/2. The first test proves the inverted claim: the client gains exactly one browser workspace and it is the document one — page and tab url blank, the document path mirrored onto both, the tab named by the document's own title, the chip still naming the file, no editor row of the retired preview species — while the host's page registry and tab snapshot both stay at their pre-action content. Both host oracles carry a presence precondition in the same run: an ordinary URL browser tab this client opens does arrive in browser.tabList and in session.tabs.list, and leaves both again when it is closed, so the preview's absence is a decision rather than an oracle that cannot see browser pages. The document still reaches nothing on its own (zero ICE candidates, unattended and post-press egress both swallowed), and the one route out still works: a real press on a target=_blank anchor routes exactly one call and opens a tab while the preview stays open. The second test quits the client with a document tab open and relaunches it on the same profile: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 108, + "summary": "Candidate with the sole delta that the preview action also opens an ordinary browser tab through the runtime — what it did before STA-5557 — failed at the client-workspace count, 2 against the 1 the claim allows. It dies there rather than at the host oracle, so it proves the exactly-one-workspace half; the host half has no single line to mutate, since a document page never calls the runtime at all, and is protected instead by the presence precondition recorded above." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 190, + "summary": "Candidate with the sole delta that the workspace row stops mirroring the document its active page shows failed in both tests on workspaceDocFilePath, null against the document path — the tab entry would go on naming nothing while the page it renders is a document. Two earlier mutants survived and are reported as ill-posed rather than as blind spots: flipping the mobile publish filter changes what this desktop publishes to its own phone clients, not what a paired client puts on its host, and flipping the document mirror in the workspace constructor is repaired on the next state write by the active-page mirror, which is the line the mutant above hits." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 195, + "summary": "Candidate with the sole delta that the preview pane does not hand focus to its guest — the tree as it stood before this change — failed at the target=_blank press: six real presses reached the anchor, the embedder had the webview as its active element, and main's own log answered each report with registered true, grant live, focused false. This is the gap the rewrite found: since previews became browser pages they had no chrome to pass focus on, so the one route out of a preview was shut." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "result": "passed", + "durationSeconds": 1.37, + "summary": "174/174 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts, re-issued at the relocated preview paths. This replaces the 2026-08-26 record of the same thirteen files at 152/152, whose command named the pre-relocation paths: it established the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal with SSH left unrestricted, reload re-minting a stale grant, and the guest deny-only navigation sinks. The count moved with the cases added since; nothing in those files changed but their directory." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.78, + "summary": "64/64 on the visible download refusal, re-issued at the relocated path. This replaces the 2026-08-27 record of the same four files at 62/62, whose ten mutants — removing the notice from the deny listener, adding it to the routing listener, dropping the guest-registry lookup that names which preview to tell, dropping the shell branch and the row, letting the row survive the failure panel, removing the per-preview throttle, sharing one throttle across previews, never expiring the window, and publishing for a contents no preview is bound to — all failed as sole deltas. The relocation moved no line of any of them." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "result": "passed", + "durationSeconds": 2.05, + "summary": "39/39 on the title-parity trio at the relocated path, replacing the 2026-08-27 record of the same three files, which killed five mutants as sole deltas: disabling the document branch restores New Tab for a named document, dropping the grant-URL refusal puts the scheme into the title, the tab mirror and the written session, returning New Tab instead of the file name breaks the fallback, removing the guest listener leaves the document unable to name its tab, and making the scheme test parse rather than prefix-match lets a title that merely looks like a preview URL through." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.54, + "summary": "14/14 on the reader-facing failure suite at its relocated path. This and the red run below replace the 2026-08-26 pair recorded against the pre-relocation path (10/10 green, 3/10 red), re-measured today so the entry/subresource split keeps a red half rather than only a green one." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "failed", + "durationSeconds": 1.27, + "summary": "Candidate with the sole delta that every failure sets the blocking reason, asset or not, failed 4/14 on the asset-notice assertions — the same mutant the retired 2026-08-26 record measured at 3/10, re-run at the relocated path. A refused subresource must name itself over a document that stays mounted; only the entry document may take the page away." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.52, + "summary": "174/174 at the relocated paths, replacing the 2026-08-27 records of the same command at 162/162 and 158/158. Those established, as sole deltas: removing the unregister-channel refusal goes red because the grab disposal beside it drops the intent an in-flight preview grab compares by identity, while an ordinary browser page driven through the identical sequence still has its grab state disposed; removing registration’s already-gone-guest refusal goes red for both a missing and a destroyed guest; removing the map deletion on the refusal path goes red at shutdown; the bridge’s own unregisterGuest branch, restored, tears down a live page over a misaddressed request; and the download fall-through for an unresolved owner hands back a same-named local file under a remote document’s name." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.5, + "summary": "175/175 at the relocated paths on the unified tool authority, replacing the 2026-08-27 record of the same command at 174/174. That run killed twelve of fourteen mutants as sole deltas — dropping the document branch from the one authority door, removing either browsing-registration refusal or the mint-side refusal that is its converse, deleting the registration notification or widening it to the worktree-wide and any-tab waits, disposing revoked state by grant instead of by page, dropping the hosting-renderer or destroyed-contents check, rebuilding the renderer tool target from the grant, and deleting the teardown identity check under a re-mint — and reported two as ill-posed rather than as kills. The relocation changed no file it names except their directory." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "result": "passed", + "durationSeconds": 22.09, + "summary": "29/29 on the candidate that makes the preview a browser tab. The preview action now creates a page located by the document; reopening the same document activates the tab it is already in rather than minting a second grant on one file; and closing that tab revokes its grant, which nothing else does now that the editor tab's close hook is gone. Red-green with each mutant as the sole delta: dropping the reuse lookup opens a second tab for a document already on screen, and dropping the release on close leaves the document readable through a grant nothing revokes until the process ends. Both are paired with presence preconditions in the same runs — a second, different document still gets its own tab, and a URL tab closed beside the document tab revokes nothing, so a release fired for every close would fail rather than pass." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "result": "passed", + "durationSeconds": 4.77, + "summary": "40/40 on the candidate that removes the editor preview species and moves its two boundary duties onto the browser tab. The publish filter moved with the tab: it used to exclude an editor file by mode, and now excludes a browser workspace by whether it is located by a document — asserted at both the group projection and the tab loop, because a mutant that filters only one of them still publishes. Migration is the other half: sessions written by builds that made previews editor tabs carry chrome whose entity id encodes the document, and whose document was never persisted, so it has always come back naming a surface no restore produces; that chrome is now dropped. Each mutant as the sole delta: filtering neither place publishes the document tab to mobile, filtering only the projection still publishes it, and removing the migration leaves the stale strip entry. Presence preconditions in the same runs: an ordinary browser tab beside the document tab does publish, and the ordinary editor tab for the very same document survives hydration." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "result": "passed", + "durationSeconds": 1.54, + "summary": "10/10 on the candidate that gives a browser page a workspace-document location. The trap this is built around: page.url is read by persistence, the mobile publisher, history and the address bar, so a grant URL parked there would outlive the grant that named it and travel to readers that cannot resolve it. Creation overrides the url rather than trusting its caller, and restore re-asserts the same thing, so a session written by another build cannot carry one in. Red-green with each mutant as the sole delta: dropping the creation override writes the grant URL into the store and into the session payload; dropping the restore override brings a foreign session's grant URL back; leaving docLocation out of either browser schema strips it on load and restores the document as a blank New Tab under a strip entry that still names it (both schemas are plain z.object, and this is the trap that already cost one round on the editor schema); dropping it from the workspace build or the mirror leaves the tab entry naming a document its page has left, which the publish boundary reads; dropping the salvage-path copy loses the document when only the tab row survived; and dropping the address-bar exclusion focuses an address bar the surface does not have. The blank-url assertions are paired with a presence precondition in the same run: an ordinary tab created through the same call keeps its URL and does ask for the address bar." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "result": "passed", + "durationSeconds": 0.32, + "summary": "55/55 on the candidate that gives every guest one attach door. A preview used to reach its policies through an installer of its own, which meant the manager's attachment bookkeeping — the set registration consults and the cleanup teardown runs — never knew a preview existed. It is now a profile on `attachGuestPolicies`, and the doc policy is what that profile installs. Red-green with each mutant as the sole delta: deleting the profile branch hands a previewed document the browsing machinery (clicked-link routing, frame routing, popup child windows, anti-detection over the debugger) and lets it navigate off-grant; dropping the manager's destroyed wiring leaves the guest answerable through the grant-keyed authority after it dies and its id marked attached forever; and making the window fork ask for the default profile is caught at the fork itself. The absence assertions are paired with a presence precondition in the same run: a browsing guest attached through the same method observably takes every one of those things, so a profile that fenced nothing, or an attach path that had stopped installing anything at all, fails rather than passing empty." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "result": "passed", + "durationSeconds": 0.17, + "summary": "22/22 on the candidate that stops a previewed document writing into this desktop's Downloads folder. The hole was read out of the shipping path, not guessed: the preview partition took the shared browser partition policies whole, including will-download, and a preview guest joins no browser-page registry, so handleGuestWillDownload resolved no owner context, routeBrowserClientDownload answered local for a non-client-hosted contents, and the item got a reserved Downloads name with no prompt and no tab to attribute it to. Downloads are now the one policy the preview partition does not inherit. Red-green with each mutant as the sole delta: making the installer ignore the deny option routes the preview partition's download to the router again, and making the preview protocol installer stop asking for the deny leaves the option unset. The absence half is paired with a presence precondition in the same run — an ordinary browsing partition installed beside the preview one in one test still reaches the download router, so a deny that fenced everything, or a download path that had been removed entirely, would fail rather than pass quietly." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", "result": "passed", "durationSeconds": 0.7, - "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split." + "summary": "99/99 on the candidate that binds a preview guest to its grant when the policy installs, not only on a later navigation. Live on a dev instance against a real SSH workspace, the previous shape refused every tool with not-ready for as long as the reader stayed on the page they opened: the embedder hands the guest over after it has already started loading its src, so the did-start-navigation latch never saw the only navigation most previews make. Following an in-document link bound it and the tools began answering, which is what identified the miss. Two cases pin it: a guest already reporting its URL at install, and one whose first navigation only reaches the commit." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 23.1, + "summary": "The journey passed 1/1 on the candidate that requires the guest to be bound to a grant before any navigation is allowed, rather than admitting an unbound one. The document still rendered and the target=_blank press still routed out, which is the half that would break if the renderer-set src reached will-navigate: it does not, because that load is browser-initiated, so the permissive branch it was written for is unreachable." + }, + { + "date": "2026-08-26", + "runner": "ci", + "platform": "linux", + "command": "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", + "result": "failed", + "durationSeconds": 72, + "summary": "The journey failed on Ubuntu at the post-press presence precondition, with #post-input-egress still reading 'idle' after 30 seconds. The trace shows the press was dispatched at 769.5,143.9, a point the guest's own hit test had just confirmed was on its heading, and the focus report before it was already isFocused true, so neither focus nor coordinates were the break: the press never entered the guest at all. A standalone Electron 43 probe under Xvfb with this suite's Linux flags reproduced it in roughly two runs in five when pressing immediately after guest attach — the embedder received the pointerdown with the webview element as its target, meaning the browser resolved the press against the embedder because the freshly attached guest had not yet registered its hit-test region. The same probe never dropped a press on macOS, which is why local runs of either project could not see this. The spec now presses until the document answers, mirroring the target=_blank phase." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 72, + "summary": "The journey passed 3/3 on the trusted-click candidate, which removed the recent-input gate, made both navigation sinks deny-only, dropped the webview's allowpopups attribute and gave the preview guest its own pinned preload. The fixture now also attempts a window.open and location.href from inside a pointerdown handler, so the egress rides a press the reader really made: it reported 'attempted' every run while both browser counts stayed at that phase's baseline and nothing routed, and the later real press on the target=_blank link still opened exactly one Orca browser tab with the preview still open." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 26.9, + "summary": "Candidate with the sole delta that the navigation sinks route again behind a two-second recent-input gate failed on clientBrowserWorkspaceCountAllWorktrees 1 versus baseline 0: the document's location.href, run from inside the handler for the reader's press on the heading, opened a real browser tab. That is the case the earlier design could not distinguish from the press's own effect, and it is why the sinks route nothing at all now. The same run still refused the unattended on-load egress, so this measures the new half specifically." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "result": "passed", + "durationSeconds": 0.97, + "summary": "97/97 passed over the preview tool-authorization seam. The channel census asserts the split is total, so a new browser channel cannot be added without classifying it: all six tool channels resolved the preview guest and refused both a renderer that does not host the preview and a grant no preview rendered, while all twenty-three page/session channels never reached the preview authority at all. Five mutations were killed \u2014 dropping the preview branch (12 failures), turning the two authorities into a fallback chain (13), ignoring the hosting renderer id (6), ignoring grant revocation (1), and removing one channel from the census (1)." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "result": "passed", + "durationSeconds": 0.36, + "summary": "The four surfaces the trusted-click route is built from passed 63/63: the preload's click policy over a real document, including an untrusted dispatched click, an untrusted middle click, an SVG animated href resolved against the document so a relative, rooted or fragment SVG link behaves exactly like the identical HTML anchor, fragment and percent-encoded fragment targets and a trusted middle click; the guest policy's deny-only sinks, its refusal of a navigation that arrives before a document has bound the guest to a grant, and its report gate for an unfocused guest, an unregistered sender, a missing or revoked grant and non-web URLs; will-attach-webview pinning the preview preload in both directions; and the click channel handing main its own sender without a trusted-renderer check." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 22.8, + "summary": "The journey passed 1/1 with the fixture document now attempting its own egress on every load: an unattended window.open and location.href to an off-machine URL, and an ICE gathering probe. Both browser counts held at baseline and the probe reported candidates=0, while the document still rendered and the later real target=_blank press still routed out as an Orca browser tab." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 23.6, + "summary": "Candidate with the sole delta that the preview guest no longer receives the disable_non_proxied_udp IP-handling policy failed on candidates=6 versus 0, proving the document can gather ICE — including a STUN reflexive candidate against an off-machine server in the standalone probe — whenever that one main-process call is missing." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 66, + "summary": "The journey passed 3/3 again under --repeat-each=3 on the candidate that clears grants on every fresh shell document and gates the mint channel on the trusted renderer, so neither change disturbs a live preview: the document still rendered, both browser counts held at baseline, and the target=_blank press still routed out as an Orca browser tab." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 59.7, + "summary": "The renamed journey passed 3/3 under --repeat-each=3 on the hardened candidate: the host document rendered in the orca-preview guest, both browser counts held at baseline through the split, focus, reactivation and close phases, and the final phase's real target=_blank press routed out as an Orca browser tab with the routing call recorded as opened while the preview tab stayed open." }, { "date": "2026-08-12", @@ -3070,78 +3532,6 @@ "durationSeconds": 3.48, "summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown." }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "result": "passed", - "durationSeconds": 16.1, - "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.6, - "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 6.5, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 10.9, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 7.6, - "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.8, - "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 8.4, - "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 12.3, - "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." - }, { "date": "2026-08-13", "runner": "local", @@ -3171,7 +3561,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing. For the STA-5557 preview behavior the rewritten journey is green 1/1 on the candidate and red when the sole delta makes the same preview action also create a browser tab, failing on clientBrowserWorkspaceCount 1 versus baseline 0. That absence assertion is gated on first reading the rendered document out of the guest, so it cannot pass by the preview never happening. Against pre-STA-5557 builds the journey is red by construction rather than by measurement: those builds have no html-preview tab mode, so the run would fail earlier, at materialization, and it has not been executed against an old build. The reader-facing half is red-green at the unit layer. The 6/6 green and 2/6 red pair recorded earlier on 2026-08-26 measured the superseded shape, where a subresource failure was filtered out entirely; the shipping shape is green 10/10 and red 3/10 when the sole delta makes every failure, asset or entry, set the blocking reason. The containment lines the second review found uncovered are red-green too: installing the scheme handler on the default session instead of the preview session, and widening the scheme privileges with allowServiceWorkers and bypassCSP, each fail the protocol suite as the sole delta, and deleting the html-preview case from the closed-tab cleanup fails the hook suite. The external-link phase added on 2026-08-26 is red-green by measurement: green 3/3 on the trusted-click design, in which the guest's own preload reports a trusted press on an anchor and main answers only a focused preview guest holding a live grant. Its earlier shape depended on the webview carrying allowpopups and went red with zero recorded routing calls when only that attribute was dropped; the preload now intercepts the press before Chromium considers a popup, so the attribute is gone and that measurement retired with it. One further run failed in the paired-client harness before reaching any preview step, when the client window never exposed its store within the launch timeout; that is a harness flake, not a preview signal. The two egress fences added on 2026-08-26 are red-green by measurement in the same journey, whose fixture attempts its own escape on every load and again from inside the handler for the reader's press on the heading: with deny-only navigation sinks the document opens no browser tab and the counts hold at baseline, and with the sole delta that those sinks route again behind a two-second recent-input gate the post-press location.href opens a real tab, clientBrowserWorkspaceCountAllWorktrees 1 against a baseline of 0. An earlier measurement of that same gate, taken when it was the shipping design, showed the unattended on-load egress opening two tabs once the gate was removed entirely; it was retired with the gate, because a gate that cannot tell a script's navigation from the click it followed was never the fence the claim needed. With the guest's IP-handling policy the document reports candidates=0, and with the sole delta that the policy call is removed it reports candidates=6. Both mutants were applied to the shipping module, rebuilt, and run against the live paired journey rather than reasoned about." }, "performanceBudget": { "required": true, @@ -3184,9 +3574,14 @@ "knownGaps": [ "Live paired-server validation currently covers macOS only.", "Multiple simultaneous viewers are not exercised by the Electron journey.", - "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." + "Local workspaces intentionally keep the embedded browser tab at a file URL and are not covered by the preview journey.", + "The SSH preview owner has unit coverage only; no live SSH host renders a preview in this gate.", + "The preview journey exercises one self-contained document. Relative subresources, reload re-reading the workspace disk, and the truncated and out-of-worktree refusals are covered by unit contracts only.", + "Grant revocation on tab close is asserted at the unit layer; the journey observes the tab and open file disappearing, not the main-process registry.", + "The external-link route trusts a real press on an anchor in a focused preview guest, so it proves the reader pressed that anchor, not that the anchor's visible text matches its target. The tab it opens is visible, which is the remaining containment.", + "WebRTC is fenced by the IP-handling policy rather than by removing the API; the guest can still construct a peer connection, and the journey measures that it gathers nothing." ], - "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." + "demotionRule": "Demote if a paired HTML preview creates a browser workspace on either side, renders from anything other than the orca-preview scheme, serves a read the host reported as truncated or over-cap, serves a document without the self-only content security policy, lets the preview session carry an off-machine request, reads an SSH path that canonicalizes outside its grant root, steals focus from the source editor when it opens, removes unrelated editor or terminal state on close, hides a document that rendered because one subresource was refused, leaves a grant readable after the tab or renderer that owns it is gone, mints one for a sender that is not the trusted renderer, opens a browser tab for a navigation the document started with no one at the keyboard, or lets the guest gather an ICE candidate; or if a paired browser creation this gate still owns produces duplicate client or host identities or loses its requested split." }, { "id": "browser-network-tunnel.bounded-fail-closed-route", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f81dbb93041..4faa844ee4c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = { index: resolve('src/main/index.ts'), // Why: sandboxed webview preloads cannot load Rollup helper chunks. 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), + 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts new file mode 100644 index 00000000000..2d9aa5e50fe --- /dev/null +++ b/src/main/browser/browser-manager-annotation-bridge.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps +} from './browser-manager-viewport-test-fixtures' + +const { webContentsFromIdMock } = browserMocks +const makeGuest = createViewportGuestFactory(browserMocks) + +const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' +} + +function registerPage(pageId: string, guest: Record): void { + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: pageId, + webContentsId: guest.id as number, + rendererWebContentsId + }) +} + +/** The production resolver's shape: read the registry now, not when the request was made. */ +function resolveFromRegistry(pageId: string): () => Electron.WebContents | null { + return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId) +} + +describe('browserManager.setAnnotationViewportBridge', () => { + beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('installs the bridge in an isolated world', async () => { + const { guest } = makeGuest(4646) + registerPage('tab-annotations', guest) + + const ok = await browserManager.setAnnotationViewportBridge( + 'tab-annotations', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-annotations') + ) + + expect(ok).toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') + }) + ], + false + ) + }) + + // Why this is the case that matters: a cross-process navigation re-registers the same page id + // with a new WebContents and leaves the retired one alive, so a bridge op that resolved its + // guest when the request arrived would inject into a page nobody is looking at — and the + // annotation badges would stop tracking the document on screen. + it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => { + const { guest: firstGuest } = makeGuest(5101) + let releaseFirstInjection = (): void => {} + // One shared gate for every call, so a wrongly-routed second injection still settles and the + // test fails on where it landed rather than on a timeout. + const firstInjectionGate = new Promise((resolve) => { + releaseFirstInjection = () => resolve() + }) + firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate) + registerPage('tab-swap', firstGuest) + + const firstDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // A second request arrives while the first still holds the chain — at this moment the page is + // still on the first guest, which is the guest a request-time resolution would capture. + const secondDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // Only now does the page swap renderer processes, while the second op is still queued. + const { guest: secondGuest } = makeGuest(5102) + registerPage('tab-swap', secondGuest) + + releaseFirstInjection() + await expect(firstDone).resolves.toBe(true) + await expect(secondDone).resolves.toBe(true) + + expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + }) + + // Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry + // is the one that can see the guest is gone. The bridge only reports the refusal. + it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => { + const { guest } = makeGuest(5103) + registerPage('tab-dies', guest) + expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103) + + webContentsFromIdMock.mockReturnValue(null) + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-dies', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-dies') + ) + ).resolves.toBe(false) + + // Why assert the registry and not just the answer: a stale guest has to clear every per-tab + // entry, or the page keeps a dead WebContents id that later ops resolve against. + expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull() + }) + + // Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by + // the wrong renderer names a page that is alive and on screen, and answering it with teardown + // would cancel that page's in-flight downloads and grabs over a misaddressed message. + it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => { + const { guest } = makeGuest(5105) + registerPage('tab-mismatch', guest) + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () => + browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1) + ) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('refuses a destroyed guest without injecting into it', async () => { + const { guest } = makeGuest(5104) + registerPage('tab-destroyed', guest) + ;(guest.isDestroyed as ReturnType).mockReturnValue(true) + + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-destroyed', + BRIDGE_OPTIONS, + () => guest as never + ) + ).resolves.toBe(false) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + // Why: a document page owns no browsing state, so treating its refusal as a stale page would run + // teardown against an id the browsing registry never held. + it('does not run page teardown when a document page resolves to nothing', async () => { + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index d1a6a16c085..6d71db94585 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -45,6 +45,32 @@ import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +/** + * A page the document half of the registry really holds. Built rather than named: membership is + * what both doors refuse on now, so an id that merely looks like a preview's would be admitted. + */ +function registerWorkspaceDocPage(browserPageId: string): void { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = { + isFocused: () => false, + isDestroyed: () => false, + getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath), + on: vi.fn(), + once: vi.fn(), + setWindowOpenHandler: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn() + } + installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() }) +} const { guestOffMock, @@ -60,6 +86,7 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() + revokeAllDocPreviewGrants() }) afterEach(() => { @@ -92,6 +119,77 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + // Why both doors: one id in both halves of the registry would make the tool door answer with a + // document guest for a page the reader is browsing in. + it.each(['registerGuest', 'registerOffscreenGuest'] as const)( + 'refuses %s for a page the document registry already holds', + (entryPoint) => { + const guest = { + id: 129, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + const browserPageId = 'doc-page-1' + registerWorkspaceDocPage(browserPageId) + + if (entryPoint === 'registerGuest') { + expect( + browserManager.registerGuest({ + browserPageId, + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(false) + } else { + expect( + browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id }) + ).toBe(false) + } + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + } + ) + + // Why this answer is load-bearing: the headless backend destroys its window on false, so a true + // for a guest that is already gone would leave a page id registered onto nothing. + it.each(['missing', 'destroyed'] as const)( + 'refuses registerOffscreenGuest when the named guest is %s', + (guestState) => { + webContentsFromIdMock.mockReturnValue( + guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) } + ) + + expect( + browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 }) + ).toBe(false) + + expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull() + } + ) + + // Why the exit door needs the same check: a document page withdraws by revoking its grant, so its + // id here is misaddressed — and unregistering opens by evicting whatever grab that id names. + it('refuses unregisterGuest for a page the document registry holds', () => { + registerWorkspaceDocPage('doc-page-2') + const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp') + + browserManager.unregisterGuest('doc-page-2') + + expect(cancelGrabOp).not.toHaveBeenCalled() + + // The presence half: the same door does evict a browsing page's grab. + browserManager.unregisterGuest('browser-page-1') + expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted') + cancelGrabOp.mockRestore() + }) + it('blocks non-web guest navigations after attach', () => { const guest = { isDestroyed: vi.fn(() => false), diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts new file mode 100644 index 00000000000..47871f64ea9 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +type GuestFake = { + id: number + url: string + listeners: Map void)[]> + windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null + webRtcPolicy: string | null + isDestroyed: () => boolean + isFocused: () => boolean + getURL: () => string + getType: () => string + setBackgroundThrottling: (value: boolean) => void + setWebRTCIPHandlingPolicy: (policy: string) => void + setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void + executeJavaScriptInIsolatedWorld: ReturnType + debugger: { + isAttached: () => boolean + attach: ReturnType + sendCommand: ReturnType + } + on: (event: string, listener: (...args: never[]) => void) => void + once: (event: string, listener: (...args: never[]) => void) => void + off: (event: string, listener: (...args: never[]) => void) => void +} + +function createGuest(id: number, url: string): GuestFake { + const listeners = new Map void)[]>() + const guest: GuestFake = { + id, + url, + listeners, + windowOpenHandler: null, + webRtcPolicy: null, + isDestroyed: () => false, + isFocused: () => true, + getURL: () => guest.url, + getType: () => 'webview', + setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: (policy: string) => { + guest.webRtcPolicy = policy + }, + setWindowOpenHandler: (handler) => { + guest.windowOpenHandler = handler + }, + executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined), + debugger: { + isAttached: () => true, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined) + }, + on: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + once: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + off: (event, listener) => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((entry) => entry !== listener) + ) + } + } + return guest +} + +function listenerCount(guest: GuestFake, event: string): number { + return guest.listeners.get(event)?.length ?? 0 +} + +/** Drives the guest's own will-navigate listeners and reports whether they refused. */ +function navigateTo(guest: GuestFake, url: string): boolean { + let prevented = false + const event = { preventDefault: () => (prevented = true) } as never + for (const listener of guest.listeners.get('will-navigate') ?? []) { + ;(listener as (event: unknown, url: string) => void)(event, url) + } + return prevented +} + +const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents + +beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + revokeAllDocPreviewGrants() + browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined) +}) + +describe('guest policy profiles', () => { + function attachPreviewGuest(id = 301): { + guest: GuestFake + grantId: string + browserPageId: string + } { + const browserPageId = `doc-page-${id}` + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html')) + browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host }) + return { guest, grantId: grant.id, browserPageId } + } + + // The presence half of every absence below: a browsing guest observably takes all of it through + // the same method, so a profile that fenced nothing — or an attach path that stopped installing + // anything at all — cannot pass these by being uniformly empty. + it('gives a browsing guest link routing, popups and anti-detection', () => { + const guest = createGuest(300, 'https://example.com/') + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(1) + expect(listenerCount(guest, 'frame-created')).toBe(1) + expect(listenerCount(guest, 'did-create-window')).toBe(1) + expect(guest.debugger.sendCommand).toHaveBeenCalled() + expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false) + }) + + it('gives a workspace-document guest none of it', () => { + const { guest } = attachPreviewGuest() + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'frame-created')).toBe(0) + expect(listenerCount(guest, 'did-create-window')).toBe(0) + expect(guest.debugger.sendCommand).not.toHaveBeenCalled() + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('holds a workspace-document guest inside the grant it is showing', () => { + const { guest, grantId } = attachPreviewGuest() + + expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false) + expect(navigateTo(guest, 'https://example.com/')).toBe(true) + expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp') + }) + + it('denies every window a workspace-document guest asks for', () => { + const { guest } = attachPreviewGuest() + + expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({ + action: 'deny' + }) + }) + + // Why teardown and not just install: registration and teardown both key on the guest having been + // policy-attached, so a profile that installs outside that bookkeeping leaves the id marked + // attached forever and stays answerable to tools after the surface is gone. + it('tears a workspace-document guest down through the same policy cleanup', () => { + const { guest, browserPageId } = attachPreviewGuest(302) + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never) + + for (const listener of guest.listeners.get('destroyed') ?? []) { + listener() + } + + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull() + // The manager's own bookkeeping was reached too: a second attach is refused while the id is + // still marked policy-attached, and accepted once its teardown has run. + browserManager.attachGuestPolicies(guest as never) + expect(listenerCount(guest, 'dom-ready')).toBe(1) + }) + + // The seam the whole split rests on: one public door answers for both halves, and each page id + // resolves in exactly one of them. Asserted against the real manager, because the IPC census + // test has to mock it. + describe('the one door across both halves of the registry', () => { + const BROWSING_PAGE_ID = 'browser-page-1' + + function registerBrowsingGuest(id = 400): GuestFake { + const guest = createGuest(id, 'https://example.com/') + browserMocks.webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: BROWSING_PAGE_ID, + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: id, + rendererWebContentsId: host.id + }) + return guest + } + + it('answers each page with the guest of its own half', () => { + const browsing = registerBrowsingGuest() + const { guest: document, browserPageId } = attachPreviewGuest() + + expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never) + expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never) + }) + + // Why this is the containment claim and not a lookup detail: page management, agent commands, + // download routing and certificate attribution all read the browsing map directly, so a + // document page being absent from it is what fences them without a guard of their own. + it('keeps a document page out of the browsing map entirely', () => { + registerBrowsingGuest() + const { browserPageId } = attachPreviewGuest() + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID]) + }) + }) + + it('attaches a guest once whatever profile it was asked for', () => { + const { guest } = attachPreviewGuest(303) + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'will-navigate')).toBe(1) + }) +}) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index c72936cf6a3..b7d3bbabe0a 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -882,34 +882,5 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).not.toHaveBeenCalled() }) - - it('installs annotation viewport bridge in an isolated world', async () => { - const { guest } = makeGuest(4646) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-annotations', - webContentsId: guest.id as number, - rendererWebContentsId - }) - - const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', { - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' - }) - - expect(ok).toBe(true) - expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( - expect.any(Number), - [ - expect.objectContaining({ - code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') - }) - ], - false - ) - }) }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7ac64b485b9..2b43e17a371 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -63,6 +63,11 @@ import { BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + isWorkspaceDocPageId +} from './doc-preview-guest-policy' import type { KeybindingOverrides } from '../../shared/keybindings' import { BrowserCertificateTrustController, @@ -157,6 +162,15 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } +const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } type PendingMainFrameNavigation = { currentUrl: string supersededUrls: string[] @@ -671,12 +685,20 @@ export class BrowserManager { attachGuestPolicies( guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY ): void { if (this.policyAttachedGuestIds.has(guest.id)) { return } this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } @@ -1016,6 +1038,30 @@ export class BrowserManager { }) } + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + private attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, // not the request header, so the header-level Firefox switch in setupClientHintsOverride // must be matched here per navigation or the two layers disagree — itself a bot tell. @@ -1318,7 +1364,9 @@ export class BrowserManager { rendererWebContentsId }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId - if (!browserTabId) { + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { return false } // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. @@ -1377,6 +1425,12 @@ export class BrowserManager { } unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. this.cancelGrabOp(browserTabId, 'evicted') @@ -1444,10 +1498,15 @@ export class BrowserManager { sessionProfileId?: string | null userAgentMode?: BrowserSessionUserAgentMode webContentsId: number - }): void { + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. this.offscreenGuestIds.add(webContentsId) @@ -1468,6 +1527,7 @@ export class BrowserManager { this.worktreeIdByTabId.set(browserPageId, worktreeId) } this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true } unregisterAll(): void { @@ -1843,12 +1903,13 @@ export class BrowserManager { async setAnnotationViewportBridge( browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options)) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) try { return await next @@ -1859,18 +1920,22 @@ export class BrowserManager { } } + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. private async doSetAnnotationViewportBridgeImpl( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) return false } @@ -1977,10 +2042,21 @@ export class BrowserManager { // --- Browser Context Grab — main-owned operations --- /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ getAuthorizedGuest( browserTabId: string, senderWebContentsId: number ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { return null diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts new file mode 100644 index 00000000000..a3667154c9b --- /dev/null +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' + +const mocks = vi.hoisted(() => ({ + handleGuestWillDownload: vi.fn(), + noticeDocPreviewDownloadBlocked: vi.fn() +})) + +type WillDownloadListener = ( + event: { preventDefault: () => void }, + item: { id: string }, + webContents: { id: number } +) => void + +type FakeSession = { + listeners: WillDownloadListener[] + on: ReturnType + removeListener: ReturnType + getUserAgent: () => string + setUserAgent: ReturnType + setPermissionRequestHandler: ReturnType + setPermissionCheckHandler: ReturnType + setDisplayMediaRequestHandler: ReturnType +} + +const sessionsByPartition = new Map() + +function fakeSession(): FakeSession { + const listeners: WillDownloadListener[] = [] + return { + listeners, + on: vi.fn((event: string, listener: WillDownloadListener) => { + if (event === 'will-download') { + listeners.push(listener) + } + }), + removeListener: vi.fn((event: string, listener: WillDownloadListener) => { + if (event !== 'will-download') { + return + } + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } + }), + getUserAgent: () => 'Mozilla/5.0 Orca', + setUserAgent: vi.fn(), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn() + } +} + +vi.mock('electron', () => ({ + session: { + fromPartition: (partition: string) => { + const existing = sessionsByPartition.get(partition) + if (existing) { + return existing + } + const created = fakeSession() + sessionsByPartition.set(partition, created) + return created + } + } +})) +vi.mock('./browser-manager', () => ({ + browserManager: { + handleGuestWillDownload: mocks.handleGuestWillDownload, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn() + } +})) +vi.mock('./doc-preview-download-block-notice', () => ({ + noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked +})) +vi.mock('./browser-media-access', () => ({ + hasSystemMediaAccess: () => false, + requestSystemMediaAccess: async () => false +})) +vi.mock('./browser-session-ua', () => ({ + cleanElectronUserAgent: (userAgent: string) => userAgent, + setupClientHintsOverride: vi.fn() +})) +vi.mock('./browser-session-user-agent-mode', () => ({ + setBrowserSessionUserAgentMode: vi.fn() +})) +vi.mock('./browser-webauthn-access', () => ({ + allowsBrowserWebAuthnPermission: () => false, + clearBrowserWebAuthnAccessHandlers: vi.fn(), + installBrowserWebAuthnAccessHandlers: vi.fn() +})) + +type PartitionPolicyInstaller = ( + profile: BrowserSessionProfile, + options?: { downloads?: 'route' | 'deny' } +) => void + +// Why imported per test rather than at the top: the installer remembers which partitions it has +// already configured in module state, so a shared import would make the second test's install a +// no-op and leave it reading the first test's listener. +async function loadInstaller(): Promise { + const module = await import('./browser-session-partition-policies') + return module.installBrowserSessionPartitionPolicies +} + +function profileFor(partition: string): BrowserSessionProfile { + return { + id: partition, + scope: 'isolated', + partition, + label: partition, + source: null, + userAgentMode: 'clean' + } +} + +/** Fires the partition's real `will-download` listener and reports what it decided. */ +function fireWillDownload(partition: string): { cancelled: boolean } { + const sess = sessionsByPartition.get(partition) + if (!sess || sess.listeners.length !== 1) { + throw new Error(`expected exactly one will-download listener on ${partition}`) + } + let cancelled = false + sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 }) + return { cancelled } +} + +beforeEach(() => { + vi.clearAllMocks() + sessionsByPartition.clear() + vi.resetModules() +}) + +describe('partition download policy', () => { + // The presence half: without it, a deny assertion passes for a partition that installed no + // listener at all, and would keep passing if the whole download path were removed. + it('routes a download on a partition that did not ask for the deny', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith( + expect.objectContaining({ guestWebContentsId: 42 }) + ) + }) + + it('cancels a download on a partition that asked for the deny, routing nothing', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled() + }) + + // Why in the same run as the routing test above: a refusal the reader cannot see is a pressed + // button that does nothing, and a notice on the routing partition would announce a download that + // is about to arrive normally. + it('tells the reader about the refusal, and only on the partition that refused', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + fireWillDownload('persist:browsing-1') + expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled() + + fireWillDownload('orca-doc-preview') + expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith( + expect.objectContaining({ id: 42 }) + ) + }) + + // Why both partitions in one run: the listener is module state shared across sessions, so a deny + // installed for one partition must not follow the next partition that installs after it. + it('keeps each partition on its own decision', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 2118c62ed68..cf542665ae4 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -11,6 +11,7 @@ import { clearBrowserWebAuthnAccessHandlers, installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' +import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice' // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() @@ -22,6 +23,23 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +/** + * Why a second listener instead of a branch inside the shared one: `will-download` is a session + * event that names no partition, so the only place the decision can be keyed by partition is which + * listener that partition's session got. A workspace-document guest has no page of its own to + * attribute a download to, so routing one lands it in this desktop's Downloads folder under a + * remote-authored name that nothing in the UI accounts for. + */ +const handleDeniedWillDownload = ( + event: Electron.Event, + _item: Electron.DownloadItem, + webContents: Electron.WebContents +): void => { + event.preventDefault() + // The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing. + noticeDocPreviewDownloadBlocked(webContents) +} + function resolvePermissionNoticeUrl( webContents: Electron.WebContents, details: Electron.PermissionRequest | undefined @@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl( } } -export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { +/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ +export type BrowserPartitionDownloadPolicy = 'route' | 'deny' + +export function installBrowserSessionPartitionPolicies( + profile: BrowserSessionProfile, + options?: { downloads?: BrowserPartitionDownloadPolicy } +): void { const { partition } = profile const sess = session.fromPartition(partition) setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') @@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr callback({ video: undefined, audio: undefined }) }) sess.removeListener('will-download', handleWillDownload) - sess.on('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) + sess.on( + 'will-download', + options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload + ) configuredPartitions.add(partition) } @@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts new file mode 100644 index 00000000000..9fc4396b329 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + publishDocPreviewFailure: vi.fn(), + boundGrantIdByGuest: new Map() +})) + +vi.mock('./doc-preview-failure-notice', () => ({ + publishDocPreviewFailure: mocks.publishDocPreviewFailure +})) +vi.mock('./doc-preview-guest-policy', () => ({ + readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null +})) + +const GRANT_ID = 'a'.repeat(32) +const OTHER_GRANT_ID = 'b'.repeat(32) + +/** Only the identity matters: the module asks the guest registry what grant this contents holds. */ +function guestBoundTo(grantId: string | null): Electron.WebContents { + const guest = {} as Electron.WebContents + if (grantId !== null) { + mocks.boundGrantIdByGuest.set(guest, grantId) + } + return guest +} + +async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> { + const module = await import('./doc-preview-download-block-notice') + return module.noticeDocPreviewDownloadBlocked +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.boundGrantIdByGuest.clear() + // Why per test: the module remembers which grants it has already told the reader about. + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('noticeDocPreviewDownloadBlocked', () => { + it('tells the shell which preview had a download refused', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({ + grantId: GRANT_ID, + reason: 'download-blocked' + }) + }) + + // Why: a document can ask in a loop. The reader learns nothing from the second notice, and every + // attempt would otherwise cross the IPC boundary and re-render the strip. + it('says it once however often the document asks', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + notice(guest) + vi.advanceTimersByTime(1_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1) + }) + + it('says it again for an attempt long after the last one', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + vi.advanceTimersByTime(2_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + }) + + // Why not one throttle for the whole app: two previews are two readers, and silencing the second + // because the first just refused something leaves that press unexplained. + it('throttles each preview on its own', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + notice(guestBoundTo(OTHER_GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({ + grantId: OTHER_GRANT_ID, + reason: 'download-blocked' + }) + }) + + // The absence half of the first test: no shell is showing this contents, so there is no preview + // to put a notice on. Without the presence tests above, this would pass on a module that never + // published anything at all. + it('says nothing for a contents no preview is bound to', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(null)) + + expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-download-block-notice.ts b/src/main/browser/doc-preview-download-block-notice.ts new file mode 100644 index 00000000000..2aefb2824a7 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.ts @@ -0,0 +1,34 @@ +import { publishDocPreviewFailure } from './doc-preview-failure-notice' +import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' + +/** + * Why the reader is told at all, when the point of the fence is that nothing happens: a press that + * produces no file and no explanation reads as Orca being broken. The notice is chrome the document + * cannot see or read back, so the refusal stays as silent to the page as it was. + * + * Why a floor between notices: a document can ask in a loop, and every attempt would otherwise + * cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth. + */ +const NOTICE_MIN_INTERVAL_MS = 2_000 +const noticedAtByGrantId = new Map() + +export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void { + const grantId = readDocPreviewGuestBoundGrantId(guest) + // Nothing to route a notice to: no shell is showing this contents as a preview. + if (grantId === null) { + return + } + const now = Date.now() + // Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a + // grant that stops downloading stops being remembered. + for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) { + if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) { + noticedAtByGrantId.delete(noticedGrantId) + } + } + if (noticedAtByGrantId.has(grantId)) { + return + } + noticedAtByGrantId.set(grantId, now) + publishDocPreviewFailure({ grantId, reason: 'download-blocked' }) +} diff --git a/src/main/browser/doc-preview-failure-notice.test.ts b/src/main/browser/doc-preview-failure-notice.test.ts new file mode 100644 index 00000000000..2272e7bf614 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme' +import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice' + +afterEach(() => { + setDocPreviewFailureSink(null) +}) + +describe('publishDocPreviewFailure', () => { + it('sends the grant, path, and reason on the failure channel', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send }) + + publishDocPreviewFailure({ + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + + expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, { + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + }) + + // Why: reads can outlive the window that asked for them; a missing sink must not throw inside + // the protocol handler. + it('is a no-op with no sink registered', () => { + expect(() => + publishDocPreviewFailure({ + grantId: 'b'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + ).not.toThrow() + }) + + it('sends nothing into a destroyed window', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send, isDestroyed: () => true }) + + publishDocPreviewFailure({ + grantId: 'c'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + + expect(send).not.toHaveBeenCalled() + }) + + // Why: a WebContents can be torn down between the liveness check and the send. + it('swallows a throwing sink and stops using it', () => { + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + setDocPreviewFailureSink({ send }) + const failure = { + grantId: 'd'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' as const + } + + expect(() => publishDocPreviewFailure(failure)).not.toThrow() + publishDocPreviewFailure(failure) + + expect(send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/doc-preview-failure-notice.ts b/src/main/browser/doc-preview-failure-notice.ts new file mode 100644 index 00000000000..1b7330d6f27 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.ts @@ -0,0 +1,34 @@ +import { + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' + +type DocPreviewFailureSink = { + send: (channel: string, payload: DocPreviewFailure) => void + isDestroyed?: () => boolean +} + +let failureSink: DocPreviewFailureSink | null = null + +export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void { + failureSink = sink +} + +/** + * The preview shell cannot read the guest's HTTP status, and a 4xx body renders as + * if it were the document. Pushing the reason lets the shell replace that with a + * localized notice for the failure the user actually hit. + */ +export function publishDocPreviewFailure(failure: DocPreviewFailure): void { + const sink = failureSink + // Why: a read can outlive the window that asked for it, and sending into torn-down + // WebContents throws — an unreadable asset must not take the protocol handler with it. + if (!sink || sink.isDestroyed?.()) { + return + } + try { + sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure) + } catch { + failureSink = null + } +} diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts new file mode 100644 index 00000000000..fc707279f8f --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + readFile: vi.fn(), + realpath: vi.fn(), + requireSshFilesystemProvider: vi.fn() +})) + +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: mocks.requireSshFilesystemProvider +})) + +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' + +function sshGrant(): ReturnType { + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +function runtimeGrant(root = '/srv/repo/docs'): ReturnType { + return mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:wt-1', + worktreeRoot: '/srv/repo' + }, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() + // Why: an unsymlinked host canonicalizes to the path it was given. + mocks.realpath.mockImplementation((path: string) => Promise.resolve(path)) + mocks.requireSshFilesystemProvider.mockReturnValue({ + readFile: mocks.readFile, + realpath: mocks.realpath + }) +}) + +describe('docPreviewContentType', () => { + it('maps document and asset extensions, defaulting to octet-stream', () => { + expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8') + expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8') + expect(docPreviewContentType('assets/logo.png')).toBe('image/png') + expect(docPreviewContentType('data.bin')).toBe('application/octet-stream') + }) +}) + +describe('readDocPreviewFile — ssh owner', () => { + it('reads text through the SSH filesystem provider', async () => { + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + const outcome = await readDocPreviewFile(sshGrant(), 'index.html') + + expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html') + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

hi

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('decodes a base64 binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + // Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is + // always whole and needs no truncation flag. + it('serves a whole SSH read that carries no truncation flag', async () => { + mocks.readFile.mockResolvedValue({ content: '

whole

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by + // design — the failure must name the file type, not a stale server. + it('reports a file type the host will not send as unsupported-asset', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2') + + expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' }) + }) + + // Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer — + // reporting it as a refused format would be a failure the workspace never reported. + it('serves an empty file the host still typed instead of calling it unsupported', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' }) + }) + + // Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a + // symlink inside the grant would otherwise read anything the account can reach. + it('404s a path that canonicalizes outside the grant root', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path) + ) + + const outcome = await readDocPreviewFile(sshGrant(), 'escape.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('reads the canonical path once containment holds', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path) + ) + mocks.readFile.mockResolvedValue({ content: '

real

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html') + }) + + // Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to. + it('keeps serving a grant whose own root is a symlink', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs')) + ) + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html') + }) + + it('404s when the host cannot canonicalize the path at all', async () => { + mocks.realpath.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('404s a path outside the grant root without touching the provider', async () => { + const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() + }) + + it('reports an over-cap SSH file as too large rather than unreadable', async () => { + mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap')) + + expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('404s when the provider read fails', async () => { + mocks.readFile.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({ + ok: false, + status: 404 + }) + }) +}) + +describe('readDocPreviewFile — paired runtime owner', () => { + it('reads text over worktree-relative files.read', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

remote

', truncated: false, byteLength: 15 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'files.read', + { worktree: 'id:wt-1', relativePath: 'docs/index.html' }, + 15_000 + ) + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

remote

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('falls back to the base64 preview RPC for a binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { + content: png.toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png') + + expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith( + 2, + '/user-data', + 'env-1', + 'files.readPreview', + { worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' }, + 15_000 + ) + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + it('degrades on an old server whose empty binary preview carries no metadata', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({ + ok: false, + status: 415, + reason: 'unsupported-asset' + }) + }) + + it('serves an empty paired asset the host typed rather than reporting a refusal', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({ + ok: true, + bytes: Buffer.alloc(0), + contentType: 'image/png' + }) + }) + + // Why: files.read clamps text at the host cap and only says so in `truncated`; serving the + // clamped bytes renders a document that silently stops halfway. + it('refuses a truncated text read instead of serving the clamped bytes', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

half of', truncated: true, byteLength: 40_000_000 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 413 }) + expect(outcome).not.toMatchObject({ ok: true }) + }) + + it('serves a read the host reports as complete', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

all

', truncated: false, byteLength: 12 } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error. + it('reports the host rejecting an over-cap binary as too large', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'file_too_large' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('does not treat an unrelated RPC failure as a binary fallback', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: false, + error: { code: 'runtime_error', message: 'permission_denied' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce() + }) + + it('404s a document outside the worktree, which files.read cannot address', async () => { + const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts new file mode 100644 index 00000000000..967461a91f9 --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.ts @@ -0,0 +1,198 @@ +import { extname } from 'node:path' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadResult +} from '../../shared/runtime-file-contracts' +import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { getCanonicalUserDataPath } from '../persistence' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 + +/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so + * a font is refused there by design, not by version. Name the file type, not the host's age. */ +const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' + +/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would + * render a silently half-finished document. */ +const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.' + +/** The paired host rejects an over-cap asset outright instead of clamping it. */ +const RUNTIME_TOO_LARGE_ERROR = 'file_too_large' + +/** Both owners refuse an over-cap file; only their error shapes differ. */ +function isTooLargeReadError(error: unknown): boolean { + return ( + error instanceof FileReadCapExceededError || + (error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR) + ) +} + +export type DocPreviewReadOutcome = + | { ok: true; bytes: Buffer; contentType: string } + | { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string } + +const DOC_PREVIEW_CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/plain; charset=utf-8', + '.csv': 'text/plain; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf' +} + +export function docPreviewContentType(relativePath: string): string { + return ( + DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream' + ) +} + +type PreviewFileBytes = { + content: string + isBinary: boolean + truncated?: boolean + /** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */ + mimeType?: string +} + +function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome { + if (source.truncated) { + return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + } + if (!source.isBinary) { + return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType } + } + if (source.content) { + return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType } + } + // Why: an empty binary body is two different answers. A host that still named the file's type + // read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined + // the format outright and has nothing to send. + return source.mimeType + ? { ok: true, bytes: Buffer.alloc(0), contentType } + : { + ok: false, + status: 415, + reason: 'unsupported-asset', + message: UNSERVABLE_ASSET_PREVIEW_MESSAGE + } +} + +async function readRuntimeDocPreviewFile( + environmentId: string, + worktreeSelector: string, + relativePath: string +): Promise { + const userDataPath = getCanonicalUserDataPath() + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.read', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (response.ok) { + const result = response.result as RuntimeFileReadResult + return { content: result.content, isBinary: false, truncated: result.truncated === true } + } + // Why: files.read rejects binaries with a typed error; the base64 preview RPC serves + // images and fonts the same way it does for markdown previews. Match the exact + // message so an unrelated failure can't spoof the fallback. + if (response.error.message !== 'binary_file') { + throw new Error(response.error.message) + } + const previewResponse = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.readPreview', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (!previewResponse.ok) { + throw new Error(previewResponse.error.message) + } + const preview = previewResponse.result as RuntimeFilePreviewResult + // Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent. + return { + content: preview.content, + isBinary: preview.isBinary, + ...(preview.mimeType ? { mimeType: preview.mimeType } : {}) + } +} + +function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome { + return { ok: false, status: 404, reason: 'unreadable', message } +} + +/** Reads one in-grant path over the same channel the editor uses for that owner. */ +export async function readDocPreviewFile( + grant: DocPreviewGrant, + relativePath: string +): Promise { + const absolutePath = resolveDocPreviewTargetPath(grant, relativePath) + if (!absolutePath) { + return notFoundOutcome() + } + const contentType = docPreviewContentType(relativePath) + try { + if (grant.owner.kind === 'ssh') { + const provider = requireSshFilesystemProvider(grant.owner.connectionId) + // Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink + // before the read — the lexical check above only proves the requested path looked contained. + const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) => + provider.realpath(path) + ) + if (!canonicalPath) { + return notFoundOutcome() + } + // Why: the SSH reader rejects an over-cap file outright, so its result is never partial. + return toOutcome(await provider.readFile(canonicalPath), contentType) + } + const worktreeRelativePath = toRuntimeWorktreeRelativePath( + grant.owner.worktreeRoot, + absolutePath + ) + if (!worktreeRelativePath) { + // Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel. + return notFoundOutcome() + } + // Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which + // canonicalizes and re-checks the worktree root server-side before reading. + return toOutcome( + await readRuntimeDocPreviewFile( + grant.owner.environmentId, + grant.owner.worktreeSelector, + worktreeRelativePath + ), + contentType + ) + } catch (error) { + return isTooLargeReadError(error) + ? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + : notFoundOutcome(error instanceof Error ? error.message : undefined) + } +} diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts new file mode 100644 index 00000000000..8aadf9ed695 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + getDocPreviewGrant, + mintDocPreviewGrant, + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const + +function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant { + return mintDocPreviewGrant({ + owner: sshOwner, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + revokeAllDocPreviewGrants() +}) + +describe('doc preview grants', () => { + it('mints unguessable ids and looks them up', () => { + const first = mintPosixGrant() + const second = mintPosixGrant() + + expect(first.id).toMatch(/^[0-9a-f]{32}$/) + expect(first.id).not.toBe(second.id) + expect(getDocPreviewGrant(first.id)).toBe(first) + }) + + it('returns nothing for an unknown or revoked grant', () => { + const grant = mintPosixGrant() + + expect(getDocPreviewGrant('0'.repeat(32))).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(true) + expect(getDocPreviewGrant(grant.id)).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(false) + }) +}) + +describe('resolveDocPreviewTargetPath', () => { + it('resolves paths inside the grant root', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe( + '/srv/repo/docs/assets/logo.png' + ) + }) + + it('refuses parent traversal, absolute escapes and empty paths', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull() + }) + + it('refuses backslash and NUL segments that could re-split on the owning host', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull() + }) + + // Why this is a traversal test and not a containment test: every request path that names a + // sibling directory has to climb out of the root first, so the `..` segment guard answers it + // before the prefix check runs. Sibling containment is exercised where it is reachable — + // against a canonicalized path, below. + it('refuses a sibling directory by refusing the traversal that reaches it', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull() + }) + + it('keeps a Windows drive root addressable instead of turning it drive-relative', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html') + }) + + it('follows the owning host path flavor rather than this process platform', () => { + const windowsGrant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\srv\\repo\\docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe( + 'C:\\srv\\repo\\docs\\assets\\logo.png' + ) + expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull() + }) + + it('normalizes a trailing separator on the root', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: '/srv/repo/docs/', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + }) +}) + +describe('resolveCanonicalDocPreviewPath', () => { + // Why here and not above: a canonical path is the one input that can name a sibling directory + // without traversing — the host resolved a symlink to it — so this is where the prefix check + // is the only thing standing between the grant and `/srv/repo/docs-private`. + it('refuses a canonical path in a sibling directory that shares the root prefix', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => + path === grant.root ? path : '/srv/repo/docs-private/secret.html' + ) + ).resolves.toBeNull() + }) + + it('answers the canonical path when it stays inside the canonical root', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path) + ).resolves.toBe('/srv/repo/docs/report.html') + }) +}) + +describe('toRuntimeWorktreeRelativePath', () => { + it('produces a worktree-relative path for files inside the worktree', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe( + 'docs/index.html' + ) + }) + + it('rejects paths outside the worktree, which files.read cannot address', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull() + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull() + }) + + it('uses Windows semantics for a Windows worktree root', () => { + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe( + 'docs/index.html' + ) + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull() + }) +}) diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts new file mode 100644 index 00000000000..51237a685f1 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -0,0 +1,208 @@ +import { randomBytes } from 'node:crypto' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +/** + * A preview grant is the only authority that turns an `orca-preview://` request + * into bytes: it names the host that owns the file and the single directory + * subtree requests may resolve inside. No grant, no bytes. + */ +export type DocPreviewOwner = + | { kind: 'ssh'; connectionId: string } + | { + kind: 'runtime' + environmentId: string + /** Selector the runtime resolves `files.read` against. */ + worktreeSelector: string + /** Worktree root on the runtime host; `files.read` only accepts paths inside it. */ + worktreeRoot: string + } + +export type DocPreviewGrant = { + id: string + owner: DocPreviewOwner + /** Containing directory of the opened document, on the owning host. */ + root: string + /** Path of the opened document relative to `root`. */ + entryRelativePath: string + /** + * Browser page the reader opened this document in. Main registers the guest under it once the + * guest commits to the grant, so the surface a tool names is the page the reader is looking at + * and not the grant, which a re-mint replaces underneath the same page. + */ + browserPageId: string +} + +const grantsById = new Map() + +function pathFlavorFor(root: string): typeof posix | typeof win32 { + return isWindowsAbsolutePathLike(root) ? win32 : posix +} + +function normalizeRootPath(root: string): string { + const flavor = pathFlavorFor(root) + const normalized = + flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root) + // Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the + // drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant. + if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) { + return normalized + } + // Why: a trailing separator would make the containment prefix check accept a sibling directory. + return normalized.length > 1 && normalized.endsWith(flavor.sep) + ? normalized.slice(0, -1) + : normalized +} + +export function mintDocPreviewGrant(params: { + owner: DocPreviewOwner + root: string + entryRelativePath: string + browserPageId: string +}): DocPreviewGrant { + const grant: DocPreviewGrant = { + id: randomBytes(16).toString('hex'), + owner: params.owner, + root: normalizeRootPath(params.root), + entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'), + browserPageId: params.browserPageId + } + grantsById.set(grant.id, grant) + return grant +} + +export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null { + return grantsById.get(grantId) ?? null +} + +/** + * Why anything listens at all: a grant is the only thing that names a preview's lifetime. State + * elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no + * other signal telling it the surface is gone, and would otherwise accrete one entry per grant + * for the life of the process. + */ +const revocationListeners = new Set<(grant: DocPreviewGrant) => void>() + +/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */ +export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void { + revocationListeners.add(listener) + return () => revocationListeners.delete(listener) +} + +function notifyRevoked(grant: DocPreviewGrant): void { + for (const listener of revocationListeners) { + listener(grant) + } +} + +export function revokeDocPreviewGrant(grantId: string): boolean { + canonicalRootByGrantId.delete(grantId) + const grant = grantsById.get(grantId) + if (!grant) { + return false + } + grantsById.delete(grantId) + notifyRevoked(grant) + return true +} + +export function revokeAllDocPreviewGrants(): void { + canonicalRootByGrantId.clear() + const revoked = [...grantsById.values()] + grantsById.clear() + for (const grant of revoked) { + notifyRevoked(grant) + } +} + +function hasUnsafeSegment(segments: string[]): boolean { + return segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + segment.includes('\0') || + segment.includes('\\') + ) +} + +/** + * Resolves a request path to an absolute path on the owning host, or null when + * it would escape the grant's root. Path flavor follows the root (the owning + * host may be Windows while this client is not), never `process.platform`. + */ +export function resolveDocPreviewTargetPath( + grant: DocPreviewGrant, + relativePath: string +): string | null { + const segments = relativePath.split('/').filter((segment, index, all) => { + // Why: keep empty segments visible to the safety check except a single trailing one from `dir/`. + return !(segment === '' && index === all.length - 1) + }) + if (segments.length === 0 || hasUnsafeSegment(segments)) { + return null + } + const flavor = pathFlavorFor(grant.root) + const resolved = flavor.normalize(flavor.join(grant.root, ...segments)) + return isInsideRoot(grant.root, resolved, flavor) ? resolved : null +} + +function isInsideRoot( + root: string, + candidate: string, + flavor: typeof posix | typeof win32 +): boolean { + const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}` + return candidate.startsWith(rootPrefix) +} + +/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */ +const canonicalRootByGrantId = new Map>() + +/** + * Second containment pass for hosts where the lexical one is not enough: a symlink + * inside the root can point anywhere, and the SSH read RPC applies no root of its + * own. Both sides are canonicalized on the owning host before the prefix re-check; + * a host that cannot canonicalize a path answers nothing. + */ +export async function resolveCanonicalDocPreviewPath( + grant: DocPreviewGrant, + absolutePath: string, + realpath: (path: string) => Promise +): Promise { + try { + let canonicalRoot = canonicalRootByGrantId.get(grant.id) + if (!canonicalRoot) { + canonicalRoot = realpath(grant.root).then(normalizeRootPath) + canonicalRootByGrantId.set(grant.id, canonicalRoot) + } + const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)]) + const flavor = pathFlavorFor(root) + return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null + } catch { + // Why: a root that no longer canonicalizes must not fall back to the lexical answer. + canonicalRootByGrantId.delete(grant.id) + return null + } +} + +/** + * Path a runtime `files.read` can address, i.e. relative to the worktree root. + * Returns null when the grant root sits outside the worktree — the runtime file + * RPCs are worktree-scoped, so those documents are unreadable client-side. + */ +export function toRuntimeWorktreeRelativePath( + worktreeRoot: string, + absolutePath: string +): string | null { + const flavor = pathFlavorFor(worktreeRoot) + const normalizedRoot = normalizeRootPath(worktreeRoot) + const relative = flavor.relative(normalizedRoot, absolutePath) + if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) { + return null + } + if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) { + return null + } + return relative.replace(/\\/g, '/') +} diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts new file mode 100644 index 00000000000..26b117c0fc0 --- /dev/null +++ b/src/main/browser/doc-preview-guest-policy.test.ts @@ -0,0 +1,550 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + readDocPreviewGuestBoundGrantId, + reportDocPreviewLinkClick +} from './doc-preview-guest-policy' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' +import { + mintDocPreviewGrant, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant +} from './doc-preview-grant-registry' + +type GuestHandlers = Record void> + +const HOST_RENDERER_ID = 42 + +function installOnFakeGuest( + hostId: number = HOST_RENDERER_ID, + /** What the guest is already showing when the embedder hands it over, as a real one usually is. */ + initialUrl = '' +): { + contents: object + handlers: GuestHandlers + hostId: number + isFocused: ReturnType + send: ReturnType + setWebRTCIPHandlingPolicy: ReturnType + windowOpenHandler: (details: { url: string }) => { action: string } + /** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */ + markContentsDestroyed: () => void +} { + const handlers: GuestHandlers = {} + let contentsDestroyed = false + const send = vi.fn() + const isFocused = vi.fn(() => true) + const setWebRTCIPHandlingPolicy = vi.fn() + let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({ + action: 'deny' + }) + const register = (event: string, handler: (...args: never[]) => void): void => { + handlers[event] = handler + } + const guest = { + isFocused, + isDestroyed: () => contentsDestroyed, + getURL: () => initialUrl, + on: vi.fn(register), + once: vi.fn(register), + setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => { + windowOpenHandler = handler + }), + setWebRTCIPHandlingPolicy + } + installDocPreviewGuestPolicy(guest as never, { id: hostId, send }) + return { + contents: guest, + handlers, + hostId, + isFocused, + send, + setWebRTCIPHandlingPolicy, + windowOpenHandler: (details) => windowOpenHandler(details), + markContentsDestroyed: () => { + contentsDestroyed = true + } + } +} + +type FakeGuest = ReturnType + +function startMainFrameNavigation(guest: FakeGuest, url: string): void { + guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never) +} + +/** A guest already showing a document, which is the only state a link can be pressed in. */ +function boundGuest(): { grant: ReturnType; guest: FakeGuest } { + const grant = mintGrant() + const guest = installOnFakeGuest() + startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html')) + return { grant, guest } +} + +function reportClick(guest: FakeGuest, url: string): void { + reportDocPreviewLinkClick(guest.contents as never, url) +} + +// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one +// would have the second silently replace the first rather than stand beside it. +let nextDocPageOrdinal = 0 + +function mintGrant(): ReturnType { + nextDocPageOrdinal += 1 + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: `doc-page-${nextDocPageOrdinal}` + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() +}) + +describe('doc preview guest policy', () => { + it('allows relative navigation within the bound grant', () => { + const { grant, guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why an unlatched guest is refused rather than trusted: the renderer-set src is + // browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is + // one the guest started for itself. + it('blocks a navigation the guest starts before a document has bound it', () => { + const grant = mintGrant() + const guest = installOnFakeGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks navigation into a different live grant once bound', () => { + const { guest } = boundGuest() + const otherGrant = mintGrant() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(otherGrant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks a revoked grant even when it matches the bound id', () => { + const { grant, guest } = boundGuest() + revokeAllDocPreviewGrants() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks an external navigation without offering it to the renderer', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks a file: navigation', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('applies the same rule to redirects', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('denies every popup and routes none of them', () => { + const { guest } = boundGuest() + + expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({ + action: 'deny' + }) + expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why: will-navigate never fires for a subframe, so an