From c7cb5d154a2927504cc051689f3b4d629ad4c5a8 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:02:38 +0000 Subject: [PATCH 01/17] fix(mobile): open Create Workspace and Close issue from Tasks on iOS (#28855) Each Tasks sheet was its own native modal, and iOS refuses to present one over another that is still showing. They now share one modal and stack by zIndex. Fixes #21003 --- .../bottom-drawer-modal-host-scope.ts | 33 ++ .../bottom-drawer-modal-host.test.tsx | 168 ++++++++++ .../components/bottom-drawer-modal-host.tsx | 69 ++-- mobile/src/components/keyed-bottom-drawer.tsx | 11 +- .../src/components/mounted-bottom-drawer.tsx | 8 +- mobile/src/tasks/MobileTasksLegacySurface.tsx | 317 +++++++++--------- mobile/src/tasks/mobile-tasks-dependencies.ts | 1 + .../mobile-tasks-project-detail-drawer.tsx | 5 +- 8 files changed, 432 insertions(+), 180 deletions(-) create mode 100644 mobile/src/components/bottom-drawer-modal-host-scope.ts create mode 100644 mobile/src/components/bottom-drawer-modal-host.test.tsx diff --git a/mobile/src/components/bottom-drawer-modal-host-scope.ts b/mobile/src/components/bottom-drawer-modal-host-scope.ts new file mode 100644 index 00000000000..2ec08b4e34d --- /dev/null +++ b/mobile/src/components/bottom-drawer-modal-host-scope.ts @@ -0,0 +1,33 @@ +import { createContext, useContext, useEffect, useRef } from 'react' + +type BottomDrawerModalHostScope = { + /** False only while a self-showing host is down; its drawers render nothing until it is up. */ + shown: boolean + /** Keeps a self-showing host up; null when the caller owns `visible`. */ + hold: (() => () => void) | null + claimBack: (dismiss: () => void) => () => void +} + +export const BottomDrawerModalHostContext = createContext(null) + +/** Non-null when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and + * must therefore skip its own native Modal (the host owns the single Modal). */ +export function useBottomDrawerModalHost(): BottomDrawerModalHostScope | null { + return useContext(BottomDrawerModalHostContext) +} + +/** Back on the host's Modal reaches the host, not BackHandler; the newest claim takes it. */ +export function useBottomDrawerModalHostBackClaim(dismiss: (() => void) | null): void { + const claimBack = useContext(BottomDrawerModalHostContext)?.claimBack + const latest = useRef(dismiss) + useEffect(() => { + latest.current = dismiss + }) + const claimed = dismiss !== null + useEffect(() => { + if (!claimBack || !claimed) { + return + } + return claimBack(() => latest.current?.()) + }, [claimBack, claimed]) +} diff --git a/mobile/src/components/bottom-drawer-modal-host.test.tsx b/mobile/src/components/bottom-drawer-modal-host.test.tsx new file mode 100644 index 00000000000..360addfa7f6 --- /dev/null +++ b/mobile/src/components/bottom-drawer-modal-host.test.tsx @@ -0,0 +1,168 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type Timing = { to: number; callback?: (finished: boolean) => void } + +const animations = vi.hoisted((): { running: Timing[] } => ({ running: [] })) + +vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} })) +vi.mock('../platform/keyboard-occlusion', () => ({ + currentSoftKeyboardHeight: () => 0, + subscribeSoftKeyboard: () => () => {} +})) +vi.mock('react-native', () => ({ + Keyboard: { dismiss: () => {} }, + Modal: 'Modal', + Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, absoluteFillObject: {} }, + View: 'View', + useWindowDimensions: () => ({ width: 412, height: 900 }) +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain: Record = {} + for (const method of [ + 'activeOffsetY', + 'simultaneousWithExternalGesture', + 'onBegin', + 'onUpdate', + 'onEnd' + ]) { + chain[method] = () => chain + } + return { + Gesture: { Pan: () => chain, Native: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('react-native-reanimated', () => ({ + default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' }, + useSharedValue: (initial: number) => ({ value: initial }), + useAnimatedStyle: () => ({}), + useAnimatedScrollHandler: () => () => {}, + withSpring: (to: number) => to, + withTiming: (to: number, _config?: unknown, callback?: (finished: boolean) => void) => { + animations.running.push({ to, callback }) + return to + }, + runOnJS: (fn: () => void) => fn, + interpolate: () => 0, + Extrapolation: { CLAMP: 'clamp' } +})) + +import { BottomDrawer } from './BottomDrawer' +import { BottomDrawerModalHost } from './bottom-drawer-modal-host' + +function finishAnimations(): void { + const finishing = animations.running + animations.running = [] + act(() => { + for (const animation of finishing) { + animation.callback?.(true) + } + }) +} + +type Layers = { detail: boolean; form: boolean } + +const closeDetail = vi.fn() +const closeForm = vi.fn() + +// Mirrors the Tasks surface: the Create Workspace form renders before the issue detail and is +// raised over it by zIndex, both inside one host. +function tasksSheets({ detail, form }: Layers) { + return ( + + + {createElement('Sheet', { name: 'form' })} + + + {createElement('Sheet', { name: 'detail' })} + + + ) +} + +let rendered: ReactTestRenderer | null = null + +function show(layers: Layers): void { + act(() => { + if (rendered) { + rendered.update(tasksSheets(layers)) + } else { + rendered = create(tasksSheets(layers)) + } + }) +} + +function nativeModals() { + return rendered?.root.findAll((node) => String(node.type) === 'Modal') ?? [] +} + +function sheetsInModal(): string[] { + return nativeModals().flatMap((modal) => + modal.findAll((node) => String(node.type) === 'Sheet').map((sheet) => String(sheet.props.name)) + ) +} + +function pressAndroidBack(): void { + act(() => nativeModals()[0]?.props.onRequestClose()) + finishAnimations() +} + +describe('BottomDrawerModalHost without a caller-owned visible', () => { + afterEach(() => { + act(() => rendered?.unmount()) + rendered = null + animations.running = [] + closeDetail.mockReset() + closeForm.mockReset() + }) + + // #21003: iOS refuses a native modal presented over a sibling one, so the form opened from an + // issue detail never appeared. Both sheets must live in the single native Modal. + it('opens a sheet over another inside the one native Modal', () => { + show({ detail: true, form: false }) + finishAnimations() + show({ detail: true, form: true }) + + expect(nativeModals()).toHaveLength(1) + expect(sheetsInModal().sort()).toEqual(['detail', 'form']) + }) + + it('keeps the Modal through the last close animation, then leaves none to swallow taps', () => { + show({ detail: true, form: true }) + finishAnimations() + + show({ detail: true, form: false }) + finishAnimations() + expect(sheetsInModal()).toEqual(['detail']) + + show({ detail: false, form: false }) + expect(sheetsInModal()).toEqual(['detail']) + finishAnimations() + expect(nativeModals()).toHaveLength(0) + }) + + it('hands Back to the sheet on top, then to the one under it', () => { + show({ detail: true, form: false }) + finishAnimations() + show({ detail: true, form: true }) + finishAnimations() + + pressAndroidBack() + expect(closeForm).toHaveBeenCalledTimes(1) + expect(closeDetail).not.toHaveBeenCalled() + + show({ detail: true, form: false }) + finishAnimations() + pressAndroidBack() + expect(closeDetail).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/components/bottom-drawer-modal-host.tsx b/mobile/src/components/bottom-drawer-modal-host.tsx index b31a31136e0..29e294739fe 100644 --- a/mobile/src/components/bottom-drawer-modal-host.tsx +++ b/mobile/src/components/bottom-drawer-modal-host.tsx @@ -1,28 +1,57 @@ -import { createContext, useContext, type ReactNode } from 'react' +import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react' import { Modal } from 'react-native' - -const BottomDrawerModalHostContext = createContext(false) - -/** True when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and - * must therefore skip its own native Modal (the host owns the single Modal). */ -export function useInsideBottomDrawerModalHost(): boolean { - return useContext(BottomDrawerModalHostContext) -} +import { BottomDrawerModalHostContext } from './bottom-drawer-modal-host-scope' type Props = { - visible: boolean - onRequestClose: () => void + /** Omit to show the host while any drawer inside is requested, shown or closing. */ + visible?: boolean + /** Omit to hand Back to the drawer that claimed it last. */ + onRequestClose?: () => void children: ReactNode } // Why: iOS cannot reliably dismiss one native modal and present another in the same -// beat. Flows that swap between sibling drawer modals (e.g. the Create Workspace form -// → its repository/agent pickers) dropped the incoming modal, leaving the sheet dead -// to taps. Hosting every drawer in ONE persistent native Modal makes those swaps -// in-window view changes instead, so no present/dismiss race can eat the transition. +// beat, and refuses outright to present one while a sibling is presented. Flows that +// swap or stack sibling drawer modals (e.g. the Create Workspace form → its pickers, or +// an issue detail → Create Workspace) dropped the incoming modal, leaving the sheet +// dead to taps. Hosting every drawer in ONE native Modal makes those swaps and stacks +// in-window view changes instead, ordered by zIndex. export function BottomDrawerModalHost({ visible, onRequestClose, children }: Props) { - if (!visible) { - return null + const [holds, setHolds] = useState(0) + const backClaims = useRef<(() => void)[]>([]) + const hold = useCallback(() => { + let held = true + setHolds((count) => count + 1) + return () => { + if (held) { + held = false + setHolds((count) => count - 1) + } + } + }, []) + const claimBack = useCallback((dismiss: () => void) => { + const claim = () => dismiss() + backClaims.current.push(claim) + return () => { + backClaims.current = backClaims.current.filter((entry) => entry !== claim) + } + }, []) + const selfShowing = visible === undefined + const shown = selfShowing ? holds > 0 : visible + const scope = useMemo( + () => ({ shown, hold: selfShowing ? hold : null, claimBack }), + [shown, selfShowing, hold, claimBack] + ) + const content = ( + + {children} + + ) + + if (!shown) { + // Why: a self-showing host keeps its drawers mounted while down so a request can raise it. + // They remount inside the Modal, which is safe because none of them is showing anything yet. + return selfShowing ? content : null } return ( backClaims.current.at(-1)?.())} > - - {children} - + {content} ) } diff --git a/mobile/src/components/keyed-bottom-drawer.tsx b/mobile/src/components/keyed-bottom-drawer.tsx index 83265cc079b..db7c6a82c6e 100644 --- a/mobile/src/components/keyed-bottom-drawer.tsx +++ b/mobile/src/components/keyed-bottom-drawer.tsx @@ -1,5 +1,6 @@ -import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { MountedBottomDrawer, type MountedBottomDrawerProps } from './mounted-bottom-drawer' +import { useBottomDrawerModalHost } from './bottom-drawer-modal-host-scope' // iOS cannot present a native Modal while another is still presented, even mid-close, so one // drawer owns what is on screen and swaps sheets only after the previous Modal has unmounted. @@ -70,8 +71,14 @@ export function KeyedBottomDrawer({ ) }, [released]) + const host = useBottomDrawerModalHost() + const holdHost = host?.hold + // Why: a self-showing host stays up from the request until this sheet's close has landed. + const wantsHost = sheet !== null || next.presented !== null || next.released !== null + useLayoutEffect(() => (holdHost && wantsHost ? holdHost() : undefined), [holdHost, wantsHost]) + const presented = next.presented - if (presented === null) { + if (presented === null || host?.shown === false) { return null } return ( diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index 2d2f3e8a371..96fb131fe4d 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -16,7 +16,10 @@ import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height' import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset' import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants' import { bottomDrawerStyles as styles } from './bottom-drawer-styles' -import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host' +import { + useBottomDrawerModalHost, + useBottomDrawerModalHostBackClaim +} from './bottom-drawer-modal-host-scope' import { useResponsiveLayout } from '../layout/responsive-layout' import { useWindowSafeAreaInsets } from '../layout/side-safe-area' import { useBackClaim } from '../navigation/use-back-claim' @@ -73,7 +76,7 @@ export function MountedBottomDrawer({ // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard // transforms below) is unchanged, so phone behavior stays identical. const { isWideLayout, modalMaxWidth } = useResponsiveLayout() - const insideModalHost = useInsideBottomDrawerModalHost() + const insideModalHost = useBottomDrawerModalHost() !== null const fillHeight = fillAvailable ? resolveBottomDrawerFillHeight({ screenHeight, @@ -202,6 +205,7 @@ export function MountedBottomDrawer({ } : null ) + useBottomDrawerModalHostBackClaim(visible && interactive ? dismiss : null) const scrollHandler = useAnimatedScrollHandler((event) => { scrollOffsetY.value = Math.max(event.contentOffset.y, 0) diff --git a/mobile/src/tasks/MobileTasksLegacySurface.tsx b/mobile/src/tasks/MobileTasksLegacySurface.tsx index 6bdef8a3c75..382c7cc186f 100644 --- a/mobile/src/tasks/MobileTasksLegacySurface.tsx +++ b/mobile/src/tasks/MobileTasksLegacySurface.tsx @@ -7,6 +7,7 @@ import { AlertTriangle, colors, ActionSheetModal, + BottomDrawerModalHost, GitBranch, ConfirmModal } from './mobile-tasks-dependencies' @@ -180,202 +181,210 @@ export function MobileTasksLegacySurface({ model }: { model: ConnectionPresentat {renderMobileTasksListSurface(model)} - {renderMobileTasksProviderPicker(model)} + {/* Why: Tasks stacks sheets (an issue detail under Create Workspace or a confirm), and iOS + refuses a native modal over a presented sibling, so they share one Modal, ordered by zIndex. */} + + {renderMobileTasksProviderPicker(model)} - {renderMobileTasksRepoPicker(model)} + {renderMobileTasksRepoPicker(model)} - {renderMobileTasksGitHubIssueSourcePicker(model)} + {renderMobileTasksGitHubIssueSourcePicker(model)} - {renderMobileTasksGitHubViewPicker(model)} + {renderMobileTasksGitHubViewPicker(model)} - {renderMobileTasksGitHubPresetPicker(model)} + {renderMobileTasksGitHubPresetPicker(model)} - {renderMobileTasksPagePicker(model)} + {renderMobileTasksPagePicker(model)} - {renderMobileTasksGitHubProjectPicker(model)} + {renderMobileTasksGitHubProjectPicker(model)} - {renderMobileTasksGitHubProjectViewPicker(model)} + {renderMobileTasksGitHubProjectViewPicker(model)} - {renderMobileTasksGitHubProjectSortPicker(model)} + {renderMobileTasksGitHubProjectSortPicker(model)} - {renderMobileTasksGitHubProjectFieldsPicker(model)} + {renderMobileTasksGitHubProjectFieldsPicker(model)} - {renderMobileTasksGitLabViewPicker(model)} + {renderMobileTasksGitLabViewPicker(model)} - {renderMobileTasksGitLabFilterPicker(model)} + {renderMobileTasksGitLabFilterPicker(model)} - {renderMobileTasksLinearFilterPicker(model)} - {renderMobileTasksJiraFilterPicker(model)} - {renderMobileTasksJiraSitePicker(model)} + {renderMobileTasksLinearFilterPicker(model)} + {renderMobileTasksJiraFilterPicker(model)} + {renderMobileTasksJiraSitePicker(model)} - {renderMobileTasksLinearWorkspacePicker(model)} + {renderMobileTasksLinearWorkspacePicker(model)} - {renderMobileTasksLinearTeamPicker(model)} + {renderMobileTasksLinearTeamPicker(model)} - {renderMobileTasksLinearStatusPicker(model)} + {renderMobileTasksLinearStatusPicker(model)} - {renderMobileTasksLinearViewPicker(model)} + {renderMobileTasksLinearViewPicker(model)} - {renderMobileTasksLinearGroupPicker(model)} + {renderMobileTasksLinearGroupPicker(model)} - {renderMobileTasksLinearOrderPicker(model)} + {renderMobileTasksLinearOrderPicker(model)} - {renderMobileTasksLinearDisplayPicker(model)} + {renderMobileTasksLinearDisplayPicker(model)} - {renderMobileTasksSortPicker(model)} + {renderMobileTasksSortPicker(model)} - {renderMobileTasksCreateDrawer(model)} + {renderMobileTasksCreateDrawer(model)} - {renderMobileTasksCreateTargetPicker(model)} + {renderMobileTasksCreateTargetPicker(model)} - {renderMobileTasksLinearConnectDrawer(model)} + {renderMobileTasksLinearConnectDrawer(model)} - {renderMobileTasksWorkspaceCreateTargetPicker(model)} + {renderMobileTasksWorkspaceCreateTargetPicker(model)} - {renderMobileTasksWorkspaceCreateDrawer(model)} + {renderMobileTasksWorkspaceCreateDrawer(model)} - {renderMobileTasksWorkspaceCreateRepoPicker(model)} + {renderMobileTasksWorkspaceCreateRepoPicker(model)} - {renderMobileTasksWorkspaceAgentPicker(model)} + {renderMobileTasksWorkspaceAgentPicker(model)} - {renderMobileTasksWorkspaceBaseBranchPicker(model)} + {renderMobileTasksWorkspaceBaseBranchPicker(model)} - {renderMobileTasksWorkspaceSparsePicker(model)} + {renderMobileTasksWorkspaceSparsePicker(model)} - {renderMobileTasksWorkspaceSparseDrawer(model)} + {renderMobileTasksWorkspaceSparseDrawer(model)} - {renderMobileTasksSetupTrustDrawer(model)} + {renderMobileTasksSetupTrustDrawer(model)} - {renderMobileTasksOrcaYamlTrustDrawer(model)} + {renderMobileTasksOrcaYamlTrustDrawer(model)} - {renderMobileTasksProjectMissingRepoDrawer(model)} + {renderMobileTasksProjectMissingRepoDrawer(model)} - {renderMobileTasksProjectDetailDrawer(model)} + {renderMobileTasksProjectDetailDrawer(model)} - {renderMobileTasksItemDetailDrawer(model)} + {renderMobileTasksItemDetailDrawer(model)} - ({ - label: getHostedReviewMergeMethodLabel(method), - icon: GitBranch, - onPress: () => { - setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method }) - } - })) - : [] - } - onClose={() => setMergeMethodProjectRow(null)} - /> - ({ - label: - mergeMethodTaskItem.provider === 'gitlab' && method === 'merge' - ? 'Merge' - : getHostedReviewMergeMethodLabel(method), - icon: GitBranch, - onPress: () => { - const item = mergeMethodTaskItem - setPendingHostedMerge({ item, method }) - } - })) - : [] - } - onClose={() => setMergeMethodTaskItem(null)} - /> - { - if (!taskUiReady || !pendingHostedMerge) { - return + ({ + label: getHostedReviewMergeMethodLabel(method), + icon: GitBranch, + onPress: () => { + setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method }) + } + })) + : [] } - void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method) - }} - onCancel={() => setPendingHostedMerge(null)} - /> - { - if (!taskUiReady || !pendingProjectGitHubMerge) { - return + onClose={() => setMergeMethodProjectRow(null)} + /> + setPendingProjectGitHubMerge(null)} - /> - { - if (!taskUiReady || !pendingHostedStateChange) { - return + actions={ + mergeMethodTaskItem + ? (mergeMethodTaskItem.provider === 'gitlab' + ? (['merge', 'squash', 'rebase'] as const) + : (['squash', 'merge', 'rebase'] as const) + ).map((method) => ({ + label: + mergeMethodTaskItem.provider === 'gitlab' && method === 'merge' + ? 'Merge' + : getHostedReviewMergeMethodLabel(method), + icon: GitBranch, + onPress: () => { + const item = mergeMethodTaskItem + setPendingHostedMerge({ item, method }) + } + })) + : [] } - if (pendingHostedStateChange.source === 'task') { - if (pendingHostedStateChange.item.provider === 'gitlab') { - void toggleGitLabStatus(pendingHostedStateChange.item) + onClose={() => setMergeMethodTaskItem(null)} + /> + { + if (!taskUiReady || !pendingHostedMerge) { return } - void toggleGitHubStatus(pendingHostedStateChange.item) - return + void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method) + }} + onCancel={() => setPendingHostedMerge(null)} + /> + setPendingHostedStateChange(null)} - /> + confirmLabel={ + pendingProjectGitHubMerge + ? getHostedReviewMergeMethodLabel(pendingProjectGitHubMerge.method) + : 'Merge' + } + onConfirm={() => { + if (!taskUiReady || !pendingProjectGitHubMerge) { + return + } + void mergeProjectGitHubPullRequest( + pendingProjectGitHubMerge.row, + pendingProjectGitHubMerge.method + ) + }} + onCancel={() => setPendingProjectGitHubMerge(null)} + /> + { + if (!taskUiReady || !pendingHostedStateChange) { + return + } + if (pendingHostedStateChange.source === 'task') { + if (pendingHostedStateChange.item.provider === 'gitlab') { + void toggleGitLabStatus(pendingHostedStateChange.item) + return + } + void toggleGitHubStatus(pendingHostedStateChange.item) + return + } + void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, { + state: pendingHostedStateChange.nextState + }) + }} + onCancel={() => setPendingHostedStateChange(null)} + /> + ) } diff --git a/mobile/src/tasks/mobile-tasks-dependencies.ts b/mobile/src/tasks/mobile-tasks-dependencies.ts index aa473eb04af..2be9049c975 100644 --- a/mobile/src/tasks/mobile-tasks-dependencies.ts +++ b/mobile/src/tasks/mobile-tasks-dependencies.ts @@ -65,6 +65,7 @@ export { classifyConnection } from '../transport/connection-health' export { StatusDot } from '../components/StatusDot' export { ActionSheetModal } from '../components/ActionSheetModal' export { BottomDrawer } from '../components/BottomDrawer' +export { BottomDrawerModalHost } from '../components/bottom-drawer-modal-host' export { ConfirmModal } from '../components/ConfirmModal' export { MobileMarkdown } from '../components/MobileMarkdown' export { MobileAgentIcon } from '../components/MobileAgentIcon' diff --git a/mobile/src/tasks/mobile-tasks-project-detail-drawer.tsx b/mobile/src/tasks/mobile-tasks-project-detail-drawer.tsx index 47a071d75f4..31bf98791cc 100644 --- a/mobile/src/tasks/mobile-tasks-project-detail-drawer.tsx +++ b/mobile/src/tasks/mobile-tasks-project-detail-drawer.tsx @@ -22,7 +22,8 @@ import { SHOW_MOBILE_PROJECT_METADATA_EDITORS, githubProjectOptionColor, canCreateWorkspaceFromProjectRow, - projectRowType + projectRowType, + TASK_SECONDARY_DRAWER_Z_INDEX } from './mobile-tasks-legacy-foundation' import { renderMobileTasksProjectFieldEditors } from './mobile-tasks-project-field-editors' import { @@ -45,6 +46,8 @@ export function renderMobileTasksProjectMissingRepoDrawer(model: ConnectionPrese onClose={() => { setProjectRepoNotInOrca(null) }} + // Why: opens over the project detail, which renders after it in the shared host. + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX} > {projectRepoNotInOrca ? ( From fbb93b73851462dd3d6967a1392d5bc0bb47d041 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:15:23 +0000 Subject: [PATCH 02/17] fix(native-chat): send typing after a click to the chat's composer (#28728) * fix(native-chat): send typing after a click to the chat's composer The default Claude/Codex chat never wired the root typing redirect, so a click in the transcript, on a toggle or link, on the chat's own tab, or on blank app chrome left typed keys with nowhere to go. The chat-view-over-TUI had the redirect but treated every button and link as a key owner. Both views now share one hook that runs the reveal focus, focuses the chat on blank clicks, routes keys typed inside the chat (but not in overlays it portals out), and, while it is the only chat visible in the focused group, routes keys typed with focus on the page body. Buttons and links pass letters but keep Space and Enter. Re-activating an already visible chat tab re-runs the composer claim, as a terminal tab refocuses xterm. * fix(native-chat): refocus the composer when its active tab is activated again Re-activating an already visible chat tab re-ran the ordinary reveal claim, which stops as soon as focus is anywhere inside the pane. With the transcript or a code-block Copy button focused, the tab shortcut left focus there and Space kept scrolling or pressing the button. The activation now moves focus to the composer from inside the pane too, unless a text field such as the find input holds it; an ordinary reveal still keeps the user's focus. --- .../native-chat/NativeChatResolvedView.tsx | 14 +- .../NativeChatStructuredSession.test.tsx | 21 +++ .../NativeChatStructuredSession.tsx | 12 +- ...native-chat-composer-reveal-focus.test.tsx | 97 +++++++++++- .../native-chat-root-key-routing.ts | 9 +- .../native-chat-typing-redirect.test.ts | 62 +++++++- .../native-chat-typing-redirect.ts | 38 +++-- .../use-native-chat-composer-reveal-focus.ts | 48 +++++- ...se-native-chat-root-input-routing.test.tsx | 147 ++++++++++++++++++ .../use-native-chat-root-input-routing.ts | 78 ++++++++++ .../lib/focus-terminal-tab-surface.test.ts | 11 +- .../src/lib/focus-terminal-tab-surface.ts | 5 + ...tured-agent-session-tab-activation.test.ts | 8 +- ...structured-agent-session-tab-activation.ts | 2 + 14 files changed, 502 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index cbdf7751baf..ffceaa14959 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -2,7 +2,6 @@ import { cn } from '@/lib/utils' import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS } from './native-chat-appearance-style' import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' import { useAppStore } from '../../store' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { useNativeChatRetainedSession } from './use-native-chat-retained-session' @@ -45,8 +44,7 @@ import { deriveNativeChatStreamingText, nativeChatStreamingMessage } from '../../../../shared/native-chat-streaming' -import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect' -import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' +import { useNativeChatRootInputRouting } from './use-native-chat-root-input-routing' import { emptyNativeChatContextMenuActions, useNativeChatContextMenu @@ -237,9 +235,11 @@ export function NativeChatResolvedView({ }) const { card: shownPromptCard, collapsedCard } = promptCardPresentation const mountedPromptCard = shownPromptCard ?? collapsedCard - useNativeChatComposerRevealFocus({ + const rootInput = useNativeChatRootInputRouting({ + tabId: terminalTabId, rootRef, composerRef, + questionAnswerInputRef, isVisible, isFocusedGroup, composerReady: shownPromptCard === null && targetPtyId !== null && canSend @@ -347,9 +347,7 @@ export function NativeChatResolvedView({ event.stopPropagation() return } - if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { - rootRef.current?.focus({ preventScroll: true }) - } + rootInput.onPointerDownCapture(event) }} onKeyDownCapture={(event) => { find.onKeyDownCapture(event) @@ -366,7 +364,7 @@ export function NativeChatResolvedView({ } return } - routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current) + rootInput.onKeyDownCapture(event) }} onContextMenuCapture={contextMenu.onContextMenuCapture} className={cn( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 0f8150db254..6263afeb4fb 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -247,6 +247,27 @@ describe('NativeChatStructuredSession', () => { act(() => useAppStore.getState().clearNativeChatLaunchDraft(draft.tabId)) }) + it('focuses the chat on a click outside its controls and sends typing there to the composer', () => { + const { container } = render( + + ) + const root = container.querySelector('[data-native-chat-root]') + if (!(root instanceof HTMLElement)) { + throw new Error('chat root missing') + } + fireEvent.pointerDown(root, { button: 0 }) + expect(document.activeElement).toBe(root) + + expect(fireEvent.keyDown(root, { key: 'h' })).toBe(false) + }) + it('wires remote structured file links through the host-aware native chat opener', () => { render( { + paneCommands.onPointerDownCapture(event) + rootInput.onPointerDownCapture(event) + }} onKeyDownCapture={(event) => { find.onKeyDownCapture(event) paneCommands.onKeyDownCapture(event) + rootInput.onKeyDownCapture(event) }} onContextMenuCapture={paneCommands.onContextMenuCapture} className={cn( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index 466082fe0c7..daab2c0bf50 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { act, createElement, useRef } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' +import { + requestNativeChatComposerFocus, + useNativeChatComposerRevealFocus +} from './use-native-chat-composer-reveal-focus' import type { NativeChatComposerHandle } from './NativeChatComposer' type HarnessProps = { @@ -58,6 +61,7 @@ function Harness(props: HarnessProps): React.JSX.Element { contains: () => false } useNativeChatComposerRevealFocus({ + tabId: 'chat-tab', rootRef, composerRef, isVisible: props.isVisible, @@ -71,7 +75,8 @@ function Harness(props: HarnessProps): React.JSX.Element { 'div', { ref: rootRef }, createElement('textarea', { ref: fieldRef, 'data-testid': 'composer' }), - createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }) + createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }), + createElement('input', { 'data-testid': 'find-input' }) ) } @@ -98,6 +103,86 @@ afterEach(() => { }) describe('useNativeChatComposerRevealFocus', () => { + it('claims the composer again when its revealed tab is activated', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const tab = document.createElement('div') + tab.tabIndex = 0 + document.body.append(tab) + tab.focus() + + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + + expect(container.querySelector('textarea')).toBe(document.activeElement) + }) + + it('takes focus from a control inside the pane when its tab is activated', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const button = container.querySelector('button') + act(() => { + button?.focus() + }) + + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + + expect(container.querySelector('textarea')).toBe(document.activeElement) + }) + + it('leaves the find input alone when its tab is activated', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const findInput = container.querySelector('input') + act(() => { + findInput?.focus() + }) + const before = focusCalls + + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + + expect(focusCalls).toBe(before) + expect(document.activeElement).toBe(findInput) + }) + + it('does not carry an unfinished tab activation into a later reveal', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + act(() => requestNativeChatComposerFocus('chat-tab')) + render({ isVisible: false, isFocusedGroup: true }) + const button = container.querySelector('button') + act(() => { + button?.focus() + }) + const before = focusCalls + + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + + expect(focusCalls).toBe(before) + expect(document.activeElement).toBe(button) + }) + + it('ignores a tab activation while the chat is not revealed or for another tab', () => { + render({ isVisible: true, isFocusedGroup: false }) + drainFrames() + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + expect(focusCalls).toBe(0) + + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const before = focusCalls + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + act(() => requestNativeChatComposerFocus('other-tab')) + drainFrames() + expect(focusCalls).toBe(before) + }) + it('focuses when it mounts already revealed', () => { render({ isVisible: true, isFocusedGroup: true }) expect(focusCalls).toBe(0) @@ -228,7 +313,9 @@ describe('useNativeChatComposerRevealFocus', () => { render({ isVisible: false, isFocusedGroup: true }) drainFrames() act(() => { - ;(document.activeElement as HTMLElement | null)?.blur() + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } }) render({ isVisible: true, isFocusedGroup: true }) drainFrames() @@ -265,7 +352,9 @@ describe('useNativeChatComposerRevealFocus', () => { render({ isVisible: true, isFocusedGroup: true, composerReady: false }) drainFrames() act(() => { - ;(document.activeElement as HTMLElement | null)?.blur() + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } }) render({ isVisible: true, isFocusedGroup: true, composerReady: true }) drainFrames() diff --git a/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts b/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts index 532d08ab576..129762e1ca4 100644 --- a/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts @@ -18,11 +18,11 @@ export function routeNativeChatRootKeyToInput( composer: NativeChatComposerHandle | null, questionAnswerInput: HTMLInputElement | null ): void { - // The focused transcript owns Space paging; typing elsewhere still reaches the composer. + // A focused transcript or code box owns Space paging; typing elsewhere still reaches the composer. if ( event.key === ' ' && event.target instanceof HTMLElement && - event.target.matches('[data-native-chat-scroll]') && + event.target.matches('[data-native-chat-scroll], [data-native-chat-code-content]') && event.target.ownerDocument.activeElement === event.target ) { return @@ -35,12 +35,13 @@ export function routeNativeChatRootKeyToInput( } // The question card replaces the composer, so at most one input exists. If it // cannot take focus (disabled), the paste reaches the bridge, which explains why. - if (shouldFocusNativeChatInputForPaste(event, getShortcutPlatform() === 'darwin')) { + const isMac = getShortcutPlatform() === 'darwin' + if (shouldFocusNativeChatInputForPaste(event, isMac)) { const input = composer ?? questionAnswerInput input?.focus() return } - if (!shouldRedirectNativeChatTyping(event)) { + if (!shouldRedirectNativeChatTyping(event, isMac)) { return } if (!composer?.insertTypedText(event.key)) { diff --git a/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts b/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts index b2e7f80b726..b357a40ef70 100644 --- a/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { describe, expect, it } from 'vitest' import { shouldFocusNativeChatComposerFromEditingKey, @@ -29,22 +30,69 @@ function interactiveTarget(): EventTarget { describe('shouldRedirectNativeChatTyping', () => { it('redirects printable typing from the native chat pane', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'x' }))).toBe(true) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'x' }), true)).toBe(true) }) it('does not redirect shortcuts or non-printable keys', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'Enter' }))).toBe(false) - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', metaKey: true }))).toBe(false) - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', ctrlKey: true }))).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'Enter' }), true)).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', metaKey: true }), true)).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', ctrlKey: true }), true)).toBe(false) }) it('does not redirect IME composition or already-handled events', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ isComposing: true }))).toBe(false) - expect(shouldRedirectNativeChatTyping(keyEvent({ defaultPrevented: true }))).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ isComposing: true }), true)).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ defaultPrevented: true }), true)).toBe(false) }) it('leaves interactive targets alone', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ target: interactiveTarget() }))).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ target: interactiveTarget() }), true)).toBe( + false + ) + }) + + it('passes typed characters but not Space through a focused button or link', () => { + const button = document.createElement('button') + const link = document.createElement('a') + link.href = 'https://example.com' + const roleButton = document.createElement('div') + roleButton.setAttribute('role', 'button') + for (const target of [button, link, roleButton]) { + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'h', target }), true)).toBe(true) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: ' ', target }), true)).toBe(false) + } + }) + + it('lets Backspace/Delete and paste pass a focused button but not a text field', () => { + const button = document.createElement('button') + const field = document.createElement('input') + expect( + shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'Backspace', target: button })) + ).toBe(true) + expect( + shouldFocusNativeChatInputForPaste( + keyEvent({ key: 'v', metaKey: true, target: button }), + true + ) + ).toBe(true) + expect( + shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'Backspace', target: field })) + ).toBe(false) + expect(shouldFocusNativeChatPaneFromPointerTarget(button)).toBe(false) + }) + + it('types Option characters on macOS but leaves Alt chords elsewhere', () => { + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'å', altKey: true }), true)).toBe(true) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'z', altKey: true }), false)).toBe(false) + }) + + it('leaves typing with controls that own letters', () => { + const option = document.createElement('div') + option.setAttribute('role', 'option') + const optionInButton = document.createElement('button') + optionInButton.setAttribute('role', 'menuitem') + for (const target of [document.createElement('input'), option, optionInButton]) { + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'h', target }), true)).toBe(false) + } }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts b/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts index 2a47c1669a7..61f9417043f 100644 --- a/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts +++ b/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts @@ -9,14 +9,14 @@ export type KeyboardRedirectEvent = { target: EventTarget | null } -const INTERACTIVE_TARGET_SELECTOR = [ - 'a[href]', - 'button', +// Plain pressables use only Space and Enter, so other keys may pass them to the chat's input. +const PRESSABLE_TARGET_SELECTOR = ['a[href]', 'button', '[role="button"]'].join(',') + +const KEY_OWNER_TARGET_SELECTOR = [ 'input', 'select', 'textarea', '[contenteditable]:not([contenteditable="false"])', - '[role="button"]', '[role="checkbox"]', '[role="combobox"]', '[role="menuitem"]', @@ -28,21 +28,33 @@ const INTERACTIVE_TARGET_SELECTOR = [ '[data-native-chat-typing-redirect-ignore="true"]' ].join(',') -export function shouldRedirectNativeChatTyping(event: KeyboardRedirectEvent): boolean { +export function shouldRedirectNativeChatTyping( + event: KeyboardRedirectEvent, + isMac: boolean +): boolean { if ( event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || + // Option types characters on macOS; elsewhere Alt+key is a shortcut. + (event.altKey === true && !isMac) || event.key.length !== 1 ) { return false } - return !isNativeChatInteractiveTarget(event.target) + if (targetMatches(event.target, KEY_OWNER_TARGET_SELECTOR)) { + return false + } + // Space presses a focused button or link. + return event.key !== ' ' || !targetMatches(event.target, PRESSABLE_TARGET_SELECTOR) } export function shouldFocusNativeChatPaneFromPointerTarget(target: EventTarget | null): boolean { - return !isNativeChatInteractiveTarget(target) + return ( + !targetMatches(target, PRESSABLE_TARGET_SELECTOR) && + !targetMatches(target, KEY_OWNER_TARGET_SELECTOR) + ) } /** Unmodified Backspace/Delete pressed outside any input should focus the @@ -62,7 +74,7 @@ export function shouldFocusNativeChatComposerFromEditingKey(event: KeyboardRedir ) { return false } - return !isNativeChatInteractiveTarget(event.target) + return !targetMatches(event.target, KEY_OWNER_TARGET_SELECTOR) } /** The platform paste chord pressed outside any input: the caller focuses an @@ -78,15 +90,11 @@ export function shouldFocusNativeChatInputForPaste( ? event.metaKey && !event.ctrlKey && event.key.toLowerCase() === 'v' : (event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'v') || (event.shiftKey === true && !event.ctrlKey && !event.metaKey && event.key === 'Insert') - return chord && !isNativeChatInteractiveTarget(event.target) + return chord && !targetMatches(event.target, KEY_OWNER_TARGET_SELECTOR) } -function isNativeChatInteractiveTarget(target: EventTarget | null): boolean { - const element = eventTargetElement(target) - if (!element) { - return false - } - return element.closest(INTERACTIVE_TARGET_SELECTOR) !== null +function targetMatches(target: EventTarget | null, selector: string): boolean { + return eventTargetElement(target)?.closest(selector) != null } function eventTargetElement(target: EventTarget | null): Element | null { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 7bc41652b0b..054d32d743e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { RefObject } from 'react' import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers' import { scheduleNextFrame } from '@/components/terminal-pane/terminal-ime-input-context-refresh' @@ -7,7 +7,16 @@ import type { NativeChatComposerHandle } from './NativeChatComposer' /** Focus attempts per reveal, including recovery from delayed programmatic restoration. */ const REVEAL_FOCUS_FRAMES = 6 -type NativeChatComposerRevealFocusArgs = { +const revealedComposerReclaims = new Map void>() + +/** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */ +export function requestNativeChatComposerFocus(tabId: string): void { + revealedComposerReclaims.get(tabId)?.() +} + +export type NativeChatComposerRevealFocusArgs = { + /** The tab whose activation re-runs the claim (the terminal tab for a chat view over a TUI). */ + tabId: string rootRef: RefObject composerRef: RefObject isVisible: boolean @@ -25,6 +34,7 @@ type NativeChatComposerRevealFocusArgs = { * the reveal edge, not mount, is the signal. */ export function useNativeChatComposerRevealFocus({ + tabId, rootRef, composerRef, isVisible, @@ -34,10 +44,31 @@ export function useNativeChatComposerRevealFocus({ }: NativeChatComposerRevealFocusArgs): void { const revealed = isVisible && isFocusedGroup const claimedRef = useRef(false) + // An explicit tab activation takes focus from inside the pane too, short of a text field. + const explicitRef = useRef(false) + const [reclaimCount, setReclaimCount] = useState(0) + + useEffect(() => { + if (!revealed || !composerReady) { + return + } + const reclaim = (): void => { + claimedRef.current = false + explicitRef.current = true + setReclaimCount((count) => count + 1) + } + revealedComposerReclaims.set(tabId, reclaim) + return () => { + if (revealedComposerReclaims.get(tabId) === reclaim) { + revealedComposerReclaims.delete(tabId) + } + } + }, [composerReady, revealed, tabId]) useEffect(() => { if (!revealed || !composerReady) { claimedRef.current = false + explicitRef.current = false return } if (claimedRef.current) { @@ -61,6 +92,7 @@ export function useNativeChatComposerRevealFocus({ } const finish = (): void => { claimedRef.current = true + explicitRef.current = false stopWatchingUserIntent() } ownerDocument?.addEventListener('pointerdown', cancelForPointer, true) @@ -76,12 +108,16 @@ export function useNativeChatComposerRevealFocus({ const active = rootRef.current?.ownerDocument.activeElement ?? null // Focus already inside this pane is our own take landing or the user's click; either ends it. // A just-collapsed card is hidden and inert, so focus left in it is about to drop to body. - if (rootRef.current?.contains(active) === true && !active?.closest('[hidden], [inert]')) { + if ( + !explicitRef.current && + rootRef.current?.contains(active) === true && + !active?.closest('[hidden], [inert]') + ) { finish() return } - // Why: a live text field elsewhere is a user edit — a batch worktree-create keeps its - // next name field open behind the pane we just revealed. + // Why: a live text field is a user edit — a batch worktree-create keeps its next name + // field open behind the pane we just revealed, and the find bar keeps its query. if (shouldPreserveEditableFocus(active)) { finish() return @@ -101,5 +137,5 @@ export function useNativeChatComposerRevealFocus({ cancelled = true stopWatchingUserIntent() } - }, [composerReady, composerRef, revealed, rootRef, scheduleFrame]) + }, [composerReady, composerRef, reclaimCount, revealed, rootRef, scheduleFrame]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx new file mode 100644 index 00000000000..f85c5df20de --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { useRef } from 'react' +import { createPortal } from 'react-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatComposerHandle } from './native-chat-composer-types' +import { useNativeChatRootInputRouting } from './use-native-chat-root-input-routing' + +const overlay = vi.hoisted(() => ({ visible: false })) +vi.mock('@/lib/visible-overlay', () => ({ hasVisibleOverlay: () => overlay.visible })) + +const insertTypedText = vi.fn(() => true) +const composer: NativeChatComposerHandle = { + focus: () => true, + insertTypedText, + appendText: () => {}, + acceptsText: () => true, + handlePasteEvent: () => {}, + pasteFromClipboard: () => {}, + contains: () => false +} + +function Chat({ revealed }: { revealed: boolean }): React.JSX.Element { + const rootRef = useRef(null) + const composerRef = useRef(composer) + const answerRef = useRef(null) + const routing = useNativeChatRootInputRouting({ + tabId: 'chat-tab', + rootRef, + composerRef, + questionAnswerInputRef: answerRef, + isVisible: true, + isFocusedGroup: revealed, + composerReady: true + }) + return ( +
+
Reply text
+ + example.com +
+ Option +
+
+ {createPortal( +
+ +
, + document.body + )} +
+ ) +} + +afterEach(() => { + cleanup() + overlay.visible = false + insertTypedText.mockClear() + document.body.replaceChildren() +}) + +describe('useNativeChatRootInputRouting', () => { + it('types into the composer from the transcript and from a focused button or link', () => { + const view = render() + fireEvent.keyDown(view.getByTestId('transcript'), { key: 'h' }) + fireEvent.keyDown(view.getByRole('button', { name: 'Copy code' }), { key: 'i' }) + fireEvent.keyDown(view.getByRole('link', { name: 'example.com' }), { key: '!' }) + + expect(insertTypedText.mock.calls).toEqual([['h'], ['i'], ['!']]) + }) + + it('leaves Space with a focused button or link and letters with a composite control', () => { + const view = render() + expect(fireEvent.keyDown(view.getByRole('button', { name: 'Copy code' }), { key: ' ' })).toBe( + true + ) + expect(fireEvent.keyDown(view.getByRole('link', { name: 'example.com' }), { key: ' ' })).toBe( + true + ) + expect(fireEvent.keyDown(view.getByRole('option'), { key: 'o' })).toBe(true) + const code = view.getByTestId('code') + code.focus() + expect(fireEvent.keyDown(code, { key: ' ' })).toBe(true) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('leaves keys in a dialog the chat portals out with that dialog', () => { + const view = render() + fireEvent.keyDown(view.getByRole('button', { name: 'Close preview' }), { key: 'a' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('types into the composer when nothing has focus and the chat is revealed', () => { + render() + expect(fireEvent.keyDown(document.body, { key: 'q' })).toBe(false) + + expect(insertTypedText).toHaveBeenCalledWith('q') + }) + + it('leaves keys typed with nothing focused alone while hidden, under an overlay, or after unmount', () => { + const view = render() + fireEvent.keyDown(document.body, { key: 'a' }) + + view.rerender() + overlay.visible = true + fireEvent.keyDown(document.body, { key: 'b' }) + overlay.visible = false + + view.unmount() + fireEvent.keyDown(document.body, { key: 'c' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('leaves keys typed with nothing focused alone while another chat is also revealed', () => { + render( + <> + + + + ) + fireEvent.keyDown(document.body, { key: 'a' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('leaves keys aimed at controls outside the chat alone', () => { + render() + const field = document.createElement('input') + const list = document.createElement('div') + list.setAttribute('role', 'listbox') + list.tabIndex = 0 + document.body.append(field, list) + + fireEvent.keyDown(field, { key: 'a' }) + fireEvent.keyDown(list, { key: 'b' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts new file mode 100644 index 00000000000..cd88ce5da42 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts @@ -0,0 +1,78 @@ +import { + useCallback, + useEffect, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + type RefObject +} from 'react' +import { hasVisibleOverlay } from '@/lib/visible-overlay' +import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' +import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect' +import { + useNativeChatComposerRevealFocus, + type NativeChatComposerRevealFocusArgs +} from './use-native-chat-composer-reveal-focus' + +// Revealed chat roots in this window: a floating panel can reveal one beside the workspace's. +const revealedChatRoots = new Set() + +/** Brings focus to the chat's input when it is revealed, and sends keys typed in the chat, or + * with nothing focused around it, to that input. */ +export function useNativeChatRootInputRouting( + args: Omit & { + questionAnswerInputRef: RefObject + } +): { + onPointerDownCapture: (event: ReactPointerEvent) => void + onKeyDownCapture: (event: ReactKeyboardEvent) => void +} { + const { rootRef, composerRef, questionAnswerInputRef } = args + useNativeChatComposerRevealFocus(args) + // The one chat visible in the focused group takes keys typed with nothing focused. + const revealed = args.isVisible && args.isFocusedGroup + useEffect(() => { + const root = rootRef.current + if (!revealed || !root) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + // Focus on or the pane's own cover (a click on blank chrome) has no key owner; + // with two chats revealed it has no clear target either. + if ( + !(event.target instanceof Node) || + event.target === root || + !event.target.contains(root) || + revealedChatRoots.size !== 1 || + hasVisibleOverlay({ ignoreDismissed: true }) + ) { + return + } + routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current) + } + revealedChatRoots.add(root) + root.ownerDocument.addEventListener('keydown', onKeyDown) + return () => { + revealedChatRoots.delete(root) + root.ownerDocument.removeEventListener('keydown', onKeyDown) + } + }, [composerRef, questionAnswerInputRef, revealed, rootRef]) + + const onPointerDownCapture = useCallback( + (event: ReactPointerEvent) => { + if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { + rootRef.current?.focus({ preventScroll: true }) + } + }, + [rootRef] + ) + const onKeyDownCapture = useCallback( + (event: ReactKeyboardEvent) => { + // Menus and dialogs the chat portals out still bubble through its React tree. + if (event.target instanceof Node && rootRef.current?.contains(event.target)) { + routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current) + } + }, + [composerRef, questionAnswerInputRef, rootRef] + ) + return { onPointerDownCapture, onKeyDownCapture } +} diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts index da28ad74eb3..a019e583ac5 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { focusTerminalTabSurface } from './focus-terminal-tab-surface' const mocks = vi.hoisted(() => ({ - refreshTerminalImeInputContext: vi.fn() + refreshTerminalImeInputContext: vi.fn(), + requestNativeChatComposerFocus: vi.fn() +})) + +vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ + requestNativeChatComposerFocus: mocks.requestNativeChatComposerFocus })) vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ @@ -18,6 +23,7 @@ const GLOBAL_HELPER_SELECTOR = describe('focusTerminalTabSurface', () => { afterEach(() => { mocks.refreshTerminalImeInputContext.mockClear() + mocks.requestNativeChatComposerFocus.mockClear() vi.unstubAllGlobals() }) @@ -104,7 +110,7 @@ describe('focusTerminalTabSurface', () => { expect(textarea.focus).not.toHaveBeenCalled() }) - it('does not focus xterm while chat covers the terminal tab', () => { + it('hands the focus to the chat composer instead of xterm while chat covers the terminal tab', () => { flushAnimationFrames() const textarea = { focus: vi.fn() } vi.stubGlobal('document', { @@ -121,6 +127,7 @@ describe('focusTerminalTabSurface', () => { focusTerminalTabSurface('tab-1') expect(textarea.focus).not.toHaveBeenCalled() + expect(mocks.requestNativeChatComposerFocus).toHaveBeenCalledWith('tab-1') }) it('skips the chat leaf helper when a split chat tab has an active terminal leaf', () => { diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts index bf7dc3ed991..f0230a5e6d9 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -1,5 +1,6 @@ import { refreshTerminalImeInputContext } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { UNCOVERED_TERMINAL_LEAF_SELECTOR } from '@/components/terminal-pane/native-chat-covered-pane' +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' /** * Move keyboard focus into the xterm instance for a freshly-mounted terminal @@ -73,6 +74,10 @@ export function focusTerminalTabSurface( const escapedTabId = cssAttributeString(tabId) const tabElement = document.querySelector(`[data-terminal-tab-id="${escapedTabId}"]`) if (tabElement?.getAttribute('data-terminal-chat-view') === 'true') { + // The chat over this terminal takes the focus in its composer instead. + if (!options.onlyIfFocusUnclaimed || document.activeElement === document.body) { + requestNativeChatComposerFocus(tabId) + } return } // Why: a split chat tab keeps a covered xterm under the chat leaf; the diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts index 866669438ae..75a71085a82 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(async () => ({ ok: true })), focusGroup: vi.fn(), setActiveTabType: vi.fn(), + requestNativeChatComposerFocus: vi.fn(), state: { unifiedTabsByWorktree: {} } as Record })) @@ -13,6 +14,10 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ + requestNativeChatComposerFocus: mocks.requestNativeChatComposerFocus +})) + vi.mock('./worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => 'env-1' })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) @@ -97,7 +102,7 @@ describe('activateStructuredAgentSessionTab', () => { } }) - it('selects the unified tab and synchronizes host focus', () => { + it('selects the unified tab, focuses its composer, and synchronizes host focus', () => { expect( activateStructuredAgentSessionTab({ worktreeId: 'wt-1', tabId: 'structured-tab-1' }) ).toBe(true) @@ -105,6 +110,7 @@ describe('activateStructuredAgentSessionTab', () => { expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1') expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' }) expect(mocks.setActiveTabType).toHaveBeenCalledWith('agent-session', 'wt-1') + expect(mocks.requestNativeChatComposerFocus).toHaveBeenCalledWith('structured-tab-1') expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-1' }, 'session.tabs.activate', diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.ts index 02854e825b5..090f6426c31 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-activation.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.ts @@ -1,4 +1,5 @@ import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' @@ -47,6 +48,7 @@ export function activateStructuredAgentSessionTab(args: { state.focusGroup(args.worktreeId, tab.groupId) state.activateTab(tab.id, { worktreeId: args.worktreeId }) state.setActiveTabType('agent-session', args.worktreeId) + requestNativeChatComposerFocus(tab.id) const environmentId = getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) void callRuntimeRpc( args.target ?? runtimeTargetForOwnerEnvironment(environmentId), From 0905ad413996824715b22676ae393d11eff1967d Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:16:13 +0000 Subject: [PATCH 03/17] feat(native-chat): copy chat selections when Copy on Select is on (#28737) * feat(native-chat): copy chat selections when Copy on Select is on The terminal's Copy on Select setting now also copies text selected in a native chat transcript, at the point the Add to chat offer already treats a selection as finished. It writes the same text right-click Copy does, skips the composer and other fields, and leaves the setting key as is. * fix(i18n): translate the Copy on Select description and search keywords Settings and Settings search showed English to es, fr, ja, ko and zh users after the description moved to new keys. Extend the old terminal-only translations to cover chat, and translate the new highlight and chat search keywords. --- config/scripts/locale-ko-key-overrides.json | 6 -- .../NativeChatSelectionQuote.test.tsx | 81 +++++++++++++++++-- .../native-chat/NativeChatSelectionQuote.tsx | 16 +++- .../native-chat-quote-selection.ts | 16 ++++ .../settings/TerminalInteractionSection.tsx | 10 ++- .../settings/terminal-clipboard-search.ts | 9 ++- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 10 ++- src/renderer/src/i18n/locales/fr.json | 10 ++- src/renderer/src/i18n/locales/ja.json | 10 ++- src/renderer/src/i18n/locales/ko.json | 10 ++- src/renderer/src/i18n/locales/zh.json | 10 ++- 12 files changed, 152 insertions(+), 44 deletions(-) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 091b094c347..6a7956efc64 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -3734,9 +3734,6 @@ "auto.components.settings.terminal.clipboard.search.5ffcd13c90": { "ko": "tmux" }, - "auto.components.settings.terminal.clipboard.search.603818e8d8": { - "ko": "터미널 선택 영역이 만들어지는 즉시 자동으로 클립보드에 복사합니다." - }, "auto.components.settings.terminal.clipboard.search.e87c6d776d": { "ko": "자동" }, @@ -5246,9 +5243,6 @@ "auto.components.settings.TerminalPane.a9d47451d1": { "ko": "\"새 탭\"은 현재 포커스를 가져가지 않고 \"Setup\"이라는 제목의 백그라운드 탭에서 설정 명령을 엽니다." }, - "auto.components.settings.TerminalPane.4729c645fc": { - "ko": "터미널에서 선택한 내용을 클립보드에 자동으로 복사합니다." - }, "auto.components.settings.TerminalPane.9129b7e805": { "ko": "터미널 패널 위에 마우스를 올리면 클릭하지 않고도 활성화됩니다." }, diff --git a/src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx index e08011eed66..db8f12143a6 100644 --- a/src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx @@ -2,6 +2,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { useAppStore } from '../../store' import type { NativeChatComposerHandle } from './native-chat-composer-types' import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' import { NATIVE_CHAT_QUOTE_SOURCE_PROPS } from './native-chat-quote-selection' @@ -24,14 +26,17 @@ function Chat(): React.JSX.Element { const rootRef = useRef(null) return (
-

my own prompt

-
-
{'Use the draft store.\n\nIt already appends.'}
+
+

my own prompt

+
+
{'Use the draft store.\n\nIt already appends.'}
+
+ +

a tool line

- -

a tool line

+

a draft reply

) @@ -146,3 +151,65 @@ describe('NativeChatSelectionQuote', () => { expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() }) }) + +describe('NativeChatSelectionQuote copy on select', () => { + const originalSettings = useAppStore.getState().settings + const writeClipboardText = vi.fn<(text: string) => Promise>() + + function copyOnSelect(enabled: boolean): void { + useAppStore.setState({ + settings: createGlobalSettingsFixture({ terminalClipboardOnSelect: enabled }) + }) + } + + beforeEach(() => { + vi.useFakeTimers() + writeClipboardText.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { writeClipboardText } }) + copyOnSelect(true) + }) + + afterEach(() => { + cleanup() + window.getSelection()?.removeAllRanges() + useAppStore.setState({ settings: originalSettings }) + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('copies a finished transcript selection, as the Copy menu item would', () => { + render() + releaseSelection(screen.getByTestId('user'), screen.getByTestId('first')) + + expect(writeClipboardText).toHaveBeenCalledTimes(1) + expect(writeClipboardText).toHaveBeenCalledWith(window.getSelection()!.toString()) + }) + + it('copies nothing while the setting is off', () => { + copyOnSelect(false) + render() + releaseSelection(screen.getByTestId('first')) + + expect(writeClipboardText).not.toHaveBeenCalled() + }) + + it('copies nothing selected outside the transcript', () => { + render() + releaseSelection(screen.getByTestId('composer')) + releaseSelection(screen.getByTestId('first'), screen.getByTestId('composer')) + + expect(writeClipboardText).not.toHaveBeenCalled() + }) + + it('copies a keyboard selection once, when it settles', () => { + render() + select(screen.getByTestId('first')) + expect(writeClipboardText).not.toHaveBeenCalled() + + fireEvent.keyUp(screen.getByTestId('first'), { key: 'Shift' }) + fireEvent.keyUp(screen.getByTestId('first'), { key: 'Shift' }) + + expect(writeClipboardText).toHaveBeenCalledTimes(1) + expect(writeClipboardText).toHaveBeenCalledWith('Use the draft store.\n\nIt already appends.') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx index 7a4de40ff44..5ca72025c26 100644 --- a/src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx @@ -4,10 +4,12 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { addViewportSizeChangeListener } from '@/hooks/viewport-size-change-listener' import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' import type { NativeChatComposerHandle } from './native-chat-composer-types' import { formatNativeChatQuote, - readNativeChatQuotableSelection + readNativeChatQuotableSelection, + readNativeChatTranscriptSelection } from './native-chat-quote-selection' // Why: shown at once, a double-click's action would sit under the pointer and take the third click. @@ -19,7 +21,10 @@ type QuoteRequest = { anchor: { getBoundingClientRect: () => DOMRect; contextElement?: Element } } -/** Offers "Add to chat" beside a selection made in an agent's reply. */ +/** + * Offers "Add to chat" beside a selection made in an agent's reply, and copies a finished + * transcript selection when Copy on Select is on. + */ export function NativeChatSelectionQuote({ rootRef, composerRef, @@ -58,6 +63,13 @@ export function NativeChatSelectionQuote({ return } selectionChanged = false + if (useAppStore.getState().settings?.terminalClipboardOnSelect) { + const text = readNativeChatTranscriptSelection(root) + if (text) { + // Swallowed like the terminal's copy on select: a failed copy leaves the selection to retry. + void window.api.ui.writeClipboardText(text).catch(() => {}) + } + } window.clearTimeout(timer) timer = window.setTimeout(() => { // Only offered where the text would land. diff --git a/src/renderer/src/components/native-chat/native-chat-quote-selection.ts b/src/renderer/src/components/native-chat/native-chat-quote-selection.ts index c5e3b44b408..98a46d99348 100644 --- a/src/renderer/src/components/native-chat/native-chat-quote-selection.ts +++ b/src/renderer/src/components/native-chat/native-chat-quote-selection.ts @@ -2,6 +2,22 @@ export const NATIVE_CHAT_QUOTE_SOURCE_PROPS = { 'data-native-chat-quote-source': '' } as const const QUOTE_SOURCE_SELECTOR = '[data-native-chat-quote-source]' +const TRANSCRIPT_SELECTOR = '[data-native-chat-scroll]' + +/** The selected text when both its ends sit in this chat's transcript (not the composer), else ''. */ +export function readNativeChatTranscriptSelection(root: HTMLElement): string { + const selection = window.getSelection() + const transcript = root.querySelector(TRANSCRIPT_SELECTOR) + if ( + !selection || + selection.isCollapsed || + !transcript?.contains(selection.anchorNode) || + !transcript.contains(selection.focusNode) + ) { + return '' + } + return selection.toString() +} /** The selection when all of it sits inside one agent reply of this chat, else null. */ export function readNativeChatQuotableSelection( diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index fb174a12a0a..0475d364f9f 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -242,14 +242,16 @@ export function TerminalInteractionSection({ diff --git a/src/renderer/src/components/settings/terminal-clipboard-search.ts b/src/renderer/src/components/settings/terminal-clipboard-search.ts index bb1a18e444c..36914040a28 100644 --- a/src/renderer/src/components/settings/terminal-clipboard-search.ts +++ b/src/renderer/src/components/settings/terminal-clipboard-search.ts @@ -9,8 +9,8 @@ export const getTerminalClipboardSearchEntries = createLocalizedCatalog(() => [ 'Copy on Select' ), description: translate( - 'auto.components.settings.terminal.clipboard.search.603818e8d8', - 'Automatically copy terminal selections to the clipboard as soon as a selection is made.' + 'components.settings.terminal.clipboard.search.copyOnSelectDescription', + 'Automatically copy text you select in a terminal or chat to the clipboard as soon as the selection is made.' ), keywords: [ ...translateSearchKeyword( @@ -29,6 +29,11 @@ export const getTerminalClipboardSearchEntries = createLocalizedCatalog(() => [ 'auto.components.settings.terminal.clipboard.search.c38c18be15', 'selection' ), + ...translateSearchKeyword( + 'components.settings.terminal.clipboard.search.highlight', + 'highlight' + ), + ...translateSearchKeyword('components.settings.terminal.clipboard.search.chat', 'chat'), ...translateSearchKeyword( 'auto.components.settings.terminal.clipboard.search.664789b73a', 'auto' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 00fa4364404..738942905e8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9105,7 +9105,6 @@ "6e6480a7df": "Let programs in the terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) copy to your system clipboard.", "3338dcf8c1": "Allow TUI Clipboard Writes (OSC 52)", "69c64a479c": "Let Zellij, tmux, Neovim, fzf, and Grok copy to the system clipboard over the PTY (including over SSH).", - "4729c645fc": "Automatically copy terminal selections to the clipboard.", "902f5dee1f": "Copy on Select", "9129b7e805": "Hovering a terminal pane activates it without needing to click.", "8eefeaa3da": "Focus Follows Mouse", @@ -10933,7 +10932,6 @@ "664789b73a": "auto", "c38c18be15": "selection", "797fdfe4ca": "select", - "603818e8d8": "Automatically copy terminal selections to the clipboard as soon as a selection is made.", "3bdc84f059": "Copy on Select", "zellij": "zellij", "grok": "grok" @@ -19210,11 +19208,15 @@ "settings": { "TerminalInteraction": { "copyTrimsGutter": "Trim Gutter on Copy", - "copyTrimsGutterDescription": "Drop the left gutter agent output is painted behind, so copied text is not indented. Only the indent every selected line shares is removed." + "copyTrimsGutterDescription": "Drop the left gutter agent output is painted behind, so copied text is not indented. Only the indent every selected line shares is removed.", + "copyOnSelectDescription": "Automatically copy text you select in a terminal or chat to the clipboard." }, "terminal": { "clipboard": { "search": { + "copyOnSelectDescription": "Automatically copy text you select in a terminal or chat to the clipboard as soon as the selection is made.", + "highlight": "highlight", + "chat": "chat", "gutter": "gutter", "indent": "indent", "margin": "margin", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 233e4ef006a..a23ddc61759 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8829,7 +8829,6 @@ "6e6480a7df": "Permite que los programas del terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) copien al portapapeles del sistema.", "3338dcf8c1": "Permitir escrituras en el portapapeles TUI (OSC 52)", "69c64a479c": "Permite que Zellij, tmux, Neovim, fzf y Grok copien al portapapeles del sistema a través de PTY (incluso por SSH).", - "4729c645fc": "Copia automáticamente las selecciones del terminal al portapapeles.", "902f5dee1f": "Copiar al seleccionar", "9129b7e805": "Al pasar el cursor sobre un panel de terminal, se activa sin necesidad de hacer clic.", "8eefeaa3da": "El foco sigue al ratón", @@ -10659,7 +10658,6 @@ "664789b73a": "auto", "c38c18be15": "selección", "797fdfe4ca": "seleccionar", - "603818e8d8": "Copia automáticamente las selecciones del terminal al portapapeles en cuanto se hace una selección.", "3bdc84f059": "Copiar al seleccionar", "zellij": "zellij", "grok": "asimilar" @@ -18987,7 +18985,8 @@ }, "TerminalInteraction": { "copyTrimsGutter": "Recortar margen al copiar", - "copyTrimsGutterDescription": "Elimina el margen izquierdo tras el que se dibuja la salida del agente, para que el texto copiado no quede indentado. Solo se quita la indentación que comparten todas las líneas seleccionadas." + "copyTrimsGutterDescription": "Elimina el margen izquierdo tras el que se dibuja la salida del agente, para que el texto copiado no quede indentado. Solo se quita la indentación que comparten todas las líneas seleccionadas.", + "copyOnSelectDescription": "Copia automáticamente al portapapeles el texto que selecciones en un terminal o un chat." }, "terminal": { "clipboard": { @@ -18997,7 +18996,10 @@ "margin": "margen", "leading": "inicial", "whitespace": "espacios en blanco", - "spaces": "espacios" + "spaces": "espacios", + "copyOnSelectDescription": "Copia automáticamente al portapapeles el texto que selecciones en un terminal o un chat en cuanto se hace la selección.", + "highlight": "resaltar", + "chat": "chat" } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c938524d82f..63dfce1e297 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8979,7 +8979,6 @@ "6e6480a7df": "Permet aux programmes du terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) de copier vers le presse-papiers système.", "3338dcf8c1": "Autoriser les écritures presse-papiers des TUI (OSC 52)", "69c64a479c": "Permet à Zellij, tmux, Neovim, fzf et Grok de copier vers le presse-papiers système via le PTY (y compris via SSH).", - "4729c645fc": "Copier automatiquement les sélections du terminal vers le presse-papiers.", "902f5dee1f": "Copie à la sélection", "9129b7e805": "Survoler un volet de terminal l'active sans avoir à cliquer.", "8eefeaa3da": "Le focus suit la souris", @@ -10806,7 +10805,6 @@ "664789b73a": "auto", "c38c18be15": "sélection", "797fdfe4ca": "sélectionner", - "603818e8d8": "Copiez automatiquement les sélections du terminal vers le presse-papiers dès qu'une sélection est faite.", "3bdc84f059": "Copie à la sélection", "zellij": "zellige", "grok": "grok" @@ -19127,7 +19125,8 @@ "settings": { "TerminalInteraction": { "copyTrimsGutter": "Couper la gouttière lors de la copie", - "copyTrimsGutterDescription": "Déposez la sortie de l'agent de gouttière gauche qui est peinte derrière, donc le texte copié n'est pas en retrait. Seul le retrait de chaque partage de ligne sélectionnée est supprimé." + "copyTrimsGutterDescription": "Déposez la sortie de l'agent de gouttière gauche qui est peinte derrière, donc le texte copié n'est pas en retrait. Seul le retrait de chaque partage de ligne sélectionnée est supprimé.", + "copyOnSelectDescription": "Copier automatiquement vers le presse-papiers le texte sélectionné dans un terminal ou un chat." }, "terminal": { "clipboard": { @@ -19137,7 +19136,10 @@ "margin": "marge", "leading": "menant", "whitespace": "espace", - "spaces": "espaces" + "spaces": "espaces", + "copyOnSelectDescription": "Copiez automatiquement vers le presse-papiers le texte sélectionné dans un terminal ou un chat dès que la sélection est faite.", + "highlight": "surligner", + "chat": "chat" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6cd150ef81d..ff95bb9584c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8851,7 +8851,6 @@ "6e6480a7df": "ターミナル内のプログラム (Zellij、tmux、Neovim、fzf、Grok、SSH) をシステムのクリップボードにコピーします。", "3338dcf8c1": "TUI クリップボードへの書き込みを許可する (OSC 52)", "69c64a479c": "Zellij、tmux、Neovim、fzf、および Grok が PTY 経由 (SSH 経由を含む) でシステムクリップボードにコピーできるようにします。", - "4729c645fc": "ターミナルの選択内容をクリップボードに自動的にコピーします。", "902f5dee1f": "選択時にコピー", "9129b7e805": "ターミナルペインにマウスを移動すると、クリックしなくてもターミナルペインがアクティブになります。", "8eefeaa3da": "フォーカスはマウスに追従します", @@ -10681,7 +10680,6 @@ "664789b73a": "自動", "c38c18be15": "選択", "797fdfe4ca": "選択する", - "603818e8d8": "ターミナルの選択が行われるとすぐに、その選択内容をクリップボードに自動的にコピーします。", "3bdc84f059": "選択時にコピー", "zellij": "ゼリジ", "grok": "グロク" @@ -19023,7 +19021,8 @@ "settings": { "TerminalInteraction": { "copyTrimsGutter": "コピー時に余白をトリミングする", - "copyTrimsGutterDescription": "左側のガターをドロップすると、Agent の出力が後ろに描画されるため、コピーされたテキストはインデントされません。選択した行ごとに共有されるインデントのみが削除されます。" + "copyTrimsGutterDescription": "左側のガターをドロップすると、Agent の出力が後ろに描画されるため、コピーされたテキストはインデントされません。選択した行ごとに共有されるインデントのみが削除されます。", + "copyOnSelectDescription": "ターミナルやチャットで選択したテキストをクリップボードに自動的にコピーします。" }, "terminal": { "clipboard": { @@ -19033,7 +19032,10 @@ "margin": "マージン", "leading": "先頭", "whitespace": "空白", - "spaces": "スペース" + "spaces": "スペース", + "copyOnSelectDescription": "ターミナルやチャットでテキストを選択するとすぐに、その内容をクリップボードに自動的にコピーします。", + "highlight": "ハイライト", + "chat": "チャット" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 57baef892a6..2ef719e0035 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8814,7 +8814,6 @@ "6e6480a7df": "terminal의 프로그램(Zellij, tmux, Neovim, fzf, Grok, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", "3338dcf8c1": "TUI 클립보드 쓰기 허용(OSC 52)", "69c64a479c": "Zellij, tmux, Neovim, fzf 및 Grok이 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", - "4729c645fc": "터미널에서 선택한 내용을 클립보드에 자동으로 복사합니다.", "902f5dee1f": "선택 시 복사", "9129b7e805": "터미널 패널 위에 마우스를 올리면 클릭하지 않고도 활성화됩니다.", "8eefeaa3da": "마우스를 따라 포커스 이동", @@ -10644,7 +10643,6 @@ "664789b73a": "자동", "c38c18be15": "선택", "797fdfe4ca": "선택", - "603818e8d8": "터미널 선택 영역이 만들어지는 즉시 자동으로 클립보드에 복사합니다.", "3bdc84f059": "선택 시 복사", "zellij": "젤리즈", "grok": "그로크" @@ -19023,7 +19021,8 @@ "settings": { "TerminalInteraction": { "copyTrimsGutter": "복사 시 여백 자르기", - "copyTrimsGutterDescription": "복사한 텍스트에서 에이전트 출력의 왼쪽 여백을 제거합니다. 선택한 모든 줄에 공통된 들여쓰기만 제거됩니다." + "copyTrimsGutterDescription": "복사한 텍스트에서 에이전트 출력의 왼쪽 여백을 제거합니다. 선택한 모든 줄에 공통된 들여쓰기만 제거됩니다.", + "copyOnSelectDescription": "터미널이나 채팅에서 선택한 내용을 클립보드에 자동으로 복사합니다." }, "terminal": { "clipboard": { @@ -19033,7 +19032,10 @@ "margin": "여유", "leading": "선행", "whitespace": "공백", - "spaces": "공백" + "spaces": "공백", + "copyOnSelectDescription": "터미널이나 채팅에서 텍스트를 선택하는 즉시 자동으로 클립보드에 복사합니다.", + "highlight": "강조", + "chat": "채팅" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 65a55a7ec2d..1310afb3c26 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8814,7 +8814,6 @@ "6e6480a7df": "让终端中的程序(Zellij、tmux、Neovim、fzf、Grok、SSH)复制到系统剪贴板。", "3338dcf8c1": "允许 TUI 剪贴板写入 (OSC 52)", "69c64a479c": "让 Zellij、tmux、Neovim、fzf 和 Grok 通过 PTY(包括通过 SSH)复制到系统剪贴板。", - "4729c645fc": "自动将终端选择复制到剪贴板。", "902f5dee1f": "选择时复制", "9129b7e805": "将鼠标悬停在终端窗格上即可将其激活,无需单击。", "8eefeaa3da": "焦点跟随鼠标", @@ -10644,7 +10643,6 @@ "664789b73a": "自动", "c38c18be15": "选择", "797fdfe4ca": "选择", - "603818e8d8": "一旦做出选择,就会自动将终端选择复制到剪贴板。", "3bdc84f059": "选择时复制", "zellij": "泽利杰", "grok": "格罗克" @@ -19023,7 +19021,8 @@ "settings": { "TerminalInteraction": { "copyTrimsGutter": "修剪副本上的装订线", - "copyTrimsGutterDescription": "将左侧装订线智能体输出绘制在后面,因此复制的文本不会缩进。仅删除每个选定行共享的缩进。" + "copyTrimsGutterDescription": "将左侧装订线智能体输出绘制在后面,因此复制的文本不会缩进。仅删除每个选定行共享的缩进。", + "copyOnSelectDescription": "自动将在终端或聊天中选择的文本复制到剪贴板。" }, "terminal": { "clipboard": { @@ -19033,7 +19032,10 @@ "margin": "利润", "leading": "领导", "whitespace": "空白", - "spaces": "空间" + "spaces": "空间", + "copyOnSelectDescription": "在终端或聊天中选择文本后,立即自动将其复制到剪贴板。", + "highlight": "高亮", + "chat": "聊天" } } }, From 222b780276c9120de3ada340873886f0a663f986 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:17:14 -0700 Subject: [PATCH 04/17] Reuse real launcher builds and guard unused runtime RPC imports (#28905) --- .../build-windows-cli-launcher.test.mjs | 73 ++++++++++++------- src/main/runtime/orca-runtime.test.ts | 1 + 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 5dc3569f00b..abdff17ec7d 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -11,7 +11,8 @@ import { import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, expect, it } from 'vitest' +import { runProcessSync } from '@orca/process-host' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { shouldReuseCompiledWindowsRustExecutable, windowsRustExecutableFileVersion, @@ -45,6 +46,43 @@ function itWindows(name, test) { runner(name, { timeout: 300_000 }, test) } +let sharedLauncherRoot = null +let sharedLauncherPath = null +let sharedLauncherBuild = null + +// Compile once; each launch case gets a private copy so it can own its fixture tree. +beforeAll(() => { + if (process.platform !== 'win32') { + return + } + sharedLauncherRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher build ')) + sharedLauncherPath = join(sharedLauncherRoot, 'orca.exe') + const result = runProcessSync({ + program: process.execPath, + args: ['config/scripts/build-windows-cli-launcher.mjs', '--output', sharedLauncherPath], + cwd: projectRoot, + timeoutMs: 300_000 + }) + sharedLauncherBuild = { status: result.code, stdout: result.stdout, stderr: result.stderr } + if (result.code !== 0) { + throw new Error(`${result.stdout}\n${result.stderr}`) + } +}, 300_000) + +afterAll(() => { + if (sharedLauncherRoot) { + removeFixtureTree(sharedLauncherRoot) + } +}) + +function copySharedLauncher(target) { + if (!sharedLauncherPath) { + throw new Error('shared Windows CLI launcher was not built') + } + mkdirSync(dirname(target), { recursive: true }) + copyFileSync(sharedLauncherPath, target) +} + describe('Windows CLI launcher', () => { it('reuses restored builds only while all embedded inputs and the release version match', () => { const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-')) @@ -107,12 +145,12 @@ describe('Windows CLI launcher', () => { const root = mkdtempSync(join(tmpdir(), 'orca launcher metadata ')) try { const launcherPath = join(root, 'orca.exe') - const build = spawnSync( - process.execPath, - ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], - { cwd: projectRoot, encoding: 'utf8' } - ) - expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + expect(sharedLauncherBuild).not.toBeNull() + expect( + sharedLauncherBuild.status, + `${sharedLauncherBuild.stdout}\n${sharedLauncherBuild.stderr}` + ).toBe(0) + copySharedLauncher(launcherPath) const inspect = spawnSync( 'powershell.exe', [ @@ -183,12 +221,7 @@ describe('Windows CLI launcher', () => { 'utf8' ) - const build = spawnSync( - process.execPath, - ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], - { cwd: projectRoot, encoding: 'utf8' } - ) - expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + copySharedLauncher(launcherPath) const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two' const powershell = spawnSync( @@ -247,12 +280,7 @@ describe('Windows CLI launcher', () => { }))\n`, 'utf8' ) - const build = spawnSync( - process.execPath, - ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], - { cwd: projectRoot, encoding: 'utf8' } - ) - expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + copySharedLauncher(launcherPath) writeFileSync( join(dirname(launcherPath), 'orca-launcher.cfg'), `node=${process.execPath}\ncli=${cliPath}\nuser-data=${profile}\n` @@ -326,12 +354,7 @@ describe('Windows CLI launcher', () => { }))\n`, 'utf8' ) - const build = spawnSync( - process.execPath, - ['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath], - { cwd: projectRoot, encoding: 'utf8' } - ) - expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0) + copySharedLauncher(launcherPath) const compiler = findFrameworkCompiler() expect(compiler).not.toBeNull() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c32d20e71d1..bc903f2c9e3 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,6 +1,7 @@ // Compatibility entrypoint: reliability gates invoke this path directly; fragments stay side-effect ordered. export {} +await import('./rpc/unused-default-rpc-methods.test-fixture') await import('./orca-runtime-test-mocks.spec') await import('./orca-runtime-test-lifecycle.spec') await import('./orca-runtime-test-fixtures.spec') From 3b11b7ac94056a56414f4b6a845292f7f12b06ff Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:18:01 -0400 Subject: [PATCH 05/17] fix(mobile): phone server rows follow the desktop's other-client and sleeping filters (#28819) * fix(mobile): phone server rows follow the desktop's other-client and sleeping filters A phone created through a desktop now records whom it acted for, so the desktop's Hide other-client workspaces filter keeps the user's own phone workspaces (locally and on servers) and hides only other people's. The desktop relay marks each server row its sidebar would hide, and the rows its own browser or chat tabs keep awake; the phone applies both. * test(e2e): dedupe sidebar row ids in the visibility spec * fix(mobile): address review: owner naming, old-server relayed phones, scope trim * refactor(mobile): record a phone's owner as creator and fold desktop tab activity into the host flag A phone now records its owner as the creator (this host for a directly paired phone, the relaying desktop for a delegated one), so the shared rule is unchanged and onBehalfOf is gone. The relay ORs the desktop's saved browser/chat tabs into hasHostSidebarActivity, so the phone's status dot follows too, and local worktree.ps counts the same signals. The old-server fallback is removed. * test(e2e): poll the phone and the sidebar together in the visibility spec --- config/scripts/ci-shard-timings.json | 2 +- .../worktree/workspace-list-sections.test.ts | 13 ++ .../src/worktree/workspace-list-sections.ts | 2 +- mobile/src/worktree/workspace-list-types.ts | 2 + .../runtime-environment-mobile-relay-hosts.ts | 3 +- .../mobile-desktop-relay-hosts.ts | 2 + .../mobile-relay-host-catalog.test.ts | 139 +++++++++++- .../mobile-relay-host-catalog.ts | 63 +++++- ...ca-runtime-pty-foreground-process-reads.ts | 10 + src/main/runtime/rpc/core.ts | 2 + .../runtime/rpc/dispatcher-stream-options.ts | 2 + .../rpc/methods/worktree-catalog-methods.ts | 22 +- .../rpc/workspace-creator-context.test.ts | 27 +++ .../runtime/rpc/workspace-creator-context.ts | 13 +- .../worktree-ps-other-client-marks.test.ts | 84 +++++++ .../rpc/worktree-ps-other-client-marks.ts | 35 +++ .../runtime-rpc/runtime-rpc-pairing.ts | 4 +- .../runtime-rpc-websocket-dispatch.ts | 1 + .../runtime/runtime-worktree-ps-activity.ts | 6 +- .../activity/activity-scope-filter.ts | 4 +- .../activity/use-agent-pane-threads.ts | 2 +- ...StructuredAgentSessionPaneOverlayLayer.tsx | 2 +- .../native-chat/native-chat-tab-directory.ts | 2 +- ...red-agent-registered-agent-surface.test.ts | 2 +- .../structured-agent-session-tabs.ts | 10 +- .../structured-attention-dispatch.test.ts | 3 +- .../structured-attention-surface.ts | 3 +- ...e-visible-workspace-kanban-worktree-ids.ts | 2 +- .../visible-worktree-options-from-state.ts | 2 +- .../sidebar/visible-worktrees.test.ts | 2 +- .../components/sidebar/visible-worktrees.ts | 4 +- .../workspace-creator-visibility.test.ts | 48 ---- .../worktree-list/listing/use-filters.ts | 6 +- .../listing/use-host-visible-scope.ts | 2 +- .../listing/use-visible-worktrees.ts | 2 +- .../use-worktree-jump-palette-worktrees.ts | 6 +- .../src/hooks/agent-auto-ack-targets.ts | 2 +- src/renderer/src/lib/running-agent-targets.ts | 2 +- .../src/lib/unread-badge-count-selector.ts | 2 +- src/renderer/src/lib/unread-badge-count.ts | 6 +- .../src/store/slices/runtime-status.test.ts | 2 +- src/shared/mobile-relay-hosts-contract.ts | 11 +- src/shared/runtime-worktree-contracts.ts | 2 + src/shared/structured-agent-session-tab.ts | 9 + .../workspace-creator-visibility.test.ts | 93 ++++++++ .../workspace-creator-visibility.ts | 27 ++- .../workspace-session-tab-activity.test.ts | 53 +++++ src/shared/workspace-session-tab-activity.ts | 26 +++ ...esktop-mirror-workspace-visibility.spec.ts | 211 ++++++++++++++++++ 49 files changed, 847 insertions(+), 133 deletions(-) create mode 100644 src/main/runtime/rpc/workspace-creator-context.test.ts create mode 100644 src/main/runtime/rpc/worktree-ps-other-client-marks.test.ts create mode 100644 src/main/runtime/rpc/worktree-ps-other-client-marks.ts delete mode 100644 src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts create mode 100644 src/shared/structured-agent-session-tab.ts create mode 100644 src/shared/workspace-creator-visibility.test.ts rename src/{renderer/src/components/sidebar => shared}/workspace-creator-visibility.ts (60%) create mode 100644 src/shared/workspace-session-tab-activity.test.ts create mode 100644 src/shared/workspace-session-tab-activity.ts create mode 100644 tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 45f16bf2035..ead2162455b 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -8422,7 +8422,7 @@ "src/renderer/src/components/sidebar/workspace-attachment-source-label.test.ts": 76, "src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts": 1483, "src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 243, - "src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 69, + "src/shared/workspace-creator-visibility.test.ts": 69, "src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 46, "src/renderer/src/components/sidebar/workspace-hover-attachments.test.ts": 106, "src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 186, diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index 0e82229f0ec..f5dfe3f52c6 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -211,6 +211,19 @@ describe('filterWorktrees', () => { ) ).toEqual([folder]) }) + + it("drops rows the desktop's Hide other-client workspaces hides", () => { + const mine = worktree({ worktreeId: 'mine' }) + const other = worktree({ worktreeId: 'other', desktopOtherClient: true }) + + expect( + filterWorktrees( + [mine, other], + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '' + ) + ).toEqual([mine]) + }) }) describe('getWorktreeStatus', () => { diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index d231d81ba54..9f3b480d4b3 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -95,7 +95,7 @@ export function filterWorktrees( filters: FilterState, search: string ): Worktree[] { - let result = worktrees.filter((w) => !w.isArchived) + let result = worktrees.filter((w) => !w.isArchived && w.desktopOtherClient !== true) if (filters.hideSleeping) { result = result.filter( (w) => isSleepingSweepExempt(w, filters.alwaysShowDefaultBranch) || isWorktreeActive(w) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index b2d592431b1..7d9061ec2ca 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -29,6 +29,8 @@ export type Worktree = { isArchived?: boolean isMainWorktree?: boolean hasHostSidebarActivity?: boolean + /** The paired desktop's "Hide other-client workspaces" hides this row. */ + desktopOtherClient?: true worktreeInstanceId?: string lineageWorktreeInstanceId?: string parentWorktreeInstanceId?: string diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts index 595ed8fae40..b04b630fcbe 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts @@ -68,7 +68,8 @@ export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHo source: environment.source, orcadDeployment: environment.orcadDeployment, pairingRevision: pairingRevision(environment), - runtimeId: environment.runtimeId + runtimeId: environment.runtimeId, + ...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {}) })), statusByEnvironmentId } diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts index 24cae0928c9..6f2a8b202a9 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts @@ -22,6 +22,8 @@ export type MobileDesktopRelayHostListing = { orcadDeployment?: ManagedOrcadDeploymentSummary | null pairingRevision: number runtimeId: string | null + /** The desktop's own device id on that server, as pairing recorded it. */ + pairedDeviceId?: string } /** Bounds a call; `expected` refuses it once the server was re-paired or its runtime changed. */ diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts index 7b4c498aaab..87ade175b5a 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts @@ -8,6 +8,10 @@ import { import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../../shared/runtime-types' import type { SshConnectionState } from '../../../shared/ssh-types' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { BrowserWorkspace } from '../../../shared/browser-workspace-types' +import type { Tab } from '../../../shared/tab-types' import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts' import { MobileRelayHostCatalog } from './mobile-relay-host-catalog' @@ -56,6 +60,7 @@ function fakeHosts( snapshot: RuntimeHostStatusSnapshot orcadDeployment?: { sshTargetId: string } runtimeId?: string | null + pairedDeviceId?: string }[] ) { const state = { @@ -68,13 +73,16 @@ function fakeHosts( const call = vi.fn() const hosts: MobileDesktopRelayHosts = { list: () => ({ - environments: state.environments.map(({ id, name, orcadDeployment, runtimeId }) => ({ - id, - name, - orcadDeployment, - pairingRevision: state.pairingRevision, - runtimeId: runtimeId === undefined ? 'runtime-a' : runtimeId - })), + environments: state.environments.map( + ({ id, name, orcadDeployment, runtimeId, pairedDeviceId }) => ({ + id, + name, + orcadDeployment, + pairingRevision: state.pairingRevision, + runtimeId: runtimeId === undefined ? 'runtime-a' : runtimeId, + pairedDeviceId + }) + ), sshTargetLabels: state.sshTargetLabels, sshConnectionStates: state.sshConnectionStates, statusByEnvironmentId: new Map( @@ -99,8 +107,25 @@ function fakeHosts( return { hosts, call, state } } -function catalog(hosts: MobileDesktopRelayHosts, labels = new Map()) { - return { catalog: new MobileRelayHostCatalog({ hosts, hostLabelOverrides: () => labels }) } +function catalog( + hosts: MobileDesktopRelayHosts, + labels = new Map(), + desktop: { + hidesWorkspacesFromOtherDevices?: boolean + sessions?: Record> + } = {} +) { + return { + catalog: new MobileRelayHostCatalog({ + hosts, + hostLabelOverrides: () => labels, + hidesWorkspacesFromOtherDevices: () => desktop.hidesWorkspacesFromOtherDevices ?? false, + getWorkspaceSession: (hostId) => { + const session = desktop.sessions?.[hostId] + return session ? { ...getDefaultWorkspaceSession(), ...session } : null + } + }) + } } describe('mobile relay host catalog', () => { @@ -397,3 +422,99 @@ describe('mobile relay host catalog', () => { expect(call).toHaveBeenCalledTimes(1) }) }) + +describe("mobile relay host catalog: the desktop sidebar's own view of server rows", () => { + // What resolveRpcWorkspaceCreatorProvenance records: a relayed phone records the desktop relaying it. + const creators = [ + { worktreeId: 'mine', creatorProvenance: { kind: 'paired-device', deviceId: 'desktop' } }, + { worktreeId: 'other', creatorProvenance: { kind: 'paired-device', deviceId: 'laptop' } }, + { worktreeId: 'server', creatorProvenance: { kind: 'host' } }, + { worktreeId: 'unknown' } + ] + + function serverRows(desktop: Parameters[2], pairedDeviceId = 'desktop') { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live'), pairedDeviceId } + ]) + call.mockResolvedValue(psReply(creators)) + return catalog(hosts, new Map(), desktop).catalog.worktrees('runtime:env') + } + + function marked(rows: Awaited>, field: string): unknown[] { + return (rows.worktrees ?? []).filter((row) => row[field] === true).map((row) => row.worktreeId) + } + + it('marks rows someone else created while the desktop hides them', async () => { + const rows = await serverRows({ hidesWorkspacesFromOtherDevices: true }) + expect(marked(rows, 'desktopOtherClient')).toEqual(['other', 'server']) + // Marked, never dropped: deep links and lineage still resolve every row. + expect(rows.worktrees).toHaveLength(creators.length) + }) + + it('marks nothing while the desktop shows other-client workspaces', async () => { + expect(marked(await serverRows({}), 'desktopOtherClient')).toEqual([]) + }) + + it('marks nothing on a server whose pairing the desktop cannot name', async () => { + const { hosts, call } = fakeHosts([ + { id: 'env', name: 'Box', snapshot: snapshot('env', 'live') } + ]) + call.mockResolvedValue(psReply(creators)) + const rows = await catalog(hosts, new Map(), { + hidesWorkspacesFromOtherDevices: true + }).catalog.worktrees('runtime:env') + expect(marked(rows, 'desktopOtherClient')).toEqual([]) + }) + + function browserTab(worktreeId: string): BrowserWorkspace { + return { + id: `browser-${worktreeId}`, + worktreeId, + url: 'about:blank', + title: '', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + } + + function tab( + worktreeId: string, + contentType: Tab['contentType'], + agent?: Tab['agentSessionAgent'] + ): Tab { + return { + id: `${contentType}-${worktreeId}`, + entityId: worktreeId, + groupId: 'group', + worktreeId, + contentType, + label: '', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...(agent ? { agentSessionAgent: agent } : {}) + } + } + + it("counts the desktop's saved browser or chat tabs as the row's sidebar activity", async () => { + const rows = await serverRows({ + sessions: { + 'runtime:env': { + browserTabsByWorktree: { mine: [browserTab('mine')], other: [] }, + unifiedTabs: { + 'worktree:server': [tab('server', 'agent-session', 'claude')], + unknown: [tab('unknown', 'terminal')] + } + }, + // Another host's session says nothing about this server's rows. + local: { browserTabsByWorktree: { other: [browserTab('other')] } } + } + }) + expect(marked(rows, 'hasHostSidebarActivity')).toEqual(['mine', 'server']) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts index 90fb0a5bab3..d64cdb5185d 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts @@ -16,6 +16,13 @@ import type { } from '../../../shared/mobile-relay-hosts-contract' import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status' import type { RuntimeStatus } from '../../../shared/runtime-types' +import { + getPairedDeviceIdsByEnvironment, + isWorkspaceOwnedBySomeoneElse +} from '../../../shared/workspace-creator-visibility' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getWorkspaceSessionTabActivityIds } from '../../../shared/workspace-session-tab-activity' +import { runtimeWorktreeIdentityKey } from '../runtime-worktree-path-identity' import type { MobileDesktopRelayHostListing, MobileDesktopRelayHosts @@ -53,6 +60,10 @@ export class MobileRelayHostCatalog { private readonly options: { hosts: MobileDesktopRelayHosts hostLabelOverrides: () => ReadonlyMap + /** The desktop's own "Hide other-client workspaces" setting. */ + hidesWorkspacesFromOtherDevices: () => boolean + /** The desktop's saved session for a host, as its sidebar last wrote it. */ + getWorkspaceSession: (hostId: ExecutionHostId) => WorkspaceSessionState | null } ) {} @@ -80,15 +91,30 @@ export class MobileRelayHostCatalog { await withinFetchBound(this.refresh(host)) } const cached = this.cached.get(host.environmentId) - const current = this.options.hosts - .list() - .environments.find((environment) => environment.id === host.environmentId) + const { environments, statusByEnvironmentId } = this.options.hosts.list() + const current = environments.find((environment) => environment.id === host.environmentId) // Why the identity read after the fetch: one answered after a re-pair stored the old server's rows. if (!cached || !current || cached.fence !== fenceOf(current)) { return { worktrees: null } } + const desktopDeviceIds = this.options.hidesWorkspacesFromOtherDevices() + ? getPairedDeviceIdsByEnvironment([current], statusByEnvironmentId) + : null + const sessionActivity = new Set( + [ + ...getWorkspaceSessionTabActivityIds( + this.options.getWorkspaceSession(toRuntimeExecutionHostId(host.environmentId)) + ) + ].map(runtimeWorktreeIdentityKey) + ) // Why: the desktop asks with background-removal support; a phone asking itself never sees these rows. - return { worktrees: cached.worktrees.filter((row) => row.removing !== true) } + return { + worktrees: cached.worktrees + .filter((row) => row.removing !== true) + .map((row) => + stampDesktopSidebarView(row, host.environmentId, desktopDeviceIds, sessionActivity) + ) + } } private describe(): DescribedHost[] { @@ -199,6 +225,35 @@ function relayVerdict( return health === 'available' ? 'ready' : 'unavailable' } +/** + * What the desktop's own sidebar decides and the server cannot: its "Hide other-client workspaces" + * (marked, not dropped, so deep links still resolve), and its own browser and chat tabs as activity. + */ +function stampDesktopSidebarView( + row: MobileRelayServerWorktreeRow, + environmentId: string, + desktopDeviceIds: ReadonlyMap | null, + sessionActivity: ReadonlySet +): MobileRelayServerWorktreeRow { + const otherClient = + desktopDeviceIds !== null && + isWorkspaceOwnedBySomeoneElse( + { creatorProvenance: row.creatorProvenance, runtimeOwnerEnvironmentId: environmentId }, + desktopDeviceIds + ) + const active = + typeof row.worktreeId === 'string' && + sessionActivity.has(runtimeWorktreeIdentityKey(row.worktreeId)) + if (!otherClient && !active) { + return row + } + return { + ...row, + ...(otherClient ? { desktopOtherClient: true } : {}), + ...(active ? { hasHostSidebarActivity: true } : {}) + } +} + /** * A server's rows name hosts relative to that server (`local`, its own `ssh:` targets). Like the * desktop sidebar, the phone shows every one of them under the server, so all become its host id. diff --git a/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts index 12eb7cddb13..a8768cd2487 100644 --- a/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts +++ b/src/main/runtime/orca-runtime-pty-foreground-process-reads.ts @@ -182,6 +182,16 @@ export class OrcaRuntimeWithPtyForegroundProcessReads extends OrcaRuntimeWithSta return this.store.getUI() } + /** This client's own sidebar setting; paired clients never receive it. */ + hidesWorkspacesFromOtherDevices(): boolean { + return this.store?.getUI?.().hideWorkspacesFromOtherDevices === true + } + + // Why null rather than runtime_unavailable: a missing session only means no activity to add. + getWorkspaceSessionForHost(hostId: ExecutionHostId): WorkspaceSessionState | null { + return this.store?.getWorkspaceSession?.(hostId) ?? null + } + updateUIState(updates: Partial): PersistedUIState { if (!this.store?.getUI || !this.store.updateUI) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index ba11c2cf4fc..4f12fe7a82b 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -99,6 +99,8 @@ export type RpcContext = { clientId?: string // Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id. pairedDeviceId?: string + // Why: a delegated phone's desktop device; its workspaces are that desktop's, not this host's. + pairedDeviceParentId?: string // Why: host-assigned from the transport (rpc-caller-identity.ts); absent when the transport could not name its caller. caller?: RpcCallerIdentity // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index 7de6511411e..df94afa7af9 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -11,6 +11,7 @@ export type RpcDispatchStreamingOptions = Pick< | 'signal' | 'clientId' | 'pairedDeviceId' + | 'pairedDeviceParentId' | 'caller' | 'clientKind' | 'clientCapabilities' @@ -45,6 +46,7 @@ export function rpcContextFromTransport( : undefined, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, + pairedDeviceParentId: options?.pairedDeviceParentId, caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, diff --git a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts index 58526529ce5..36e5ef60e78 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-methods.ts @@ -1,6 +1,7 @@ import { defineMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { coarsenWorktreePsActivity } from '../worktree-ps-coarse-activity' +import { markWorktreePsOtherClientRows } from '../worktree-ps-other-client-marks' import { supportsWorktreeVisibilitySourceDefaults } from '../worktree-visibility-client-capability' import { projectWorktreeListRemovals, @@ -20,16 +21,19 @@ export const WORKTREE_CATALOG_METHODS = [ params: WorktreePsParams, handler: async (params, context) => { const pendingAtScan = snapshotPendingWorktreeRemovals() - const result = projectWorktreePsRemovals( - await context.runtime.getWorktreePs( - params.limit, - supportsWorktreeVisibilitySourceDefaults( - context, - params.supportsWorktreeVisibilitySourceDefaults - ) + const result = markWorktreePsOtherClientRows( + projectWorktreePsRemovals( + await context.runtime.getWorktreePs( + params.limit, + supportsWorktreeVisibilitySourceDefaults( + context, + params.supportsWorktreeVisibilitySourceDefaults + ) + ), + context, + pendingAtScan ), - context, - pendingAtScan + context ) // Why: callers that never send the field get the byte-exact legacy response. if (params.afterSnapshotId === undefined) { diff --git a/src/main/runtime/rpc/workspace-creator-context.test.ts b/src/main/runtime/rpc/workspace-creator-context.test.ts new file mode 100644 index 00000000000..35cc80df1a3 --- /dev/null +++ b/src/main/runtime/rpc/workspace-creator-context.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { resolveRpcWorkspaceCreatorProvenance } from './workspace-creator-context' + +describe('resolveRpcWorkspaceCreatorProvenance', () => { + it("records a phone paired here as this host's owner", () => { + expect( + resolveRpcWorkspaceCreatorProvenance({ pairedDeviceId: 'phone', clientKind: 'mobile' }) + ).toEqual({ kind: 'host' }) + }) + + it('records a delegated phone as the desktop that relays it', () => { + expect( + resolveRpcWorkspaceCreatorProvenance({ + pairedDeviceId: 'child', + pairedDeviceParentId: 'desktop', + clientKind: 'mobile' + }) + ).toEqual({ kind: 'paired-device', deviceId: 'desktop' }) + }) + + it('records a paired desktop as itself, and an in-process caller as the host', () => { + expect( + resolveRpcWorkspaceCreatorProvenance({ pairedDeviceId: 'laptop', clientKind: 'runtime' }) + ).toEqual({ kind: 'paired-device', deviceId: 'laptop' }) + expect(resolveRpcWorkspaceCreatorProvenance({})).toEqual({ kind: 'host' }) + }) +}) diff --git a/src/main/runtime/rpc/workspace-creator-context.ts b/src/main/runtime/rpc/workspace-creator-context.ts index 6bf1adf9949..fcbdad4d1eb 100644 --- a/src/main/runtime/rpc/workspace-creator-context.ts +++ b/src/main/runtime/rpc/workspace-creator-context.ts @@ -2,10 +2,19 @@ import type { WorkspaceCreatorProvenance } from '../../../shared/worktree/types' import type { RpcContext } from './core' export function resolveRpcWorkspaceCreatorProvenance( - context: Pick + context: Pick< + RpcContext, + 'pairedDeviceId' | 'pairedDeviceParentId' | 'clientId' | 'clientKind' | 'connectionId' + > ): WorkspaceCreatorProvenance { if (context.pairedDeviceId) { - return { kind: 'paired-device', deviceId: context.pairedDeviceId } + if (context.clientKind !== 'mobile') { + return { kind: 'paired-device', deviceId: context.pairedDeviceId } + } + // Why the owner, not the phone: a phone acts for whoever paired it, so its rows are theirs. + return context.pairedDeviceParentId + ? { kind: 'paired-device', deviceId: context.pairedDeviceParentId } + : { kind: 'host' } } if (context.clientId || context.clientKind || context.connectionId) { throw new Error('authenticated_device_identity_missing') diff --git a/src/main/runtime/rpc/worktree-ps-other-client-marks.test.ts b/src/main/runtime/rpc/worktree-ps-other-client-marks.test.ts new file mode 100644 index 00000000000..0750d99f4c2 --- /dev/null +++ b/src/main/runtime/rpc/worktree-ps-other-client-marks.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import type { + RuntimeWorktreePsResult, + RuntimeWorktreePsSummary +} from '../../../shared/runtime-worktree-contracts' +import type { RpcContext } from './core' +import { markWorktreePsOtherClientRows } from './worktree-ps-other-client-marks' + +function row( + worktreeId: string, + creatorProvenance?: RuntimeWorktreePsSummary['creatorProvenance'] +): RuntimeWorktreePsSummary { + return { + worktreeId, + repoId: 'repo', + repo: 'repo', + path: `/repo/${worktreeId}`, + branch: worktreeId, + isArchived: false, + isMainWorktree: false, + hasHostSidebarActivity: false, + parentWorktreeId: null, + childWorktreeIds: [], + displayName: worktreeId, + workspaceStatus: 'in-progress', + sortOrder: 0, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + comment: '', + isPinned: false, + isActive: false, + unread: false, + liveTerminalCount: 0, + hasAttachedPty: false, + lastOutputAt: null, + preview: '', + status: 'inactive', + agents: [], + ...(creatorProvenance ? { creatorProvenance } : {}) + } +} + +const rows = [ + row('desktop', { kind: 'host' }), + row('laptop', { kind: 'paired-device', deviceId: 'laptop' }), + row('unknown') +] +const result: RuntimeWorktreePsResult = { worktrees: rows, totalCount: 4, truncated: false } + +function context( + overrides: Partial>, + hides = true +): Parameters[1] { + return { runtime: { hidesWorkspacesFromOtherDevices: () => hides }, ...overrides } +} + +function marked(next: RuntimeWorktreePsResult): string[] { + return next.worktrees.filter((entry) => entry.desktopOtherClient).map((entry) => entry.worktreeId) +} + +describe('markWorktreePsOtherClientRows', () => { + it("marks, for this host's own phone, the rows its sidebar hides", () => { + expect( + marked(markWorktreePsOtherClientRows(result, context({ clientKind: 'mobile' }))) + ).toEqual(['laptop']) + }) + + it('leaves rows untouched while the setting is off, or for any other caller', () => { + expect(markWorktreePsOtherClientRows(result, context({ clientKind: 'mobile' }, false))).toBe( + result + ) + expect(markWorktreePsOtherClientRows(result, context({ clientKind: 'runtime' }))).toBe(result) + expect(markWorktreePsOtherClientRows(result, context({}))).toBe(result) + expect( + markWorktreePsOtherClientRows( + result, + context({ clientKind: 'mobile', pairedDeviceParentId: 'other-desktop' }) + ) + ).toBe(result) + }) +}) diff --git a/src/main/runtime/rpc/worktree-ps-other-client-marks.ts b/src/main/runtime/rpc/worktree-ps-other-client-marks.ts new file mode 100644 index 00000000000..adaf5988860 --- /dev/null +++ b/src/main/runtime/rpc/worktree-ps-other-client-marks.ts @@ -0,0 +1,35 @@ +import type { RuntimeWorktreePsResult } from '../../../shared/runtime-worktree-contracts' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + isWorkspaceOwnedBySomeoneElse +} from '../../../shared/workspace-creator-visibility' +import type { RpcContext } from './core' + +/** + * Marks the rows this host's own sidebar hides under "Hide other-client workspaces", for a phone + * paired to it: the setting is the desktop's own and never crosses the pairing. A delegated phone + * belongs to another desktop, whose view this host cannot speak for. + */ +export function markWorktreePsOtherClientRows( + result: T, + context: Pick & { + runtime: Pick + } +): T { + if ( + context.clientKind !== 'mobile' || + context.pairedDeviceParentId !== undefined || + !context.runtime.hidesWorkspacesFromOtherDevices() + ) { + return result + } + let marked = false + const worktrees = result.worktrees.map((row) => { + if (!isWorkspaceOwnedBySomeoneElse(row, EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT)) { + return row + } + marked = true + return { ...row, desktopOtherClient: true as const } + }) + return marked ? { ...result, worktrees } : result +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 2069c67ff74..ba241e5dc43 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -105,7 +105,9 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { this.mobileRelayHostCatalog = hosts ? new MobileRelayHostCatalog({ hosts, - hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings()) + hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings()), + hidesWorkspacesFromOtherDevices: () => this.runtime.hidesWorkspacesFromOtherDevices(), + getWorkspaceSession: (hostId) => this.runtime.getWorkspaceSessionForHost(hostId) }) : null } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 22c6290675a..4e6cf61bc5c 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -237,6 +237,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices { connectionId, clientId: principal.clientId, pairedDeviceId: device.deviceId, + pairedDeviceParentId: device.parentDeviceId, // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, callerScope, diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index 1b347c9080c..9232f684b63 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -3,6 +3,7 @@ import type { Repo } from '../../shared/repo-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { parsePaneKey } from '../../shared/stable-pane-id' +import { getWorkspaceSessionTabActivityIds } from '../../shared/workspace-session-tab-activity' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { RuntimeWorktreePsSummaryLookup } from './runtime-worktree-summary-paths' @@ -212,10 +213,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: { summary.hasHostSidebarActivity = true } } - for (const [worktreeId, tabs] of Object.entries(session.browserTabsByWorktree ?? {})) { - if (tabs.length === 0) { - continue - } + for (const worktreeId of getWorkspaceSessionTabActivityIds(session)) { const summary = args.getSummary(worktreeId) if (summary) { summary.hasHostSidebarActivity = true diff --git a/src/renderer/src/components/activity/activity-scope-filter.ts b/src/renderer/src/components/activity/activity-scope-filter.ts index b8330ffc866..d84f152d7fc 100644 --- a/src/renderer/src/components/activity/activity-scope-filter.ts +++ b/src/renderer/src/components/activity/activity-scope-filter.ts @@ -5,7 +5,7 @@ import { isAutomationGeneratedWorkspace, isCliCreatedWorkspace } from '@/components/sidebar/visible-worktree-kinds' -import { isWorkspaceFromOtherDevice } from '@/components/sidebar/workspace-creator-visibility' +import { isWorkspaceOwnedBySomeoneElse } from '../../../../shared/workspace-creator-visibility' import type { AgentPaneThread } from './activity-thread-types' /** Host/project/workspace-origin scope for the Agents activity surfaces. Persisted and separate @@ -105,7 +105,7 @@ export function threadMatchesActivityScope( } if ( scope.hideWorkspacesFromOtherDevices && - isWorkspaceFromOtherDevice(thread.worktree, scope.pairedDeviceIdsByEnvironment) + isWorkspaceOwnedBySomeoneElse(thread.worktree, scope.pairedDeviceIdsByEnvironment) ) { return false } diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 5f3cb64d09f..5c587f8eed3 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -14,7 +14,7 @@ import { collectChildAgentPaneKeys } from './activity-thread-child-agent' import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, getPairedDeviceIdsByEnvironment -} from '@/components/sidebar/workspace-creator-visibility' +} from '../../../../shared/workspace-creator-visibility' const EMPTY_PANE_KEYS: ReadonlySet = new Set() import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter' diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx index 942a6a5a520..1cb17596965 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -8,7 +8,7 @@ import { } from '@/runtime/structured-agent-session-owner' import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' import NativeChatView from './NativeChatView' -import { isStructuredTab } from './structured-agent-session-tabs' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' type StructuredAgentSessionTab = Tab & { contentType: 'agent-session' diff --git a/src/renderer/src/components/native-chat/native-chat-tab-directory.ts b/src/renderer/src/components/native-chat/native-chat-tab-directory.ts index 632cb7ace21..6a35367cbe0 100644 --- a/src/renderer/src/components/native-chat/native-chat-tab-directory.ts +++ b/src/renderer/src/components/native-chat/native-chat-tab-directory.ts @@ -2,7 +2,7 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' import { resolveWorkspaceDirectory, type WorkspaceDirectoryState } from '@/lib/workspace-directory' import type { StructuredSessionLaunchDirectory } from '@/store/slices/structured-session-launch-directories' -import { isStructuredTab } from './structured-agent-session-tabs' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' type NativeChatDirectoryTab = { id: string diff --git a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts index 8fe2ca853e5..8088de18c79 100644 --- a/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-registered-agent-surface.test.ts @@ -7,7 +7,7 @@ import { parseWorkspaceSession } from '../../../../shared/workspace-session-sche vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}), subscribe: () => () => {} } })) import { structuredAgentAcceptsImages } from '@/runtime/use-host-structured-agent' -import { isStructuredTab } from './structured-agent-session-tabs' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' const chatTab = (agentSessionAgent: unknown) => ({ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts index 4650c6bd7a0..188babc270f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts @@ -1,16 +1,8 @@ -import { isStructuredAgentId } from '../../../../shared/agent-session-provider-handle' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import type { Tab } from '../../../../shared/tab-types' export type StructuredTab = Tab & { contentType: 'agent-session' } -/** A chat tab of any agent its host registered; the host publishes tabs of no other agent. Takes - * only the fields it reads, so readers with their own tab shape share this one predicate. */ -export function isStructuredTab( - tab: T -): tab is T & { contentType: 'agent-session' } { - return tab.contentType === 'agent-session' && isStructuredAgentId(tab.agentSessionAgent) -} - const structuredTabsByUnifiedTabsSnapshot = new WeakMap< Record, readonly StructuredTab[] diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts index 1dac0ba50bb..8031b8a633a 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts @@ -22,7 +22,8 @@ import { TEST_REPO } from '@/store/slices/store-test-helpers' import type { NotificationDispatchRequest } from '../../../../shared/notification-settings-types' -import { isStructuredTab, type StructuredTab } from './structured-agent-session-tabs' +import type { StructuredTab } from './structured-agent-session-tabs' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' // The user is present unless a test says otherwise. diff --git a/src/renderer/src/components/native-chat/structured-attention-surface.ts b/src/renderer/src/components/native-chat/structured-attention-surface.ts index d1b2abbc5fc..9cb7056e8f5 100644 --- a/src/renderer/src/components/native-chat/structured-attention-surface.ts +++ b/src/renderer/src/components/native-chat/structured-attention-surface.ts @@ -16,7 +16,8 @@ import type { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { isTabOnScreen, isWorkspaceOnScreen } from '@/hooks/agent-auto-ack-targets' -import { isStructuredTab, type StructuredTab } from './structured-agent-session-tabs' +import type { StructuredTab } from './structured-agent-session-tabs' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' type StoreSnapshot = ReturnType diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index ed550fdc61d..262aa9823eb 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -10,7 +10,7 @@ import type { AppState } from '@/store/types' import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, getPairedDeviceIdsByEnvironment -} from './workspace-creator-visibility' +} from '../../../../shared/workspace-creator-visibility' import { getStructuredChatWorktreeIds } from './visible-worktree-activity-inputs' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' diff --git a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts index 9ab318e26a3..8b9484e4305 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts @@ -6,7 +6,7 @@ import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-in import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, getPairedDeviceIdsByEnvironment -} from './workspace-creator-visibility' +} from '../../../../shared/workspace-creator-visibility' import type { VisibleWorktreeOptions } from './visible-worktrees' /** diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 81b2ff9d1f4..7f1a723db74 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { computeVisibleWorktreeIds } from './visible-worktrees' -import { getPairedDeviceIdsByEnvironment } from './workspace-creator-visibility' +import { getPairedDeviceIdsByEnvironment } from '../../../../shared/workspace-creator-visibility' import type { Repo } from '../../../../shared/repo-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 581f2e1fef2..4ea4c41fe1a 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -47,7 +47,7 @@ import { computeRenderedSidebarWorktreeOrder, computeRenderedSidebarWorktrees } from './rendered-sidebar-worktree-order' -import { isWorkspaceFromOtherDevice } from './workspace-creator-visibility' +import { isWorkspaceOwnedBySomeoneElse } from '../../../../shared/workspace-creator-visibility' import { isDefaultBranchWorkspace } from './default-branch-workspace' import { getLineageAncestorIndex, getSortedWorktreeRankIndex } from './visible-worktree-indexes' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' @@ -105,7 +105,7 @@ export function computeVisibleWorktrees( if (opts.hideWorkspacesFromOtherDevices) { all = all.filter( - (worktree) => !isWorkspaceFromOtherDevice(worktree, opts.pairedDeviceIdsByEnvironment) + (worktree) => !isWorkspaceOwnedBySomeoneElse(worktree, opts.pairedDeviceIdsByEnvironment) ) } diff --git a/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts b/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts deleted file mode 100644 index f21c61c5279..00000000000 --- a/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import { filterFolderWorkspacesFromOtherDevices } from './workspace-creator-visibility' - -function makeFolderWorkspace( - id: string, - creatorProvenance: FolderWorkspace['creatorProvenance'] -): FolderWorkspace { - return { - id, - projectGroupId: 'project', - name: id, - folderPath: `/tmp/${id}`, - executionHostId: 'runtime:env-1', - creatorProvenance, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - createdAt: 0, - updatedAt: 0 - } -} - -describe('filterFolderWorkspacesFromOtherDevices', () => { - it('uses the same client ownership rules as git workspaces', () => { - const own = makeFolderWorkspace('own', { - kind: 'paired-device', - deviceId: 'device-a' - }) - const other = makeFolderWorkspace('other', { - kind: 'paired-device', - deviceId: 'device-b' - }) - const host = makeFolderWorkspace('host', { kind: 'host' }) - const legacy = makeFolderWorkspace('legacy', undefined) - - const result = filterFolderWorkspacesFromOtherDevices( - [own, other, host, legacy], - new Map([['env-1', 'device-a']]) - ) - - expect(result.map((workspace) => workspace.id)).toEqual(['own', 'legacy']) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts index bb8629d7276..970c9b7b131 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -19,8 +19,8 @@ import { isDefaultBranchWorkspace } from '../../default-branch-workspace' import { getFolderWorkspaceExecutionHostIdForRows } from './host-filtering' import { getPairedDeviceIdsByEnvironment, - isWorkspaceFromOtherDevice -} from '../../workspace-creator-visibility' + isWorkspaceOwnedBySomeoneElse +} from '../../../../../../shared/workspace-creator-visibility' import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { @@ -113,7 +113,7 @@ export function useSidebarWorktreeFilters() { state.runtimeEnvironments, state.runtimeStatusByEnvironmentId ) - if (isWorkspaceFromOtherDevice(worktree, pairedDeviceIds)) { + if (isWorkspaceOwnedBySomeoneElse(worktree, pairedDeviceIds)) { state.setHideWorkspacesFromOtherDevices(false) } } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts index 738a2999248..ed04725513c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts @@ -7,7 +7,7 @@ import { type ExecutionHostId } from '../../../../../../shared/execution-host' import type { SidebarWorktreeFilters } from './use-filters' -import { filterFolderWorkspacesFromOtherDevices } from '../../workspace-creator-visibility' +import { filterFolderWorkspacesFromOtherDevices } from '../../../../../../shared/workspace-creator-visibility' import { filterFolderWorkspacesForVisibleHosts, filterProjectGroupsForVisibleHosts, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index 6b42ea04b22..f7d7bd767d5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -9,7 +9,7 @@ import { computeVisibleWorktrees } from '../../visible-worktrees' import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, getPairedDeviceIdsByEnvironment -} from '../../workspace-creator-visibility' +} from '../../../../../../shared/workspace-creator-visibility' import { getVisibleWorktreeBrowserActivityTabs, getVisibleWorktreeTerminalActivityTabs, diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index 335f8a0ea9c..7b9c67139a3 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -19,8 +19,8 @@ import { buildPaletteWorktreeIndex, resolvePaletteWorktree } from '@/lib/palette import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, getPairedDeviceIdsByEnvironment, - isWorkspaceFromOtherDevice -} from '@/components/sidebar/workspace-creator-visibility' + isWorkspaceOwnedBySomeoneElse +} from '../../../shared/workspace-creator-visibility' import type { Worktree } from '../../../shared/worktree/types' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { EMPTY_SORTED_WORKTREES } from './worktree-jump-palette-model' @@ -100,7 +100,7 @@ export function useWorktreeJumpPaletteWorktrees({ } if ( hideWorkspacesFromOtherDevices && - isWorkspaceFromOtherDevice(worktree, pairedDeviceIdsByEnvironment) + isWorkspaceOwnedBySomeoneElse(worktree, pairedDeviceIdsByEnvironment) ) { return false } diff --git a/src/renderer/src/hooks/agent-auto-ack-targets.ts b/src/renderer/src/hooks/agent-auto-ack-targets.ts index 29088c78e34..68d8c86f70e 100644 --- a/src/renderer/src/hooks/agent-auto-ack-targets.ts +++ b/src/renderer/src/hooks/agent-auto-ack-targets.ts @@ -1,6 +1,6 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { isOrcaWindowForegroundFocused } from '@/components/terminal-pane/terminal-notification-pane-visibility' -import { isStructuredTab } from '@/components/native-chat/structured-agent-session-tabs' +import { isStructuredTab } from '../../../shared/structured-agent-session-tab' import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' import { collectTabGroupLayoutGroupIds } from '../../../shared/workspace-layout/tab-group-moves' import { isPresenceConfirmed } from './agent-auto-ack-presence' diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 76c4c3ce607..d48d88d4a4c 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -4,7 +4,7 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { structuredAgentSessionPaneKey } from '../../../shared/structured-agent-session-projection' import { resolveUnifiedTabLabel } from '../../../shared/tab-title-resolution' -import { isStructuredTab } from '@/components/native-chat/structured-agent-session-tabs' +import { isStructuredTab } from '../../../shared/structured-agent-session-tab' import { resolvePaneAgentActivity } from '@/lib/pane-agent-evidence' import type { AgentMessageTarget } from './agent-message-target' import { detectAgentSendTitleStatus } from './agent-send-title-status' diff --git a/src/renderer/src/lib/unread-badge-count-selector.ts b/src/renderer/src/lib/unread-badge-count-selector.ts index 6d3b2f8749f..1e271165420 100644 --- a/src/renderer/src/lib/unread-badge-count-selector.ts +++ b/src/renderer/src/lib/unread-badge-count-selector.ts @@ -1,5 +1,5 @@ import { getVisibleWorkspaceHostIdSet } from '@/components/sidebar/visible-worktree-host-scope' -import { getPairedDeviceIdsByEnvironment } from '@/components/sidebar/workspace-creator-visibility' +import { getPairedDeviceIdsByEnvironment } from '../../../shared/workspace-creator-visibility' import { getRepoMapFromState } from '@/store/selectors' import type { AppState } from '@/store/types' import { getSettingsFocusedExecutionHostId } from '../../../shared/execution-host' diff --git a/src/renderer/src/lib/unread-badge-count.ts b/src/renderer/src/lib/unread-badge-count.ts index 67e72b884e2..ab61cd928d0 100644 --- a/src/renderer/src/lib/unread-badge-count.ts +++ b/src/renderer/src/lib/unread-badge-count.ts @@ -6,8 +6,8 @@ import { getRenderableFolderWorkspaces } from '@/components/sidebar/worktree-lis import { worktreeMatchesVisibleHost } from '@/components/sidebar/visible-worktree-host-scope' import { filterFolderWorkspacesFromOtherDevices, - isWorkspaceFromOtherDevice -} from '@/components/sidebar/workspace-creator-visibility' + isWorkspaceOwnedBySomeoneElse +} from '../../../shared/workspace-creator-visibility' import type { ExecutionHostId } from '../../../shared/execution-host' import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../shared/project-group-types' @@ -46,7 +46,7 @@ export function getUnreadBadgeCount(sources: UnreadBadgeCountSources): number { worktree.isUnread && !worktree.isArchived && (!sources.hiddenOtherDevicePairings || - !isWorkspaceFromOtherDevice(worktree, sources.hiddenOtherDevicePairings)) && + !isWorkspaceOwnedBySomeoneElse(worktree, sources.hiddenOtherDevicePairings)) && worktreeMatchesVisibleHost(worktree, visibleHostIds, sources.repoMap, defaultHostId) ) { unreadWorktrees.add(worktree.id) diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 640bf70087f..d2bc1968c07 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -15,7 +15,7 @@ import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' -import { getPairedDeviceIdsByEnvironment } from '@/components/sidebar/workspace-creator-visibility' +import { getPairedDeviceIdsByEnvironment } from '../../../../shared/workspace-creator-visibility' import { stubRuntimeEnvironmentBridge } from '../../runtime/runtime-environment-bridge-fixture' vi.mock('sonner', () => ({ diff --git a/src/shared/mobile-relay-hosts-contract.ts b/src/shared/mobile-relay-hosts-contract.ts index 975108e2dc5..4b42cf9ce96 100644 --- a/src/shared/mobile-relay-hosts-contract.ts +++ b/src/shared/mobile-relay-hosts-contract.ts @@ -22,10 +22,11 @@ export const MobileRelayHostWorktreesParamsSchema = z .strict() /** One of a server's own `worktree.ps` rows (RuntimeWorktreePsSummary), carrying the server's host id. */ -export type MobileRelayServerWorktreeRow = Record & { hostId: `runtime:${string}` } +export type MobileRelayServerWorktreeRow = Record & { + hostId: `runtime:${string}` + /** The desktop's "Hide other-client workspaces" hides this row; sent only while that is on. */ + desktopOtherClient?: true +} -/** - * The server's own `worktree.ps` rows as the desktop last fetched them, refreshed when the server - * answers; rows are never rewritten beyond their host id. - */ +/** The server's own `worktree.ps` rows as the desktop last fetched them, refreshed when the server answers. */ export type MobileRelayHostWorktreesResult = { worktrees: MobileRelayServerWorktreeRow[] | null } diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index db9dc3307a6..beca01e0be7 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -93,6 +93,8 @@ export type RuntimeWorktreePsSummary = { removing?: true /** See `GitWorktreeInfo.removalError`. */ removalError?: string + /** This host's "Hide other-client workspaces" hides the row; sent only to its own phones. */ + desktopOtherClient?: true } export type RuntimeGitLocalBranches = { diff --git a/src/shared/structured-agent-session-tab.ts b/src/shared/structured-agent-session-tab.ts new file mode 100644 index 00000000000..d38c0545ba8 --- /dev/null +++ b/src/shared/structured-agent-session-tab.ts @@ -0,0 +1,9 @@ +import { isStructuredAgentId } from './agent-session-provider-handle' + +/** A chat tab of any agent its host registered; the host publishes tabs of no other agent. Takes + * only the fields it reads, so readers with their own tab shape share this one predicate. */ +export function isStructuredTab( + tab: T +): tab is T & { contentType: 'agent-session' } { + return tab.contentType === 'agent-session' && isStructuredAgentId(tab.agentSessionAgent) +} diff --git a/src/shared/workspace-creator-visibility.test.ts b/src/shared/workspace-creator-visibility.test.ts new file mode 100644 index 00000000000..d01259be040 --- /dev/null +++ b/src/shared/workspace-creator-visibility.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from './folder-workspace-types' +import { + filterFolderWorkspacesFromOtherDevices, + isWorkspaceOwnedBySomeoneElse +} from './workspace-creator-visibility' + +function makeFolderWorkspace( + id: string, + creatorProvenance: FolderWorkspace['creatorProvenance'] +): FolderWorkspace { + return { + id, + projectGroupId: 'project', + name: id, + folderPath: `/tmp/${id}`, + executionHostId: 'runtime:env-1', + creatorProvenance, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } +} + +describe('filterFolderWorkspacesFromOtherDevices', () => { + it('uses the same client ownership rules as git workspaces', () => { + const own = makeFolderWorkspace('own', { + kind: 'paired-device', + deviceId: 'device-a' + }) + const other = makeFolderWorkspace('other', { + kind: 'paired-device', + deviceId: 'device-b' + }) + const host = makeFolderWorkspace('host', { kind: 'host' }) + const legacy = makeFolderWorkspace('legacy', undefined) + + const result = filterFolderWorkspacesFromOtherDevices( + [own, other, host, legacy], + new Map([['env-1', 'device-a']]) + ) + + expect(result.map((workspace) => workspace.id)).toEqual(['own', 'legacy']) + }) +}) + +describe('isWorkspaceOwnedBySomeoneElse', () => { + const desktopOnServer = new Map([['server', 'desktop']]) + // What resolveRpcWorkspaceCreatorProvenance records for each creator. + const created = { + byThisHostOrItsPhone: { kind: 'host' }, + byPairedDesktopOrItsRelayedPhone: { kind: 'paired-device', deviceId: 'desktop' }, + byAnotherClientOrItsPhone: { kind: 'paired-device', deviceId: 'laptop' }, + byLegacyPhone: { kind: 'paired-device', deviceId: 'phone' } + } as const + + function hidden(creatorProvenance: unknown, runtimeOwnerEnvironmentId?: string): boolean { + return isWorkspaceOwnedBySomeoneElse( + { creatorProvenance, runtimeOwnerEnvironmentId }, + desktopOnServer + ) + } + + it("keeps a local row this desktop or its phone created, and hides other clients'", () => { + expect(hidden(created.byThisHostOrItsPhone)).toBe(false) + expect(hidden(created.byAnotherClientOrItsPhone)).toBe(true) + }) + + it('keeps a legacy local phone row hidden: an older host recorded the phone, not its owner', () => { + expect(hidden(created.byLegacyPhone)).toBe(true) + }) + + it('keeps a server row this desktop or its relayed phone created', () => { + expect(hidden(created.byPairedDesktopOrItsRelayedPhone, 'server')).toBe(false) + }) + + it('hides a server row another client, its phone, or the server itself created', () => { + expect(hidden(created.byAnotherClientOrItsPhone, 'server')).toBe(true) + expect(hidden(created.byThisHostOrItsPhone, 'server')).toBe(true) + }) + + it('keeps rows with no creator, or on a server whose pairing is unknown', () => { + expect(hidden(undefined)).toBe(false) + expect(hidden(undefined, 'server')).toBe(false) + expect(hidden(created.byAnotherClientOrItsPhone, 'unpaired')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-creator-visibility.ts b/src/shared/workspace-creator-visibility.ts similarity index 60% rename from src/renderer/src/components/sidebar/workspace-creator-visibility.ts rename to src/shared/workspace-creator-visibility.ts index ca0b929a40c..28b97d98550 100644 --- a/src/renderer/src/components/sidebar/workspace-creator-visibility.ts +++ b/src/shared/workspace-creator-visibility.ts @@ -1,16 +1,16 @@ -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' -import { normalizeWorkspaceCreatorProvenance } from '../../../../shared/workspace-creator-provenance' +import type { RuntimeStatus } from './runtime-types' +import type { PublicKnownRuntimeEnvironment } from './runtime-environments' +import type { FolderWorkspace } from './folder-workspace-types' +import { folderWorkspaceToWorktree } from './folder-workspace-worktree' +import { normalizeWorkspaceCreatorProvenance } from './workspace-creator-provenance' -type RuntimeStatusEntry = { status: RuntimeStatus | null } +type RuntimeStatusEntry = { status: Pick | null } export const EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT: ReadonlyMap = new Map() +/** This client's own device id on each paired server. */ export function getPairedDeviceIdsByEnvironment( - environments: readonly PublicKnownRuntimeEnvironment[], + environments: readonly Pick[], statuses: ReadonlyMap ): ReadonlyMap { const result = new Map() @@ -24,8 +24,13 @@ export function getPairedDeviceIdsByEnvironment( return result } -export function isWorkspaceFromOtherDevice( - worktree: Worktree, +/** + * The "Hide other-client workspaces" rule: a workspace this client did not create is someone + * else's, including one created on the server itself. Phones record the owner they act for as the + * creator (workspace-creator-context.ts). No creator, or an unnamed pairing, stays visible. + */ +export function isWorkspaceOwnedBySomeoneElse( + worktree: { creatorProvenance?: unknown; runtimeOwnerEnvironmentId?: string }, pairedDeviceIdsByEnvironment: ReadonlyMap ): boolean { const creator = normalizeWorkspaceCreatorProvenance(worktree.creatorProvenance) @@ -47,7 +52,7 @@ export function isFolderWorkspaceFromOtherDevice( workspace: FolderWorkspace, pairedDeviceIdsByEnvironment: ReadonlyMap ): boolean { - return isWorkspaceFromOtherDevice( + return isWorkspaceOwnedBySomeoneElse( folderWorkspaceToWorktree(workspace), pairedDeviceIdsByEnvironment ) diff --git a/src/shared/workspace-session-tab-activity.test.ts b/src/shared/workspace-session-tab-activity.test.ts new file mode 100644 index 00000000000..ba90bfae141 --- /dev/null +++ b/src/shared/workspace-session-tab-activity.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { BrowserWorkspace } from './browser-workspace-types' +import type { Tab } from './tab-types' +import { getWorkspaceSessionTabActivityIds } from './workspace-session-tab-activity' + +function browser(worktreeId: string): BrowserWorkspace { + return { + id: `browser-${worktreeId}`, + worktreeId, + url: 'about:blank', + title: '', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } +} + +function tab(worktreeId: string, contentType: Tab['contentType'], agent?: string): Tab { + return { + id: `${contentType}-${worktreeId}`, + entityId: worktreeId, + groupId: 'group', + worktreeId, + contentType, + label: '', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...(agent ? { agentSessionAgent: agent } : {}) + } +} + +describe('getWorkspaceSessionTabActivityIds', () => { + it('counts open browser and native-chat tabs under the workspace id their key names', () => { + expect( + getWorkspaceSessionTabActivityIds({ + browserTabsByWorktree: { 'worktree:repo::/a': [browser('repo::/a')], empty: [] }, + unifiedTabs: { + 'folder:f1': [tab('folder:f1', 'agent-session', 'claude')], + terminalOnly: [tab('terminalOnly', 'terminal')] + } + }) + ).toEqual(new Set(['repo::/a', 'folder:f1'])) + }) + + it('reads a missing session as no activity', () => { + expect(getWorkspaceSessionTabActivityIds(null)).toEqual(new Set()) + }) +}) diff --git a/src/shared/workspace-session-tab-activity.ts b/src/shared/workspace-session-tab-activity.ts new file mode 100644 index 00000000000..b7547b1331d --- /dev/null +++ b/src/shared/workspace-session-tab-activity.ts @@ -0,0 +1,26 @@ +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { isStructuredTab } from './structured-agent-session-tab' +import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope' + +/** + * Workspaces a saved session holds open browser or native-chat tabs for: the two signals of the + * desktop sidebar's activity rule (worktree-activity-state.ts) that live in the client's session + * rather than on the execution host. Live terminals and agents are the host's to report. Reads + * the saved session's keys; the renderer's live-state twin is getWorktreeIdsWithStructuredChat. + */ +export function getWorkspaceSessionTabActivityIds( + session: Pick | null | undefined +): Set { + const ids = new Set() + for (const [key, tabs] of Object.entries(session?.browserTabsByWorktree ?? {})) { + if (tabs.length > 0) { + ids.add(normalizeWorkspaceSessionKeyToWorkspaceId(key)) + } + } + for (const [key, tabs] of Object.entries(session?.unifiedTabs ?? {})) { + if (tabs.some(isStructuredTab)) { + ids.add(normalizeWorkspaceSessionKeyToWorkspaceId(key)) + } + } + return ids +} diff --git a/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts b/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts new file mode 100644 index 00000000000..946782d2e15 --- /dev/null +++ b/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts @@ -0,0 +1,211 @@ +/** + * Phone paired with a desktop that is a client of an `orca serve` host: the phone's server rows + * must be the ones the desktop's own sidebar shows, under the desktop's "Hide other-client + * workspaces" and "Hide sleeping" filters, which the server alone cannot evaluate. + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' +import { expect, test } from './helpers/orca-app' +import { launchPhoneMirrorTopology } from './helpers/phone-mirror-topology' +import type { PairedMobileSocket } from './helpers/paired-mobile-client' +import { + filterWorktrees, + getWorktreeStatus, + type Worktree +} from '../../mobile/src/worktree/workspace-list-sections' + +const ServerRowsSchema = z.object({ + worktrees: z.array( + z.looseObject({ + worktreeId: z.string(), + repoId: z.string(), + path: z.string(), + branch: z.string(), + isMainWorktree: z.boolean().optional(), + hasHostSidebarActivity: z.boolean().optional(), + status: z.enum(['working', 'active', 'permission', 'done', 'inactive']).optional(), + desktopOtherClient: z.literal(true).optional() + }) + ) +}) +const ReposSchema = z.object({ + repos: z.array(z.looseObject({ id: z.string(), path: z.string() })) +}) +const HostsSchema = z.object({ hosts: z.array(z.looseObject({ hostId: z.string() })) }) +const TabsSchema = z.looseObject({ tabs: z.array(z.looseObject({ id: z.string() })) }) + +let requestId = 0 +async function result( + socket: PairedMobileSocket, + method: string, + params: unknown, + executionHost?: string +): Promise { + requestId += 1 + const id = `visibility-${requestId}` + socket.send(id, method, params, executionHost) + let frame: PairedMobileSocket['frames'][number] | undefined + await expect + .poll(() => (frame = socket.frames.find((entry) => entry.id === id)), { timeout: 30_000 }) + .toBeDefined() + expect(frame?.ok, `${method}: ${JSON.stringify(frame?.error)}`).toBe(true) + return frame?.result +} + +test("phone lists a server's workspaces exactly as the desktop sidebar filters them", async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch. +{}, testInfo) => { + test.setTimeout(240_000) + const serverRepo = testInfo.outputPath('server-repo') + mkdirSync(serverRepo, { recursive: true }) + writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n') + const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' }) + git('init', '-b', 'main') + git('add', 'README.md') + git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init') + const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology( + { phoneTo: 'desktop' }, + testInfo + ) + try { + await host.client.call('repo.add', { path: serverRepo, kind: 'git' }) + const socket = await phone.openSocket() + const { hosts } = HostsSchema.parse(await result(socket, 'mobileRelay.hosts.list', {})) + const serverHostId = hosts[0]?.hostId ?? '' + expect(serverHostId).toMatch(/^runtime:/) + const { repos } = ReposSchema.parse(await result(socket, 'repo.list', {}, serverHostId)) + const repo = `id:${repos.find((entry) => entry.path === serverRepo)?.id ?? ''}` + const create = { setupDecision: 'skip' } + + // One workspace each: created on the server itself, by the desktop, and by the phone through it. + await host.client.call('worktree.create', { repo, name: 'from-server', ...create }) + await desktop.page.evaluate( + async ({ environmentId, params }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'worktree.create', + params + }) + if (!response.ok) { + throw new Error(response.error.message) + } + }, + { environmentId: desktop.environmentId, params: { repo, name: 'from-desktop', ...create } } + ) + await result(socket, 'worktree.create', { repo, name: 'from-phone', ...create }, serverHostId) + + const phoneRows = async () => + ServerRowsSchema.parse( + await result(socket, 'mobileRelay.hosts.worktrees', { hostId: serverHostId }) + ).worktrees + const named = (rows: { path: string }[], suffix: string) => + rows.some((row) => row.path.endsWith(suffix)) + /** The phone's rows after its own filters, against the server rows the desktop sidebar renders. */ + const expectPhoneMatchesSidebar = async (hideSleeping: boolean): Promise => { + let shown: string[] = [] + // Why both sides inside the poll: either can still be catching up with the last change. + await expect + .poll( + async () => { + const rows = await phoneRows() + const serverIds = new Set(rows.map((row) => row.worktreeId)) + const phone = filterWorktrees( + rows.map(toMobileRow), + { filterRepoIds: new Set(), hideSleeping, hideDefaultBranch: false }, + '' + ) + shown = phone.map((row) => row.path) + const rendered = await desktop.page + .locator('[data-worktree-id]') + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-worktree-id'))) + const sidebar = [...new Set(rendered)] + .filter((id): id is string => id !== null && serverIds.has(id)) + .sort() + return ( + JSON.stringify(sidebar) === JSON.stringify(phone.map((row) => row.worktreeId).sort()) + ) + }, + { timeout: 30_000 } + ) + .toBe(true) + return shown + } + + // Hide other-client workspaces: the server's own row goes, the desktop's and its phone's stay. + await desktop.page.evaluate(() => + window.__store!.getState().setHideWorkspacesFromOtherDevices(true) + ) + await expect + .poll( + async () => { + const marked = (await phoneRows()).filter((row) => row.desktopOtherClient) + return [ + named(marked, 'from-server'), + named(marked, 'from-desktop'), + named(marked, 'from-phone') + ] + }, + { timeout: 60_000 } + ) + .toEqual([true, false, false]) + const shown = await expectPhoneMatchesSidebar(false) + expect(shown.map((row) => path.basename(row))).toEqual( + expect.arrayContaining(['from-desktop', 'from-phone']) + ) + + // Hide sleeping too, with a browser tab open in the desktop's workspace: only the desktop knows + // that tab is there, so the server alone reports the workspace asleep. + const desktopRowStatus = async () => { + const row = (await phoneRows()).find((entry) => entry.path.endsWith('from-desktop')) + return row ? getWorktreeStatus(toMobileRow(row)) : null + } + // Creation opened a terminal there; close it so only the desktop's own tab can keep it awake. + const desktopRow = (await phoneRows()).find((entry) => entry.path.endsWith('from-desktop')) + const worktree = `id:${desktopRow?.worktreeId ?? ''}` + const { tabs } = TabsSchema.parse( + (await host.client.call('session.tabs.list', { worktree })).result + ) + for (const tab of tabs) { + await host.client.call('session.tabs.close', { worktree, tabId: tab.id, reason: 'user' }) + } + await expect.poll(desktopRowStatus, { timeout: 30_000 }).toBe('inactive') + await desktop.page.evaluate(async () => { + const state = window.__store!.getState() + state.setShowSleepingWorkspaces(false) + const worktree = state.allWorktrees().find((entry) => entry.path.endsWith('from-desktop')) + if (!worktree) { + throw new Error('the desktop does not list its own server workspace') + } + state.setActiveWorktree(worktree.id, worktree.hostId) + state.setBrowserDefaultUrl('about:blank') + await state.openNewBrowserTabInActiveWorkspace(state.activeGroupIdByWorktree[worktree.id]) + }) + // The phone's status dot and its Hide sleeping both read the desktop's view of the row. + await expect.poll(desktopRowStatus, { timeout: 60_000 }).toBe('active') + const awake = await expectPhoneMatchesSidebar(true) + expect(awake.map((row) => path.basename(row))).toContain('from-desktop') + } finally { + await dispose() + } +}) + +function toMobileRow(row: z.infer['worktrees'][number]): Worktree { + return { + worktreeId: row.worktreeId, + repoId: row.repoId, + repo: '', + branch: row.branch, + displayName: row.worktreeId, + path: row.path, + isMainWorktree: row.isMainWorktree, + hasHostSidebarActivity: row.hasHostSidebarActivity, + desktopOtherClient: row.desktopOtherClient, + status: row.status, + liveTerminalCount: 0, + hasAttachedPty: false, + unread: false, + isPinned: false, + linkedPR: null + } +} From f23fa8ca76205ca8cbd2f1593d7a9d02b3d82ab8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:18:24 -0700 Subject: [PATCH 06/17] Prepare Windows natives with scoped compiler settings and measure servicing (#28881) * Scope Windows native compiler setup and measure ARM inbox preparation * Add a controlled Windows native preparation comparison * Test native preparation through an injected process runner * Guard reviewed Windows native batch runners --- .../actions/prepare-native-runtime/action.yml | 9 +- .../ci-cache-warmup-platform-jobs.yml | 8 + .../workflows/ci-cache-warmup-platforms.yml | 3 + .github/workflows/ci-cache-warmup.yml | 3 + .github/workflows/ssh-windows-hosts.yml | 45 +- .../windows-native-preparation-benchmark.yml | 64 +++ .../preview-ssh/prove-preview-openssh.ps1 | 2 +- .../preview-ssh/test-preview-diagnostics.ps1 | 67 ++- .../preview-ssh/windows-ssh-capability.ps1 | 36 +- .../benchmark-windows-native-preparation.mjs | 518 ++++++++++++++++++ .../capture-windows-native-environment.cmd | 6 + .../initialize-windows-native-toolchain.mjs | 159 ++++++ ...itialize-windows-native-toolchain.test.mjs | 85 +++ config/scripts/node-server-change-scope.mjs | 2 + .../orcad-windows-prebuild-workflow.test.mjs | 129 ++++- config/scripts/pr-code-change-scope.mjs | 4 + .../windows-cmd-shim-spawn-boundary.test.mjs | 21 +- ...dows-native-batch-reference-policy.test.ts | 166 ++++++ .../windows-native-batch-reference-policy.ts | 229 ++++++++ .../windows-native-benchmark-observer.mjs | 118 ++++ ...dows-native-preparation-benchmark.test.mjs | 212 +++++++ .../windows-native-toolchain-routing.test.mjs | 55 ++ 22 files changed, 1890 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/windows-native-preparation-benchmark.yml create mode 100644 config/scripts/benchmark-windows-native-preparation.mjs create mode 100644 config/scripts/capture-windows-native-environment.cmd create mode 100644 config/scripts/initialize-windows-native-toolchain.mjs create mode 100644 config/scripts/initialize-windows-native-toolchain.test.mjs create mode 100644 config/scripts/windows-native-batch-reference-policy.test.ts create mode 100644 config/scripts/windows-native-batch-reference-policy.ts create mode 100644 config/scripts/windows-native-benchmark-observer.mjs create mode 100644 config/scripts/windows-native-preparation-benchmark.test.mjs create mode 100644 config/scripts/windows-native-toolchain-routing.test.mjs diff --git a/.github/actions/prepare-native-runtime/action.yml b/.github/actions/prepare-native-runtime/action.yml index 64712b2aae7..76f9954b7e2 100644 --- a/.github/actions/prepare-native-runtime/action.yml +++ b/.github/actions/prepare-native-runtime/action.yml @@ -54,7 +54,7 @@ runs: env: NATIVE_RUNTIME: ${{ inputs.native-runtime }} NODE_VERSION: ${{ inputs.node-version }} - NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }} + NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }} run: | scope="${ImageOS:-$RUNNER_OS}" if [ -r /etc/os-release ]; then @@ -120,4 +120,9 @@ runs: shell: bash env: NATIVE_RUNTIME: ${{ inputs.native-runtime }} - run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" + run: | + if [ "$RUNNER_OS" = Windows ] && [ "$NATIVE_RUNTIME" = node ]; then + node config/scripts/initialize-windows-native-toolchain.mjs + else + node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" + fi diff --git a/.github/workflows/ci-cache-warmup-platform-jobs.yml b/.github/workflows/ci-cache-warmup-platform-jobs.yml index b4a7be987d0..07fb93918b8 100644 --- a/.github/workflows/ci-cache-warmup-platform-jobs.yml +++ b/.github/workflows/ci-cache-warmup-platform-jobs.yml @@ -54,6 +54,7 @@ jobs: run: node config/scripts/ensure-native-runtime.mjs --check-only - name: Cache Windows CLI launcher + if: runner.arch == 'X64' uses: actions/cache@v5 with: path: | @@ -65,6 +66,7 @@ jobs: key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'native/windows-agent-hook/**', 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/build-windows-agent-hook.mjs', 'config/scripts/windows-rust-executable-build.mjs', 'resources/build/icon.ico', 'package.json') }} - name: Build the Windows native executables + if: runner.arch == 'X64' shell: pwsh run: | if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { @@ -76,6 +78,12 @@ jobs: node config/scripts/build-windows-cli-launcher.mjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Warm the Windows ARM server launcher + if: runner.arch == 'ARM64' + uses: ./.github/actions/prepare-windows-cli-launcher + with: + arches: arm64 + warm-linux-package-fixtures: concurrency: group: ci-cache-warmup-linux-package-fixtures-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/ci-cache-warmup-platforms.yml b/.github/workflows/ci-cache-warmup-platforms.yml index 8f49c2a63aa..e8f37a09c78 100644 --- a/.github/workflows/ci-cache-warmup-platforms.yml +++ b/.github/workflows/ci-cache-warmup-platforms.yml @@ -10,6 +10,7 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' + - '.github/actions/prepare-windows-cli-launcher/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' @@ -21,6 +22,8 @@ on: - '.npmrc' - '.pnpmfile.cjs' - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/initialize-windows-native-toolchain.mjs' + - 'config/scripts/capture-windows-native-environment.cmd' - 'config/scripts/rebuild-native-deps.mjs' - 'config/scripts/node-pty-job-ownership.cjs' - 'config/scripts/windows-pe-machine.cjs' diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index b44598f7ac6..8487d95ef34 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -14,6 +14,7 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' + - '.github/actions/prepare-windows-cli-launcher/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-headless-compiler/**' - 'config/scripts/headless-detector-compiler-cache.mjs' @@ -30,6 +31,8 @@ on: - '.npmrc' - '.pnpmfile.cjs' - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/initialize-windows-native-toolchain.mjs' + - 'config/scripts/capture-windows-native-environment.cmd' - 'config/scripts/rebuild-native-deps.mjs' - 'config/scripts/node-pty-job-ownership.cjs' - 'config/scripts/windows-pe-machine.cjs' diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 4a3b910c620..4b5bb1ce5f2 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -62,12 +62,23 @@ on: description: Comma-separated cell ids from src/main/ssh/ssh-windows-host-cells.ts; empty runs the default set (orcad-cli-* cells run only when named). required: false default: '' + preparation_mode: + description: Compare inbox capability preparation overlapping builds or running after them. + type: choice + options: [overlap, serial] + required: false + default: overlap + arm_inbox_only: + description: Run only the native ARM inbox lane for a preparation timing comparison. + type: boolean + required: false + default: false permissions: contents: read concurrency: - group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }} + group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only && format('-{0}', inputs.preparation_mode || 'overlap') || '' }} cancel-in-progress: true jobs: @@ -77,21 +88,10 @@ jobs: strategy: fail-fast: false matrix: - include: - - arch: x64 - runner: windows-2022 - server: inbox - - arch: x64 - runner: windows-2022 - server: preview - archive: OpenSSH-Win64.zip - - arch: arm64 - runner: windows-11-arm - server: inbox - - arch: arm64 - runner: windows-11-arm - server: preview - archive: OpenSSH-ARM64.zip + include: >- + ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only && + '[{"arch":"arm64","runner":"windows-11-arm","server":"inbox"}]' || + '[{"arch":"x64","runner":"windows-2022","server":"inbox"},{"arch":"x64","runner":"windows-2022","server":"preview","archive":"OpenSSH-Win64.zip"},{"arch":"arm64","runner":"windows-11-arm","server":"inbox"},{"arch":"arm64","runner":"windows-11-arm","server":"preview","archive":"OpenSSH-ARM64.zip"}]') }} runs-on: ${{ matrix.runner }} # Installing the inbox capability alone can take several minutes on a fresh image; the CLI matrix # cells launch the e2e app several times each. @@ -99,6 +99,7 @@ jobs: env: ORCA_BACKGROUND_LAUNCH: '1' ORCA_ISOLATED_SSH_CI: '1' + INBOX_PREPARATION_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.preparation_mode == 'serial' && 'serial' || 'overlap' }} steps: - uses: actions/checkout@v6 with: @@ -127,11 +128,11 @@ jobs: background: true shell: pwsh run: | - if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){ + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64' -and $env:INBOX_PREPARATION_MODE -eq 'overlap'){ $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' New-Item -ItemType Directory -Force -Path $receipts | Out-Null . config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 - Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') + Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE } - uses: ./.github/actions/install-node-dependencies id: dependencies @@ -164,6 +165,14 @@ jobs: pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" - wait: inbox-capability + - name: Prepare the Windows inbox SSH capability after the builds + if: ${{ matrix.server == 'inbox' && matrix.arch == 'arm64' && env.INBOX_PREPARATION_MODE == 'serial' }} + shell: pwsh + run: | + $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' + New-Item -ItemType Directory -Force -Path $receipts | Out-Null + . config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 + Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE - name: Run the Windows host cells against a private ${{ matrix.server }} sshd shell: pwsh timeout-minutes: ${{ contains(github.event.inputs.cells || '', 'orcad-cli') && 130 || 50 }} diff --git a/.github/workflows/windows-native-preparation-benchmark.yml b/.github/workflows/windows-native-preparation-benchmark.yml new file mode 100644 index 00000000000..477fff42c56 --- /dev/null +++ b/.github/workflows/windows-native-preparation-benchmark.yml @@ -0,0 +1,64 @@ +name: Windows native preparation comparison + +on: + workflow_dispatch: + inputs: + source_sha: + description: Exact commit containing this benchmark and the Windows compiler candidate. + required: true + type: string + +permissions: + contents: read + +concurrency: + group: windows-native-preparation-comparison-${{ github.ref }} + cancel-in-progress: false + +env: + ORCA_BACKGROUND_LAUNCH: '1' + +jobs: + compare: + name: Native preparation comparison (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + max-parallel: 2 + matrix: + os: [windows-2022, windows-11-arm] + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_sha }} + persist-credentials: false + - name: Record measurement start and validate source + shell: bash + env: + SOURCE_SHA: ${{ inputs.source_sha }} + run: | + [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + mkdir -p .build/windows-native-benchmark + node -e 'require("node:fs").writeFileSync(".build/windows-native-benchmark/job-start.json", JSON.stringify({startedAt:new Date().toISOString()}))' + - uses: ./.github/actions/install-node-dependencies + with: + node-version: '24.21.0' + native-runtime: none + cache-pnpm-store: 'false' + cache-pnpm-verification: 'false' + - name: Compare isolated cold and same-build warm preparation + shell: bash + env: + SOURCE_SHA: ${{ inputs.source_sha }} + BENCHMARK_RUNNER: ${{ matrix.os }} + run: node config/scripts/benchmark-windows-native-preparation.mjs + - uses: actions/upload-artifact@v7 + if: always() + with: + name: windows-native-preparation-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/windows-native-benchmark/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 diff --git a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 index 5b86447c109..56961bbd78c 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -168,7 +168,7 @@ try { $report.nativeInputs=$verified Write-Stage 'preview-native-input-verification-complete' } else { - Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} + Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt))) $verified=@() foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){ $path=Join-Path $sshDir $binary diff --git a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 index 088534f7be5..9de1a4765d0 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -38,22 +38,52 @@ try { function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}} function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}} function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}} -function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}} -function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}} +function Assert-CapabilityCommand([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction,[string]$Operation){ + if(-not $Online -or $Name -ne 'OpenSSH.Server~~~~0.0.1.0' -or $ErrorAction -ne 'Stop' -or [IO.Path]::GetDirectoryName($LogPath) -ne $script:capabilityLogs -or [IO.Path]::GetFileName($LogPath) -notlike "inbox-capability-$Operation-*.log"){throw 'Capability command changed its online identity, error handling or receipt log boundary'} + $script:capabilityLogPaths.Add($LogPath) + [IO.File]::WriteAllText($LogPath,"$Operation fixture") +} +function Get-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){ + $script:capabilityQueries++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'query' + if($script:failCapabilityQuery){Write-Error 'Injected capability query failure' -ErrorAction $ErrorAction} + return @{State=$script:capabilityState} +} +function Add-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){ + $script:capabilityAdds++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'install' + if($script:activeCapabilityReceipt){ + $script:installObservedReceipt=Get-Content -LiteralPath $script:activeCapabilityReceipt -Raw | ConvertFrom-Json + if($script:installObservedReceipt.inboxCapabilityInitialState -ne 'NotPresent' -or $script:installObservedReceipt.inboxCapabilityOperations[0].status -ne 'passed' -or $script:installObservedReceipt.inboxCapabilityOperations[1].status -ne 'running'){throw 'Initial capability state was not durably recorded before installation'} + } + if($script:failCapabilityInstall){Write-Error 'Injected capability install failure' -ErrorAction $ErrorAction} + return @{Path='C:\';Online=$true;RestartNeeded=$script:capabilityRestartNeeded} +} function Reset-CapabilityControl([string]$State){ $script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0 $script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0 - $script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false + $script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false;$script:failCapabilityQuery=$false + $script:activeCapabilityReceipt=$null;$script:installObservedReceipt=$null;$script:capabilityRestartNeeded=$false $script:capabilityStages=[Collections.Generic.List[string]]::new() + $script:capabilityLogPaths=[Collections.Generic.List[string]]::new() } +$script:capabilityLogs=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) +try { foreach($state in @('Installed','NotPresent')){ Reset-CapabilityControl $state $capabilityReport=@{} - Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} + Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs $expectedAdds=if($state -eq 'Installed'){0}else{1} - if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'} + $expectedStages=@('inbox-capability-start','inbox-capability-query-start','inbox-capability-query-finished') + if($expectedAdds){$expectedStages+=@('inbox-capability-install-start','inbox-capability-install-finished')} + $expectedStages+=@('inbox-capability-guards-start','inbox-capability-complete') + if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityQueries -ne 1 -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne ($expectedStages -join ',')){throw 'Shared capability preparation did not preserve the install and guard boundaries'} + if($capabilityReport.inboxCapabilityOperations.Count -ne 1+$expectedAdds){throw 'Capability operations missing'} + foreach($operation in $capabilityReport.inboxCapabilityOperations){ + if($operation.status -ne 'passed' -or $operation.elapsedMs -isnot [long] -or $operation.elapsedMs -lt 0 -or -not [IO.File]::Exists((Join-Path $script:capabilityLogs $operation.log))){throw 'Capability operation did not retain its timing, verdict or diagnostic log'} + } + if($expectedAdds -and ($capabilityReport.inboxCapabilityInstallResult.online -ne $true -or $capabilityReport.inboxCapabilityInstallResult.path -ne 'C:\' -or $capabilityReport.inboxCapabilityInstallResult.restartNeeded -ne $false)){throw 'Capability install result omitted'} + if(-not $expectedAdds -and $capabilityReport.ContainsKey('inboxCapabilityInstallResult')){throw 'Installed capability invented an install result'} } -foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){ +foreach($fault in @('host','global-before','shell-before','global-after','shell-after','query','install')){ Reset-CapabilityControl 'NotPresent' switch($fault){ 'host' {$script:refuseCapabilityHost=$true} @@ -61,26 +91,45 @@ foreach($fault in @('host','global-before','shell-before','global-after','shell- 'shell-before' {$script:refuseShellCheck=1} 'global-after' {$script:refuseGlobalCheck=2} 'shell-after' {$script:refuseShellCheck=2} + 'query' {$script:failCapabilityQuery=$true} 'install' {$script:failCapabilityInstall=$true} } $rejected=$false - try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true} + $faultReport=@{} + try {Install-WindowsInboxSshCapability 'x64' $faultReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs} catch {$rejected=$true} if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"} if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'} + if($fault -eq 'query' -and $script:capabilityAdds){throw 'Failed query proceeded to installation'} + if($fault -in @('query','install') -and ($faultReport.inboxCapabilityOperations[-1].status -ne 'failed' -or $faultReport.inboxCapabilityOperations[-1].elapsedMs -isnot [long])){throw 'Failed servicing operation lost its verdict or duration'} } +Reset-CapabilityControl 'NotPresent' +foreach($invocation in 1..2){Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs} +if($script:capabilityLogPaths.Count -ne 4 -or ($script:capabilityLogPaths | Select-Object -Unique).Count -ne 4){throw 'Separate servicing operations or invocations reused a diagnostic log'} +} finally {Remove-Item -LiteralPath $script:capabilityLogs -Recurse -Force -ErrorAction SilentlyContinue} $capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) try { New-Item -ItemType Directory -Path $capabilityRoot | Out-Null $capabilityReceipt=Join-Path $capabilityRoot 'capability.json' + $script:capabilityLogs=$capabilityRoot Reset-CapabilityControl 'Installed' Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt $completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json - if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'} - Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true + if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed' -or $completed.preparationMode -ne 'overlap'){throw 'Capability success receipt missing its observed initial state or default mode'} + Reset-CapabilityControl 'NotPresent';$script:capabilityLogs=$capabilityRoot;$script:activeCapabilityReceipt=$capabilityReceipt;$script:capabilityRestartNeeded=$true + Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt 'serial' + $installed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if($installed.status -ne 'passed' -or $installed.preparationMode -ne 'serial' -or $installed.inboxCapabilityInstallResult.restartNeeded -ne $true -or -not $script:installObservedReceipt){throw 'Serial preparation lost its mode, restart result or pre-install receipt'} + Reset-CapabilityControl 'NotPresent';$script:activeCapabilityReceipt=$capabilityReceipt;$script:failCapabilityInstall=$true $rejected=$false try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true} $failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'} + if($failed.inboxCapabilityOperations[-1].status -ne 'failed' -or -not $failed.stages.stage.Contains('inbox-capability-install-finished')){throw 'Failed preparation omitted its servicing result'} + Reset-CapabilityControl 'NotPresent';$script:failCapabilityQuery=$true + $rejected=$false + try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true} + $failedQuery=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if(-not $rejected -or $failedQuery.status -ne 'failed' -or $failedQuery.inboxCapabilityOperations[0].status -ne 'failed' -or $failedQuery.inboxCapabilityOperations.Count -ne 1 -or $script:capabilityAdds -or $failedQuery.inboxCapabilityInitialState){throw 'Failed query receipt invented a capability state or proceeded to installation'} } finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue} & { param($ProofAst) diff --git a/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 index d2bdc9096a3..df0bbaa707e 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 @@ -33,26 +33,48 @@ function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[strin return $written } -function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) { +function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage,[Parameter(Mandatory=$true)][string]$LogDirectory) { Assert-IsolatedWindowsSshCi $Arch Assert-WindowsSshGlobalServerDormant 'inbox' Assert-WindowsSshStockShell & $Stage 'inbox-capability-start' - $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' - $Report.inboxCapabilityInitialState=[string]$capability.State - if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} + New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null + $invocation=[Guid]::NewGuid().ToString('N') + $query=@{operation='query';status='running';log="inbox-capability-query-$invocation.log"} + $Report.inboxCapabilityOperations=@($query) + & $Stage 'inbox-capability-query-start' + $watch=[Diagnostics.Stopwatch]::StartNew() + try { + $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $query.log) -ErrorAction Stop + $Report.inboxCapabilityInitialState=[string]$capability.State + $query.status='passed' + } catch {$query.status='failed';$query.error=$_.Exception.Message;throw} + finally {$watch.Stop();$query.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-query-finished'} + if($capability.State -ne 'Installed'){ + $install=@{operation='install';status='running';log="inbox-capability-install-$invocation.log"} + $Report.inboxCapabilityOperations+=@($install) + & $Stage 'inbox-capability-install-start' + $watch=[Diagnostics.Stopwatch]::StartNew() + try { + $result=Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $install.log) -ErrorAction Stop + $Report.inboxCapabilityInstallResult=@{path=$result.Path;online=$result.Online;restartNeeded=$result.RestartNeeded} + $install.status='passed' + } catch {$install.status='failed';$install.error=$_.Exception.Message;throw} + finally {$watch.Stop();$install.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-install-finished'} + } + & $Stage 'inbox-capability-guards-start' Assert-WindowsSshGlobalServerDormant 'inbox' Assert-WindowsSshStockShell & $Stage 'inbox-capability-complete' } -function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) { - $report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'} +function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt,[ValidateSet('overlap','serial')][string]$PreparationMode='overlap') { + $report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;preparationMode=$PreparationMode;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'} try { if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'} Install-WindowsInboxSshCapability $Arch $report {param($name) if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'} - } + } ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt))) $report.status='passed' } catch {$report.status='failed';$report.error=$_.Exception.Message;throw} finally { diff --git a/config/scripts/benchmark-windows-native-preparation.mjs b/config/scripts/benchmark-windows-native-preparation.mjs new file mode 100644 index 00000000000..336e6fbbd26 --- /dev/null +++ b/config/scripts/benchmark-windows-native-preparation.mjs @@ -0,0 +1,518 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' +import { runProcess } from '@orca/process-host' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' +import { hasWindowsCompilerSelection } from './initialize-windows-native-toolchain.mjs' +import { + compilerEnvironmentKey, + environmentReceipt, + hash +} from './windows-native-benchmark-observer.mjs' + +const addonNames = [ + 'node-pty', + '@orca/windows-registry', + '@orca/windows-security', + '@vscode/windows-process-tree' +] +const candidate = 'initialize-windows-native-toolchain.mjs' +const control = 'ensure-native-runtime.mjs' +const unchangedFiles = [ + `.github/actions/prepare-native-runtime/action.yml`, + `config/scripts/${control}`, + `config/scripts/${candidate}`, + 'config/scripts/capture-windows-native-environment.cmd', + 'config/scripts/windows-native-benchmark-observer.mjs', + 'config/scripts/benchmark-windows-native-preparation.mjs' +] + +export function validateObservedPreparation(receipt, variant, warm = false) { + assert.equal( + receipt.parentBefore.hash, + receipt.parentAfter.hash, + 'Outer compiler environment changed' + ) + const calls = (phase) => receipt.calls.filter((call) => call.phase === phase) + const captured = variant !== 'control' && !warm + assert.equal(calls('discovery').length, captured ? 1 : 0, 'Unexpected compiler discovery count') + assert.equal(calls('capture').length, captured ? 1 : 0, 'Unexpected developer command count') + assert.equal( + calls('probe').length, + variant === 'control' ? 0 : 1, + 'Missing helper preflight observation' + ) + for (const call of receipt.calls) { + assert( + !call.error && !call.timedOut && !call.outputTruncated, + 'Incomplete compiler observation' + ) + } + if (variant === 'control') { + assert.equal(calls('addon-build').length, warm ? 0 : 4, 'Expected all four cold addon builds') + assert( + receipt.calls.every((call) => call.code === 0), + 'Control preparation failed' + ) + return + } + const [prepare] = calls('preparation') + assert.equal(calls('preparation').length, 1, 'Missing unchanged preparation child') + assert.equal(prepare.code, 0, 'Native preparation failed') + assert.equal(calls('probe')[0].code, warm ? 0 : 1, 'Unexpected helper cold/warm probe') + if (warm || variant === 'fallback') { + assert.equal( + prepare.environment.hash, + receipt.parentBefore.hash, + 'Fallback/warm changed compiler selection' + ) + assert.deepEqual(prepare.changedNames, [], 'Fallback/warm altered child environment') + } else { + assert.equal(calls('discovery')[0].code, 0) + assert.equal(calls('capture')[0].code, 0) + assert(prepare.changedNames.length > 0, 'Captured environment was not applied') + assert( + prepare.changedNames.every((name) => compilerEnvironmentKey.test(name)), + 'Unrelated environment changed' + ) + assert(prepare.environment.compiler.vcinstalldir, 'Prepared child lacks compiler selection') + } + if (variant === 'fallback' && !warm) { + assert.equal(calls('discovery')[0].code, 0) + assert.equal(calls('capture')[0].code, 7, 'Fixed failure fixture did not run') + } +} + +export function assertNativeColdProbe(result) { + assert.equal(result.code, 1, 'Cold fixture unexpectedly passed') + for (const name of addonNames) { + assert(result.stderr.includes(`${name}:`), `Cold fixture did not fail ${name}`) + } +} + +export function assertNoHeaderDownloads(result) { + assert( + !/gyp http (GET|200|download)/i.test(`${result.stdout}\n${result.stderr}`), + 'Timed preparation downloaded headers/libraries' + ) +} + +function digestFiles(root, paths) { + return Object.fromEntries( + paths + .filter((path) => existsSync(join(root, path))) + .map((path) => [path, hash(readFileSync(join(root, path)))]) + ) +} + +function treeDigest(root) { + const digest = createHash('sha256') + const files = [] + const visit = (path) => { + for (const name of readdirSync(path).sort()) { + const child = join(path, name) + if (statSync(child).isDirectory()) { + visit(child) + } else { + files.push(child) + } + } + } + visit(root) + for (const file of files) { + digest.update(relative(root, file)).update('\0').update(readFileSync(file)) + } + return { sha256: digest.digest('hex'), files: files.length } +} + +function nativeDirectories(snapshot) { + const require = createRequire(join(snapshot, 'package.json')) + const root = realpathSync(snapshot) + return addonNames.map((name) => { + const directory = realpathSync(dirname(require.resolve(`${name}/package.json`))) + assert(directory.startsWith(`${root}${sep}`), `Addon escapes its isolated copy: ${name}`) + return { name, directory } + }) +} + +function binaryReceipt(snapshot) { + const require = createRequire(join(snapshot, 'package.json')) + const paths = [ + join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty.node'), + join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/conpty.dll'), + join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/OpenConsole.exe'), + join(snapshot, 'native/windows-registry/build/Release/orca_windows_registry.node'), + join(snapshot, 'native/windows-security/build/Release/orca_windows_security.node'), + join( + dirname(require.resolve('@vscode/windows-process-tree/package.json')), + 'build/Release/windows_process_tree.node' + ) + ] + return paths.map((path) => { + assert(existsSync(path), `Missing prepared artifact: ${path}`) + return { + path: relative(snapshot, path), + bytes: statSync(path).size, + sha256: hash(readFileSync(path)) + } + }) +} + +export async function benchmarkWindowsNativePreparation( + root = resolve(import.meta.dirname, '../..') +) { + assert.equal(process.platform, 'win32', 'This hosted comparison requires Windows') + assert(['x64', 'arm64'].includes(process.arch), 'Unsupported Windows runner') + assert.equal(process.versions.node, '24.21.0', 'Comparison requires pinned Node') + assert( + !hasWindowsCompilerSelection(process.env), + 'Explicit compiler selection prevents a qualified automatic-capture comparison' + ) + assert(/^[0-9a-f]{40}$/.test(process.env.SOURCE_SHA ?? ''), 'SOURCE_SHA must be an exact commit') + const output = join(root, '.build/windows-native-benchmark') + mkdirSync(output, { recursive: true }) + const temporary = mkdtempSync( + join(process.env.RUNNER_TEMP || tmpdir(), 'orca-native-comparison-') + ) + const archive = join(temporary, 'source.tar') + const headers = join(temporary, 'headers') + const pnpm = resolvePnpmCliInvocation() + const environment = { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + npm_config_devdir: headers, + npm_package_config_node_gyp_devdir: headers + } + const parentBefore = environmentReceipt(process.env) + const githubEnvironmentBefore = existsSync(process.env.GITHUB_ENV ?? '') + ? hash(readFileSync(process.env.GITHUB_ENV)) + : null + const report = { + schemaVersion: 1, + startedAt: new Date().toISOString(), + qualification: 'incomplete', + firstRecordedJobStep: JSON.parse(readFileSync(join(output, 'job-start.json'), 'utf8')), + phases: [], + samples: [], + scope: { + warm: 'Same-build warm preparation; no Actions native-cache restore/save is performed.', + metric: + 'Cold addon preparation with identical diagnostic preload; installs, header downloads, standalone qualification probes and artifact upload are recorded separately; the helper preflight remains included.', + occupancy: + 'Use complete hosted job started_at/completed_at, including setup and upload/post steps, for actual runner occupancy. This artifact only measures its explicit window.', + excluded: + 'No Node-to-Electron, cross-target template, exact-key restore, or CI workload gain is claimed.' + } + } + const writeReport = () => + writeFileSync(join(output, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`) + const command = async (label, program, args, cwd = root, env = environment, accepted = [0]) => { + const startedAt = new Date().toISOString() + const start = performance.now() + const result = await runProcess({ + program, + args, + cwd, + env, + timeoutMs: 1_200_000, + maxOutputBytes: 64 * 1024 * 1024, + terminationBarrier: true + }) + const elapsedMs = performance.now() - start + const stdoutFile = `${label}.stdout.log` + const stderrFile = `${label}.stderr.log` + writeFileSync(join(output, stdoutFile), result.stdout) + writeFileSync(join(output, stderrFile), result.stderr) + report.phases.push({ + label, + program, + args, + startedAt, + finishedAt: new Date().toISOString(), + durationMs: elapsedMs, + code: result.code, + signal: result.signal, + timedOut: result.timedOut, + outputTruncated: result.outputTruncated, + stdoutFile, + stderrFile, + stdoutSha256: hash(result.stdout), + stderrSha256: hash(result.stderr) + }) + writeReport() + assert( + !result.timedOut && + !result.signal && + !result.outputTruncated && + accepted.includes(result.code), + `${label} failed: ${result.stderr.slice(-4000)}` + ) + return { ...result, durationMs: elapsedMs } + } + const pnpmCommand = (label, args, cwd) => + command(label, pnpm.command, [...pnpm.prefixArgs, ...args], cwd) + try { + const head = (await command('source-head', 'git', ['rev-parse', 'HEAD'])).stdout.trim() + assert.equal(head, process.env.SOURCE_SHA, 'Checkout differs from frozen source') + await command('source-clean', 'git', ['diff', '--exit-code', 'HEAD', '--']) + const tree = (await command('source-tree', 'git', ['rev-parse', 'HEAD^{tree}'])).stdout.trim() + const pnpmVersion = (await pnpmCommand('pnpm-version', ['--version'], root)).stdout.trim() + assert.equal(pnpmVersion, '12.8.1', 'Comparison requires pinned pnpm') + const action = readFileSync( + join(root, '.github/actions/prepare-native-runtime/action.yml'), + 'utf8' + ) + const hashExpression = action.match(/NATIVE_SOURCE_HASH: (.+)/)?.[1] + assert(hashExpression, 'Missing production source identity') + const inputs = [...hashExpression.matchAll(/'([^']+)'/g)].map((match) => match[1]) + assert( + inputs.includes(`config/scripts/${candidate}`), + 'Frozen source lacks the candidate native-cache fingerprint' + ) + const requiredScriptHashes = digestFiles(root, unchangedFiles) + assert.equal( + Object.keys(requiredScriptHashes).length, + unchangedFiles.length, + 'Frozen source lacks required scripts' + ) + const sourceInputs = digestFiles(root, inputs) + report.identity = { + sourceSha: head, + tree, + runtime: 'node', + node: process.versions.node, + nodeAbi: process.versions.modules, + arch: process.arch, + pnpm: pnpmVersion, + runner: process.env.BENCHMARK_RUNNER, + imageOS: process.env.ImageOS, + imageVersion: process.env.ImageVersion, + workflowRef: process.env.GITHUB_WORKFLOW_REF, + workflowSha: process.env.GITHUB_WORKFLOW_SHA, + runId: process.env.GITHUB_RUN_ID, + runAttempt: process.env.GITHUB_RUN_ATTEMPT, + sourceInputs, + requiredScriptHashes, + sourceInputDigest: hash(JSON.stringify(sourceInputs)), + productionHashExpression: hashExpression, + parentCompilerEnvironment: parentBefore.compiler + } + await command('archive-source', 'git', ['archive', '--format=tar', '--output', archive, 'HEAD']) + report.identity.archiveSha256 = hash(readFileSync(archive)) + const require = createRequire(join(root, 'package.json')) + const nodeGyp = process.env.npm_config_node_gyp || require.resolve('node-gyp/bin/node-gyp.js') + report.identity.nodeGyp = JSON.parse( + readFileSync(resolve(dirname(nodeGyp), '..', 'package.json'), 'utf8') + ).version + report.identity.nodeGypEntrySha256 = hash(readFileSync(nodeGyp)) + await command('download-headers-before-measurement', process.execPath, [ + nodeGyp, + 'install', + `--target=${process.versions.node}`, + `--arch=${process.arch}`, + `--devdir=${headers}` + ]) + const headersBefore = treeDigest(headers) + report.identity.headers = headersBefore + const physicalDirectories = new Set() + const schedule = buildCounterbalancedSchedule(2, 'control', 'candidate').flat() + report.schedule = schedule + for (const [index, variant] of [...schedule, 'fallback'].entries()) { + const label = `sample-${String(index + 1).padStart(2, '0')}` + const snapshot = join(temporary, label) + mkdirSync(snapshot) + await command(`${label}-extract`, 'tar', ['-xf', archive, '-C', snapshot]) + await pnpmCommand( + `${label}-install`, + [ + 'install', + '--offline', + '--frozen-lockfile', + '--ignore-scripts', + '--package-import-method=copy', + '--side-effects-cache=false' + ], + snapshot + ) + await pnpmCommand(`${label}-internal-packages`, ['run', 'build:packages'], snapshot) + const directories = nativeDirectories(snapshot) + for (const { directory } of directories) { + assert( + !physicalDirectories.has(directory.toLowerCase()), + 'Native copies share a build directory' + ) + physicalDirectories.add(directory.toLowerCase()) + rmSync(join(directory, 'build'), { recursive: true, force: true }) + } + const initial = await command( + `${label}-cold-probe`, + process.execPath, + [join(snapshot, 'config/scripts', control), '--check-only'], + snapshot, + environment, + [1] + ) + assertNativeColdProbe(initial) + const snapshotScriptHashes = digestFiles(snapshot, unchangedFiles) + assert.deepEqual( + snapshotScriptHashes, + requiredScriptHashes, + 'Ordinary snapshot source/config drift' + ) + const originalCapture = readFileSync( + join(snapshot, 'config/scripts/capture-windows-native-environment.cmd') + ) + if (variant === 'fallback') { + writeFileSync( + join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'), + '@echo off\r\nexit /b 7\r\n' + ) + } + const sample = { + label, + variant, + sourceHashesBeforeInjection: snapshotScriptHashes, + originalCaptureSha256: hash(originalCapture), + addonDirectories: directories, + measurements: [] + } + report.samples.push(sample) + for (const warm of [false, true]) { + const stage = `${label}-${warm ? 'warm' : 'cold'}` + const traceFile = join(output, `${stage}.trace.json`) + const script = join(snapshot, 'config/scripts', variant === 'control' ? control : candidate) + const args = [ + '--import', + pathToFileURL(join(snapshot, 'config/scripts/windows-native-benchmark-observer.mjs')) + .href, + script, + ...(variant === 'control' ? ['--runtime=node'] : []) + ] + const result = await command(stage, process.execPath, args, snapshot, { + ...environment, + ORCA_NATIVE_BENCHMARK_TRACE: traceFile + }) + assertNoHeaderDownloads(result) + const receipt = JSON.parse(readFileSync(traceFile, 'utf8')) + validateObservedPreparation(receipt, variant, warm) + if (variant === 'fallback' && !warm) { + assert(result.stderr.includes('retaining node-gyp discovery'), 'Fallback warning missing') + } + const final = await command( + `${stage}-final-probe`, + process.execPath, + [join(snapshot, 'config/scripts', control), '--check-only'], + snapshot + ) + assert.equal(final.code, 0) + const calls = receipt.calls + const milliseconds = (phase) => + calls + .filter((call) => call.phase === phase) + .reduce((total, call) => total + call.durationMs, 0) + sample.measurements.push({ + warm, + totalPreparationMs: result.durationMs, + helperDiscoveryMs: milliseconds('discovery'), + helperCaptureMs: milliseconds('capture'), + helperPreprobeMs: milliseconds('probe'), + unchangedPreparationChildMs: variant === 'control' ? null : milliseconds('preparation'), + helperOutsidePreparationChildMs: + variant === 'control' ? null : result.durationMs - milliseconds('preparation'), + traceFile: relative(output, traceFile), + traceSha256: hash(readFileSync(traceFile)), + binaries: binaryReceipt(snapshot) + }) + assert.deepEqual( + treeDigest(headers), + headersBefore, + 'Timed preparation changed prefilled headers/libraries' + ) + assert.equal( + environmentReceipt(process.env).hash, + parentBefore.hash, + 'Parent environment changed' + ) + assert.equal( + existsSync(process.env.GITHUB_ENV ?? '') + ? hash(readFileSync(process.env.GITHUB_ENV)) + : null, + githubEnvironmentBefore, + 'Compiler setup wrote GITHUB_ENV' + ) + writeReport() + } + sample.isolatedCopyWindowMs = report.phases + .filter((phase) => phase.label.startsWith(`${label}-`)) + .reduce((total, phase) => total + phase.durationMs, 0) + sample.injectedCaptureSha256 = + variant === 'fallback' + ? hash( + readFileSync(join(snapshot, 'config/scripts/capture-windows-native-environment.cmd')) + ) + : null + } + report.comparison = Object.fromEntries( + [false, true].map((warm) => { + const timings = (variant) => + report.samples + .filter((sample) => sample.variant === variant) + .map( + (sample) => + sample.measurements.find((measurement) => measurement.warm === warm) + .totalPreparationMs + ) + const baseline = timings('control') + const proposed = timings('candidate') + return [ + warm ? 'sameBuildWarm' : 'cold', + { + control: summarizeBenchmarkSamples(baseline), + candidate: summarizeBenchmarkSamples(proposed), + pairDeltaMs: baseline.map((value, index) => proposed[index] - value), + role: 'Two counterbalanced pairs are descriptive; no significance or ordinary CI hit-rate estimate.' + } + ] + }) + ) + report.parentEnvironment = { + before: parentBefore, + after: environmentReceipt(process.env), + githubEnvironmentBefore, + githubEnvironmentAfter: githubEnvironmentBefore + } + report.qualification = 'passed' + } catch (error) { + report.failure = error.message + report.qualification = 'failed' + throw error + } finally { + rmSync(temporary, { recursive: true, force: true }) + report.finishedAt = new Date().toISOString() + report.explicitWindowMs = Date.parse(report.finishedAt) - Date.parse(report.startedAt) + report.explicitJobWindowMs = + Date.parse(report.finishedAt) - Date.parse(report.firstRecordedJobStep.startedAt) + writeReport() + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await benchmarkWindowsNativePreparation() +} diff --git a/config/scripts/capture-windows-native-environment.cmd b/config/scripts/capture-windows-native-environment.cmd new file mode 100644 index 00000000000..52dab775b3f --- /dev/null +++ b/config/scripts/capture-windows-native-environment.cmd @@ -0,0 +1,6 @@ +@echo off +chcp 65001 >nul +call "%ORCA_WINDOWS_VSDEVCMD%" -no_logo -arch=%ORCA_WINDOWS_NATIVE_ARCH% -host_arch=%ORCA_WINDOWS_NATIVE_ARCH% +if errorlevel 1 exit /b 1 +echo ORCA_WINDOWS_NATIVE_ENVIRONMENT +set diff --git a/config/scripts/initialize-windows-native-toolchain.mjs b/config/scripts/initialize-windows-native-toolchain.mjs new file mode 100644 index 00000000000..86be338d549 --- /dev/null +++ b/config/scripts/initialize-windows-native-toolchain.mjs @@ -0,0 +1,159 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { runProcessSync } from '@orca/process-host' + +const ENVIRONMENT_MARKER = 'ORCA_WINDOWS_NATIVE_ENVIRONMENT' +const compilerEnvironmentKey = + /^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i + +export function parseWindowsCompilerEnvironment(output, arch) { + const lines = output.split(/\r?\n/) + const marker = lines.indexOf(ENVIRONMENT_MARKER) + if (marker === -1) { + throw new Error('Developer environment receipt is missing') + } + const environment = {} + for (const line of lines.slice(marker + 1)) { + const separator = line.indexOf('=') + if (separator <= 0) { + continue + } + const name = line.slice(0, separator) + if (compilerEnvironmentKey.test(name)) { + environment[name] = line.slice(separator + 1) + } + } + const value = (name) => { + const key = Object.keys(environment).find((key) => key.toLowerCase() === name.toLowerCase()) + return key ? environment[key] : undefined + } + if (!value('VCINSTALLDIR') || !value('VSCMD_VER') || !value('WindowsSDKVersion')) { + throw new Error('Developer environment lacks the compiler or Windows SDK') + } + if ( + !/^\d+\.\d+\./.test(value('VSCMD_VER')) || + !/^\d+\.\d+\.\d+\.\d+\\?$/.test(value('WindowsSDKVersion')) + ) { + throw new Error('Developer environment has an invalid compiler or Windows SDK version') + } + const target = value('VSCMD_ARG_TGT_ARCH')?.toLowerCase() + if (!(target === arch || (arch === 'x64' && target === 'amd64'))) { + throw new Error('Developer environment targets a different architecture') + } + return environment +} + +export function hasWindowsCompilerSelection(environment) { + return Object.entries(environment).some( + ([name, value]) => + /^(VCINSTALLDIR|GYP_MSVS_VERSION|npm_config_msvs_version|npm_package_config_node_gyp_msvs_version)$/i.test( + name + ) && value + ) +} + +export function mergeWindowsCompilerEnvironment(environment, compilerEnvironment) { + const compilerKeys = new Set(Object.keys(compilerEnvironment).map((key) => key.toLowerCase())) + return { + ...Object.fromEntries( + Object.entries(environment).filter(([key]) => !compilerKeys.has(key.toLowerCase())) + ), + ...compilerEnvironment + } +} + +export function initializeWindowsNativeToolchain() { + if (process.platform !== 'win32') { + return + } + if (hasWindowsCompilerSelection(process.env)) { + return + } + try { + if (!['x64', 'arm64'].includes(process.arch)) { + throw new Error('Windows compiler setup requires a supported runner architecture') + } + const programFiles = Object.entries(process.env) + .filter(([key]) => /^(ProgramFiles|ProgramFiles\(x86\))$/i.test(key)) + .map(([, value]) => value) + const vswhere = programFiles + .map((directory) => join(directory, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe')) + .find(existsSync) + if (!vswhere) { + throw new Error('Visual Studio discovery tool is unavailable') + } + const discovery = runProcessSync({ + program: vswhere, + args: [ + '-latest', + '-products', + '*', + '-utf8', + '-requires', + process.arch === 'arm64' + ? 'Microsoft.VisualStudio.Component.VC.Tools.ARM64' + : 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', + 'installationPath' + ], + stdio: 'pipe', + timeoutMs: 30_000 + }) + const installations = discovery.stdout.trim().split(/\r?\n/).filter(Boolean) + if (discovery.code !== 0 || installations.length !== 1) { + throw new Error('Visual Studio discovery did not select one compiler') + } + const devcmd = join(installations[0], 'Common7', 'Tools', 'VsDevCmd.bat') + if (!existsSync(devcmd)) { + throw new Error('Visual Studio developer command is unavailable') + } + // process-host owns the fixed batch runner's quoting and hidden console. + const result = runProcessSync({ + program: join(import.meta.dirname, 'capture-windows-native-environment.cmd'), + args: [], + env: { + ...process.env, + ORCA_WINDOWS_VSDEVCMD: devcmd, + ORCA_WINDOWS_NATIVE_ARCH: process.arch + }, + stdio: 'pipe', + timeoutMs: 60_000 + }) + if (result.code !== 0) { + throw new Error('Visual Studio developer command failed') + } + const environment = parseWindowsCompilerEnvironment(result.stdout, process.arch) + console.log(`[native-runtime] Initialized Windows ${process.arch} compiler environment`) + return environment + } catch { + console.warn( + '[native-runtime] Compiler environment setup unavailable; retaining node-gyp discovery' + ) + } +} + +export function prepareWindowsNodeRuntime(runProcess = runProcessSync) { + const script = join(import.meta.dirname, 'ensure-native-runtime.mjs') + const check = runProcess({ + program: process.execPath, + args: [script, '--check-only'], + stdio: 'pipe', + timeoutMs: null + }) + const compilerEnvironment = check.code === 0 ? undefined : initializeWindowsNativeToolchain() + const result = runProcess({ + program: process.execPath, + args: [script, '--runtime=node'], + env: compilerEnvironment + ? mergeWindowsCompilerEnvironment(process.env, compilerEnvironment) + : process.env, + stdio: 'inherit', + timeoutMs: null + }) + return result.code ?? 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = prepareWindowsNodeRuntime() +} diff --git a/config/scripts/initialize-windows-native-toolchain.test.mjs b/config/scripts/initialize-windows-native-toolchain.test.mjs new file mode 100644 index 00000000000..d9765d6523d --- /dev/null +++ b/config/scripts/initialize-windows-native-toolchain.test.mjs @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { + hasWindowsCompilerSelection, + mergeWindowsCompilerEnvironment, + parseWindowsCompilerEnvironment, + prepareWindowsNodeRuntime +} from './initialize-windows-native-toolchain.mjs' + +const receipt = [ + 'Developer Command Prompt banner, not an environment variable', + 'TOKEN=should-not-be-exported', + 'ORCA_WINDOWS_NATIVE_ENVIRONMENT', + 'VCINSTALLDIR=C:\\Visual Studio\\VC\\', + 'VSCMD_VER=18.10.12224.181', + 'WindowsSDKVersion=10.0.28000.0\\', + 'VSCMD_ARG_TGT_ARCH=arm64', + 'Path=C:\\工具;C:\\Program Files\\Visual Studio', + 'INCLUDE=C:\\SDK=Custom\\Include', + 'TOKEN=should-not-be-exported', + '=C:=C:\\', + '' +].join('\r\n') + +describe('Windows compiler environment receipt', () => { + it('keeps compiler paths and equals signs while excluding unrelated variables and banners', () => { + expect(parseWindowsCompilerEnvironment(receipt, 'arm64')).toEqual({ + VCINSTALLDIR: 'C:\\Visual Studio\\VC\\', + VSCMD_VER: '18.10.12224.181', + WindowsSDKVersion: '10.0.28000.0\\', + VSCMD_ARG_TGT_ARCH: 'arm64', + Path: 'C:\\工具;C:\\Program Files\\Visual Studio', + INCLUDE: 'C:\\SDK=Custom\\Include' + }) + }) + + it.each([ + receipt.replace('ORCA_WINDOWS_NATIVE_ENVIRONMENT', 'missing'), + receipt.replace('VCINSTALLDIR=', 'unrelated='), + receipt.replace('VSCMD_VER=', 'unrelated='), + receipt.replace('WindowsSDKVersion=', 'unrelated='), + receipt.replace('10.0.28000.0\\', 'unavailable'), + receipt.replace('18.10.12224.181', 'unavailable'), + receipt.replace('VSCMD_ARG_TGT_ARCH=arm64', 'VSCMD_ARG_TGT_ARCH=x64') + ])('rejects incomplete, invalid or wrong-architecture setup before use', (output) => { + expect(() => parseWindowsCompilerEnvironment(output, 'arm64')).toThrow() + }) +}) + +describe('Windows native compiler environment scope', () => { + it('replaces path keys case-insensitively without changing the parent environment', () => { + const original = { PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' } + expect( + mergeWindowsCompilerEnvironment(original, { Path: 'compiler-path', Lib: 'compiler-lib' }) + ).toEqual({ Path: 'compiler-path', Lib: 'compiler-lib', TOKEN: 'unchanged' }) + expect(original).toEqual({ PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' }) + }) + + it.each([ + 'VCINSTALLDIR', + 'GYP_MSVS_VERSION', + 'npm_config_msvs_version', + 'npm_package_config_node_gyp_msvs_version' + ])('retains an existing compiler selection through %s', (name) => { + expect(hasWindowsCompilerSelection({ [name.toLowerCase()]: 'requested' })).toBe(true) + expect(hasWindowsCompilerSelection({ [name]: '' })).toBe(false) + }) + + it('uses the original preparation and environment after a passing warm probe', () => { + const runProcess = vi.fn(() => ({ code: 0 })) + expect(prepareWindowsNodeRuntime(runProcess)).toBe(0) + expect(runProcess).toHaveBeenCalledTimes(2) + expect(runProcess).toHaveBeenLastCalledWith( + expect.objectContaining({ + args: [expect.stringContaining('ensure-native-runtime.mjs'), '--runtime=node'], + env: process.env, + stdio: 'inherit' + }) + ) + }) + + it('propagates native preparation failure after a passing warm probe', () => { + const runProcess = vi.fn().mockReturnValueOnce({ code: 0 }).mockReturnValueOnce({ code: 9 }) + expect(prepareWindowsNodeRuntime(runProcess)).toBe(9) + }) +}) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 9e66329e65d..cd06384f996 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -41,6 +41,8 @@ const ALWAYS_FILES = new Set([ 'config/scripts/workspace-source-exports.mjs', 'config/scripts/workspace-source-exports.test.mjs', 'config/scripts/headless-detector-compiler-cache.mjs', + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/node-server-qualification.mjs', // The source graph sees process-host source, not the build that emits the dist servers load. 'src/packages/process-host/.gitignore', diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index 97f54cf3bd7..26cb30088fc 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -59,6 +59,8 @@ function context(os, arch, event, ref, template = false) { }, success: () => true, fromJSON: JSON.parse, + format: (template, ...values) => + template.replace(/\{(\d+)\}/g, (_match, index) => String(values[Number(index)])), contains: (values, value) => values.includes(value) } } @@ -173,6 +175,38 @@ describe('SSH Windows consumers of qualified server slots', () => { const sshWorkflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8')) const sshSteps = sshWorkflow.jobs.hosts.steps const sshPrepare = sshSteps.find((step) => step.uses === prepare.uses) + const inboxBackground = sshSteps.find((step) => step.id === 'inbox-capability') + const inboxSerial = sshSteps.find((step) => step.name?.endsWith('after the builds')) + const allLanes = [ + { arch: 'x64', runner: 'windows-2022', server: 'inbox' }, + { arch: 'x64', runner: 'windows-2022', server: 'preview', archive: 'OpenSSH-Win64.zip' }, + { arch: 'arm64', runner: 'windows-11-arm', server: 'inbox' }, + { arch: 'arm64', runner: 'windows-11-arm', server: 'preview', archive: 'OpenSSH-ARM64.zip' } + ] + + function sshContext(event, inputs = {}) { + const ctx = context('Windows', 'ARM64', event, 'refs/heads/main') + ctx.github.event = { inputs, pull_request: { number: undefined } } + ctx.inputs = inputs + ctx.env = { + INBOX_PREPARATION_MODE: sshValue(sshWorkflow.jobs.hosts.env.INBOX_PREPARATION_MODE, ctx) + } + return ctx + } + + function sshValue(value, ctx) { + return evaluate(value.slice(3, -2).trim(), ctx) + } + + function backgroundPreparationRuns(ctx) { + const condition = inboxBackground.run + .match(/^\s*if\((.*)\)\{\s*$/m)[1] + .replace(/'\$\{\{ matrix\.(arch|server) \}\}'/g, (_match, name) => `matrix.${name}`) + .replaceAll('$env:INBOX_PREPARATION_MODE', 'env.INBOX_PREPARATION_MODE') + .replaceAll(' -eq ', ' === ') + .replaceAll(' -and ', ' && ') + return evaluate(condition, ctx) + } it.each(['pull_request', 'workflow_dispatch'])( 'restores only PR slots and keeps manual %s qualification fresh', @@ -188,13 +222,8 @@ describe('SSH Windows consumers of qualified server slots', () => { it('keeps both architectures, both sshd versions and the existing build order', () => { expect( - sshWorkflow.jobs.hosts.strategy.matrix.include.map(({ arch, server }) => [arch, server]) - ).toEqual([ - ['x64', 'inbox'], - ['x64', 'preview'], - ['arm64', 'inbox'], - ['arm64', 'preview'] - ]) + sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, sshContext('pull_request')) + ).toEqual(allLanes) const addon = sshSteps.findIndex((step) => step.run?.includes('build-windows-process-tree-addon.mjs') ) @@ -215,6 +244,92 @@ describe('SSH Windows consumers of qualified server slots', () => { 'config/scripts/orcad-windows-prebuild-cache.mjs' ) }) + + it.each([ + ['pull_request', {}, 'overlap', false], + ['pull_request', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false], + ['push', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false], + ['workflow_dispatch', {}, 'overlap', false], + ['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: false }, 'overlap', false], + ['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: false }, 'serial', false], + ['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: true }, 'overlap', true], + ['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: true }, 'serial', true] + ])('%s inputs=%j mode=%s ARM-only=%s', (event, inputs, mode, armOnly) => { + const ctx = sshContext(event, inputs) + const lanes = sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, ctx) + expect(lanes).toEqual(armOnly ? [allLanes[2]] : allLanes) + expect(ctx.env.INBOX_PREPARATION_MODE).toBe(mode) + for (const matrix of lanes) { + ctx.matrix = matrix + const inboxArm = matrix.server === 'inbox' && matrix.arch === 'arm64' + expect(backgroundPreparationRuns(ctx)).toBe(inboxArm && mode === 'overlap') + expect(sshValue(inboxSerial.if, ctx)).toBe(inboxArm && mode === 'serial') + } + }) + + it('defaults to the existing overlap schedule and keeps serial after the successful wait', () => { + expect(sshWorkflow.on.workflow_dispatch.inputs.preparation_mode).toMatchObject({ + type: 'choice', + default: 'overlap', + options: ['overlap', 'serial'] + }) + expect(sshWorkflow.on.workflow_dispatch.inputs.arm_inbox_only).toMatchObject({ + type: 'boolean', + default: false + }) + const dependencies = sshSteps.findIndex((step) => step.id === 'dependencies') + const template = sshSteps.findIndex((step) => step.run?.includes('build-orcad-template.mjs')) + const wait = sshSteps.findIndex((step) => step.wait === 'inbox-capability') + const serial = sshSteps.indexOf(inboxSerial) + const hosts = sshSteps.findIndex((step) => step.name?.startsWith('Run the Windows host cells')) + expect(inboxBackground.background).toBe(true) + expect(inboxBackground.if).toBeUndefined() + expect(sshSteps.indexOf(inboxBackground)).toBeLessThan(dependencies) + expect(template).toBeLessThan(wait) + expect(serial).toBe(wait + 1) + expect(hosts).toBe(serial + 1) + expect(inboxSerial.background).toBeUndefined() + for (const preparation of [inboxBackground, inboxSerial]) { + expect(preparation['continue-on-error']).toBeUndefined() + expect(preparation.run).toContain( + '. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1' + ) + expect(preparation.run).toContain( + "-Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE" + ) + } + expect(sshSteps[hosts].run).toContain('-InboxPreparationReceipt $preparation') + expect(sshWorkflow.jobs.hosts.env.ORCA_BACKGROUND_LAUNCH).toBe('1') + expect(sshWorkflow.jobs.hosts.env.ORCA_ISOLATED_SSH_CI).toBe('1') + for (const event of ['pull_request', 'workflow_dispatch']) { + for (const preparation_mode of ['overlap', 'serial']) { + for (const [cells, jobMinutes, hostMinutes] of [ + ['', 75, 50], + ['orcad-cli-managed', 160, 130] + ]) { + const ctx = sshContext(event, { preparation_mode, arm_inbox_only: true, cells }) + expect(sshValue(sshWorkflow.jobs.hosts['timeout-minutes'], ctx)).toBe(jobMinutes) + expect(sshValue(sshSteps[hosts]['timeout-minutes'], ctx)).toBe(hostMinutes) + } + } + } + }) + + it('isolates manual comparison modes while preserving ordinary cancellation groups', () => { + const group = (ctx) => + sshWorkflow.concurrency.group.replace(/\$\{\{(.*?)\}\}/g, (_match, expression) => + String(evaluate(expression.trim(), ctx)) + ) + const regular = sshContext('pull_request') + regular.github.event.pull_request = { number: 28669 } + expect(group(regular)).toBe('ssh-windows-hosts-28669') + expect(group(sshContext('workflow_dispatch'))).toBe('ssh-windows-hosts-refs/heads/main') + for (const mode of ['overlap', 'serial']) { + expect( + group(sshContext('workflow_dispatch', { arm_inbox_only: true, preparation_mode: mode })) + ).toBe(`ssh-windows-hosts-refs/heads/main-${mode}`) + } + }) }) const commandMocks = ` diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 29fb7490431..ff383bdaa7b 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -267,6 +267,8 @@ const MANAGED_HOOK_PREFIXES = [ const NATIVE_RUNTIME_PREFIXES = [ 'config/scripts/ensure-native-runtime', + 'config/scripts/initialize-windows-native-toolchain', + 'config/scripts/capture-windows-native-environment', 'config/scripts/rebuild-native-deps', 'config/scripts/node-pty-job-ownership', 'config/scripts/windows-pe-machine', @@ -288,6 +290,8 @@ const NATIVE_CACHE_FILES = new Set([ '.npmrc', '.pnpmfile.cjs', 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', diff --git a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs index afd6af518d1..3d6958389e0 100644 --- a/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs +++ b/config/scripts/windows-cmd-shim-spawn-boundary.test.mjs @@ -2,10 +2,11 @@ import { readdirSync, readFileSync } from 'node:fs' import path from 'node:path' import { describe, expect, it } from 'vitest' import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs' +import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy' /** - * Bare JS scripts cannot use the TS runProcess chokepoint; resolve package bins - * under process.execPath instead (see oxc-cli-invocation.mjs). The list only shrinks. + * Resolve package bins under process.execPath (see oxc-cli-invocation.mjs). + * The legacy list only shrinks; reviewed native compiler references use process-host. * The literal check catches quoted .cmd/.bat paths, excluding drive letters and * templates; comment-only lines are dropped, but trailing comments fail closed. * The AST check catches spawn/spawnSync/execFile/execFileSync first arguments @@ -13,10 +14,10 @@ import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs' * local variable initializers and ternaries. It does not follow imports, function * returns, assignments, destructuring, aliased spawn names or computed members. * Names are matched without scope/import resolution; shadowed names and non-path - * join/resolve calls can fail closed. Paths are joined textually, without reducing - * dot segments. Neither check proves a Windows branch or an actual unsafe spawn. + * join/resolve calls can fail closed. Package paths fold dot segments. + * Neither check proves a Windows branch or an actual unsafe spawn. */ -const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i +const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/gi const SCANNED_ROOTS = ['config/scripts', 'tests/tools'] @@ -83,7 +84,13 @@ describe('windows batch shim spawn boundary', () => { ) const offenders = scripts.filter((relativePath) => { const contents = readFileSync(path.join(repoRoot, relativePath), 'utf8') - return WINDOWS_SHIM_LITERAL.test(codeText(contents)) || hasNodeModulesBinSpawn(contents) + const literals = [...codeText(contents).matchAll(WINDOWS_SHIM_LITERAL)].map((match) => + match[0].slice(1, -1) + ) + return ( + hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals) || + hasNodeModulesBinSpawn(contents) + ) }) it('scans a plausible number of scripts', () => { @@ -95,7 +102,7 @@ describe('windows batch shim spawn boundary', () => { const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name)) expect( unlisted, - 'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxc-cli-invocation.mjs.' + 'Resolve package shims to their real executable. Fixed native compiler batch runners must pass the scoped process-host policy.' ).toEqual([]) }) diff --git a/config/scripts/windows-native-batch-reference-policy.test.ts b/config/scripts/windows-native-batch-reference-policy.test.ts new file mode 100644 index 00000000000..72bcb34079b --- /dev/null +++ b/config/scripts/windows-native-batch-reference-policy.test.ts @@ -0,0 +1,166 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy' + +const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs' +const benchmark = 'config/scripts/benchmark-windows-native-preparation.mjs' +const observer = 'config/scripts/windows-native-benchmark-observer.mjs' +const metadataCallers = [ + 'config/scripts/node-server-change-scope.mjs', + 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/windows-native-toolchain-routing.test.mjs' +] +const capture = 'capture-windows-native-environment.cmd' +const initializerSource = ` + import { runProcessSync as captureEnvironment } from '@orca/process-host' + const devcmd = join(installation, 'Common7', 'Tools', 'VsDevCmd.bat') + existsSync(devcmd) + captureEnvironment({ + program: join(import.meta.dirname, '${capture}'), + env: { ORCA_WINDOWS_VSDEVCMD: devcmd } + }) +` +const benchmarkSource = ` + import { runProcess } from '@orca/process-host' + const source = 'config/scripts/${capture}' + await runProcess({ program: process.execPath, args: [source] }) +` + +function hasViolation(relativePath: string, contents: string): boolean { + const literals = [...contents.matchAll(/['"][\w./\\-]*\.(?:cmd|bat)['"]/gi)].map((match) => + match[0].slice(1, -1) + ) + return hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals) +} + +describe('reviewed Windows native batch references', () => { + it.each([...metadataCallers, initializer, benchmark, observer])( + 'accepts the current reviewed source in %s', + (relativePath) => { + const root = path.resolve(import.meta.dirname, '..', '..') + expect(hasViolation(relativePath, readFileSync(path.join(root, relativePath), 'utf8'))).toBe( + false + ) + } + ) + + it.each(metadataCallers)('allows the fixed source path as metadata in %s', (relativePath) => { + expect(hasViolation(relativePath, `const source = 'config/scripts/${capture}'`)).toBe(false) + }) + + it('accepts the process-host executor through its imported alias', () => { + expect(hasViolation(initializer, initializerSource)).toBe(false) + }) + + it('accepts the benchmark executor', () => { + expect(hasViolation(benchmark, benchmarkSource)).toBe(false) + }) + + it.each([ + 'config/scripts/other-native-preparation.mjs', + 'tests/tools/initialize-windows-native-toolchain.mjs', + `config/scripts/nested/${path.basename(initializer)}` + ])('rejects the fixed runner in another caller: %s', (relativePath) => { + expect(hasViolation(relativePath, initializerSource)).toBe(true) + }) + + it.each([ + 'elsewhere/capture-windows-native-environment.cmd', + './capture-windows-native-environment.cmd', + 'CAPTURE-WINDOWS-NATIVE-ENVIRONMENT.cmd', + 'other.cmd', + 'other.bat' + ])('rejects another batch path in a reviewed executor: %s', (literal) => { + expect(hasViolation(initializer, `${initializerSource}\nconst other = '${literal}'`)).toBe(true) + }) + + it.each(metadataCallers)('rejects other batch metadata in %s', (relativePath) => { + expect( + hasViolation(relativePath, `const sources = ['config/scripts/${capture}', 'other.cmd']`) + ).toBe(true) + expect(hasViolation(relativePath, `const command = 'VsDevCmd.bat'`)).toBe(true) + }) + + it.each([ + [initializer, initializerSource], + [benchmark, benchmarkSource], + [observer, `require('@orca/process-host'); const capture = '${capture}'`] + ])('rejects a missing process-host load in %s', (relativePath, contents) => { + expect(hasViolation(relativePath, contents.replace('@orca/process-host', 'other-runner'))).toBe( + true + ) + }) + + it('rejects an unused process-host import beside an unverified executor', () => { + expect( + hasViolation(initializer, initializerSource.replace('captureEnvironment({', 'otherRunner({')) + ).toBe(true) + }) + + it('rejects another executor for the fixed capture script beside a valid call', () => { + expect( + hasViolation( + initializer, + `${initializerSource}\notherRunner({ program: join(import.meta.dirname, '${capture}') })` + ) + ).toBe(true) + }) + + it.each([ + "import { spawnSync as execute } from 'node:child_process'", + "import * as cp from 'child_process'", + "await import('node:child_process')", + "const builtins = 'node:child_process'; process.getBuiltinModule(builtins)", + "const require = createRequire(import.meta.url); const cp = require('child_process')" + ])('rejects raw child-process module loads: %s', (load) => { + expect(hasViolation(initializer, `${initializerSource}\n${load}`)).toBe(true) + }) + + it.each([ + "spawn('program', [])", + "cp.spawnSync('program', [])", + "cp['execFile']('program', [])", + "execFileSync('program', [])", + "exec('program')", + "execSync('program')", + "fork('program')" + ])('rejects raw process calls: %s', (call) => { + expect(hasViolation(initializer, `${initializerSource}\n${call}`)).toBe(true) + }) + + it.each([ + "runProcess({ program: 'node_modules/.bin/pnpm' })", + "const bin = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program: bin })", + "runProcess({ program: join(root, 'node_modules', 'tools', '..', '.bin', 'pnpm') })", + "const spec = { 'program': 'root\\\\node_modules\\\\.bin\\\\pnpm' }; runProcess(spec)", + "const program = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program })", + "runProcess({ ['program']: join(root, 'node_modules', '.bin', 'pnpm') })" + ])('rejects package-bin programs through process-host: %s', (call) => { + expect(hasViolation(benchmark, `${benchmarkSource}\n${call}`)).toBe(true) + }) + + it.each(metadataCallers)('rejects process execution added to metadata in %s', (relativePath) => { + expect( + hasViolation( + relativePath, + `const capture = 'config/scripts/${capture}'; runProcess({ program: capture })` + ) + ).toBe(true) + expect( + hasViolation( + relativePath, + `import { runProcess as execute } from '@orca/process-host'; const capture = 'config/scripts/${capture}'; execute({ program: capture })` + ) + ).toBe(true) + }) + + it.each([ + "captureEnvironment({ program: 'VsDevCmd.bat' })", + 'captureEnvironment({ program: devcmd })', + 'const program = devcmd; captureEnvironment({ program })', + "const devcmd = join(other, 'VsDevCmd.bat')" + ])('keeps the discovered developer command as metadata: %s', (extra) => { + expect(hasViolation(initializer, `${initializerSource}\n${extra}`)).toBe(true) + }) +}) diff --git a/config/scripts/windows-native-batch-reference-policy.ts b/config/scripts/windows-native-batch-reference-policy.ts new file mode 100644 index 00000000000..60dcc4b8ca6 --- /dev/null +++ b/config/scripts/windows-native-batch-reference-policy.ts @@ -0,0 +1,229 @@ +import ts from 'typescript-api' +import { + collectModuleSpecifiers, + literalString, + propertyName +} from './static-module-specifiers.mjs' +import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs' + +const references = new Map([ + [ + 'config/scripts/node-server-change-scope.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/windows-native-toolchain-routing.test.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/windows-native-benchmark-observer.mjs', + 'capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'capture-windows-native-environment.cmd' + ], + [ + 'config/scripts/benchmark-windows-native-preparation.mjs', + 'config/scripts/capture-windows-native-environment.cmd' + ] +]) +const executors = new Map([ + ['config/scripts/initialize-windows-native-toolchain.mjs', 'runProcessSync'], + ['config/scripts/benchmark-windows-native-preparation.mjs', 'runProcess'] +]) +const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs' +const observer = 'config/scripts/windows-native-benchmark-observer.mjs' +const rawProcessMethods = new Set([ + 'spawn', + 'spawnSync', + 'exec', + 'execSync', + 'execFile', + 'execFileSync', + 'fork' +]) +const processHostMethods = new Set(['runProcess', 'runProcessSync', 'spawnProcess']) + +function callName(node: ts.Expression): string { + return ts.isIdentifier(node) ? node.text : (propertyName(node) ?? '') +} + +function isDeveloperCommandMetadata(node: ts.StringLiteral): boolean { + const call = node.parent + const declaration = call.parent + return ( + ts.isCallExpression(call) && + callName(call.expression) === 'join' && + call.arguments.at(-1) === node && + ts.isVariableDeclaration(declaration) && + ts.isIdentifier(declaration.name) && + declaration.name.text === 'devcmd' + ) +} + +function isDeveloperCommandUse(node: ts.Identifier): boolean { + const parent = node.parent + return ( + (ts.isVariableDeclaration(parent) && parent.name === node) || + (ts.isCallExpression(parent) && + callName(parent.expression) === 'existsSync' && + parent.arguments[0] === node) || + (ts.isPropertyAssignment(parent) && + parent.initializer === node && + ts.isIdentifier(parent.name) && + parent.name.text === 'ORCA_WINDOWS_VSDEVCMD') + ) +} + +function isProcessHostCapture(node: ts.StringLiteral, importedMethods: Set): boolean { + const path = node.parent + if ( + !ts.isCallExpression(path) || + callName(path.expression) !== 'join' || + path.arguments.length !== 2 || + path.arguments[0].getText() !== 'import.meta.dirname' || + path.arguments[1] !== node + ) { + return false + } + const program = path.parent + if ( + !ts.isPropertyAssignment(program) || + !ts.isIdentifier(program.name) || + program.name.text !== 'program' + ) { + return false + } + const spec = program.parent + const call = spec.parent + return ( + ts.isObjectLiteralExpression(spec) && + ts.isCallExpression(call) && + call.arguments[0] === spec && + ts.isIdentifier(call.expression) && + importedMethods.has(call.expression.text) + ) +} + +export function hasWindowsNativeBatchReferenceViolation( + relativePath: string, + contents: string, + literals: readonly string[] +): boolean { + const capture = references.get(relativePath) + if (!capture) { + return literals.length > 0 + } + if ( + literals.some( + (literal) => + literal !== capture && !(relativePath === initializer && literal === 'VsDevCmd.bat') + ) + ) { + return true + } + const modules = collectModuleSpecifiers(relativePath, contents) + if (modules.some((module) => module === 'child_process' || module === 'node:child_process')) { + return true + } + const expectedMethod = executors.get(relativePath) + if ((expectedMethod || relativePath === observer) && !modules.includes('@orca/process-host')) { + return true + } + if (!expectedMethod && relativePath !== observer && modules.includes('@orca/process-host')) { + return true + } + const source = ts.createSourceFile(relativePath, contents, ts.ScriptTarget.Latest, true) + const importedMethods = new Set() + const calls: ts.CallExpression[] = [] + const programs: ts.Expression[] = [] + const captureReferences: ts.StringLiteral[] = [] + let violation = false + let developerCommandDeclarations = 0 + function visit(node: ts.Node): void { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === '@orca/process-host' + ) { + const bindings = node.importClause?.namedBindings + if (bindings && ts.isNamedImports(bindings)) { + for (const binding of bindings.elements) { + if ((binding.propertyName ?? binding.name).text === expectedMethod) { + importedMethods.add(binding.name.text) + } + } + } + } + if (ts.isCallExpression(node)) { + calls.push(node) + const method = callName(node.expression) + if (rawProcessMethods.has(method)) { + violation = true + } + if (!expectedMethod && relativePath !== observer && processHostMethods.has(method)) { + violation = true + } + } + if ( + ts.isPropertyAssignment(node) && + ((ts.isIdentifier(node.name) && node.name.text === 'program') || + (ts.isStringLiteral(node.name) && node.name.text === 'program') || + (ts.isComputedPropertyName(node.name) && literalString(node.name.expression) === 'program')) + ) { + programs.push(node.initializer) + } + if (ts.isShorthandPropertyAssignment(node) && node.name.text === 'program') { + programs.push(node.name) + } + if (relativePath === initializer) { + if (ts.isStringLiteral(node) && node.text === capture) { + captureReferences.push(node) + } + if ( + ts.isStringLiteral(node) && + node.text === 'VsDevCmd.bat' && + !isDeveloperCommandMetadata(node) + ) { + violation = true + } + if (ts.isIdentifier(node) && node.text === 'devcmd') { + if (!isDeveloperCommandUse(node)) { + violation = true + } + if (ts.isVariableDeclaration(node.parent) && node.parent.name === node) { + developerCommandDeclarations += 1 + } + } + } + ts.forEachChild(node, visit) + } + visit(source) + if ( + expectedMethod && + !calls.some( + (call) => ts.isIdentifier(call.expression) && importedMethods.has(call.expression.text) + ) + ) { + violation = true + } + if (relativePath === initializer && developerCommandDeclarations !== 1) { + violation = true + } + if (captureReferences.some((node) => !isProcessHostCapture(node, importedMethods))) { + violation = true + } + // Reuse package-path folding for process-host's named program option. + return ( + violation || + hasNodeModulesBinSpawn(contents) || + programs.some((program) => + hasNodeModulesBinSpawn(`${contents}\nspawnSync(${program.getText(source)})`) + ) + ) +} diff --git a/config/scripts/windows-native-benchmark-observer.mjs b/config/scripts/windows-native-benchmark-observer.mjs new file mode 100644 index 00000000000..8694f5b7706 --- /dev/null +++ b/config/scripts/windows-native-benchmark-observer.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { basename } from 'node:path' +import { performance } from 'node:perf_hooks' + +export const compilerEnvironmentKey = + /^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i +export const hash = (value) => createHash('sha256').update(value).digest('hex') + +export function environmentReceipt(environment) { + const entries = Object.entries(environment) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => [name.toLowerCase(), String(value)]) + .sort(([left], [right]) => left.localeCompare(right)) + assert.equal( + new Set(entries.map(([name]) => name)).size, + entries.length, + 'Duplicate environment keys' + ) + return { + hash: hash(JSON.stringify(entries)), + versions: Object.fromEntries( + entries.filter( + ([name, value]) => + /^(visualstudioversion|vscmd_ver|windowssdkversion|vctoolsversion|vscmd_arg_tgt_arch)$/.test( + name + ) && /^(?:[\d.]+\\?|x64|amd64|arm64)$/.test(value) + ) + ), + compiler: Object.fromEntries( + entries + .filter(([name]) => compilerEnvironmentKey.test(name)) + .map(([name, value]) => [name, hash(value)]) + ) + } +} + +export function changedEnvironmentNames(before, after) { + const normalize = (environment) => + new Map(Object.entries(environment).map(([name, value]) => [name.toLowerCase(), value])) + const original = normalize(before) + const prepared = normalize(after) + return [...new Set([...original.keys(), ...prepared.keys()])] + .filter((name) => original.get(name) !== prepared.get(name)) + .sort() +} + +export function processPhase(spec) { + const name = basename(spec.program).toLowerCase() + if (name === 'vswhere.exe') { + return 'discovery' + } + if (name === 'capture-windows-native-environment.cmd') { + return 'capture' + } + if (spec.args?.some((arg) => arg.endsWith('ensure-native-runtime.mjs'))) { + return spec.args.includes('--check-only') ? 'probe' : 'preparation' + } + return spec.args?.includes('rebuild') ? 'addon-build' : 'other' +} + +export function installNativeBenchmarkObserver(api, output, environment = process.env) { + const original = api.runProcessSync + const parent = environmentReceipt(environment) + const receipt = { parentBefore: parent, parentAfter: parent, calls: [] } + writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`) + api.runProcessSync = (spec) => { + const startedAt = new Date().toISOString() + const start = performance.now() + const childEnvironment = spec.env ?? environment + const call = { + phase: processPhase(spec), + program: basename(spec.program), + args: spec.args, + startedAt, + environment: environmentReceipt(childEnvironment), + changedNames: changedEnvironmentNames(environment, childEnvironment) + } + try { + const result = original(spec) + Object.assign(call, { + code: result.code, + timedOut: result.timedOut, + outputTruncated: result.outputTruncated, + stdoutSha256: hash(result.stdout ?? ''), + stderrSha256: hash(result.stderr ?? ''), + stdoutBytes: Buffer.byteLength(result.stdout ?? ''), + stderrBytes: Buffer.byteLength(result.stderr ?? '') + }) + return result + } catch (error) { + call.error = error.message + throw error + } finally { + call.durationMs = performance.now() - start + call.finishedAt = new Date().toISOString() + receipt.calls.push(call) + receipt.parentAfter = environmentReceipt(environment) + assert.equal( + receipt.parentAfter.hash, + parent.hash, + 'Compiler setup changed its parent environment' + ) + writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`) + } + } + return receipt +} + +if (process.env.ORCA_NATIVE_BENCHMARK_TRACE) { + const require = createRequire(import.meta.url) + installNativeBenchmarkObserver( + require('@orca/process-host'), + process.env.ORCA_NATIVE_BENCHMARK_TRACE + ) +} diff --git a/config/scripts/windows-native-preparation-benchmark.test.mjs b/config/scripts/windows-native-preparation-benchmark.test.mjs new file mode 100644 index 00000000000..fa5ea5253aa --- /dev/null +++ b/config/scripts/windows-native-preparation-benchmark.test.mjs @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { test } from 'vitest' +import { parse } from 'yaml' +import { runProcess } from '@orca/process-host' +import { + assertNativeColdProbe, + assertNoHeaderDownloads, + validateObservedPreparation +} from './benchmark-windows-native-preparation.mjs' +import { + changedEnvironmentNames, + environmentReceipt, + installNativeBenchmarkObserver +} from './windows-native-benchmark-observer.mjs' + +const original = { Path: 'unchanged', TOKEN: 'private' } +const parent = environmentReceipt(original) +const captureEnvironment = { ...original, VCINSTALLDIR: 'compiler' } +const call = (phase, code = 0, prepared = false) => ({ + phase, + code, + environment: prepared ? environmentReceipt(captureEnvironment) : parent, + changedNames: prepared ? ['vcinstalldir'] : [] +}) +const receipt = (calls) => ({ parentBefore: parent, parentAfter: parent, calls }) +const candidate = () => + receipt([call('probe', 1), call('discovery'), call('capture'), call('preparation', 0, true)]) + +for (const [variant, warm, calls] of [ + [ + 'control', + false, + [call('addon-build'), call('addon-build'), call('addon-build'), call('addon-build')] + ], + ['control', true, []], + ['candidate', false, candidate().calls], + ['candidate', true, [call('probe'), call('preparation')]], + [ + 'fallback', + false, + [call('probe', 1), call('discovery'), call('capture', 7), call('preparation')] + ], + ['fallback', true, [call('probe'), call('preparation')]] +]) { + test(`qualifies ${variant} ${warm ? 'warm' : 'cold'} receipts`, () => + validateObservedPreparation(receipt(calls), variant, warm)) +} + +test('fails closed on an unobserved or unchanged candidate', () => { + assert.throws(() => validateObservedPreparation(receipt([]), 'candidate'), /discovery count/) + const missingCapture = candidate() + missingCapture.calls.splice(2, 1) + assert.throws( + () => validateObservedPreparation(missingCapture, 'candidate'), + /developer command count/ + ) + const unchanged = candidate() + unchanged.calls[3] = call('preparation') + assert.throws(() => validateObservedPreparation(unchanged, 'candidate'), /not applied/) +}) + +test('rejects failed preparation and unrelated or persistent environment changes', () => { + for (const mutate of [ + (value) => { + value.calls[3].code = 2 + }, + (value) => { + value.calls[3].changedNames = ['token'] + }, + (value) => { + value.parentAfter = environmentReceipt(captureEnvironment) + }, + (value) => { + value.calls[2].timedOut = true + } + ]) { + const value = candidate() + mutate(value) + assert.throws(() => validateObservedPreparation(value, 'candidate')) + } + assert.throws( + () => + validateObservedPreparation( + receipt([call('probe'), call('preparation', 0, true)]), + 'candidate', + true + ), + /changed compiler/ + ) +}) + +test('requires all four initial addon failures and excludes timed header downloads', () => { + const result = { + code: 1, + stderr: [ + 'node-pty', + '@orca/windows-registry', + '@orca/windows-security', + '@vscode/windows-process-tree' + ] + .map((name) => `${name}: unavailable`) + .join('\n') + } + assertNativeColdProbe(result) + assert.throws(() => + assertNativeColdProbe({ ...result, stderr: result.stderr.replace('node-pty:', 'missing:') }) + ) + assertNoHeaderDownloads({ stdout: '', stderr: 'gyp info find VS using VS2026' }) + assert.throws( + () => + assertNoHeaderDownloads({ + stdout: '', + stderr: 'gyp http GET https://nodejs.org/header.tar.gz' + }), + /downloaded/ + ) +}) + +test('uses case-insensitive environment comparisons and never publishes values', () => { + assert.equal( + environmentReceipt(original).hash, + environmentReceipt({ PATH: 'unchanged', token: 'private' }).hash + ) + assert.deepEqual( + changedEnvironmentNames(original, { + PATH: 'unchanged', + TOKEN: 'private', + VCINSTALLDIR: 'compiler' + }), + ['vcinstalldir'] + ) + assert(!JSON.stringify(parent).includes('private')) + assert.throws(() => environmentReceipt({ PATH: 'first', Path: 'second' }), /Duplicate/) +}) + +test('writes an empty warm trace and observes calls without changing execution', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-test-')) + try { + const output = join(directory, 'trace.json') + const result = { code: 0, stdout: 'original stdout', stderr: '', timedOut: false } + const seen = [] + const api = { + runProcessSync: (spec) => { + seen.push(spec) + return result + } + } + installNativeBenchmarkObserver(api, output, original) + assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).calls, []) + const spec = { program: 'vswhere.exe', args: ['-latest'], stdio: 'pipe' } + assert.equal(api.runProcessSync(spec), result) + assert.deepEqual(seen, [spec]) + const value = JSON.parse(readFileSync(output, 'utf8')) + assert.equal(value.calls[0].phase, 'discovery') + assert.equal(value.calls[0].stdoutBytes, result.stdout.length) + assert.equal(value.parentBefore.hash, value.parentAfter.hash) + assert(!readFileSync(output, 'utf8').includes('original stdout')) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('preload observes the CJS process-host export imported by production ESM code', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-esm-test-')) + try { + const trace = join(directory, 'trace.json') + const program = `import { runProcessSync } from '@orca/process-host'; const value = runProcessSync({program:process.execPath,args:['-e','process.stdout.write("proof")']}); if(value.stdout !== 'proof') process.exit(2);` + const result = await runProcess({ + program: process.execPath, + args: [ + '--import', + pathToFileURL(join(import.meta.dirname, 'windows-native-benchmark-observer.mjs')).href, + '--input-type=module', + '-e', + program + ], + cwd: join(import.meta.dirname, '../..'), + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_NATIVE_BENCHMARK_TRACE: trace } + }) + assert.equal(result.code, 0, result.stderr) + const value = JSON.parse(readFileSync(trace, 'utf8')) + assert.equal(value.calls.length, 1) + assert.equal(value.calls[0].code, 0) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) + +test('manual workflow freezes source and limits Windows occupancy without changing a PR gate', () => { + const workflow = parse( + readFileSync( + join(import.meta.dirname, '../../.github/workflows/windows-native-preparation-benchmark.yml'), + 'utf8' + ) + ) + assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch']) + assert.equal(workflow.jobs.compare.strategy['max-parallel'], 2) + assert.deepEqual(workflow.jobs.compare.strategy.matrix.os, ['windows-2022', 'windows-11-arm']) + assert.equal(workflow.jobs.compare.steps[0].with.ref, '${{ inputs.source_sha }}') + const install = workflow.jobs.compare.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + assert.equal(install.with['native-runtime'], 'none') + assert.equal(install.with['node-version'], '24.21.0') + assert.equal(install.with['cache-pnpm-store'], 'false') + assert.equal(workflow.jobs.compare.steps.at(-1).if, 'always()') + assert(!Object.hasOwn(workflow.jobs, 'verify')) +}) diff --git a/config/scripts/windows-native-toolchain-routing.test.mjs b/config/scripts/windows-native-toolchain-routing.test.mjs new file mode 100644 index 00000000000..a8e066b1610 --- /dev/null +++ b/config/scripts/windows-native-toolchain-routing.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' +import { classifyNodeServerChanges } from './node-server-change-scope.mjs' +import { nodeServerQualification } from './node-server-qualification.mjs' + +const toolchainSources = [ + 'config/scripts/initialize-windows-native-toolchain.mjs', + 'config/scripts/capture-windows-native-environment.cmd' +] + +describe('Windows native toolchain CI routing', () => { + it.each(toolchainSources)( + 'prepares native caches and packages for a change to %s alone', + (file) => { + expect(classifyPrJobs([file])).toMatchObject({ + should_run: true, + native_cache_changed: true, + package: true, + package_windows: true + }) + } + ) + + it.each(toolchainSources)( + 'selects both Windows server runners for a change to %s alone', + async (file) => { + const collect = vi.fn(async () => new Set()) + for (const options of [ + {}, + { deferGraph: true }, + { modifiedFiles: [file] }, + { deferGraph: true, modifiedFiles: [file] } + ]) { + const scope = await classifyNodeServerChanges([file], collect, options) + expect(scope).toEqual({ shouldRun: true, reason: `Build or CI input changed: ${file}` }) + expect(nodeServerQualification([file], scope)).toEqual({ + qualification: false, + runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'] + }) + } + expect(collect).not.toHaveBeenCalled() + } + ) + + it('preserves unrelated build-script routing', async () => { + const file = 'config/scripts/unrelated-toolchain.mjs' + expect(classifyPrJobs([file])).toMatchObject({ + test: true, + native_cache_changed: false, + package: false, + package_windows: false + }) + expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(false) + }) +}) From 095399aa4d5608b509e6d5397680eeda2458d2ad Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:22:13 +0000 Subject: [PATCH 07/17] fix(mobile): render markdown with markdown-it instead of a hand-written reader (#28858) * fix(mobile): size markdown table columns to their content * fix(mobile): always show the copy button under chat messages Revealing it on tap shifted the transcript. * fix(mobile): read markdown with a CommonMark parser markdown-it rather than desktop's micromark: about 8x faster on the phone's JavaScript engine. --- mobile/package.json | 6 + mobile/pnpm-lock.yaml | 105 +++++ .../MobileMarkdown.file-links.test.ts | 5 +- .../MobileMarkdown.rendering.test.tsx | 164 ++++++++ mobile/src/components/MobileMarkdown.test.ts | 59 +-- mobile/src/components/MobileMarkdown.tsx | 393 ++---------------- .../markdown-inline-matcher.test.ts | 19 +- .../src/components/markdown-inline-matcher.ts | 9 +- .../markdown-inline-token-rules.test.ts | 73 ---- .../components/markdown-inline-token-rules.ts | 55 --- .../src/components/mobile-markdown-blocks.tsx | 249 +++++++++++ .../src/components/mobile-markdown-inline.tsx | 154 +++++++ .../mobile-markdown-parser-progress.test.ts | 46 -- .../src/components/mobile-markdown-parser.ts | 143 ------- .../src/components/mobile-markdown-styles.ts | 21 +- .../mobile-markdown-table-columns.ts | 38 ++ .../components/mobile-markdown-table.test.tsx | 75 ++++ .../components/mobile-markdown-text-setup.ts | 14 + .../src/components/mobile-markdown-text.tsx | 16 + mobile/src/components/mobile-markdown-tree.ts | 309 ++++++++++++++ .../mobile-markdown-visual-lines.test.ts | 33 +- .../mobile-markdown-visual-lines.ts | 35 +- .../components/use-mobile-markdown-blocks.ts | 28 -- .../components/use-mobile-markdown-tree.ts | 22 + .../MobileNativeChatLongPressContent.tsx | 16 +- .../MobileNativeChatMessage.android.test.ts | 4 +- .../MobileNativeChatMessage.copy.test.tsx | 41 +- .../src/session/MobileNativeChatMessage.tsx | 9 +- 28 files changed, 1257 insertions(+), 884 deletions(-) create mode 100644 mobile/src/components/MobileMarkdown.rendering.test.tsx delete mode 100644 mobile/src/components/markdown-inline-token-rules.test.ts delete mode 100644 mobile/src/components/markdown-inline-token-rules.ts create mode 100644 mobile/src/components/mobile-markdown-blocks.tsx create mode 100644 mobile/src/components/mobile-markdown-inline.tsx delete mode 100644 mobile/src/components/mobile-markdown-parser-progress.test.ts delete mode 100644 mobile/src/components/mobile-markdown-parser.ts create mode 100644 mobile/src/components/mobile-markdown-table-columns.ts create mode 100644 mobile/src/components/mobile-markdown-table.test.tsx create mode 100644 mobile/src/components/mobile-markdown-text-setup.ts create mode 100644 mobile/src/components/mobile-markdown-text.tsx create mode 100644 mobile/src/components/mobile-markdown-tree.ts delete mode 100644 mobile/src/components/use-mobile-markdown-blocks.ts create mode 100644 mobile/src/components/use-mobile-markdown-tree.ts diff --git a/mobile/package.json b/mobile/package.json index bd704db5504..565b35af925 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -54,6 +54,8 @@ "fflate": "0.8.3", "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", + "markdown-it": "^14.3.2", + "markdown-it-footnote": "^4.0.0", "mermaid": "11.17.2", "react": "19.2.8", "react-dom": "19.2.8", @@ -67,6 +69,7 @@ "react-native-web": "^0.21.3", "react-native-webview": "13.16.2", "react-native-worklets": "^0.8.3", + "remend": "1.4.0", "tweetnacl": "^1.0.3", "ws": "^8.22.0", "zod": "~4.6.5", @@ -74,6 +77,9 @@ }, "devDependencies": { "@orca/process-host": "link:../src/packages/process-host", + "@types/markdown-it": "14.1.2", + "@types/markdown-it-footnote": "^3.0.4", + "@types/mdast": "^4.0.4", "@types/react": "~19.2.18", "@types/react-test-renderer": "19.1.0", "@types/ws": "^8.18.1", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index ce6b6c4e37d..1602c4ea75f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -275,6 +275,12 @@ importers: lucide-react-native: specifier: ^1.14.0 version: 1.14.0(react-native-svg@15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + markdown-it: + specifier: ^14.3.2 + version: 14.3.2 + markdown-it-footnote: + specifier: ^4.0.0 + version: 4.0.0 mermaid: specifier: 11.17.2 version: 11.17.2 @@ -314,6 +320,9 @@ importers: react-native-worklets: specifier: ^0.8.3 version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) + remend: + specifier: 1.4.0 + version: 1.4.0 tweetnacl: specifier: ^1.0.3 version: 1.0.3 @@ -330,6 +339,15 @@ importers: '@orca/process-host': specifier: link:../src/packages/process-host version: link:../src/packages/process-host + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 + '@types/markdown-it-footnote': + specifier: ^3.0.4 + version: 3.0.4 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/react': specifier: ~19.2.18 version: 19.2.18 @@ -2883,6 +2901,24 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it-footnote@3.0.4': + resolution: {integrity: sha512-XJ6n+v+2u+2gmYLSHcxyoNT/YrgrKvHuDJQlykFjuxCQCr86P2/fx1V6/0lcKxv5cSIlCaJ6sUcNS3zDI7I+LA==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -5443,6 +5479,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5514,6 +5553,13 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + + markdown-it@14.3.2: + resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -5529,6 +5575,9 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -6071,6 +6120,10 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6281,6 +6334,9 @@ packages: resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} hasBin: true + remend@1.4.0: + resolution: {integrity: sha512-mHiYhhwphs1IgIZmkToKLwJn0U99IjPXuutkA2acrNcG0vlEq2G4TGnd2eT0saIHwh2KjaCpvleHcsd5SAz96A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -6840,6 +6896,9 @@ packages: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -10239,6 +10298,28 @@ snapshots: '@types/json5@0.0.29': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it-footnote@3.0.4': + dependencies: + '@types/markdown-it': 14.2.0 + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/markdown-it@14.2.0': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -13388,6 +13469,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -13455,6 +13540,17 @@ snapshots: dependencies: tmpl: 1.0.5 + markdown-it-footnote@4.0.0: {} + + markdown-it@14.3.2: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + marked@16.4.2: {} marky@1.3.0: {} @@ -13463,6 +13559,8 @@ snapshots: mdn-data@2.0.14: {} + mdurl@2.1.0: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: {} @@ -14244,6 +14342,8 @@ snapshots: dependencies: punycode: 2.3.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -14520,6 +14620,8 @@ snapshots: dependencies: jsesc: 3.1.0 + remend@1.4.0: {} + require-directory@2.1.1: {} requires-port@1.0.0: {} @@ -15102,6 +15204,8 @@ snapshots: ua-parser-js@1.0.41: {} + uc.micro@2.1.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -15408,3 +15512,4 @@ snapshots: dependencies: '@types/emscripten': 1.41.5 type-fest: 5.6.0 + diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index c2243cd6cc3..f6ac87ace55 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -62,9 +62,10 @@ describe('MobileMarkdown file links', () => { expect(pressables(tree)).toEqual([]) }) - it('preserves nested labels and empty image labels', () => { + it('links the inner label of a nested bracket, and names an empty image label', () => { const tree = render('[[nested](https://example.com) ![](https://example.com/image)') - pressByText(tree, '[nested') + expect(flattenText(tree.root)).toBe('[nested image') + pressByText(tree, 'nested') expect(openURL).toHaveBeenLastCalledWith('https://example.com') pressByText(tree, 'image') expect(openURL).toHaveBeenLastCalledWith('https://example.com/image') diff --git a/mobile/src/components/MobileMarkdown.rendering.test.tsx b/mobile/src/components/MobileMarkdown.rendering.test.tsx new file mode 100644 index 00000000000..abbcfd39204 --- /dev/null +++ b/mobile/src/components/MobileMarkdown.rendering.test.tsx @@ -0,0 +1,164 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdown } from './MobileMarkdown' + +const openURL = vi.fn((_url: string) => Promise.resolve()) + +vi.mock('react-native', () => ({ + Linking: { openURL: (url: string) => openURL(url) }, + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) + +let renderer: ReactTestRenderer | null = null + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + openURL.mockClear() +}) + +function render(content: string, growing = false): ReactTestRenderer { + act(() => { + renderer = create(createElement(MobileMarkdown, { content, growing })) + }) + return renderer! +} + +function flatten(node: ReactTestInstance | string): string { + return typeof node === 'string' ? node : node.children.map(flatten).join('') +} + +const isText = (node: ReactTestInstance): boolean => String(node.type) === 'Text' + +/** Every Text not inside another Text, in reading order: what the reader sees, block by block. */ +function texts(tree: ReactTestRenderer): string[] { + return tree.root + .findAll((node) => isText(node) && !(node.parent && hasTextAncestor(node.parent))) + .map(flatten) +} + +function hasTextAncestor(node: ReactTestInstance): boolean { + return isText(node) || (node.parent !== null && hasTextAncestor(node.parent)) +} + +function press(tree: ReactTestRenderer, label: string): void { + const target = tree.root.find( + (node) => isText(node) && typeof node.props.onPress === 'function' && flatten(node) === label + ) + act(() => target.props.onPress()) +} + +describe('MobileMarkdown rendering', () => { + it.each([ + [ + 'C1: nested lists keep their own markers and numbers', + '1. **Frontend**\n - React\n - Vite\n2. Backend', + ['1.', 'Frontend', '-', 'React', '-', 'Vite', '2.', 'Backend'] + ], + [ + 'C1: blank lines keep one list, and a list cut short keeps its numbers', + '1. Install\n\n Pulls in everything.\n\n2. Build\n\nDone.\n\n3. Ship', + ['1.', 'Install', 'Pulls in everything.', '2.', 'Build', 'Done.', '3.', 'Ship'] + ], + [ + 'C1: a wrapped item stays one item', + '- a long item\nwrapped at sixty\n- next', + ['-', 'a long item\nwrapped at sixty', '-', 'next'] + ], + [ + 'C1: a number starting a wrapped line is not a list', + 'We counted to\n42. Then we stop.', + ['We counted to\n42. Then we stop.'] + ], + [ + 'C2: code, links and emphasis inside bold', + 'Edit **`file.ts`** per **[docs](https://x.com)**, ***both***, *a **b** c*, **bold _ital_**', + ['Edit file.ts per docs, both, a b c, bold ital'] + ], + [ + 'C4: a fence with an unusual language tag', + '```c++\nint x;\n```\nAfter.\n\n```ts title="a.ts"\nconst a = 1\n```', + ['c++', 'int x;', 'After.', 'ts', 'const a = 1'] + ], + [ + 'C5: tilde and four-backtick fences, with HTML inside left alone', + '~~~html\n
Hi
\n~~~\n\n````md\n```js\nx\n```\n````', + ['html', '
Hi
', 'md', '```js\nx\n```'] + ], + [ + 'C6: a fence under a list step', + '1. Install:\n ```bash\n pnpm i\n ```\n2. Run', + ['1.', 'Install:', 'bash', 'pnpm i', '2.', 'Run'] + ], + [ + 'C7: a table right under a sentence', + 'Here:\n| a | b |\n| --- | --- |\n| 1 | 2 |', + ['Here:', 'a', 'b', '1', '2'] + ], + ['C8: short separator rows', '| a | b |\n|:-|-:|\n| 1 | 2 |', ['a', 'b', '1', '2']], + [ + 'C9: a line break inside a table cell', + '| Flag | Note |\n| --- | --- |\n| -f | Skips checks
Use with care |', + ['Flag', 'Note', '-f', 'Skips checks\nUse with care'] + ], + ['C14: backslash escapes', '\\*not italic\\* a\\_b a \\| b \\#', ['*not italic* a_b a | b #']], + ['C15: lone asterisks', '2 * 3 * 4 and *.ts and *.tsx', ['2 * 3 * 4 and *.ts and *.tsx']], + ['C16: inline code holding a backtick', '``a`b``', ['a`b']], + [ + 'C18: blocks inside a quote', + '> - a\n>\n> ```sh\n> ls\n> ```\n> > inner', + ['-', 'a', 'sh', 'ls', 'inner'] + ], + ['C19: underlined headings and closing hashes', 'Title\n=====\n\n## Sub ##', ['Title', 'Sub']], + [ + 'a README header block reads as its heading', + '

Orca

\n\nText', + ['Orca', 'Text'] + ] + ])('%s', (_name, content, expected) => { + expect(texts(render(content))).toEqual(expected) + }) + + it('C12: opens a link with brackets in it, and one with a title, at their own address', () => { + const tree = render( + '[wiki](https://en.wikipedia.org/wiki/Foo_(bar)) and [docs](https://x.com/a "The docs")' + ) + press(tree, 'wiki') + expect(openURL).toHaveBeenLastCalledWith('https://en.wikipedia.org/wiki/Foo_(bar)') + press(tree, 'docs') + expect(openURL).toHaveBeenLastCalledWith('https://x.com/a') + }) + + it('C13: opens an angle-bracket link without its brackets', () => { + const tree = render('Open .') + expect(texts(tree)).toEqual(['Open http://localhost:3000.']) + press(tree, 'http://localhost:3000') + expect(openURL).toHaveBeenLastCalledWith('http://localhost:3000') + }) + + it('C17: resolves a reference link and draws a footnote', () => { + const tree = render( + 'See [the docs][1] and a note[^1].\n\n[1]: https://example.com/docs\n\n[^1]: The note.' + ) + expect(texts(tree)).toEqual(['See the docs and a note[1].', '[1]', 'The note.']) + press(tree, 'the docs') + expect(openURL).toHaveBeenLastCalledWith('https://example.com/docs') + }) + + it('C21: closes open markup while a reply grows, and never links a half-written address', () => { + expect(texts(render('2 * 3 is six.\n\nRead **src/main/ses', true))).toEqual([ + '2 * 3 is six.', + 'Read src/main/ses' + ]) + const tree = render('See [config loader](https://exa', true) + expect(texts(tree)).toEqual(['See config loader']) + expect(tree.root.findAll((node) => typeof node.props.onPress === 'function')).toEqual([]) + }) +}) diff --git a/mobile/src/components/MobileMarkdown.test.ts b/mobile/src/components/MobileMarkdown.test.ts index e85eb1ab197..455c05f153d 100644 --- a/mobile/src/components/MobileMarkdown.test.ts +++ b/mobile/src/components/MobileMarkdown.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { isMobileMermaidLanguage } from './mobile-mermaid-language' import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' -import { parseMobileMarkdown } from './mobile-markdown-parser' describe('isMobileMermaidLanguage', () => { it('matches mermaid case-insensitively after trim', () => { @@ -18,63 +17,7 @@ describe('isMobileMermaidLanguage', () => { }) }) -describe('parseMobileMarkdown', () => { - it('parses mermaid fences as code blocks with language mermaid', () => { - expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([ - { type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true } - ]) - expect(isMobileMermaidLanguage('mermaid')).toBe(true) - }) - - it('marks an unterminated fence as not closed while it streams', () => { - expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([ - { type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false } - ]) - expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({ - closed: true - }) - }) - - it('parses GFM tables into table blocks', () => { - expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([ - { - type: 'table', - headers: ['Name', 'State'], - rows: [['Orca', 'Open']] - } - ]) - }) - - it('keeps an escaped pipe inside the cell that escaped it', () => { - expect(parseMobileMarkdown('| Cmd | Note |\n| --- | --- |\n| a \\| b | c |')).toEqual([ - { - type: 'table', - headers: ['Cmd', 'Note'], - rows: [['a | b', 'c']] - } - ]) - }) - - it('ends a cell at the pipe following an escaped backslash', () => { - expect(parseMobileMarkdown('| A | B |\n| --- | --- |\n| x\\\\|y |')).toEqual([ - { - type: 'table', - headers: ['A', 'B'], - rows: [['x\\', 'y']] - } - ]) - }) - - it('parses standalone HTTPS images without folding them into paragraphs', () => { - expect(parseMobileMarkdown('![Screenshot](https://example.com/screen.png)')).toEqual([ - { - type: 'image', - alt: 'Screenshot', - url: 'https://example.com/screen.png' - } - ]) - }) - +describe('normalizeMobileMarkdownPreviewHtml', () => { it('normalizes common README HTML into readable Markdown preview text', () => { const normalized = normalizeMobileMarkdownPreviewHtml(`

diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 31c92d8c9eb..e6db4813e98 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -1,33 +1,13 @@ -import { openExternalLink } from '../platform/external-link' -import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher' import { INLINE_TEXT_SELECTION } from './inline-text-selection' import { MobileSelectableText } from './MobileSelectableText' -import { - Fragment, - createElement, - createContext, - memo, - useContext, - useMemo, - type ComponentType, - type ReactNode -} from 'react' -import { Pressable, ScrollView, Text as NativeText, View, type TextProps } from 'react-native' +import { memo, useContext, useMemo, type ReactNode } from 'react' +import { Text as NativeText, View } from 'react-native' import { styles } from './mobile-markdown-styles' -import { - detectFilePathSegments, - isFilePathCodeSpan, - normalizeFilePath -} from './markdown-file-path-detection' -import { routeMarkdownHref } from './markdown-href-routing' -import { - isIntrawordUnderscoreToken, - trimAutolinkTrailingPunctuation -} from './markdown-inline-token-rules' -import { isMobileMermaidLanguage } from './mobile-mermaid-language' -import { useMobileMarkdownBlocks } from './use-mobile-markdown-blocks' +import { renderBlocks, type MarkdownBlockContext } from './mobile-markdown-blocks' +import { MarkdownText } from './mobile-markdown-text' +import { MarkdownTextContext, type MarkdownTextSetup } from './mobile-markdown-text-setup' +import { useMobileMarkdownTree } from './use-mobile-markdown-tree' import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive' -import { MermaidDiagram } from './pr-sidebar/MermaidDiagram' type Props = { content?: string @@ -47,188 +27,9 @@ type Props = { /** Native-chat assistant prose only: renders `::orca-visual{...}` directive lines. Without it, * a directive line is ordinary text. Must be referentially stable (this component is memoized). */ renderVisual?: (directive: NativeChatVisualDirective, index: number) => ReactNode -} - -const MAX_TABLE_ROWS = 40 -const MAX_TABLE_COLUMNS = 8 -/** Prose base size — passed to MermaidDiagram fallback mono text. */ -const MERMAID_BASE = 13 -type MarkdownTextSetup = { - TextComponent: ComponentType - /** Disable native selection only within the Android transcript. */ - androidTranscript: boolean - onLongPress?: () => void -} -const MarkdownTextContext = createContext({ - TextComponent: NativeText, - androidTranscript: false -}) - -function MarkdownText(props: TextProps): React.JSX.Element { - const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext) - if (!androidTranscript) { - return createElement(TextComponent, props) - } - // Override selection without changing the nested spans' inherited behavior. - return createElement(TextComponent, { - ...props, - ...(props.selectable === true ? { selectable: false } : {}), - ...(onLongPress && props.onPress ? { onLongPress } : {}) - }) -} - -// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and -// scheme-less paths — the entire desktop file-link contract) go to onOpenFile. -function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void { - const route = routeMarkdownHref(href) - if (route.kind === 'web') { - // The seam, not react-native's `Linking`: this module is in the tasks page closure, and inside - // the shell's WebView `openURL` resolves without opening anything. - openExternalLink(route.url) - return - } - if (route.kind === 'file' && onOpenFile) { - onOpenFile(route.pathText) - } -} - -// Render a plain (non-token) text run, splitting out tappable file paths when -// onOpenFile is provided. Without it, paths stay plain text. -function renderTextRun( - text: string, - keyPrefix: string, - onOpenFile?: (pathText: string) => void -): ReactNode { - if (!onOpenFile) { - return text - } - const segments = detectFilePathSegments(text) - if (segments.length === 1 && segments[0]!.type === 'text') { - return text - } - return segments.map((segment, segmentIndex) => { - if (segment.type === 'file') { - return ( - onOpenFile(segment.path)} - > - {segment.value} - - ) - } - return {segment.value} - }) -} - -function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] { - const parts: ReactNode[] = [] - const pattern = createMarkdownInlineMatcher( - text, - /(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g, - true - ) - let pendingStart = 0 - let match: MarkdownInlineMatch | null - - while ((match = pattern.exec())) { - const token = match[0] - // Intraword `_` runs (snake_case, dunder tails) are literal text per - // CommonMark; leaving them unflushed keeps surrounding file paths whole - // for detection in the eventual text run. - if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) { - // Resume after the opener so real tokens inside the rejected span are still scanned. - pattern.lastIndex = match.index + 1 - continue - } - if (match.index > pendingStart) { - parts.push( - renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile) - ) - } - pendingStart = pattern.lastIndex - const key = `${match.index}:${token}` - const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/) - const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/) - if (image) { - parts.push( - openMarkdownHref(image[2]!, onOpenFile)} - > - {image[1] || 'image'} - - ) - } else if (link) { - parts.push( - openMarkdownHref(link[2]!, onOpenFile)} - > - {link[1]} - - ) - } else if (/^https?:\/\//i.test(token)) { - const { url, trailing } = trimAutolinkTrailingPunctuation(token) - parts.push( - openMarkdownHref(url, onOpenFile)} - > - {url} - - ) - if (trailing) { - parts.push({trailing}) - } - } else if (token.startsWith('`')) { - const code = token.slice(1, -1) - if (onOpenFile && isFilePathCodeSpan(code)) { - parts.push( - onOpenFile(normalizeFilePath(code.trim()))} - > - {code} - - ) - } else { - parts.push( - - {code} - - ) - } - } else if (token.startsWith('~~')) { - parts.push( - - {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)} - - ) - } else if (token.startsWith('**') || token.startsWith('__')) { - parts.push( - - {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)} - - ) - } else { - parts.push( - - {renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)} - - ) - } - } - - if (pendingStart < text.length) { - parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile)) - } - return parts + /** The text is still being appended to: markup its end leaves open is closed, as on desktop, + * and a diagram stays code until the text stops. */ + growing?: boolean } function MobileMarkdownContent({ @@ -237,18 +38,13 @@ function MobileMarkdownContent({ rangeSelectable = false, textScale = 1, onOpenFile, - renderVisual + renderVisual, + growing = false }: Props) { // Interactive children own their touches and must forward the row action. const setup = useContext(MarkdownTextContext) - const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined const text = content?.trim() ?? '' - const { blocks, directives } = useMobileMarkdownBlocks(text, renderVisual !== undefined) - // Scale prose sizes; inline spans inherit fontSize from the wrapping Text. - const scaled = (size: number): { fontSize: number; lineHeight: number } | null => - textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null - const proseScale = scaled(13) - const listScale = scaled(14) + const { root, directives } = useMobileMarkdownTree(text, renderVisual !== undefined, growing) if (!text) { return fallback ? ( @@ -256,159 +52,24 @@ function MobileMarkdownContent({ ) : null } - const mermaidSourceOccurrences = new Map() + // Scale prose sizes; inline spans inherit fontSize from the wrapping Text. + const scaled = (size: number): { fontSize: number; lineHeight: number } | null => + textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null + const ctx: MarkdownBlockContext = { + growing, + onOpenFile, + listText: [styles.listText, scaled(14)], + rowLongPress: setup.androidTranscript ? setup.onLongPress : undefined, + directives, + renderVisual, + mermaidOccurrences: new Map() + } // Native-chat range selection is set on each block; nested inline spans inherit it. - return ( - {blocks.map((block, index) => { - if (block.type === 'visual') { - const directive = directives[block.index] - return directive && renderVisual ? ( - - {renderVisual(directive, block.index)} - - ) : null - } - if (block.type === 'heading') { - return ( - - {renderInline(block.text, onOpenFile)} - - ) - } - if (block.type === 'quote') { - return ( - - - {renderInline(block.text, onOpenFile)} - - - ) - } - if (block.type === 'code') { - // Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar. - // Unclosed fences are still streaming: mounting the WebView per tick would - // reload its document up to 20x/sec, so they stay raw code until terminated. - if (isMobileMermaidLanguage(block.language) && block.closed) { - const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0 - mermaidSourceOccurrences.set(block.text, occurrence + 1) - return ( - - ) - } - return ( - - {block.language ? ( - {block.language} - ) : null} - - {block.text} - - - ) - } - if (block.type === 'image') { - return ( - openMarkdownHref(block.url, onOpenFile)} - onLongPress={rowLongPress} - > - {block.alt || 'Open image'} - - {block.url} - - - ) - } - if (block.type === 'table') { - const visibleHeaders = block.headers.slice(0, MAX_TABLE_COLUMNS) - const visibleRows = block.rows.slice(0, MAX_TABLE_ROWS) - const hiddenRows = Math.max(0, block.rows.length - visibleRows.length) - const hiddenColumns = Math.max(0, block.headers.length - visibleHeaders.length) - return ( - - - - {visibleHeaders.map((header, cellIndex) => ( - - {renderInline(header, onOpenFile)} - - ))} - - {visibleRows.map((row, rowIndex) => ( - - {visibleHeaders.map((_, cellIndex) => ( - - {renderInline(row[cellIndex] ?? '', onOpenFile)} - - ))} - - ))} - {hiddenRows > 0 || hiddenColumns > 0 ? ( - - {hiddenRows > 0 ? `${hiddenRows} more rows` : ''} - {hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''} - {hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''} - - ) : null} - - - ) - } - if (block.type === 'list') { - return ( - - {block.items.map((item, itemIndex) => ( - - - {item.checked == null - ? block.ordered - ? `${itemIndex + 1}.` - : '-' - : item.checked - ? '[x]' - : '[ ]'} - - - {renderInline(item.text, onOpenFile)} - - - ))} - - ) - } - if (block.type === 'rule') { - return - } - return ( - - {block.text.split('\n').map((line, lineIndex) => ( - - {lineIndex > 0 ? '\n' : null} - {renderInline(line, onOpenFile)} - - ))} - - ) + {renderBlocks(root.children, ctx, { + style: [styles.paragraph, scaled(13)], + selectable: rangeSelectable })} ) diff --git a/mobile/src/components/markdown-inline-matcher.test.ts b/mobile/src/components/markdown-inline-matcher.test.ts index b252a434b0c..b1a6da3e66e 100644 --- a/mobile/src/components/markdown-inline-matcher.test.ts +++ b/mobile/src/components/markdown-inline-matcher.test.ts @@ -1,19 +1,14 @@ import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { createMarkdownInlineMatcher } from './markdown-inline-matcher' -import { isIntrawordUnderscoreToken } from './markdown-inline-token-rules' import { parseInline } from './pr-sidebar/markdown-blocks' -const ORIGINAL_CHAT = - /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g -const CHAT_OTHER = - /(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g const ORIGINAL_REVIEW = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g const REVIEW_OTHER = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g -function tokens(text: string, chat: boolean, original: boolean) { - const pattern = new RegExp(chat ? ORIGINAL_CHAT : ORIGINAL_REVIEW) +function tokens(text: string, original: boolean) { + const pattern = new RegExp(ORIGINAL_REVIEW) const matcher = original ? { get lastIndex() { @@ -24,21 +19,17 @@ function tokens(text: string, chat: boolean, original: boolean) { }, exec: () => pattern.exec(text) } - : createMarkdownInlineMatcher(text, new RegExp(chat ? CHAT_OTHER : REVIEW_OTHER), chat) + : createMarkdownInlineMatcher(text, new RegExp(REVIEW_OTHER)) const result: Array<{ text: string; index: number; end: number }> = [] let match while ((match = matcher.exec())) { - if (chat && isIntrawordUnderscoreToken(text, match.index, match[0])) { - matcher.lastIndex = match.index + 1 - continue - } result.push({ text: match[0], index: match.index, end: matcher.lastIndex }) } return result } describe('mobile inline link scanning', () => { - it.each([false, true])('preserves original token order (chat=%s)', (chat) => { + it('preserves original token order', () => { const fragments = [ '[', ']', @@ -71,7 +62,7 @@ describe('mobile inline link scanning', () => { { length: 1 + (random() % 40) }, () => fragments[random() % fragments.length] ).join('') - expect(tokens(text, chat, false), text).toEqual(tokens(text, chat, true)) + expect(tokens(text, false), text).toEqual(tokens(text, true)) } }) diff --git a/mobile/src/components/markdown-inline-matcher.ts b/mobile/src/components/markdown-inline-matcher.ts index ba86597f32a..02e8e84994f 100644 --- a/mobile/src/components/markdown-inline-matcher.ts +++ b/mobile/src/components/markdown-inline-matcher.ts @@ -3,8 +3,7 @@ export type MarkdownInlineMatch = { 0: string; index: number; end: number } /** Merge a global non-link regex with links; search starts must advance between calls. */ export function createMarkdownInlineMatcher( text: string, - nonLinkPattern: RegExp, - images = false + nonLinkPattern: RegExp ): { lastIndex: number; exec: () => MarkdownInlineMatch | null } { let nextOther: MarkdownInlineMatch | null | undefined let nextLink: MarkdownInlineMatch | null | undefined @@ -26,8 +25,7 @@ export function createMarkdownInlineMatcher( noMoreLabels = true return null } - const image = images && open > from && text[open - 1] === '!' - if ((image || labelEnd > open + 1) && text[labelEnd + 1] === '(') { + if (labelEnd > open + 1 && text[labelEnd + 1] === '(') { if (destinationEnd < labelEnd + 2) { destinationEnd = text.indexOf(')', labelEnd + 2) } @@ -36,8 +34,7 @@ export function createMarkdownInlineMatcher( return null } if (destinationEnd > labelEnd + 2) { - const index = image ? open - 1 : open - return { 0: text.slice(index, destinationEnd + 1), index, end: destinationEnd + 1 } + return { 0: text.slice(open, destinationEnd + 1), index: open, end: destinationEnd + 1 } } } // Every opener before this closing bracket shares the same invalid suffix. diff --git a/mobile/src/components/markdown-inline-token-rules.test.ts b/mobile/src/components/markdown-inline-token-rules.test.ts deleted file mode 100644 index 27d44a36d9c..00000000000 --- a/mobile/src/components/markdown-inline-token-rules.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isIntrawordUnderscoreToken, - trimAutolinkTrailingPunctuation -} from './markdown-inline-token-rules' - -describe('isIntrawordUnderscoreToken', () => { - it('rejects snake_case emphasis spans', () => { - const text = 'src/foo_bar.ts and src/baz_qux.ts' - const index = text.indexOf('_') - const token = text.slice(index, text.lastIndexOf('_') + 1) - expect(isIntrawordUnderscoreToken(text, index, token)).toBe(true) - }) - - it('keeps standalone emphasis', () => { - expect(isIntrawordUnderscoreToken('say _hello_ now', 4, '_hello_')).toBe(false) - expect(isIntrawordUnderscoreToken('_hello_.', 0, '_hello_')).toBe(false) - }) - - it('rejects emphasis closed against a following word', () => { - expect(isIntrawordUnderscoreToken('_foo_s bar', 0, '_foo_')).toBe(true) - }) - - it('rejects dunder emphasis inside a path', () => { - expect(isIntrawordUnderscoreToken('src/__init__.py', 4, '__init__')).toBe(true) - expect(isIntrawordUnderscoreToken(String.raw`src\__init__.py`, 4, '__init__')).toBe(true) - }) - - it('ignores non-underscore tokens', () => { - expect(isIntrawordUnderscoreToken('a*b*c', 1, '*b*')).toBe(false) - }) -}) - -describe('trimAutolinkTrailingPunctuation', () => { - it('splits sentence punctuation off the URL', () => { - expect(trimAutolinkTrailingPunctuation('https://x.com/a.')).toEqual({ - url: 'https://x.com/a', - trailing: '.' - }) - expect(trimAutolinkTrailingPunctuation('https://x.com/a,')).toEqual({ - url: 'https://x.com/a', - trailing: ',' - }) - expect(trimAutolinkTrailingPunctuation('https://x.com/a?!')).toEqual({ - url: 'https://x.com/a', - trailing: '?!' - }) - }) - - it('keeps balanced parens and strips unbalanced ones', () => { - expect(trimAutolinkTrailingPunctuation('https://x.com/a_(b)')).toEqual({ - url: 'https://x.com/a_(b)', - trailing: '' - }) - expect(trimAutolinkTrailingPunctuation('https://x.com/a).')).toEqual({ - url: 'https://x.com/a', - trailing: ').' - }) - }) - - it('handles long unmatched closing-parenthesis tails', () => { - const url = 'https://x.com/a_(b)' - const trailing = ')'.repeat(4096) - expect(trimAutolinkTrailingPunctuation(`${url}${trailing}`)).toEqual({ url, trailing }) - }) - - it('leaves clean URLs untouched', () => { - expect(trimAutolinkTrailingPunctuation('https://x.com/a')).toEqual({ - url: 'https://x.com/a', - trailing: '' - }) - }) -}) diff --git a/mobile/src/components/markdown-inline-token-rules.ts b/mobile/src/components/markdown-inline-token-rules.ts deleted file mode 100644 index 1afa8477fbb..00000000000 --- a/mobile/src/components/markdown-inline-token-rules.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Post-checks for inline markdown tokens that a single-pass tokenizer regex -// cannot express on its own. - -const INTRAWORD_FLANK_PATTERN = /[\w\\/]/ - -/** - * True when a `_…_` / `__…__` token sits inside a word (snake_case, dunder - * tails). CommonMark treats intraword underscores as literal text; path - * separators count as flanks so dunder path segments also stay whole. - */ -export function isIntrawordUnderscoreToken(text: string, index: number, token: string): boolean { - if (!token.startsWith('_')) { - return false - } - const prev = index > 0 ? text[index - 1]! : '' - const next = text[index + token.length] ?? '' - return INTRAWORD_FLANK_PATTERN.test(prev) || INTRAWORD_FLANK_PATTERN.test(next) -} - -/** - * Split sentence punctuation off an autolinked URL tail ("see https://x.com/a."), - * keeping a trailing ')' only when the URL itself opened a paren. - */ -export function trimAutolinkTrailingPunctuation(url: string): { url: string; trailing: string } { - let end = url.length - let parenthesisCountsReady = false - let openParentheses = 0 - let closeParentheses = 0 - while (end > 0) { - const char = url[end - 1]! - if ('.,;:!?'.includes(char)) { - end-- - continue - } - if (char === ')') { - if (!parenthesisCountsReady) { - for (let index = 0; index < end; index++) { - if (url[index] === '(') { - openParentheses++ - } else if (url[index] === ')') { - closeParentheses++ - } - } - parenthesisCountsReady = true - } - if (closeParentheses > openParentheses) { - end-- - closeParentheses-- - continue - } - } - break - } - return { url: url.slice(0, end), trailing: url.slice(end) } -} diff --git a/mobile/src/components/mobile-markdown-blocks.tsx b/mobile/src/components/mobile-markdown-blocks.tsx new file mode 100644 index 00000000000..eeef850dbe0 --- /dev/null +++ b/mobile/src/components/mobile-markdown-blocks.tsx @@ -0,0 +1,249 @@ +import type { Code, List, RootContent, Table } from 'mdast' +import { Fragment, type ReactNode } from 'react' +import { + Pressable, + ScrollView, + Text as NativeText, + View, + type StyleProp, + type TextStyle +} from 'react-native' +import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive' +import { isMobileMermaidLanguage } from './mobile-mermaid-language' +import { mobileMarkdownTableColumnWidths } from './mobile-markdown-table-columns' +import { mobileMarkdownVisualPlaceholderIndex } from './mobile-markdown-visual-lines' +import { + openMarkdownHref, + renderInline, + type MarkdownInlineContext +} from './mobile-markdown-inline' +import { styles } from './mobile-markdown-styles' +import { MarkdownText } from './mobile-markdown-text' +import { codeFenceClosed, plainText } from './mobile-markdown-tree' +import { MermaidDiagram } from './pr-sidebar/MermaidDiagram' + +/** Prose base size — passed to MermaidDiagram fallback mono text. */ +const MERMAID_BASE = 13 +/** Containers nested deeper than this draw as plain text, so a pathological nesting cannot + * exhaust native layout. */ +const MAX_NESTING = 8 + +export type MarkdownBlockContext = MarkdownInlineContext & { + /** The text is still arriving: a diagram stays code until it stops. */ + growing: boolean + listText: StyleProp + /** Interactive children own their touches and must forward the row action. */ + rowLongPress?: () => void + directives: readonly NativeChatVisualDirective[] + renderVisual?: (directive: NativeChatVisualDirective, index: number) => ReactNode + mermaidOccurrences: Map +} + +/** How a paragraph draws in the container holding it. */ +export type ParagraphText = { style: StyleProp; selectable: boolean } + +function renderCode(node: Code, key: number, ctx: MarkdownBlockContext): ReactNode { + // Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar. + // Growing or unclosed fences are still streaming: mounting the WebView per tick would + // reload its document up to 20x/sec, so they stay raw code until done. + if (isMobileMermaidLanguage(node.lang ?? undefined) && !ctx.growing && codeFenceClosed(node)) { + const occurrence = ctx.mermaidOccurrences.get(node.value) ?? 0 + ctx.mermaidOccurrences.set(node.value, occurrence + 1) + return ( + + ) + } + return ( + + {node.lang ? {node.lang} : null} + + {node.value} + + + ) +} + +function renderList(node: List, key: number, ctx: MarkdownBlockContext, depth: number): ReactNode { + const itemText: ParagraphText = { style: ctx.listText, selectable: true } + return ( + + {node.children.map((item, itemIndex) => ( + + + {item.checked == null + ? node.ordered + ? `${(node.start ?? 1) + itemIndex}.` + : '-' + : item.checked + ? '[x]' + : '[ ]'} + + + {renderBlocks(item.children, ctx, itemText, depth + 1)} + + + ))} + + ) +} + +function renderTable(node: Table, key: number, ctx: MarkdownBlockContext): ReactNode { + const rows = node.children.map((row) => row.children) + // Rows lay out independently, so a shared width per column is what lines them up. + const columnWidths = mobileMarkdownTableColumnWidths( + rows.map((cells) => cells.map((cell) => plainText(cell))) + ) + return ( + + + {rows.map((cells, rowIndex) => ( + + {columnWidths.map((width, cellIndex) => ( + + {renderInline(cells[cellIndex]?.children ?? [], ctx)} + + ))} + + ))} + + + ) +} + +function renderBlock( + node: RootContent, + key: number, + ctx: MarkdownBlockContext, + text: ParagraphText, + depth: number +): ReactNode { + if (depth > MAX_NESTING && 'children' in node) { + return ( + + {plainText(node)} + + ) + } + switch (node.type) { + case 'paragraph': { + const [only] = node.children + const visual = + node.children.length === 1 && only?.type === 'text' + ? mobileMarkdownVisualPlaceholderIndex(only.value, ctx.directives.length) + : null + if (visual !== null) { + const directive = ctx.directives[visual] + return directive && ctx.renderVisual ? ( + + {ctx.renderVisual(directive, visual)} + + ) : null + } + if (node.children.length === 1 && only?.type === 'image' && /^https?:\/\//i.test(only.url)) { + return ( + openMarkdownHref(only.url, ctx.onOpenFile)} + onLongPress={ctx.rowLongPress} + > + {only.alt || 'Open image'} + + {only.url} + + + ) + } + return ( + + {renderInline(node.children, ctx)} + + ) + } + case 'heading': + return ( + + {renderInline(node.children, ctx)} + + ) + case 'blockquote': + return ( + + {renderBlocks( + node.children, + ctx, + { style: styles.quoteText, selectable: true }, + depth + 1 + )} + + ) + case 'code': + return renderCode(node, key, ctx) + case 'list': + return renderList(node, key, ctx, depth) + case 'table': + return renderTable(node, key, ctx) + case 'thematicBreak': + return + case 'footnoteDefinition': + return ( + + {`[${node.label ?? node.identifier}]`} + + {renderBlocks(node.children, ctx, { style: ctx.listText, selectable: true }, depth + 1)} + + + ) + case 'html': + return ( + + {node.value} + + ) + // Inline content never sits at block level in a parsed tree; drawn as text if it does. + case 'break': + case 'delete': + case 'emphasis': + case 'footnoteReference': + case 'image': + case 'imageReference': + case 'inlineCode': + case 'link': + case 'linkReference': + case 'strong': + case 'text': + return ( + + {renderInline([node], ctx)} + + ) + case 'definition': + case 'listItem': + case 'tableCell': + case 'tableRow': + case 'yaml': + return null + } +} + +/** Block content as views; `text` is how paragraphs draw in the container holding them. */ +export function renderBlocks( + nodes: readonly RootContent[], + ctx: MarkdownBlockContext, + text: ParagraphText, + depth = 0 +): ReactNode[] { + return nodes.map((node, index) => renderBlock(node, index, ctx, text, depth)) +} diff --git a/mobile/src/components/mobile-markdown-inline.tsx b/mobile/src/components/mobile-markdown-inline.tsx new file mode 100644 index 00000000000..79d1dc3454c --- /dev/null +++ b/mobile/src/components/mobile-markdown-inline.tsx @@ -0,0 +1,154 @@ +import type { PhrasingContent } from 'mdast' +import { Fragment, type ReactNode } from 'react' +import type { TextProps } from 'react-native' +import { openExternalLink } from '../platform/external-link' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' +import { routeMarkdownHref } from './markdown-href-routing' +import { styles } from './mobile-markdown-styles' +import { MarkdownText } from './mobile-markdown-text' +import { plainText } from './mobile-markdown-tree' + +export type MarkdownInlineContext = { + /** When provided, detected file paths and file-target hrefs render as tappable and invoke + * this with the path text; without it, paths render as plain text. */ + onOpenFile?: (pathText: string) => void +} + +type SpanStyle = TextProps['style'] + +// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and +// scheme-less paths — the entire desktop file-link contract) go to onOpenFile. +export function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void { + const route = routeMarkdownHref(href) + if (route.kind === 'web') { + // The seam, not react-native's `Linking`: this module is in the tasks page closure, and inside + // the shell's WebView `openURL` resolves without opening anything. + openExternalLink(route.url) + return + } + if (route.kind === 'file' && onOpenFile) { + onOpenFile(route.pathText) + } +} + +// A plain text run, with tappable file paths split out when onOpenFile is provided. +function renderTextRun( + text: string, + key: string, + onOpenFile: ((pathText: string) => void) | undefined, + outer: SpanStyle +): ReactNode { + if (!onOpenFile) { + return text + } + const segments = detectFilePathSegments(text) + if (segments.length === 1 && segments[0]!.type === 'text') { + return text + } + return segments.map((segment, segmentIndex) => + segment.type === 'file' ? ( + onOpenFile(segment.path)} + > + {segment.value} + + ) : ( + {segment.value} + ) + ) +} + +function renderLink( + key: string, + label: string, + url: string, + ctx: MarkdownInlineContext, + outer: SpanStyle +): ReactNode { + return ( + openMarkdownHref(url, ctx.onOpenFile)} + > + {label} + + ) +} + +function renderPhrasing( + node: PhrasingContent, + key: string, + ctx: MarkdownInlineContext, + outer: SpanStyle +): ReactNode { + switch (node.type) { + case 'text': + return {renderTextRun(node.value, key, ctx.onOpenFile, outer)} + case 'break': + return {'\n'} + case 'emphasis': + case 'strong': + case 'delete': { + const emphasis = + node.type === 'strong' + ? styles.bold + : node.type === 'emphasis' + ? styles.italic + : styles.strike + const spanStyle = [outer, emphasis] + return ( + + {renderInline(node.children, ctx, spanStyle, `${key}.`)} + + ) + } + case 'inlineCode': + return ctx.onOpenFile && isFilePathCodeSpan(node.value) ? ( + ctx.onOpenFile?.(normalizeFilePath(node.value.trim()))} + > + {node.value} + + ) : ( + + {node.value} + + ) + case 'link': + return renderLink(key, plainText(node), node.url, ctx, outer) + case 'image': + return renderLink(key, node.alt || 'image', node.url, ctx, outer) + case 'footnoteReference': + return ( + + {`[${node.label ?? node.identifier}]`} + + ) + case 'html': + return {node.value} + // markdown-it resolves references itself, so these only carry their text. + case 'linkReference': + case 'imageReference': + return {plainText(node)} + } +} + +/** Inline content as spans. iOS range-selectable spans are styled from the root alone, so `outer` + * restates the enclosing emphasis on every span inside it. */ +export function renderInline( + nodes: readonly PhrasingContent[], + ctx: MarkdownInlineContext, + outer?: SpanStyle, + keyPrefix = '' +): ReactNode[] { + return nodes.map((node, index) => renderPhrasing(node, `${keyPrefix}${index}`, ctx, outer)) +} diff --git a/mobile/src/components/mobile-markdown-parser-progress.test.ts b/mobile/src/components/mobile-markdown-parser-progress.test.ts deleted file mode 100644 index e5cf3181d0c..00000000000 --- a/mobile/src/components/mobile-markdown-parser-progress.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { runInNewContext } from 'node:vm' -import { describe, expect, it } from 'vitest' -import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' -import { parseMobileMarkdown } from './mobile-markdown-parser' - -function parseWithDeadline(input: string) { - // A synchronous parser loop must fail without hanging the test worker. - return runInNewContext('parse(input)', { parse: parseMobileMarkdown, input }, { timeout: 250 }) -} - -describe('mobile Markdown parser progress', () => { - it.each(['```c++', '```c#', '``` ts', '```ts title="file.ts"', '````', '```!'])( - 'retains unsupported fence %s as paragraph text', - (fence) => { - expect(parseWithDeadline(fence)).toEqual([{ type: 'paragraph', text: fence }]) - expect(parseWithDeadline(`before\n${fence}\nafter`)).toEqual([ - { type: 'paragraph', text: `before\n${fence}\nafter` } - ]) - } - ) - - it.each(['# ', '## ', '###### ', '#\t'])('consumes incomplete heading %s', (heading) => { - expect(parseWithDeadline(`${heading}\nnext`)).toEqual([ - { type: 'paragraph', text: `${heading}\nnext` } - ]) - }) - - it('handles unsupported fences through the production preview normalization', () => { - const input = normalizeMobileMarkdownPreviewHtml('```c++\nint x = 1;') - expect(parseWithDeadline(input)).toEqual([{ type: 'paragraph', text: input }]) - }) - - it('recognizes a supported fence after unsupported fence text', () => { - expect(parseWithDeadline('```c++\n```ts\nconst x = 1\n```')).toEqual([ - { type: 'paragraph', text: '```c++' }, - { type: 'code', text: 'const x = 1', language: 'ts', closed: true } - ]) - }) - - it('preserves supported fences and their streaming state', () => { - expect(parseWithDeadline('before\n```ts\nconst x = 1')).toEqual([ - { type: 'paragraph', text: 'before' }, - { type: 'code', text: 'const x = 1', language: 'ts', closed: false } - ]) - }) -}) diff --git a/mobile/src/components/mobile-markdown-parser.ts b/mobile/src/components/mobile-markdown-parser.ts deleted file mode 100644 index 8c56bd7516d..00000000000 --- a/mobile/src/components/mobile-markdown-parser.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { isTableSeparator, splitTableRow } from './rich-markdown/markdown-table-rows' -import { mobileMarkdownVisualPlaceholderIndex } from './mobile-markdown-visual-lines' - -export type MobileMarkdownBlock = - | { type: 'paragraph'; text: string } - | { type: 'heading'; level: number; text: string } - | { type: 'quote'; text: string } - | { type: 'code'; text: string; language?: string; closed: boolean } - | { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> } - | { type: 'image'; alt: string; url: string } - | { type: 'table'; headers: string[]; rows: string[][] } - | { type: 'rule' } - /** A native-chat visual directive; `index` is its position in the protected directive list. */ - | { type: 'visual'; index: number } - -const HEADING = /^(#{1,6})\s+(.+)$/ -const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/ - -/** `visualCount`: how many directive placeholders `protectMobileMarkdownVisualLines` left in - * `content`; zero (the default) leaves every line to the ordinary rules. */ -export function parseMobileMarkdown(content: string, visualCount = 0): MobileMarkdownBlock[] { - const visualIndex = (line: string): number | null => - visualCount > 0 ? mobileMarkdownVisualPlaceholderIndex(line, visualCount) : null - const lines = content.replace(/\r\n?/g, '\n').split('\n') - const blocks: MobileMarkdownBlock[] = [] - let index = 0 - - while (index < lines.length) { - const line = lines[index] ?? '' - if (!line.trim()) { - index += 1 - continue - } - - const fence = line.match(CODE_FENCE) - if (fence) { - index += 1 - const code: string[] = [] - while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) { - code.push(lines[index] ?? '') - index += 1 - } - // closed=false means the fence is still streaming in (no terminator yet). - const closed = index < lines.length - if (closed) { - index += 1 - } - blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed }) - continue - } - - const visual = visualIndex(line) - if (visual !== null) { - blocks.push({ type: 'visual', index: visual }) - index += 1 - continue - } - - if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { - blocks.push({ type: 'rule' }) - index += 1 - continue - } - - const standaloneImage = line.match(/^!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)\s*$/i) - if (standaloneImage) { - blocks.push({ type: 'image', alt: standaloneImage[1] ?? '', url: standaloneImage[2]! }) - index += 1 - continue - } - - if ( - line.includes('|') && - index + 1 < lines.length && - isTableSeparator(lines[index + 1] ?? '') - ) { - const headers = splitTableRow(line) - index += 2 - const rows: string[][] = [] - while (index < lines.length && (lines[index] ?? '').includes('|') && lines[index]?.trim()) { - rows.push(splitTableRow(lines[index] ?? '')) - index += 1 - } - blocks.push({ type: 'table', headers, rows }) - continue - } - - const heading = line.match(HEADING) - if (heading) { - blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() }) - index += 1 - continue - } - - if (/^>\s?/.test(line)) { - const quote: string[] = [] - while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) { - quote.push((lines[index] ?? '').replace(/^>\s?/, '')) - index += 1 - } - blocks.push({ type: 'quote', text: quote.join('\n').trim() }) - continue - } - - if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) { - const items: Array<{ text: string; checked?: boolean }> = [] - let ordered = false - while (index < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '')) { - const current = lines[index] ?? '' - const orderedMatch = current.match(/^\s*\d+[.)]\s+(.+)$/) - const unorderedMatch = current.match(/^\s*[-*+]\s+(.+)$/) - ordered ||= Boolean(orderedMatch) - const rawText = (orderedMatch?.[1] ?? unorderedMatch?.[1] ?? '').trim() - const task = rawText.match(/^\[([ xX])\]\s+(.+)$/) - items.push({ - text: task?.[2] ?? rawText, - checked: task ? task[1]?.toLowerCase() === 'x' : undefined - }) - index += 1 - } - blocks.push({ type: 'list', ordered, items }) - continue - } - - const paragraph: string[] = [] - while ( - index < lines.length && - lines[index]?.trim() && - !CODE_FENCE.test(lines[index] ?? '') && - !HEADING.test(lines[index] ?? '') && - !/^>\s?/.test(lines[index] ?? '') && - !/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') && - !/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '') && - visualIndex(lines[index] ?? '') === null - ) { - paragraph.push(lines[index] ?? '') - index += 1 - } - blocks.push({ type: 'paragraph', text: paragraph.join('\n').trim() }) - } - - return blocks -} diff --git a/mobile/src/components/mobile-markdown-styles.ts b/mobile/src/components/mobile-markdown-styles.ts index 414a8daf57b..cb61a56d70f 100644 --- a/mobile/src/components/mobile-markdown-styles.ts +++ b/mobile/src/components/mobile-markdown-styles.ts @@ -49,7 +49,8 @@ export const styles = StyleSheet.create({ quote: { borderLeftWidth: 2, borderLeftColor: colors.borderSubtle, - paddingLeft: spacing.sm + paddingLeft: spacing.sm, + gap: spacing.xs }, quoteText: { fontSize: 13, @@ -101,8 +102,6 @@ export const styles = StyleSheet.create({ flexDirection: 'row' }, tableCell: { - minWidth: 112, - maxWidth: 220, borderRightWidth: 1, borderBottomWidth: 1, borderColor: colors.borderSubtle, @@ -116,11 +115,6 @@ export const styles = StyleSheet.create({ fontWeight: '700', backgroundColor: colors.bgRaised }, - tableTruncated: { - padding: spacing.sm, - fontSize: 12, - color: colors.textMuted - }, list: { gap: spacing.xs }, @@ -130,19 +124,26 @@ export const styles = StyleSheet.create({ gap: spacing.sm }, listMarker: { - width: 22, + // A floor, not a width: "10." would wrap inside a fixed 22. + minWidth: 22, fontSize: 13, lineHeight: 19, color: colors.textSecondary, fontFamily: typography.monoFamily }, - listText: { + listItemBody: { flex: 1, minWidth: 0, + gap: spacing.xs + }, + listText: { fontSize: 13, lineHeight: 19, color: colors.textPrimary }, + footnoteReference: { + color: colors.textSecondary + }, rule: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderSubtle diff --git a/mobile/src/components/mobile-markdown-table-columns.ts b/mobile/src/components/mobile-markdown-table-columns.ts new file mode 100644 index 00000000000..1feae1a02a9 --- /dev/null +++ b/mobile/src/components/mobile-markdown-table-columns.ts @@ -0,0 +1,38 @@ +import { spacing } from '../theme/mobile-theme' + +// Per-glyph advances at the 12px table font, measured on agent tables: 6.4 regular and 6.9 bold +// on average, and up to 8.7 for a bold word. A wrong guess wraps text; it cannot misalign columns. +const AVERAGE_GLYPH_WIDTH = 7 +const WIDEST_GLYPH_WIDTH = 9 +// Horizontal padding plus the right border of `styles.tableCell`. +const CELL_CHROME = spacing.sm * 2 + 1 +const MIN_COLUMN_WIDTH = 48 +const MAX_COLUMN_WIDTH = 220 +const WHITESPACE = /\s/ + +function cellContentWidth(text: string): number { + let columns = 0 + let word = 0 + let longestWord = 0 + for (const char of text) { + // CJK, kana, Hangul, fullwidth forms and emoji draw about two Latin glyphs wide. + const wide = char.codePointAt(0)! >= 0x2e80 + columns += wide ? 2 : 1 + // Lines break at spaces and around wide glyphs; only a longer unbroken run splits mid-word. + word = wide || WHITESPACE.test(char) ? 0 : word + 1 + longestWord = Math.max(longestWord, word) + } + return Math.max(columns * AVERAGE_GLYPH_WIDTH, longestWord * WIDEST_GLYPH_WIDTH) +} + +/** One width per header column, sized to the column's widest cell. `rows` holds each cell's + * drawn text, header row first. */ +export function mobileMarkdownTableColumnWidths(rows: readonly string[][]): number[] { + return (rows[0] ?? []).map((_, column) => { + let widest = 0 + for (const row of rows) { + widest = Math.max(widest, cellContentWidth(row[column] ?? '')) + } + return Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, widest + CELL_CHROME)) + }) +} diff --git a/mobile/src/components/mobile-markdown-table.test.tsx b/mobile/src/components/mobile-markdown-table.test.tsx new file mode 100644 index 00000000000..8a80c8d92ef --- /dev/null +++ b/mobile/src/components/mobile-markdown-table.test.tsx @@ -0,0 +1,75 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { MobileMarkdown } from './MobileMarkdown' +import { styles } from './mobile-markdown-styles' + +vi.mock('react-native', () => ({ + Linking: { openURL: () => Promise.resolve() }, + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) + +/** Each rendered table row's cell styles, header row first. */ +function renderTableCellStyles(content: string): unknown[][] { + let renderer: ReactTestRenderer | null = null + act(() => { + renderer = create(createElement(MobileMarkdown, { content })) + }) + const rows = renderer!.root.findAll((node) => node.props.style === styles.tableRow) + const cellStyles = rows.map((row) => + row.children.map((cell) => (typeof cell === 'string' ? undefined : cell.props.style)) + ) + act(() => renderer!.unmount()) + return cellStyles +} + +function cellWidth(style: unknown): number { + for (const entry of [style].flat()) { + if (entry && typeof entry === 'object' && 'width' in entry && typeof entry.width === 'number') { + return entry.width + } + } + throw new Error('table cell has no width') +} + +function markdownTable(headers: string[], rows: string[][]): string { + return [headers, headers.map(() => '---'), ...rows] + .map((row) => `| ${row.join(' | ')} |`) + .join('\n') +} + +describe('MobileMarkdown tables', () => { + it('renders every row and column with one width per column', () => { + const headers = Array.from({ length: 10 }, (_, column) => `Column ${column}`) + const rows = Array.from({ length: 50 }, (_, row) => + headers.map((_, column) => (row === 7 && column === 3 ? 'a much longer cell value' : 'x')) + ) + const cellStyles = renderTableCellStyles(markdownTable(headers, rows)) + + expect(cellStyles.map((row) => row.length)).toEqual(Array.from({ length: 51 }, () => 10)) + const widths = cellStyles.map((row) => row.map(cellWidth)) + for (const row of widths) { + expect(row).toEqual(widths[0]) + } + expect(widths[0]![3]).toBeGreaterThan(widths[0]![2]!) + }) + + it('sizes a column by the text it draws, not its markdown source', () => { + const [widths] = renderTableCellStyles( + markdownTable( + ['A', 'B', 'C'], + [['docs', '[docs](https://example.com/a/very/long/path)', '文档文档']] + ) + ).map((row) => row.map(cellWidth)) + + expect(widths![1]).toBe(widths![0]) + expect(widths![2]).toBeGreaterThan(widths![0]!) + }) +}) diff --git a/mobile/src/components/mobile-markdown-text-setup.ts b/mobile/src/components/mobile-markdown-text-setup.ts new file mode 100644 index 00000000000..87d8fd4e17f --- /dev/null +++ b/mobile/src/components/mobile-markdown-text-setup.ts @@ -0,0 +1,14 @@ +import { createContext, type ComponentType } from 'react' +import { Text as NativeText, type TextProps } from 'react-native' + +export type MarkdownTextSetup = { + TextComponent: ComponentType + /** Disable native selection only within the Android transcript. */ + androidTranscript: boolean + onLongPress?: () => void +} + +export const MarkdownTextContext = createContext({ + TextComponent: NativeText, + androidTranscript: false +}) diff --git a/mobile/src/components/mobile-markdown-text.tsx b/mobile/src/components/mobile-markdown-text.tsx new file mode 100644 index 00000000000..bd818852d64 --- /dev/null +++ b/mobile/src/components/mobile-markdown-text.tsx @@ -0,0 +1,16 @@ +import { createElement, useContext } from 'react' +import type { TextProps } from 'react-native' +import { MarkdownTextContext } from './mobile-markdown-text-setup' + +export function MarkdownText(props: TextProps): React.JSX.Element { + const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext) + if (!androidTranscript) { + return createElement(TextComponent, props) + } + // Override selection without changing the nested spans' inherited behavior. + return createElement(TextComponent, { + ...props, + ...(props.selectable === true ? { selectable: false } : {}), + ...(onLongPress && props.onPress ? { onLongPress } : {}) + }) +} diff --git a/mobile/src/components/mobile-markdown-tree.ts b/mobile/src/components/mobile-markdown-tree.ts new file mode 100644 index 00000000000..4b4cb3494fe --- /dev/null +++ b/mobile/src/components/mobile-markdown-tree.ts @@ -0,0 +1,309 @@ +import MarkdownIt from 'markdown-it' +import footnote from 'markdown-it-footnote' +import type { + AlignType, + Code, + Nodes, + Parent, + PhrasingContent, + Root, + RootContent, + Table +} from 'mdast' +import remend from 'remend' +import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' + +// CommonMark with GitHub's tables, strikethrough, autolinks and footnotes: the rules desktop's +// renderer follows, read by a parser fast enough for the phone's JavaScript engine. +const markdown = new MarkdownIt({ html: true, linkify: true }).use(footnote) +// Every href parses as a link, file: included; routeMarkdownHref decides what a tap opens. +markdown.validateLink = () => true + +type Token = ReturnType[number] + +const PATH_FLANK = /[\w\\/]/ +const TASK_MARKER = /^\[([ xX])\](?:\s+|$)/ +const LINE_BREAK_TAG = /^$/i +const HEADING_DEPTHS = [1, 2, 3, 4, 5, 6] as const +/** Parents that hold blocks; any other parent holds inline content. */ +const BLOCK_PARENTS = new Set(['root', 'blockquote', 'listItem', 'footnoteDefinition']) +const CLOSING_TOKENS = new Set([ + 'paragraph_close', + 'heading_close', + 'blockquote_close', + 'bullet_list_close', + 'ordered_list_close', + 'list_item_close', + 'table_close', + 'tr_close', + 'th_close', + 'td_close', + 'footnote_close' +]) + +/** The text a node draws, as mdast-util-to-string reads it. */ +export function plainText(node: Nodes): string { + if ('value' in node) { + return node.value + } + if (node.type === 'image') { + return node.alt ?? '' + } + return 'children' in node ? node.children.map(plainText).join('') : '' +} + +function pushText(nodes: PhrasingContent[], value: string): void { + const previous = nodes.at(-1) + if (previous?.type === 'text') { + previous.value += value + } else { + nodes.push({ type: 'text', value }) + } +} + +/** Inline tokens up to `close`, and the index of that closing token. */ +function phrasing( + tokens: readonly Token[], + start: number, + close?: string +): { nodes: PhrasingContent[]; next: number } { + const nodes: PhrasingContent[] = [] + let index = start + while (index < tokens.length && tokens[index]!.type !== close) { + const token = tokens[index]! + index += 1 + if (token.type === 'text') { + pushText(nodes, token.content) + } else if (token.type === 'softbreak') { + pushText(nodes, '\n') + } else if (token.type === 'hardbreak') { + nodes.push({ type: 'break' }) + } else if (token.type === 'code_inline') { + nodes.push({ type: 'inlineCode', value: token.content }) + } else if (token.type === 'html_inline') { + nodes.push({ type: 'html', value: token.content }) + } else if (token.type === 'image') { + nodes.push({ type: 'image', url: token.attrGet('src') ?? '', alt: token.content || null }) + } else if (token.type === 'footnote_ref') { + const label = String(token.meta?.label ?? '') + nodes.push({ type: 'footnoteReference', identifier: label, label }) + } else if (token.nesting === 1) { + const inner = phrasing(tokens, index, token.type.replace(/_open$/, '_close')) + index = inner.next + 1 + const previous = nodes.at(-1) + const before = previous?.type === 'text' ? previous.value.slice(-1) : '' + const after = tokens[index]?.type === 'text' ? (tokens[index]?.content[0] ?? '') : '' + if (token.type === 'link_open') { + nodes.push({ type: 'link', url: token.attrGet('href') ?? '', children: inner.nodes }) + } else if ( + // `_` emphasis against a path separator stays literal, so a dunder segment + // (`a/__tests__/x.ts`) keeps its path whole and tappable. + token.markup.startsWith('_') && + (PATH_FLANK.test(before) || PATH_FLANK.test(after)) + ) { + const literal = inner.nodes.map(plainText).join('') + pushText(nodes, `${token.markup}${literal}${token.markup}`) + } else if (token.type === 'strong_open') { + nodes.push({ type: 'strong', children: inner.nodes }) + } else if (token.type === 'em_open') { + nodes.push({ type: 'emphasis', children: inner.nodes }) + } else if (token.type === 's_open') { + nodes.push({ type: 'delete', children: inner.nodes }) + } else { + nodes.push(...inner.nodes) + } + } + } + return { nodes, next: index } +} + +/** Whether a code block's closing fence has arrived; an unclosed one is still streaming in. */ +export function codeFenceClosed(node: Code): boolean { + return !('closed' in node) || node.closed !== false +} + +function codeNode(token: Token, lines: readonly string[]): Code & { closed: boolean } { + if (token.type !== 'fence') { + return { type: 'code', lang: null, value: token.content.replace(/\n$/, ''), closed: true } + } + const [start, end] = token.map ?? [0, 0] + // A fence inside a quote carries the quote's marker on its closing line. + const closing = end - start > 1 ? (lines[end - 1] ?? '').replace(/^[\s>]*/, '').trim() : '' + return { + type: 'code', + lang: token.info.trim().split(/\s+/)[0] || null, + value: token.content.replace(/\n$/, ''), + closed: + closing.length >= token.markup.length && + [...closing].every((char) => char === token.markup[0]) + } +} + +function alignOf(token: Token): AlignType { + const align = /text-align:\s*(left|right|center)/.exec(token.attrGet('style') ?? '')?.[1] + return align === 'left' || align === 'right' || align === 'center' ? align : null +} + +/** GitHub task list items: `[ ]` or `[x]` opening the item's first paragraph. */ +function readTaskMarker(item: Parent & { checked?: boolean | null }): void { + const paragraph = item.children[0] + const text = paragraph?.type === 'paragraph' ? paragraph.children[0] : undefined + const marker = text?.type === 'text' ? TASK_MARKER.exec(text.value) : null + if (text?.type === 'text' && marker) { + item.checked = marker[1] !== ' ' + text.value = text.value.slice(marker[0].length) + } +} + +/** markdown-it's flat token stream as the mdast tree the renderer draws. */ +function toTree(tokens: readonly Token[], source: string): Root { + const root: Root = { type: 'root', children: [] } + const lines = source.split('\n') + const stack: Parent[] = [root] + let table: Table | undefined + const open = (node: Parent & RootContent): void => { + stack.at(-1)!.children.push(node) + stack.push(node) + } + for (const token of tokens) { + const parent = stack.at(-1)! + switch (token.type) { + case 'paragraph_open': + open({ type: 'paragraph', children: [] }) + break + case 'heading_open': + open({ + type: 'heading', + depth: HEADING_DEPTHS.find((depth) => token.tag === `h${depth}`) ?? 6, + children: [] + }) + break + case 'blockquote_open': + open({ type: 'blockquote', children: [] }) + break + case 'bullet_list_open': + case 'ordered_list_open': + open({ + type: 'list', + ordered: token.type === 'ordered_list_open', + start: Number(token.attrGet('start') ?? 1), + children: [] + }) + break + case 'list_item_open': + open({ type: 'listItem', checked: null, children: [] }) + break + case 'table_open': + table = { type: 'table', align: [], children: [] } + open(table) + break + case 'tr_open': + open({ type: 'tableRow', children: [] }) + break + case 'th_open': + case 'td_open': + if (token.type === 'th_open') { + table?.align?.push(alignOf(token)) + } + open({ type: 'tableCell', children: [] }) + break + case 'footnote_open': { + const label = String(token.meta?.label ?? '') + open({ type: 'footnoteDefinition', identifier: label, label, children: [] }) + break + } + case 'inline': + parent.children.push(...phrasing(token.children ?? [], 0).nodes) + break + case 'fence': + case 'code_block': + parent.children.push(codeNode(token, lines)) + break + case 'hr': + parent.children.push({ type: 'thematicBreak' }) + break + case 'html_block': + parent.children.push({ type: 'html', value: token.content }) + break + default: + if (CLOSING_TOKENS.has(token.type)) { + const closed = stack.pop() + if (closed?.type === 'listItem') { + readTaskMarker(closed) + } + } + } + } + return root +} + +/** + * Raw HTML the parser found, and only that: code is never an `html` node, so nothing inside a + * code block or span is touched. A block is read as the Markdown the preview cleanup makes of + * it (README headers and badges); an inline tag becomes the text the cleanup leaves, if any. + * `nested`: a block the cleanup produced, where leftover block HTML stays text. + */ +function withoutHtml(parent: Parent, nested: boolean): void { + const inline = !BLOCK_PARENTS.has(parent.type) + parent.children = parent.children.flatMap((child): RootContent[] => { + if (child.type !== 'html') { + if ('children' in child) { + withoutHtml(child, nested) + } + return [child] + } + if (inline && LINE_BREAK_TAG.test(child.value.trim())) { + return [{ type: 'break' }] + } + const text = normalizeMobileMarkdownPreviewHtml(child.value) + if (!text) { + return [] + } + if (inline) { + return [{ type: 'text', value: text }] + } + if (nested) { + return [{ type: 'paragraph', children: [{ type: 'text', value: text }] }] + } + const blocks = toTree(markdown.parse(text, {}), text) + withoutHtml(blocks, true) + return blocks.children + }) +} + +/** The text with the markup its last top-level block leaves open closed, as desktop closes it. + * Only that block: a lone `*` earlier in the reply is no opener, and remend would close it. */ +function withLastBlockClosed(source: string, tokens: readonly Token[]): string { + const lastBlock = tokens.findLast((token) => token.level === 0 && token.map)?.map?.[0] ?? 0 + const lines = source.split('\n') + const tail = lines.slice(lastBlock).join('\n') + const closed = remend(tail, { linkMode: 'text-only' }) + return closed === tail ? source : [...lines.slice(0, lastBlock), closed].join('\n') +} + +/** `growing`: the text is still arriving, so markup its end leaves open is closed first, instead + * of flashing raw markers and half-written links. */ +export function parseMobileMarkdownTree(source: string, growing: boolean): Root { + let tokens = markdown.parse(source, {}) + let text = source + if (growing) { + text = withLastBlockClosed(source, tokens) + tokens = text === source ? tokens : markdown.parse(text, {}) + } + const root = toTree(tokens, text) + withoutHtml(root, false) + return root +} + +/** The 0-based lines markdown-it reads as code, at any depth. */ +export function mobileMarkdownCodeLines(source: string): Set { + const lines = new Set() + for (const token of markdown.parse(source, {})) { + if ((token.type === 'fence' || token.type === 'code_block') && token.map) { + for (let line = token.map[0]; line < token.map[1]; line += 1) { + lines.add(line) + } + } + } + return lines +} diff --git a/mobile/src/components/mobile-markdown-visual-lines.test.ts b/mobile/src/components/mobile-markdown-visual-lines.test.ts index 61fd60ad45e..e4c33f21a40 100644 --- a/mobile/src/components/mobile-markdown-visual-lines.test.ts +++ b/mobile/src/components/mobile-markdown-visual-lines.test.ts @@ -1,16 +1,31 @@ import { describe, expect, it } from 'vitest' import { NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE } from '../../../src/shared/native-chat-visual-directive' -import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' -import { parseMobileMarkdown } from './mobile-markdown-parser' -import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines' +import { parseMobileMarkdownTree, plainText } from './mobile-markdown-tree' +import { + mobileMarkdownVisualPlaceholderIndex, + protectMobileMarkdownVisualLines +} from './mobile-markdown-visual-lines' + +/** The top-level blocks drawn, with a lone placeholder paragraph read as its visual. */ +function topLevelBlocks(text: string, visualCount: number) { + return parseMobileMarkdownTree(text, false).children.map((node) => { + const visual = + node.type === 'paragraph' + ? mobileMarkdownVisualPlaceholderIndex(plainText(node), visualCount) + : null + if (visual !== null) { + return { type: 'visual', index: visual } + } + return node.type === 'code' + ? { type: 'code', text: node.value, language: node.lang } + : { type: node.type, text: plainText(node) } + }) +} /** The same steps MobileMarkdown runs when a transcript renders visuals. */ function blocksOf(content: string) { const { text, directives } = protectMobileMarkdownVisualLines(content) - return { - directives, - blocks: parseMobileMarkdown(normalizeMobileMarkdownPreviewHtml(text), directives.length) - } + return { directives, blocks: topLevelBlocks(text, directives.length) } } describe('native-chat visual lines in mobile markdown', () => { @@ -40,7 +55,7 @@ describe('native-chat visual lines in mobile markdown', () => { const fenced = blocksOf('```md\n::orca-visual{file="a.html"}\n```') expect(fenced.directives).toEqual([]) expect(fenced.blocks).toEqual([ - { type: 'code', text: '::orca-visual{file="a.html"}', language: 'md', closed: true } + { type: 'code', text: '::orca-visual{file="a.html"}', language: 'md' } ]) expect(blocksOf('> ::orca-visual{file="a.html"}').directives).toEqual([]) expect(blocksOf('- ::orca-visual{file="a.html"}').directives).toEqual([]) @@ -83,7 +98,7 @@ describe('native-chat visual lines in mobile markdown', () => { }) it('never treats a placeholder-looking line as a visual when none were protected', () => { - expect(parseMobileMarkdown('\uE000ORCA_VISUAL_0\uE000')).toEqual([ + expect(topLevelBlocks('\uE000ORCA_VISUAL_0\uE000', 0)).toEqual([ { type: 'paragraph', text: '\uE000ORCA_VISUAL_0\uE000' } ]) }) diff --git a/mobile/src/components/mobile-markdown-visual-lines.ts b/mobile/src/components/mobile-markdown-visual-lines.ts index 56ab8c14d90..2c04ea5a67e 100644 --- a/mobile/src/components/mobile-markdown-visual-lines.ts +++ b/mobile/src/components/mobile-markdown-visual-lines.ts @@ -1,12 +1,10 @@ import { + NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER, NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE, parseNativeChatVisualDirectiveLine, type NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive' - -/** Same fence shapes the block parser opens and closes on, so a directive inside code stays code. */ -const CODE_FENCE_OPEN = /^```([A-Za-z0-9_-]+)?\s*$/ -const CODE_FENCE_CLOSE = /^```\s*$/ +import { mobileMarkdownCodeLines } from './mobile-markdown-tree' // Private-use delimiters, as the preview normalizer's own code placeholders use. const PLACEHOLDER_PREFIX = '\uE000ORCA_VISUAL_' const PLACEHOLDER = /^\uE000ORCA_VISUAL_(\d+)\uE000$/ @@ -22,29 +20,24 @@ function placeholderFor(index: number): string { } /** - * Lifts native-chat visual directive lines out of assistant prose before preview normalization, - * which decodes entities and strips tags and would otherwise rewrite a title. Only top-level lines - * outside fenced code count; quotes and list items never start with the marker. Past the per-message - * cap a directive stays literal text. + * Lifts native-chat visual directive lines out of assistant prose before it is parsed, which + * decodes entities and reads tags and would otherwise rewrite a title. Only lines outside code + * count, as the parser reads it; quotes and list items never start with the marker. Past the + * per-message cap a directive stays literal text. */ export function protectMobileMarkdownVisualLines(content: string): MobileMarkdownVisualLines { // Text that already spells a placeholder would alias a real one; it renders no visuals at all. - if (content.includes(PLACEHOLDER_PREFIX)) { + if ( + content.includes(PLACEHOLDER_PREFIX) || + !content.includes(NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER) + ) { return { text: content, directives: [] } } - const lines = content.replace(/\r\n?/g, '\n').split('\n') + const normalized = content.replace(/\r\n?/g, '\n') + const code = mobileMarkdownCodeLines(normalized) const directives: NativeChatVisualDirective[] = [] - let inFence = false - const next = lines.map((line) => { - if (inFence) { - inFence = !CODE_FENCE_CLOSE.test(line) - return line - } - if (CODE_FENCE_OPEN.test(line)) { - inFence = true - return line - } - if (directives.length >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) { + const next = normalized.split('\n').map((line, index) => { + if (code.has(index) || directives.length >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) { return line } const directive = parseNativeChatVisualDirectiveLine(line) diff --git a/mobile/src/components/use-mobile-markdown-blocks.ts b/mobile/src/components/use-mobile-markdown-blocks.ts deleted file mode 100644 index 05074eefcac..00000000000 --- a/mobile/src/components/use-mobile-markdown-blocks.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useMemo } from 'react' -import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive' -import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' -import { parseMobileMarkdown, type MobileMarkdownBlock } from './mobile-markdown-parser' -import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines' - -const NO_DIRECTIVES: NativeChatVisualDirective[] = [] - -/** The blocks MobileMarkdown draws; with `visuals`, directive lines become `visual` blocks. */ -export function useMobileMarkdownBlocks( - text: string, - visuals: boolean -): { blocks: MobileMarkdownBlock[]; directives: NativeChatVisualDirective[] } { - const visualLines = useMemo( - () => (visuals ? protectMobileMarkdownVisualLines(text) : null), - [text, visuals] - ) - const previewText = useMemo( - () => normalizeMobileMarkdownPreviewHtml(visualLines?.text ?? text), - [visualLines, text] - ) - const directives = visualLines?.directives ?? NO_DIRECTIVES - const blocks = useMemo( - () => parseMobileMarkdown(previewText, directives.length), - [previewText, directives.length] - ) - return { blocks, directives } -} diff --git a/mobile/src/components/use-mobile-markdown-tree.ts b/mobile/src/components/use-mobile-markdown-tree.ts new file mode 100644 index 00000000000..67f7928beb7 --- /dev/null +++ b/mobile/src/components/use-mobile-markdown-tree.ts @@ -0,0 +1,22 @@ +import type { Root } from 'mdast' +import { useMemo } from 'react' +import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive' +import { parseMobileMarkdownTree } from './mobile-markdown-tree' +import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines' + +const NO_DIRECTIVES: NativeChatVisualDirective[] = [] + +/** The tree MobileMarkdown draws; with `visuals`, directive lines become placeholders. */ +export function useMobileMarkdownTree( + text: string, + visuals: boolean, + growing: boolean +): { root: Root; directives: NativeChatVisualDirective[] } { + const visualLines = useMemo( + () => (visuals ? protectMobileMarkdownVisualLines(text) : null), + [text, visuals] + ) + const source = visualLines?.text ?? text + const root = useMemo(() => parseMobileMarkdownTree(source, growing), [source, growing]) + return { root, directives: visualLines?.directives ?? NO_DIRECTIVES } +} diff --git a/mobile/src/session/MobileNativeChatLongPressContent.tsx b/mobile/src/session/MobileNativeChatLongPressContent.tsx index ca6e1b149b3..1481dc468d1 100644 --- a/mobile/src/session/MobileNativeChatLongPressContent.tsx +++ b/mobile/src/session/MobileNativeChatLongPressContent.tsx @@ -1,30 +1,20 @@ import type { ComponentProps, ReactNode } from 'react' import { Pressable, View } from 'react-native' -// A held press selects text natively; claiming it keeps that release from counting as a tap. -function ignoreLongPress(): void {} - /** A message body that opens the actions sheet on long press (Android, which has no inline - * selection) or reveals its copy button on tap (platforms with inline selection). */ + * selection); elsewhere a plain view, so a held press selects text natively. */ export function MobileNativeChatLongPressContent({ onLongPress, - onPress, style, children }: { onLongPress?: () => void - onPress?: () => void style: ComponentProps['style'] children: ReactNode }): React.JSX.Element { - return onLongPress || onPress ? ( + return onLongPress ? ( // Not one accessible element: screen readers keep reaching the text and controls inside. - + {children} ) : ( diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts index 52032ebae90..24ef4bfb698 100644 --- a/mobile/src/session/MobileNativeChatMessage.android.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -76,7 +76,7 @@ describe('MobileNativeChatMessage on Android', () => { expect(byType('MessageActionsSheet')).toHaveLength(0) expect( byType('Pressable').filter((node) => node.props.accessibilityLabel === 'Copy message') - ).toHaveLength(0) + ).toHaveLength(1) const [bubble] = byType('Pressable') expect(typeof bubble!.props.onLongPress).toBe('function') @@ -155,7 +155,7 @@ describe('MobileNativeChatMessage on Android', () => { } expect( byType('Pressable').filter((node) => node.props.accessibilityLabel === 'Copy message') - ).toHaveLength(0) + ).toHaveLength(1) act(() => byType('Pressable')[0]!.props.onLongPress()) expect(byType('MessageActionsSheet')).toHaveLength(1) }) diff --git a/mobile/src/session/MobileNativeChatMessage.copy.test.tsx b/mobile/src/session/MobileNativeChatMessage.copy.test.tsx index 090de93dc7a..f0793188d19 100644 --- a/mobile/src/session/MobileNativeChatMessage.copy.test.tsx +++ b/mobile/src/session/MobileNativeChatMessage.copy.test.tsx @@ -69,18 +69,10 @@ describe('iOS whole-message copy', () => { } return node } - function tapMessage() { - const body = renderer!.root - .findByType(MobileNativeChatLongPressContent) - .findAll((node) => String(node.type) === 'Pressable')[0] - act(() => body?.props.onPress?.()) - } - // Every copy case starts from a tapped message, the only state that shows the button. function render(value = message) { act(() => { renderer = create(createElement(MobileNativeChatMessage, { message: value })) }) - tapMessage() } beforeEach(() => { vi.clearAllMocks() @@ -122,11 +114,8 @@ describe('iOS whole-message copy', () => { for (const node of nodes('MobileMarkdown')) { expect(node.props.onLongPress).toBeUndefined() } - // A held press selects text natively: no actions sheet, and no copy toggle. - const press = body.findAll((node) => String(node.type) === 'Pressable')[0] - act(() => press?.props.onLongPress()) - expect(nodes('MessageActionsSheet')).toHaveLength(0) - expect(copyButton()).toBeDefined() + // A held press selects text natively: the body claims no press of its own. + expect(body.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0) await act(async () => copy.props.onPress()) expect(writeText.mock.calls).toEqual([[first + '\n\n' + last]]) expect(nodes('MessageActionsSheet')).toHaveLength(0) @@ -141,25 +130,13 @@ describe('iOS whole-message copy', () => { } ) - it('shows the copy button only after a tap on the message, and hides it on the next', () => { - act(() => { - renderer = create(createElement(MobileNativeChatMessage, { message })) - }) - const hasCopy = () => - nodes('Pressable').some((node) => node.props.accessibilityLabel === 'Copy message') - const body = renderer!.root - .findByType(MobileNativeChatLongPressContent) - .findAll((node) => String(node.type) === 'Pressable')[0] - expect(hasCopy()).toBe(false) - expect(body?.props).toMatchObject({ accessible: false }) - act(() => body?.props.onLongPress()) - expect(hasCopy()).toBe(false) - - tapMessage() - expect(hasCopy()).toBe(true) - tapMessage() - expect(hasCopy()).toBe(false) - }) + it.each(['user', 'assistant'] as const)( + 'shows the %s message copy button without a tap', + (role) => { + render({ ...message, role }) + expect(copyButton()).toBeDefined() + } + ) it.each([ 'Please repair this chart:\n::orca-visual{file="usage.html"}\nKeep this text.', diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index e539f506222..3dac76c5db2 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -211,9 +211,6 @@ function MobileNativeChatMessageImpl({ // Keep the memoized Markdown context stable as the message streams. const openActions = useCallback(() => setActionsOpen(true), []) const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions - // Like desktop's hover-only copy: a tap on the message shows it, another tap hides it. - const [copyShown, setCopyShown] = useState(false) - const toggleCopy = useCallback(() => setCopyShown((shown) => !shown), []) const renderVisual = useContext(MobileNativeChatVisualContext) ?? undefined // Structured replies grow in place: only the last block of the newest row may still be typing. const growingBlock = @@ -262,8 +259,9 @@ function MobileNativeChatMessageImpl({ ) } + // Always shown, so the row never shifts when copy appears. const copyText = - INLINE_TEXT_SELECTION && (message.role === 'user' || message.role === 'assistant') + message.role === 'user' || message.role === 'assistant' ? nativeChatMessagePlainText(message) : '' return ( @@ -273,7 +271,6 @@ function MobileNativeChatMessageImpl({ 0 ? toggleCopy : undefined} style={[ styles.content, isUser && styles.userBubble, @@ -322,7 +319,7 @@ function MobileNativeChatMessageImpl({ ) : null} {unsentNotice ? {unsentNotice} : null} - {copyShown && copyText.length > 0 ? ( + {copyText.length > 0 ? ( Date: Wed, 14 Oct 2026 18:22:39 -0700 Subject: [PATCH 08/17] feat(native-chat): chat scratch folders expire after 30 days unused (#28394) * fix(files): never write through a dangling symlink A path that doesn't exist yet was authorized by walking up to the nearest existing folder. A dangling symlink fails realpath with ENOENT, so the walk stepped over it and approved the link's own path; the write then followed the link and created its target outside the allowed folders. The walk now continues from the link's target, and returns that real destination. * refactor(native-chat): rename the chat visuals folder to the chat scratch folder The per-chat folder agents write into is the chat's scratch space, not only a visuals folder. Its root becomes /chat-scratch/ (the hash is unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every agent child inherits, Pi included. The visuals skill names the new variable and says the folder is temporary. * test(native-chat): keep the delivery test to the rename The launch parity snapshot already pins that every agent drops the pre-rename variable. * feat(native-chat): chat scratch folders expire after 30 days unused A chat's scratch folder was removed only when its record was gone or its local workspace was proven deleted, so the folder of a long-idle chat lived forever. The sweep now expires a chat's folder once its newest journal row and the newest entry anywhere in the folder (complete, no-follow scan) are older than 30 days, unless the host holds a running agent, a delivery or an open dispatch for the chat. The final recheck and a rename to .expired run in the chat's session lane and then a per-folder lock that Orca scratch saves (desktop IPC and host RPC) also take; the delete runs outside both. A save that settles after the age scan marks that expiry stale. Leftover .expired folders and the pre-rename native-chat-visuals root are removed. The workspace-removed rule and its catalog verdict are deleted. * fix(files): refuse any dangling symlink in the missing-path walk Following a dangling link by hand resolved relative targets against the lexical parent, not the link's real folder: through a folder link the walk could loop forever or authorize the wrong file. Refuse instead: realpath can't say where a dangling link leads, but a write would follow it. * test(files): drop the loop case that duplicated the dangling-link refusal * refactor(native-chat): trim the scratch expiry and test the real live-work veto - Remove the other-profile catalog options (running profile id, never-written profiles) that only the deleted workspace verdict used; history cleanup's reader is unchanged. - The sweep keeps its original remove-and-warn block; an unused chat's folder is parked by one function. Drop the unused `now` option and the candidate identity check. - The live-agent case runs against the host's real runWhileExecutionIdle. * style(native-chat): keep the session host within its line limit after the main sync * fix(native-chat): every scratch save and every overlapping sweep share the folder lock Binary (base64) runtime saves now take the scratch folder lock like text saves, and each folder keeps a set of expiry candidates so one sweep finishing no longer hides a save from another. --- config/scripts/ci-shard-timings.json | 1 - .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../filesystem/filesystem-write-handlers.ts | 6 +- .../journal-row-table.ts | 11 + ...red-agent-session-conversation-lifetime.ts | 26 +- .../structured-agent-session-host.ts | 5 +- .../native-chat-scratch-folder-lock.ts | 72 +++++ .../native-chat/native-chat-scratch-folder.ts | 6 + .../native-chat-visuals-sweep.test.ts | 157 +++++++--- .../native-chat/native-chat-visuals-sweep.ts | 145 +++++++--- .../other-profile-workspace-catalog.test.ts | 25 -- .../other-profile-workspace-catalog.ts | 47 +-- src/main/runtime/files/file-mutations.ts | 20 +- ...ive-chat-visuals-workspace-verdict.test.ts | 271 ------------------ .../native-chat-visuals-workspace-verdict.ts | 204 ------------- .../runtime/orca-runtime-get-worktree-ps.ts | 10 +- .../structured-agent-session-runtime.ts | 15 +- 17 files changed, 364 insertions(+), 658 deletions(-) create mode 100644 src/main/native-chat/native-chat-scratch-folder-lock.ts delete mode 100644 src/main/runtime/native-chat-visuals-workspace-verdict.test.ts delete mode 100644 src/main/runtime/native-chat-visuals-workspace-verdict.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index ead2162455b..19d51b803bb 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -4456,7 +4456,6 @@ "src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts": 5902, "src/main/runtime/mobile-subscribe-integration.test.ts": 22684, "src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 5282, - "src/main/runtime/native-chat-visuals-workspace-verdict.test.ts": 326, "src/main/runtime/omp-readiness-transcripts.test.ts": 7797, "src/main/runtime/opencode-agent-row-enter-transcript.test.ts": 562, "src/main/runtime/opencode-agent-row-structure-transcript.test.ts": 347, diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index c21975ecdc0..540856c7122 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -152,6 +152,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts', + 'src/main/native-chat/native-chat-visuals-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts', diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index ee9646ac3e6..6bf20868f28 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -14,6 +14,8 @@ import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import { registerAgentSessionAttachmentUploadHandlers } from '../agent-session-attachment-upload-ipc' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' +import { runNativeChatScratchSave } from '../../native-chat/native-chat-scratch-folder-lock' +import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { const { store } = context @@ -50,7 +52,9 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex } } await assertLocalWriteTargetIsRegularFile(filePath) - await writeFile(filePath, args.content, 'utf-8') + await runNativeChatScratchSave(getProfileUserDataPath(), filePath, () => + writeFile(filePath, args.content, 'utf-8') + ) } ) diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index 7ff185857cf..8a108c5f80f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -21,12 +21,23 @@ const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' +const SELECT_LAST_ACTIVITY = 'SELECT MAX(ts) AS ts FROM journal_rows WHERE session_id = ?' export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null { const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch return typeof epoch === 'string' ? epoch : null } +/** When the chat's newest row was saved, across every epoch; null for a chat with no rows. One + * aggregate over the chat's key range, so no row body reaches this process. */ +export function readJournalSessionLastActivityAt( + db: Database.Database, + sessionId: string +): number | null { + const ts = db.prepare(SELECT_LAST_ACTIVITY).get(sessionId)?.ts + return typeof ts === 'number' ? ts : null +} + /** Points the chat at `epoch`. Only an epoch change writes this row. */ export function publishJournalSessionEpoch( db: Database.Database, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 431ca9e761d..2574f6c3a8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -77,6 +77,13 @@ export function createStructuredAgentSessionConversationLifetime(host: { return closed } + const hasOpenDispatch = (sessionId: string): boolean => { + const record = deps().store.getRecord(sessionId) + return record !== null && deps().hasOpenDispatch?.(record) === true + } + const providerHoldsDispatch = (sessionId: string): boolean => + deps().adapter.holdsDispatch?.(sessionId) === true + const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, serialize, @@ -84,11 +91,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { isDisposed: () => disposed, deliveryActive: host.deliveryActive, childWork: host.readChildWork, - hasOpenDispatch: (sessionId) => { - const record = deps().store.getRecord(sessionId) - return record !== null && deps().hasOpenDispatch?.(record) === true - }, - providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, + hasOpenDispatch, + providerHoldsDispatch, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), // A host stop: the child's end rejects what it was handed and writes the one error row, and @@ -130,6 +134,18 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) ) }, + /** Runs `task` in the chat's lane, so no start interleaves with it, unless quit has begun or + * the host holds a child, a delivery or a dispatch for the chat; null then. */ + runWhileExecutionIdle: (sessionId: string, task: () => Promise): Promise => + serialize(sessionId, async () => + disposed || + sessions.get(sessionId)?.child || + host.deliveryActive(sessionId) || + providerHoldsDispatch(sessionId) || + hasOpenDispatch(sessionId) + ? null + : await task() + ), /** Quit has begun: nothing opens a conversation or sweeps one after this. */ dispose: (): void => { disposed = true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b5716d6cdfc..3e364c4a8ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -177,8 +177,7 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) - hasLoadedProviders = (): boolean => - [...this.sessions.values()].some(({ child }) => child !== null) + hasLoadedProviders = () => [...this.sessions.values()].some(({ child }) => child !== null) sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => @@ -232,6 +231,8 @@ export class StructuredAgentSessionHost { showSessionTabs = this.tabs.showSessionTabs setSessionTabVisibility = this.tabs.setSessionTabVisibility notifySessionTabHidden = this.tabs.notifySessionTabHidden + runWhileExecutionIdle = (sessionId: string, task: () => Promise): Promise => + this.lifetime.runWhileExecutionIdle(sessionId, task) /** This runtime holds a chat record, readable or not. */ holdsSessions = (): boolean => this.deps.store.holdsRecords() onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener) diff --git a/src/main/native-chat/native-chat-scratch-folder-lock.ts b/src/main/native-chat/native-chat-scratch-folder-lock.ts new file mode 100644 index 00000000000..a270f804151 --- /dev/null +++ b/src/main/native-chat/native-chat-scratch-folder-lock.ts @@ -0,0 +1,72 @@ +// Orders an Orca save into a chat's scratch folder against the sweep expiring that folder. Expiry +// renames the folder aside under this lock, so a save either lands first (its candidate mark keeps +// the folder) or runs after and fails on the missing folder, leaving the draft unsaved. A save +// never takes the session lane, so it never waits for an agent start. + +import { relative } from 'node:path' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' +import { + isNativeChatScratchFolderName, + nativeChatScratchRootFor +} from './native-chat-scratch-folder' + +type ExpiryCandidate = { current: boolean } + +const folderQueues = new Map>() +// The age scan runs outside the lock, so a save that settles after it starts makes it stale. +// A set per folder: each runtime install runs its own sweep, so two can scan one folder at once. +const expiryCandidates = new Map>() + +/** The scratch folder `filePath` lies inside, or null for any other path. */ +function scratchFolderNameOf(stateDirectory: string, filePath: string): string | null { + const segments = relative(nativeChatScratchRootFor(stateDirectory), filePath).split(/[\\/]/) + return segments.length > 1 && isNativeChatScratchFolderName(segments[0]) ? segments[0] : null +} + +/** Runs an Orca save; one into a chat scratch folder holds that folder's lock. */ +export function runNativeChatScratchSave( + stateDirectory: string, + filePath: string, + save: () => Promise +): Promise { + const folderName = scratchFolderNameOf(stateDirectory, filePath) + if (!folderName) { + return save() + } + return runKeyedSerializedOperation(folderQueues, folderName, async () => { + try { + return await save() + } finally { + for (const candidate of expiryCandidates.get(folderName) ?? []) { + candidate.current = false + } + } + }) +} + +export function runUnderNativeChatScratchFolderLock( + folderName: string, + task: () => Promise +): Promise { + return runKeyedSerializedOperation(folderQueues, folderName, task) +} + +/** Registered before the sweep's age scan; the scan stays usable only while `isCurrent`. */ +export function trackNativeChatScratchExpiryCandidate(folderName: string): { + isCurrent: () => boolean + dispose: () => void +} { + const candidate: ExpiryCandidate = { current: true } + const candidates = expiryCandidates.get(folderName) ?? new Set() + candidates.add(candidate) + expiryCandidates.set(folderName, candidates) + return { + isCurrent: () => candidate.current, + dispose: () => { + candidates.delete(candidate) + if (candidates.size === 0) { + expiryCandidates.delete(folderName) + } + } + } +} diff --git a/src/main/native-chat/native-chat-scratch-folder.ts b/src/main/native-chat/native-chat-scratch-folder.ts index e52acaf1624..bd82f646b36 100644 --- a/src/main/native-chat/native-chat-scratch-folder.ts +++ b/src/main/native-chat/native-chat-scratch-folder.ts @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto' import { join } from 'node:path' const NATIVE_CHAT_SCRATCH_DIR_NAME = 'chat-scratch' +const FOLDER_NAME = /^[0-9a-f]{32}$/ /** Where every chat's scratch folder on this host lives. */ export function nativeChatScratchRootFor(stateDirectory: string): string { @@ -16,6 +17,11 @@ export function nativeChatScratchFolderName(sessionId: string): string { return createHash('sha256').update(sessionId, 'utf8').digest('hex').slice(0, 32) } +/** A name this host minted for a chat's folder; nothing else under the root is a chat's. */ +export function isNativeChatScratchFolderName(name: string): boolean { + return FOLDER_NAME.test(name) +} + /** * `/chat-scratch/`. Keyed by the session id * alone, the chat record's primary key, so the folder needs no workspace lookup to find or remove; diff --git a/src/main/native-chat/native-chat-visuals-sweep.test.ts b/src/main/native-chat/native-chat-visuals-sweep.test.ts index d5daee4eaa3..6592f3d6735 100644 --- a/src/main/native-chat/native-chat-visuals-sweep.test.ts +++ b/src/main/native-chat/native-chat-visuals-sweep.test.ts @@ -1,16 +1,35 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' import type { StructuredAgentSessionLogger } from './agent-session-wire/structured-agent-session-logger' import { nativeChatScratchFolderFor, nativeChatScratchRootFor } from './native-chat-scratch-folder' +import { + runNativeChatScratchSave, + trackNativeChatScratchExpiryCandidate +} from './native-chat-scratch-folder-lock' +import { + attach, + hostTestState +} from './agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION } from './agent-session-wire/structured-agent-session-host-test-data' import { scheduleNativeChatVisualsSweep, sweepNativeChatVisualsFolders, - type NativeChatVisualsSweepDeps, - type NativeChatVisualsWorkspaceVerdict + type NativeChatVisualsSweepDeps } from './native-chat-visuals-sweep' const scratch: string[] = [] @@ -34,25 +53,36 @@ function visualsFor(state: string, sessionId: string): string { return folder } -const location = (workspaceId: string): AgentSessionExecutionLocation => ({ - executionHostId: LOCAL_EXECUTION_HOST_ID, - wslDistro: null, - workspaceId, - workspaceKind: 'git-worktree' -}) +const DAY_MS = 24 * 60 * 60_000 + +/** Sets the mtime of `path` and everything under it to `daysAgo`, children before parents. */ +function age(path: string, daysAgo = 40): void { + if (statSync(path).isDirectory()) { + for (const name of readdirSync(path)) { + age(join(path, name), daysAgo) + } + } + const at = new Date(Date.now() - daysAgo * DAY_MS) + utimesSync(path, at, at) +} function deps( state: string, held: readonly string[] | null, - verdicts: Record = {} + facts: { journal?: Record } = {} ): NativeChatVisualsSweepDeps & { logger: { warn: ReturnType> } + lanes: Map> } { + const lanes = new Map>() return { stateDirectory: state, listHeldSessionIds: () => held, - locationOf: (sessionId) => (held?.includes(sessionId) ? location(`ws-${sessionId}`) : null), - workspaceVerdicts: () => async (where) => verdicts[where.workspaceId] ?? 'present', + lastJournalActivityAt: (sessionId) => facts.journal?.[sessionId] ?? null, + // Stands in for the host's lane: one task at a time per chat. + runWhileExecutionIdle: (sessionId: string, task: () => Promise) => + runKeyedSerializedOperation(lanes, sessionId, task), + lanes, logger: { warn: vi.fn(), error: vi.fn() } } } @@ -72,32 +102,86 @@ describe('the visuals folder sweep', () => { expect(existsSync(orphan)).toBe(false) }) - it('removes a chat folder only when its local workspace is proven removed', async () => { + it('expires a chat unused for 30 days and keeps one with recent use', async () => { const state = stateDirectory() - const removed = visualsFor(state, 'chat-a') - const unverifiable = visualsFor(state, 'chat-b') - const present = visualsFor(state, 'chat-c') - await sweepNativeChatVisualsFolders( - deps(state, ['chat-a', 'chat-b', 'chat-c'], { - 'ws-chat-a': 'removed', - 'ws-chat-b': 'unverifiable' + const unused = visualsFor(state, 'chat-unused') + const talkedTo = visualsFor(state, 'chat-journal') + const edited = visualsFor(state, 'chat-nested') + mkdirSync(join(edited, 'drafts')) + writeFileSync(join(edited, 'drafts', 'v2.html'), '

v2

') + const legacy = join(state, 'native-chat-visuals', 'a'.repeat(32)) + mkdirSync(legacy, { recursive: true }) + age(nativeChatScratchRootFor(state)) + const fresh = new Date() + utimesSync(join(edited, 'drafts', 'v2.html'), fresh, fresh) + + const result = await sweepNativeChatVisualsFolders( + deps(state, ['chat-unused', 'chat-journal', 'chat-nested'], { + journal: { 'chat-unused': Date.now() - 31 * DAY_MS, 'chat-journal': Date.now() - DAY_MS } }) ) - expect(existsSync(removed)).toBe(false) - expect(existsSync(unverifiable)).toBe(true) - expect(existsSync(present)).toBe(true) + + expect(result).toEqual({ removed: 1, failed: 0 }) + expect(existsSync(unused)).toBe(false) + expect(existsSync(`${unused}.expired`)).toBe(false) + expect(existsSync(talkedTo)).toBe(true) + expect(existsSync(edited)).toBe(true) + expect(existsSync(join(state, 'native-chat-visuals'))).toBe(false) }) - it('keeps a held chat whose workspace verdict throws', async () => { + it('keeps a chat past 30 days while the host runs its agent', async () => { + await attach() + const state = stateDirectory() + const folder = visualsFor(state, HOST_TEST_SESSION) + age(folder) + const result = await sweepNativeChatVisualsFolders({ + ...deps(state, [HOST_TEST_SESSION]), + runWhileExecutionIdle: hostTestState().host.runWhileExecutionIdle + }) + expect(result).toEqual({ removed: 0, failed: 0 }) + expect(existsSync(folder)).toBe(true) + }) + + it('gives a start queued behind expiry a fresh folder', async () => { const state = stateDirectory() const folder = visualsFor(state, 'chat-a') - await sweepNativeChatVisualsFolders({ - ...deps(state, ['chat-a']), - workspaceVerdicts: () => async () => { - throw new Error('catalog unavailable') + age(folder) + const sweep = deps(state, ['chat-a']) + const result = await sweepNativeChatVisualsFolders({ + ...sweep, + runWhileExecutionIdle: (sessionId, task) => { + const expiry = sweep.runWhileExecutionIdle(sessionId, task) + // The start's own lane task, as the host's agent start runs it. + void runKeyedSerializedOperation(sweep.lanes, sessionId, async () => { + mkdirSync(folder, { recursive: true }) + writeFileSync(join(folder, 'new.html'), '

new

') + }) + return expiry } }) - expect(existsSync(folder)).toBe(true) + expect(result).toEqual({ removed: 1, failed: 0 }) + expect(readdirSync(folder)).toEqual(['new.html']) + expect(existsSync(`${folder}.expired`)).toBe(false) + }) + + it('keeps a folder saved into after its age scan', async () => { + const state = stateDirectory() + const folder = visualsFor(state, 'chat-a') + age(folder) + const sweep = deps(state, ['chat-a']) + await sweepNativeChatVisualsFolders({ + ...sweep, + runWhileExecutionIdle: async (sessionId, task) => { + // An overlapping sweep of the same folder ends first; this sweep's candidate must survive. + trackNativeChatScratchExpiryCandidate(basename(folder)).dispose() + // Settles between the scan and the locked recheck, which does not scan again. + await runNativeChatScratchSave(state, join(folder, 'chart.html'), () => + writeFile(join(folder, 'chart.html'), '

saved

') + ) + return sweep.runWhileExecutionIdle(sessionId, task) + } + }) + expect(readFileSync(join(folder, 'chart.html'), 'utf8')).toBe('

saved

') }) it('removes nothing when the held chats cannot be read', async () => { @@ -177,13 +261,4 @@ describe('the visuals folder sweep', () => { await sweepNativeChatVisualsFolders(deps(state, [])) expect(existsSync(join(elsewhere, 'a'.repeat(32)))).toBe(true) }) - - it('takes one verdict snapshot per run', async () => { - const state = stateDirectory() - visualsFor(state, 'chat-a') - visualsFor(state, 'chat-b') - const workspaceVerdicts = vi.fn(() => async () => 'present' as const) - await sweepNativeChatVisualsFolders({ ...deps(state, ['chat-a', 'chat-b']), workspaceVerdicts }) - expect(workspaceVerdicts).toHaveBeenCalledOnce() - }) }) diff --git a/src/main/native-chat/native-chat-visuals-sweep.ts b/src/main/native-chat/native-chat-visuals-sweep.ts index 8306a2aeb9b..01cfd046153 100644 --- a/src/main/native-chat/native-chat-visuals-sweep.ts +++ b/src/main/native-chat/native-chat-visuals-sweep.ts @@ -1,40 +1,49 @@ // How a chat's scratch folder dies. The host never deletes a chat record, so no deletion event -// exists to hang cleanup on; instead each folder's life is derived from the records every run: +// exists to hang cleanup on; instead each folder's life is re-derived every run: // 1. a folder no chat record (readable or not) maps to is removed, so any future record // deletion cleans up for free; -// 2. a folder whose chat ran in a local workspace this host can prove was removed is removed. -// Anything unproven is kept. A failure is logged and never blocks anything else. +// 2. a folder whose chat went unused for the retention window is removed: its newest journal row +// and everything in the folder are older than that, and the host holds no execution for it. +// Rule 2 decides again under the chat's lane and then its folder lock, so a start or a save that +// lands after the first look keeps the folder. A failure is logged and never blocks anything else. import type { Dirent } from 'node:fs' -import { lstat, readdir, rm } from 'node:fs/promises' +import { lstat, readdir, rename, rm } from 'node:fs/promises' import { join } from 'node:path' -import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { yieldToEventLoop } from '../../shared/event-loop-yield' import type { StructuredAgentSessionLogger } from './agent-session-wire/structured-agent-session-logger' -import { nativeChatScratchFolderName, nativeChatScratchRootFor } from './native-chat-scratch-folder' +import { + isNativeChatScratchFolderName, + nativeChatScratchFolderName, + nativeChatScratchRootFor +} from './native-chat-scratch-folder' +import { + runUnderNativeChatScratchFolderLock, + trackNativeChatScratchExpiryCandidate +} from './native-chat-scratch-folder-lock' -/** `removed` only on positive proof from the host that owns the workspace. */ -export type NativeChatVisualsWorkspaceVerdict = 'present' | 'removed' | 'unverifiable' +/** How long a chat's scratch files outlive its last use. */ +export const NATIVE_CHAT_SCRATCH_RETENTION_MS = 30 * 24 * 60 * 60_000 export type NativeChatVisualsSweepDeps = { stateDirectory: string /** Every chat this host holds a row for, readable or not; null when that can't be trusted. */ listHeldSessionIds: () => readonly string[] | null - locationOf: (sessionId: string) => AgentSessionExecutionLocation | null - /** Called at most once per run, so one run judges every chat against one catalog snapshot. - * Absent: only rule 1 runs. */ - workspaceVerdicts?: () => ( - location: AgentSessionExecutionLocation - ) => Promise + /** When the chat's newest journal row was saved; null when it has none. */ + lastJournalActivityAt: (sessionId: string) => number | null + /** Runs `task` in the chat's lane unless the host holds execution for it; null when it does. */ + runWhileExecutionIdle: (sessionId: string, task: () => Promise) => Promise logger: StructuredAgentSessionLogger remove?: (path: string) => Promise } export type NativeChatVisualsSweepResult = { removed: number; failed: number } -// Only names this host minted: anything else in the folder is never touched. -const FOLDER_NAME = /^[0-9a-f]{32}$/ +// Renamed under the locks and deleted after them; a cut-short delete leaves it for a later run. +const EXPIRED_SUFFIX = '.expired' +// The pre-rename root: native chat had no real users, and an update restarts every old child. +const LEGACY_ROOT_NAME = 'native-chat-visuals' // Bounds one run's disk work; whatever is left goes in a later run. const MAX_REMOVALS_PER_RUN = 200 const YIELD_EVERY = 32 @@ -60,6 +69,13 @@ export async function sweepNativeChatVisualsFolders( ): Promise { const result = { removed: 0, failed: 0 } const root = nativeChatScratchRootFor(deps.stateDirectory) + await rm(join(deps.stateDirectory, LEGACY_ROOT_NAME), { recursive: true, force: true }).catch( + (error: unknown) => + deps.logger.warn('the pre-rename chat visuals root could not be removed', { + scope: 'nativeChatVisuals.sweep', + error + }) + ) // Listed before the records are read: a folder is created only after its chat's record, so // every folder seen here already has its record in the snapshot below. const entries = await listFolders(root) @@ -71,9 +87,7 @@ export async function sweepNativeChatVisualsFolders( held.map((sessionId) => [nativeChatScratchFolderName(sessionId), sessionId]) ) const remove = deps.remove ?? ((path: string) => rm(path, { recursive: true, force: true })) - let verdict: ReturnType> | undefined - const verdictFor = (location: AgentSessionExecutionLocation) => - (verdict ??= deps.workspaceVerdicts?.())?.(location) + const cutoff = Date.now() - NATIVE_CHAT_SCRATCH_RETENTION_MS let visited = 0 for (const entry of entries) { if (isStopped() || result.removed + result.failed >= MAX_REMOVALS_PER_RUN) { @@ -82,18 +96,22 @@ export async function sweepNativeChatVisualsFolders( if (++visited % YIELD_EVERY === 0) { await yieldToEventLoop() } + const expired = entry.name.endsWith(EXPIRED_SUFFIX) + const folderName = expired ? entry.name.slice(0, -EXPIRED_SUFFIX.length) : entry.name // A Dirent describes the entry itself, so a symlink is never followed or removed here. - if (!entry.isDirectory() || !FOLDER_NAME.test(entry.name)) { - continue - } - const sessionId = sessionByFolder.get(entry.name) - const reason = - sessionId === undefined ? 'no-record' : await removedWorkspace(deps, sessionId, verdictFor) - if (!reason) { + if (!entry.isDirectory() || !isNativeChatScratchFolderName(folderName)) { continue } + const sessionId = expired ? undefined : sessionByFolder.get(entry.name) + const reason = expired ? 'expired' : sessionId === undefined ? 'no-record' : 'unused' try { - await remove(join(root, entry.name)) + if ( + sessionId !== undefined && + !(await parkUnusedFolder(deps, root, folderName, sessionId, cutoff)) + ) { + continue + } + await remove(join(root, sessionId === undefined ? entry.name : folderName + EXPIRED_SUFFIX)) result.removed += 1 } catch (error) { result.failed += 1 @@ -108,26 +126,69 @@ export async function sweepNativeChatVisualsFolders( return result } -async function removedWorkspace( - deps: Pick, +/** Renames a chat's folder to `.expired` under the chat's lane and then its folder lock; + * false when the chat was used since `cutoff`, holds live work, or a save landed after the scan. */ +async function parkUnusedFolder( + deps: NativeChatVisualsSweepDeps, + root: string, + name: string, sessionId: string, - verdictFor: ( - location: AgentSessionExecutionLocation - ) => Promise | undefined -): Promise<'workspace-removed' | null> { - const location = deps.locationOf(sessionId) - if (!location) { - return null - } + cutoff: number +): Promise { + const folder = join(root, name) + const usedSince = (): boolean => (deps.lastJournalActivityAt(sessionId) ?? 0) >= cutoff + // Before the scan, so a save already running when it starts still marks it stale. + const candidate = trackNativeChatScratchExpiryCandidate(name) try { - return (await verdictFor(location)) === 'removed' ? 'workspace-removed' : null - } catch { - return null + if (usedSince() || (await changedSince(folder, cutoff))) { + return false + } + const parked = await deps.runWhileExecutionIdle(sessionId, () => + runUnderNativeChatScratchFolderLock(name, async () => { + if (!candidate.isCurrent() || usedSince()) { + return false + } + await rename(folder, folder + EXPIRED_SUFFIX) + return true + }) + ) + return parked === true + } catch (error) { + if (isDefinitiveAbsence(error)) { + return false + } + throw error + } finally { + candidate.dispose() } } -// Why minutes, not at boot: the first sweep must not compete with session restore and the -// workspace catalog loading; after that, a few runs a day is enough for files nothing reads. +/** Whether the folder or anything under it changed at or after `cutoff`. Every entry is read with + * lstat, so a link is judged as itself and never followed; the scan stops at the first change. */ +async function changedSince(folder: string, cutoff: number): Promise { + const pending = [folder] + let visited = 0 + for (let dir = pending.pop(); dir !== undefined; dir = pending.pop()) { + if ((await lstat(dir)).mtimeMs >= cutoff) { + return true + } + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (++visited % YIELD_EVERY === 0) { + await yieldToEventLoop() + } + const path = join(dir, entry.name) + if (entry.isDirectory()) { + pending.push(path) + } else if ((await lstat(path)).mtimeMs >= cutoff) { + return true + } + } + } + return false +} + +// Why minutes, not at boot: the first sweep must not compete with session restore; after that, a +// few runs a day is enough for files nothing reads. const FIRST_SWEEP_DELAY_MS = 2 * 60_000 const SWEEP_INTERVAL_MS = 6 * 60 * 60_000 diff --git a/src/main/orca-profiles/other-profile-workspace-catalog.test.ts b/src/main/orca-profiles/other-profile-workspace-catalog.test.ts index 43fd24d919b..ae85981096a 100644 --- a/src/main/orca-profiles/other-profile-workspace-catalog.test.ts +++ b/src/main/orca-profiles/other-profile-workspace-catalog.test.ts @@ -287,29 +287,4 @@ describe('readOtherProfileWorkspaceCatalog', () => { ]) expect(readOtherProfileWorkspaceCatalog(root).unreadableProfiles).toBe(1) }) - - it('skips the running profile even when the index already names another as active', () => { - const root = userDataWithProfiles('next', [ - { id: 'running', state: { repos: [{ id: 'repo-running' }] } }, - { id: 'next', state: { repos: [{ id: 'repo-next' }] } } - ]) - const catalog = readOtherProfileWorkspaceCatalog(root, { runningProfileId: 'running' }) - expect(catalog.repoIds).toEqual(new Set(['repo-next'])) - }) - - it('counts a never-written profile as empty only when asked', () => { - const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'fresh' }]) - expect(readOtherProfileWorkspaceCatalog(root).unreadableProfiles).toBe(1) - const lenient = readOtherProfileWorkspaceCatalog(root, { neverWrittenIsEmpty: true }) - expect(lenient.unreadableProfiles).toBe(0) - expect(lenient.repoIds.size).toBe(0) - }) - - it('never counts a profile with only backups of its data file as empty', () => { - const root = userDataWithProfiles('active', [{ id: 'active', state: {} }, { id: 'lost' }]) - writeFileSync(join(root, 'profiles', 'lost', 'orca-data.json.bak.0'), '{}') - expect( - readOtherProfileWorkspaceCatalog(root, { neverWrittenIsEmpty: true }).unreadableProfiles - ).toBe(1) - }) }) diff --git a/src/main/orca-profiles/other-profile-workspace-catalog.ts b/src/main/orca-profiles/other-profile-workspace-catalog.ts index 1ba2e91a114..a52f7316e79 100644 --- a/src/main/orca-profiles/other-profile-workspace-catalog.ts +++ b/src/main/orca-profiles/other-profile-workspace-catalog.ts @@ -1,5 +1,4 @@ import { lstatSync, readFileSync } from 'node:fs' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' import { folderWorkspaceKey } from '../../shared/workspace-scope' import { getOrcaProfileDataFile, @@ -9,10 +8,6 @@ import { import { getOrcaProfileIndexPath, readProfileIndex } from './profile-index-store' import { readProfileStateDomains } from '../persistence/profile-state/profile-state-domain-reader' import { assertNoRetainedProfileStateExports } from '../persistence/profile-state/profile-state-recovery-required' -import { - PROFILE_STATE_LEGACY_BACKUP_COUNT, - profileStateLegacyBackupPath -} from '../persistence/profile-state/legacy-json/profile-state-legacy-backup-path' /** * Worktree ids owned by Orca profiles OTHER than the running one. @@ -45,20 +40,10 @@ type ProfileWorkspaceIds = { repoIds: Set } -export type OtherProfileCatalogOptions = { - /** The profile this process runs, whose catalog the caller reads live. Defaults to the index's - * active profile, which a profile switch rewrites before the running one exits. */ - runningProfileId?: string - /** A profile with no state database, sidecar or data file at all has never been written and - * holds nothing; by default it counts as unreadable like a missing file anywhere else. */ - neverWrittenIsEmpty?: boolean -} - /** The workspaces and projects every profile other than the running one holds. Any profile whose * state can't be read is counted, never skipped silently: its ids are unknown. */ export function readOtherProfileWorkspaceCatalog( - userDataPath = getProfileUserDataPath(), - options: OtherProfileCatalogOptions = {} + userDataPath = getProfileUserDataPath() ): ProfileWorkspaceIds & { unreadableProfiles: number } { const ids = new Set() const repoIds = new Set() @@ -67,12 +52,11 @@ export function readOtherProfileWorkspaceCatalog( return { ids, repoIds, unreadableProfiles: 0 } } let unreadableProfiles = 0 - const running = options.runningProfileId ?? index.activeProfileId for (const profile of index.profiles) { - if (profile.id === running) { + if (profile.id === index.activeProfileId) { continue } - const collected = readProfileWorktreeIds(profile.id, userDataPath, options) + const collected = readProfileWorktreeIds(profile.id, userDataPath) if (!collected) { unreadableProfiles += 1 continue @@ -85,8 +69,7 @@ export function readOtherProfileWorkspaceCatalog( function readProfileWorktreeIds( profileId: string, - userDataPath: string, - options: OtherProfileCatalogOptions = {} + userDataPath: string ): ProfileWorkspaceIds | null { const databaseFile = getOrcaProfileStateDatabaseFile(profileId, userDataPath) // A present database is authoritative. In particular, do not fall back to a @@ -105,31 +88,9 @@ function readProfileWorktreeIds( } catch { return null } - if (options.neverWrittenIsEmpty && neverWritten(dataFile)) { - return { ids: new Set(), repoIds: new Set() } - } return readProfileWorktreeIdsFromJson(dataFile) } -/** No data file and none of Orca's own backups of it: a lost primary file is not a fresh profile. */ -function neverWritten(dataFile: string): boolean { - for (let index = 0; index < PROFILE_STATE_LEGACY_BACKUP_COUNT; index += 1) { - if (!definitivelyAbsent(profileStateLegacyBackupPath(dataFile, index))) { - return false - } - } - return definitivelyAbsent(dataFile) -} - -function definitivelyAbsent(path: string): boolean { - try { - lstatSync(path) - return false - } catch (error) { - return isDefinitiveAbsence(error) - } -} - function profileStateDatabasePresence(path: string): 'absent' | 'present' | 'unreadable' { let mainDatabasePresent = false try { diff --git a/src/main/runtime/files/file-mutations.ts b/src/main/runtime/files/file-mutations.ts index d7a98d651e8..7f8f1858d58 100644 --- a/src/main/runtime/files/file-mutations.ts +++ b/src/main/runtime/files/file-mutations.ts @@ -6,6 +6,8 @@ import { resolveFileExplorerPaths } from './file-target' import type { RuntimeFileCommandHost } from './file-command-host' +import { runNativeChatScratchSave } from '../../native-chat/native-chat-scratch-folder-lock' +import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' import { resolveRuntimeAuthorizedPath } from './chat-scratch-file-root' import { constants, copyFile, mkdir, rm } from 'node:fs/promises' import { dirname } from 'node:path' @@ -33,7 +35,9 @@ export class RuntimeFileMutations { expectedSshTargetId, expectedSshConnectionGeneration ) - await requireRuntimeFileProvider(target, this.host).writeFile(target.path, content) + await runNativeChatScratchSave(getProfileUserDataPath(), target.path, () => + requireRuntimeFileProvider(target, this.host).writeFile(target.path, content) + ) return { ok: true } } @@ -52,7 +56,9 @@ export class RuntimeFileMutations { expectedSshTargetId, expectedSshConnectionGeneration ) - await requireRuntimeFileProvider(target, this.host).writeFileBase64(target.path, contentBase64) + await runNativeChatScratchSave(getProfileUserDataPath(), target.path, () => + requireRuntimeFileProvider(target, this.host).writeFileBase64(target.path, contentBase64) + ) return { ok: true } } @@ -72,10 +78,12 @@ export class RuntimeFileMutations { expectedSshTargetId, expectedSshConnectionGeneration ) - await requireRuntimeFileProvider(target, this.host).writeFileBase64Chunk( - target.path, - contentBase64, - append + await runNativeChatScratchSave(getProfileUserDataPath(), target.path, () => + requireRuntimeFileProvider(target, this.host).writeFileBase64Chunk( + target.path, + contentBase64, + append + ) ) return { ok: true } } diff --git a/src/main/runtime/native-chat-visuals-workspace-verdict.test.ts b/src/main/runtime/native-chat-visuals-workspace-verdict.test.ts deleted file mode 100644 index 07ad7372d75..00000000000 --- a/src/main/runtime/native-chat-visuals-workspace-verdict.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { - createNativeChatVisualsWorkspaceVerdicts, - readNativeChatVisualsWorkspaceCatalogs, - type NativeChatVisualsFilesystem, - type NativeChatVisualsWorkspaceCatalogs -} from './native-chat-visuals-workspace-verdict' - -const REPO_PATH = '/work/repo-1' -const WORKTREE_PATH = '/work/repo-1-feature' -const WORKTREE_ID = `repo-1::${WORKTREE_PATH}` - -function catalogs( - options: { - repos?: string[] - meta?: string[] - folders?: string[] - others?: Partial - } = {} -): NativeChatVisualsWorkspaceCatalogs { - return { - active: { - getRepo: (id: string) => - (options.repos ?? ['repo-1']).includes(id) ? { path: REPO_PATH } : undefined, - getAllWorktreeMeta: () => Object.fromEntries((options.meta ?? []).map((id) => [id, {}])), - getFolderWorkspaces: () => (options.folders ?? []).map((id) => ({ id })) - }, - others: { ids: new Set(), repoIds: new Set(), unreadableProfiles: 0, ...options.others } - } -} - -const at = ( - workspaceId: string, - overrides: Partial = {} -): AgentSessionExecutionLocation => ({ - executionHostId: LOCAL_EXECUTION_HOST_ID, - wslDistro: null, - workspaceId, - workspaceKind: 'git-worktree', - ...overrides -}) - -type Presence = 'present' | 'absent' | 'unknown' - -function verdictWith( - known: NativeChatVisualsWorkspaceCatalogs | null, - paths: Record = {}, - gitRecords: boolean | null = false -) { - const fs: NativeChatVisualsFilesystem = { - presence: vi.fn(async (path: string): Promise => paths[path] ?? 'present'), - gitRecordsWorktree: vi.fn(async () => gitRecords) - } - return { verdict: createNativeChatVisualsWorkspaceVerdicts(() => known, fs)(), fs } -} - -describe('whether a chat workspace is provably removed', () => { - it('is removed when its project was removed from Orca in every profile', async () => { - const { verdict, fs } = verdictWith(catalogs({ repos: [] })) - await expect(verdict(at(WORKTREE_ID))).resolves.toBe('removed') - expect(fs.presence).not.toHaveBeenCalled() - }) - - it("keeps a chat whose project or folder only another profile holds (chats are shared, catalogs aren't)", async () => { - const { verdict } = verdictWith( - catalogs({ - repos: [], - others: { repoIds: new Set(['repo-1']), ids: new Set(['folder:f-2']) } - }) - ) - await expect(verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable') - await expect(verdict(at('folder:f-2', { workspaceKind: 'folder' }))).resolves.toBe('present') - }) - - it('decides nothing while any profile cannot be read', async () => { - const { verdict } = verdictWith(catalogs({ repos: [], others: { unreadableProfiles: 1 } })) - await expect(verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable') - await expect(verdict(at('folder:gone', { workspaceKind: 'folder' }))).resolves.toBe( - 'unverifiable' - ) - }) - - it('is present while any profile still tracks the worktree', async () => { - for (const known of [ - catalogs({ meta: [WORKTREE_ID] }), - catalogs({ others: { ids: new Set([WORKTREE_ID]) } }) - ]) { - const { verdict, fs } = verdictWith(known, { [WORKTREE_PATH]: 'absent' }) - await expect(verdict(at(WORKTREE_ID))).resolves.toBe('present') - expect(fs.presence).not.toHaveBeenCalled() - } - }) - - it('is removed when the worktree folder is gone and git no longer records it', async () => { - const { verdict, fs } = verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, false) - await expect(verdict(at(WORKTREE_ID))).resolves.toBe('removed') - expect(fs.gitRecordsWorktree).toHaveBeenCalledWith(REPO_PATH, WORKTREE_PATH) - }) - - it('keeps a worktree on an unmounted drive: git still records it, or the project is unreachable', async () => { - await expect( - verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, true).verdict(at(WORKTREE_ID)) - ).resolves.toBe('unverifiable') - await expect( - verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent' }, null).verdict(at(WORKTREE_ID)) - ).resolves.toBe('unverifiable') - await expect( - verdictWith(catalogs(), { [WORKTREE_PATH]: 'absent', [REPO_PATH]: 'absent' }).verdict( - at(WORKTREE_ID) - ) - ).resolves.toBe('unverifiable') - }) - - it("never removes the project's own checkout while the project is known", async () => { - const { verdict } = verdictWith(catalogs(), { [REPO_PATH]: 'absent' }) - await expect(verdict(at(`repo-1::${REPO_PATH}`))).resolves.toBe('unverifiable') - }) - - it('keeps a worktree whose folder is there or unreadable', async () => { - await expect(verdictWith(catalogs()).verdict(at(WORKTREE_ID))).resolves.toBe('present') - await expect( - verdictWith(catalogs(), { [WORKTREE_PATH]: 'unknown' }).verdict(at(WORKTREE_ID)) - ).resolves.toBe('unverifiable') - }) - - it('reads a folder workspace from every catalog', async () => { - const folder = (id: string) => at(`folder:${id}`, { workspaceKind: 'folder' }) - const { verdict } = verdictWith(catalogs({ folders: ['f-1'] })) - await expect(verdict(folder('f-1'))).resolves.toBe('present') - await expect(verdict(folder('f-2'))).resolves.toBe('removed') - }) - - it('never decides for another host, a WSL distro, the floating workspace, or without catalogs', async () => { - const { verdict, fs } = verdictWith(catalogs({ repos: [] })) - await expect(verdict(at(WORKTREE_ID, { executionHostId: 'ssh:box' }))).resolves.toBe( - 'unverifiable' - ) - await expect(verdict(at(WORKTREE_ID, { wslDistro: 'Ubuntu' }))).resolves.toBe('unverifiable') - await expect( - verdict(at(FLOATING_TERMINAL_WORKTREE_ID, { workspaceKind: 'folder' })) - ).resolves.toBe('unverifiable') - await expect(verdictWith(null).verdict(at(WORKTREE_ID))).resolves.toBe('unverifiable') - expect(fs.presence).not.toHaveBeenCalled() - }) - - it('reads the catalogs once per run, however many chats it judges', async () => { - const read = vi.fn(() => catalogs({ repos: [] })) - const verdict = createNativeChatVisualsWorkspaceVerdicts(read)() - await verdict(at(WORKTREE_ID)) - await verdict(at('repo-1::/work/other')) - expect(read).toHaveBeenCalledOnce() - }) -}) - -describe("git's own record of a linked worktree", () => { - const scratch: string[] = [] - afterEach(() => { - for (const dir of scratch.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } - }) - - function repoWithWorktreeRecord(recordedWorktree: string | null): string { - const repo = realpathSync(mkdtempSync(join(tmpdir(), 'orca-visuals-git-'))) - scratch.push(repo) - mkdirSync(join(repo, '.git', 'worktrees', 'feature'), { recursive: true }) - if (recordedWorktree) { - writeFileSync( - join(repo, '.git', 'worktrees', 'feature', 'gitdir'), - `${recordedWorktree}/.git\n` - ) - } - return repo - } - - it('removes only a worktree git no longer records', async () => { - const gone = join(tmpdir(), 'orca-visuals-missing-worktree-a') - const verdicts = (repo: string) => - createNativeChatVisualsWorkspaceVerdicts(() => ({ - ...catalogs(), - active: { - ...catalogs().active, - getRepo: () => ({ path: repo }) - } - }))() - const recorded = repoWithWorktreeRecord(gone) - await expect(verdicts(recorded)(at(`repo-1::${gone}`))).resolves.toBe('unverifiable') - const pruned = repoWithWorktreeRecord(null) - await expect(verdicts(pruned)(at(`repo-1::${gone}`))).resolves.toBe('removed') - }) - - it('reads a record git wrote relative to its own folder', async () => { - const repo = repoWithWorktreeRecord(null) - const worktree = join(repo, '..', 'orca-visuals-relative-worktree-b') - writeFileSync( - join(repo, '.git', 'worktrees', 'feature', 'gitdir'), - '../../../../orca-visuals-relative-worktree-b/.git\n' - ) - const verdict = createNativeChatVisualsWorkspaceVerdicts(() => ({ - ...catalogs(), - active: { ...catalogs().active, getRepo: () => ({ path: repo }) } - }))() - await expect(verdict(at(`repo-1::${worktree}`))).resolves.toBe('unverifiable') - }) - - it('resolves a relative record against the real path when the project was added through a link', async () => { - const real = realpathSync(mkdtempSync(join(tmpdir(), 'orca-visuals-real-'))) - scratch.push(real) - const nested = join(real, 'a', 'b', 'c') - mkdirSync(join(nested, 'repo', '.git', 'worktrees', 'wt'), { recursive: true }) - const linkedParent = join(real, 'deep') - symlinkSync(nested, linkedParent) - const worktree = join(real, 'wts', 'wt') - // What git writes: relative from the record folder's real path. - writeFileSync( - join(nested, 'repo', '.git', 'worktrees', 'wt', 'gitdir'), - '../../../../../../../wts/wt/.git\n' - ) - const verdict = createNativeChatVisualsWorkspaceVerdicts(() => ({ - ...catalogs(), - active: { ...catalogs().active, getRepo: () => ({ path: join(linkedParent, 'repo') }) } - }))() - await expect(verdict(at(`repo-1::${worktree}`))).resolves.toBe('unverifiable') - }) -}) - -describe('reading the catalogs for a sweep run', () => { - it('skips the running profile named by its own storage folder', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-visuals-profiles-')) - try { - mkdirSync(join(root, 'profiles', 'running'), { recursive: true }) - mkdirSync(join(root, 'profiles', 'next'), { recursive: true }) - const profile = (id: string) => ({ - id, - name: id, - kind: 'local', - createdAt: 0, - updatedAt: 0, - lastOpenedAt: 0, - avatar: { kind: 'initials', initials: id.slice(0, 2), color: 'neutral' } - }) - // A switch already named the next profile active; this process still runs the old one. - writeFileSync( - join(root, 'orca-profile-index.json'), - JSON.stringify({ activeProfileId: 'next', profiles: [profile('running'), profile('next')] }) - ) - for (const id of ['running', 'next']) { - writeFileSync( - join(root, 'profiles', id, 'orca-data.json'), - JSON.stringify({ repos: [{ id: `repo-${id}` }] }) - ) - } - const read = readNativeChatVisualsWorkspaceCatalogs( - { - ...catalogs().active, - getProfileStorageDirectory: () => join(root, 'profiles', 'running') - }, - root - ) - expect([...read.others.repoIds]).toEqual(['repo-next']) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/runtime/native-chat-visuals-workspace-verdict.ts b/src/main/runtime/native-chat-visuals-workspace-verdict.ts deleted file mode 100644 index 3dce96bb0f7..00000000000 --- a/src/main/runtime/native-chat-visuals-workspace-verdict.ts +++ /dev/null @@ -1,204 +0,0 @@ -// Whether the local workspace a chat ran in is provably gone, for the scratch folder sweep. -// Chat records and visuals live at the user-data root, shared by every Orca profile, while each -// profile keeps its own workspace catalog: so only absence from EVERY profile's catalog counts. A -// worktree still in a known project is gone only when git itself no longer records it. Never runs -// git or lists a repo's worktrees: that walk can touch protected folders. - -import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises' -import { basename, dirname, isAbsolute, join, resolve } from 'node:path' -import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' -import type { Store } from '../persistence' -import { readOtherProfileWorkspaceCatalog } from '../orca-profiles/other-profile-workspace-catalog' -import { - getOrcaProfilesDirectory, - getProfileUserDataPath -} from '../orca-profiles/profile-storage-paths' -import type { NativeChatVisualsWorkspaceVerdict } from '../native-chat/native-chat-visuals-sweep' - -/** This profile's live catalog and every other profile's persisted one. The running profile is - * named by its own storage folder, not the profile index, which a switch rewrites first. */ -export function readNativeChatVisualsWorkspaceCatalogs( - store: NativeChatVisualsWorkspaceCatalogs['active'] & Pick, - userDataPath = getProfileUserDataPath() -): NativeChatVisualsWorkspaceCatalogs { - const storage = store.getProfileStorageDirectory() - const runningProfileId = samePath(dirname(storage), getOrcaProfilesDirectory(userDataPath)) - ? basename(storage) - : undefined - return { - active: store, - others: readOtherProfileWorkspaceCatalog(userDataPath, { - ...(runningProfileId ? { runningProfileId } : {}), - // Chat records are never deleted, so this rule is what cleans up; a profile created but never - // opened must not switch it off. - neverWrittenIsEmpty: true - }) - } -} - -export type NativeChatVisualsWorkspaceCatalogs = { - /** The running profile's live catalog: the Store, narrowed to what a verdict reads. */ - active: { - getRepo: (id: string) => { path: string } | undefined - getAllWorktreeMeta: () => Readonly> - getFolderWorkspaces: () => readonly { id: string }[] - } - /** Every other profile's worktree ids, folder workspace keys and project ids. */ - others: { ids: ReadonlySet; repoIds: ReadonlySet; unreadableProfiles: number } -} - -export type NativeChatVisualsFilesystem = { - /** `present`, `absent` (definitively), or `unknown` for any other answer. */ - presence: (path: string) => Promise<'present' | 'absent' | 'unknown'> - /** Whether git in the repo at `repoPath` records a linked worktree at `worktreePath`; null when - * its records can't be read. */ - gitRecordsWorktree: (repoPath: string, worktreePath: string) => Promise -} - -async function presence(path: string): Promise<'present' | 'absent' | 'unknown'> { - try { - await stat(path) - return 'present' - } catch (error) { - return isDefinitiveAbsence(error) ? 'absent' : 'unknown' - } -} - -const samePath = (left: string, right: string): boolean => - normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) - -async function gitRecordsWorktree(repoPath: string, worktreePath: string): Promise { - const gitDir = join(repoPath, '.git') - try { - // A `.git` file (the repo is itself a linked worktree or a submodule) is not read here. - if (!(await lstat(gitDir)).isDirectory()) { - return null - } - let names: string[] - try { - names = await readdir(join(gitDir, 'worktrees')) - } catch (error) { - if (isDefinitiveAbsence(error)) { - return false - } - throw error - } - for (const name of names) { - let recorded: string - try { - recorded = await readFile(join(gitDir, 'worktrees', name, 'gitdir'), 'utf8') - } catch (error) { - if (isDefinitiveAbsence(error)) { - continue - } - throw error - } - // The record names the worktree's own `.git` file; git 2.48+ may write it relative to the - // record folder's real path, which differs from the stored one when that runs through a link. - const gitFile = recorded.trim() - const absolute = isAbsolute(gitFile) - ? gitFile - : resolve(await realpath(join(gitDir, 'worktrees', name)), gitFile) - if (samePath(dirname(absolute), worktreePath)) { - return true - } - } - return false - } catch { - return null - } -} - -const NODE_FILESYSTEM: NativeChatVisualsFilesystem = { presence, gitRecordsWorktree } - -async function worktreeVerdict( - catalogs: NativeChatVisualsWorkspaceCatalogs, - workspaceId: string, - fs: NativeChatVisualsFilesystem -): Promise { - const worktree = splitWorktreeIdForFilesystem(workspaceId) - if (!worktree?.repoId || !worktree.worktreePath) { - return 'unverifiable' - } - const repo = catalogs.active.getRepo(worktree.repoId) - // The project was removed from Orca in every profile. - if (!repo && !catalogs.others.repoIds.has(worktree.repoId)) { - return 'removed' - } - if ( - Object.hasOwn(catalogs.active.getAllWorktreeMeta(), workspaceId) || - catalogs.others.ids.has(workspaceId) - ) { - return 'present' - } - // The project's own checkout, or one only another profile can say anything about. - if (!repo || samePath(repo.path, worktree.worktreePath)) { - return 'unverifiable' - } - const own = await fs.presence(worktree.worktreePath) - if (own !== 'absent') { - return own === 'present' ? 'present' : 'unverifiable' - } - // A worktree on an unmounted drive is still recorded by git, so absence alone proves nothing. - if ((await fs.presence(repo.path)) !== 'present') { - return 'unverifiable' - } - return (await fs.gitRecordsWorktree(repo.path, worktree.worktreePath)) === false - ? 'removed' - : 'unverifiable' -} - -/** - * A verdict function over one snapshot of every profile's catalog; call once per sweep run. - * Null catalogs, or any profile whose state can't be read, decide nothing. - */ -export function createNativeChatVisualsWorkspaceVerdicts( - readCatalogs: () => NativeChatVisualsWorkspaceCatalogs | null, - fs: NativeChatVisualsFilesystem = NODE_FILESYSTEM -): () => (location: AgentSessionExecutionLocation) => Promise { - return () => { - let snapshot: NativeChatVisualsWorkspaceCatalogs | null | undefined - const catalogs = (): NativeChatVisualsWorkspaceCatalogs | null => { - if (snapshot === undefined) { - try { - snapshot = readCatalogs() - } catch { - snapshot = null - } - } - return snapshot && snapshot.others.unreadableProfiles === 0 ? snapshot : null - } - return async (location) => { - // Another host or a WSL distro owns its own answer; loss of contact is never removal. - if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { - return 'unverifiable' - } - // The floating workspace names a setting, not a place the user removes. - if (isFloatingWorkspaceId(location.workspaceId)) { - return 'unverifiable' - } - const known = catalogs() - if (!known) { - return 'unverifiable' - } - if (location.workspaceKind !== 'folder') { - return worktreeVerdict(known, location.workspaceId, fs) - } - if (parseWorkspaceKey(location.workspaceId)?.type !== 'folder') { - return 'unverifiable' - } - const listed = - known.others.ids.has(location.workspaceId) || - known.active - .getFolderWorkspaces() - .some((workspace) => folderWorkspaceKey(workspace.id) === location.workspaceId) - return listed ? 'present' : 'removed' - } - } -} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 845ed09c28c..795c29fb92b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -56,10 +56,6 @@ import { withoutHiddenHosts } from './runtime-visible-catalog' import { claudeCliFlagSupport } from '../claude/claude-cli-flag-support' -import { - createNativeChatVisualsWorkspaceVerdicts, - readNativeChatVisualsWorkspaceCatalogs -} from './native-chat-visuals-workspace-verdict' export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { async getWorktreePs( @@ -241,11 +237,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Wired only here, so a test runtime never runs a real `claude --version`. claudeCliFlags: claudeCliFlagSupport, nativeChatVisuals: { - isEnabled: () => this.requireStore().getSettings().nativeChatInlineVisuals !== false, - // Chats and their visuals are shared by every profile; each profile keeps its own catalog. - workspaceVerdicts: createNativeChatVisualsWorkspaceVerdicts(() => - this.store ? readNativeChatVisualsWorkspaceCatalogs(this.store) : null - ) + isEnabled: () => this.requireStore().getSettings().nativeChatInlineVisuals !== false }, resolveShellEnvironmentPolicy: () => nativeChatShellEnvironmentPolicy(this.requireStore().getSettings()), diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3ec22cc3ac0..a9e2d69dda1 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -67,10 +67,8 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support' -import { - scheduleNativeChatVisualsSweep, - type NativeChatVisualsSweepDeps -} from '../native-chat/native-chat-visuals-sweep' +import { scheduleNativeChatVisualsSweep } from '../native-chat/native-chat-visuals-sweep' +import { readJournalSessionLastActivityAt } from '../native-chat/agent-session-journal/journal-row-table' /** Whether this profile holds a structured chat: a record or tab in the journal database. */ export function hasPersistedStructuredAgentSessionStore( @@ -104,11 +102,11 @@ export type StructuredAgentSessionRuntimeDeps = { /** Which version-gated flags a Claude CLI takes; absent never passes one. */ claudeCliFlags?: ClaudeCliFlagSupport /** Gives each chat a scratch folder and the visuals skill that teaches it, and sweeps folders - * whose chat is gone. Wired by the real hosts only, so a test runtime never loads the bundled skill. */ + * whose chat is gone or long unused. Wired by the real hosts only, so a test runtime never loads + * the bundled skill. */ nativeChatVisuals?: { /** Read this host's preference when a chat starts a provider process. */ isEnabled: () => boolean - workspaceVerdicts: NonNullable } /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] @@ -368,8 +366,9 @@ async function installOnJournal( ? scheduleNativeChatVisualsSweep({ stateDirectory: deps.stateDirectory, listHeldSessionIds: () => (store.readOnly ? null : store.listHeldSessionIds()), - locationOf: (sessionId) => store.getRecord(sessionId)?.location ?? null, - workspaceVerdicts: deps.nativeChatVisuals.workspaceVerdicts, + lastJournalActivityAt: (sessionId) => + readJournalSessionLastActivityAt(journalDatabase.db, sessionId), + runWhileExecutionIdle: host.runWhileExecutionIdle, logger: deps.logger }) : undefined From d1bb7fec80db8912f69673c10f67e48e71792f1f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:23:35 -0700 Subject: [PATCH 09/17] [CI] Reduce repeated test imports and fixture overhead (#28627) Co-authored-by: Claude --- ...lectron-builder-runtime-resources.test.mjs | 9 ++++++-- .../scripts/process-host-build-dist.test.mjs | 1 + .../scripts/process-host-dev-rebuild.test.ts | 1 + src/main/daemon/daemon-idle-shutdown.test.ts | 4 ++++ .../terminal-history-tombstone-retry.test.ts | 1 + .../AutomationRunDetailsPage.test.tsx | 20 +++++++++++++++-- .../MonacoEditor.content-owner.test.tsx | 2 ++ ...arkdown-large-text-paste.selection.test.ts | 18 +++++++++++++-- .../rich-markdown-large-text-paste.test.ts | 18 +++++++++++++-- .../NativeChatImageAttachmentPreview.test.tsx | 11 +++++++++- .../NativeChatMarkdown.code-wrap.test.tsx | 13 +++++++++++ .../NativeChatMarkdown.mermaid.test.tsx | 13 +++++++++++ .../native-chat/NativeChatMarkdown.test.tsx | 15 ++++++++++++- ...eChatMessageList.growth-windowing.test.tsx | 20 +++++++++++++++++ ...tiveChatMessageList.older-history.test.tsx | 20 +++++++++++++++++ ...ativeChatMessageList.opening-send.test.tsx | 20 +++++++++++++++++ ...ChatMessageList.queued-turn-order.test.tsx | 20 +++++++++++++++++ ...ChatMessageList.rail-history-jump.test.tsx | 20 +++++++++++++++++ ...ChatMessageList.reader-navigation.test.tsx | 20 +++++++++++++++++ ...NativeChatMessageList.rewind-rows.test.tsx | 20 +++++++++++++++++ ...hatMessageList.stream-render.perf.test.tsx | 20 +++++++++++++++++ ...ChatMessageList.subagent-sections.test.tsx | 20 +++++++++++++++++ .../NativeChatMessageList.test.tsx | 20 +++++++++++++++++ ...eChatMessageList.tool-stream-cost.test.tsx | 20 +++++++++++++++++ ...iveChatMessageList.turn-indicator.test.tsx | 20 +++++++++++++++++ ...veChatMessageList.turn-membership.test.tsx | 20 +++++++++++++++++ ...NativeChatMessageList.turn-status.test.tsx | 20 +++++++++++++++++ ...iveChatMessageList.unsent-message.test.tsx | 20 +++++++++++++++++ .../NativeChatMessageList.windowing.test.tsx | 20 +++++++++++++++++ ...tiveChatMessageList.work-run-memo.test.tsx | 20 +++++++++++++++++ .../native-chat/NativeChatMessageRow.test.tsx | 20 +++++++++++++++++ .../NativeChatMessageRow.work-run.test.tsx | 22 ++++++++++++++++++- .../NativeChatTranscriptChrome.test.tsx | 18 ++++++++++++++- ...ative-chat-visual-markdown-syntax.test.tsx | 19 +++++++++++++++- ...tured-agent-session.queued-gating.test.tsx | 3 ++- .../CommentMarkdown.file-link-cases.test.tsx | 18 ++++++++++++++- ...tMarkdown.github-attachment-image.test.tsx | 13 ++++++++++- .../sidebar/CommentMarkdown.growing.test.tsx | 15 ++++++++++++- .../CommentMarkdown.link-click.test.tsx | 11 ++++++++++ .../CommentMarkdown.word-fade.test.tsx | 11 ++++++++++ ...e-runtime-connect-failure-recovery.test.ts | 15 ++++++++++++- ...ime-disconnected-input-grace-retry.test.ts | 15 ++++++++++++- ...port-activation-inventory-fallback.test.ts | 15 ++++++++++++- ...-pty-transport-attach-subscription.test.ts | 15 ++++++++++++- ...-runtime-pty-transport-end-verdict.test.ts | 15 ++++++++++++- ...ty-transport-expired-pane-recovery.test.ts | 15 ++++++++++++- ...ty-transport-host-absence-evidence.test.ts | 15 ++++++++++++- ...transport-host-surface-replacement.test.ts | 15 ++++++++++++- ...ime-pty-transport-input-coalescing.test.ts | 15 ++++++++++++- ...ntime-pty-transport-input-fallback.test.ts | 15 ++++++++++++- ...runtime-pty-transport-input-replay.test.ts | 15 ++++++++++++- ...y-transport-pane-binding-ownership.test.ts | 15 ++++++++++++- ...y-transport-pane-handle-resolution.test.ts | 15 ++++++++++++- ...nsport-pending-host-surface-attach.test.ts | 13 +++++++++++ ...time-pty-transport-snapshot-replay.test.ts | 15 ++++++++++++- ...ty-transport-split-leaf-activation.test.ts | 15 ++++++++++++- ...ty-transport-stale-handle-recovery.test.ts | 15 ++++++++++++- ...ransport-sticky-replacement-policy.test.ts | 15 ++++++++++++- ...ime-pty-transport-stream-reconnect.test.ts | 15 ++++++++++++- ...-pty-transport-unmounted-host-pane.test.ts | 15 ++++++++++++- ...y-transport-unpublished-host-graph.test.ts | 15 ++++++++++++- ...-pty-transport-web-mirror-recovery.test.ts | 15 ++++++++++++- .../src/lib/user-opened-local-path.test.ts | 21 +++++++++++++++++- 63 files changed, 926 insertions(+), 39 deletions(-) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 7c774e4cd7d..2d0c60099ca 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -477,7 +477,12 @@ describe('packaged runtime resources', () => { { recursive: true } ) await seedBundledRipgrep(resourcesDir) - await seedClipboardPublisher(resourcesDir, 0x3e) + // Layout fixtures use a cross-arch slice; native PTY probes need real packaged binaries. + const target = + process.arch === 'x64' + ? { electronArch: 3, machine: 0xb7 } + : { electronArch: 1, machine: 0x3e } + await seedClipboardPublisher(resourcesDir, target.machine) await stubObjdump(root) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) // Why: afterPack now fails hard when the unpacked daemon entry is @@ -512,7 +517,7 @@ describe('packaged runtime resources', () => { await electronBuilderConfig.afterPack({ appOutDir: join(root, 'linux-unpacked'), electronPlatformName: 'linux', - arch: 1, + arch: target.electronArch, packager: { appInfo: { version: '9.9.9' } } }) diff --git a/config/scripts/process-host-build-dist.test.mjs b/config/scripts/process-host-build-dist.test.mjs index b4b560b37d5..7386699fc1a 100644 --- a/config/scripts/process-host-build-dist.test.mjs +++ b/config/scripts/process-host-build-dist.test.mjs @@ -54,6 +54,7 @@ function fixture(onTestFinished, distSeed) { JSON.stringify({ compilerOptions: { target: 'ES2022', + lib: ['ES2022'], module: 'Node16', moduleResolution: 'Node16', rootDir: 'src', diff --git a/config/scripts/process-host-dev-rebuild.test.ts b/config/scripts/process-host-dev-rebuild.test.ts index fa27981184b..f732451beef 100644 --- a/config/scripts/process-host-dev-rebuild.test.ts +++ b/config/scripts/process-host-dev-rebuild.test.ts @@ -69,6 +69,7 @@ function fixture(): { root: string; packageDir: string; main: string } { extends: '../../../tsconfig.base.json', compilerOptions: { target: 'ES2022', + lib: ['ES2022'], module: 'Node16', moduleResolution: 'Node16', rootDir: 'src', diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 43a82df72bb..051bd1ff1ec 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -464,10 +464,14 @@ describe('current daemon lifecycle retirement', () => { liveSessions: 1, admissionReopened: true }) + const alreadyLiveSubprocess = subprocess + subprocess = createMockSubprocess() await expect( adapter.spawn({ sessionId: 'allowed-after-refusal', cols: 80, rows: 24 }) ).resolves.toMatchObject({ id: 'allowed-after-refusal' }) expect(onIdleShutdown).not.toHaveBeenCalled() + alreadyLiveSubprocess.exit(0) + subprocess.exit(0) adapter.dispose() }) diff --git a/src/main/terminal-history-tombstone-retry.test.ts b/src/main/terminal-history-tombstone-retry.test.ts index 575d81415a9..0cca210dc14 100644 --- a/src/main/terminal-history-tombstone-retry.test.ts +++ b/src/main/terminal-history-tombstone-retry.test.ts @@ -141,6 +141,7 @@ describe('tombstoned history removal retries', () => { for (let pass = 0; removeHostTreeMock.mock.calls.length < 1_000; pass++) { expect(pass).toBeLessThan(200) + await vi.waitFor(() => expect(releases.length).toBeGreaterThan(0)) releases.splice(0).forEach((release) => release()) await vi.advanceTimersByTimeAsync(0) await settleTombstoneDirectoryReads() diff --git a/src/renderer/src/components/automations/AutomationRunDetailsPage.test.tsx b/src/renderer/src/components/automations/AutomationRunDetailsPage.test.tsx index 785c002478e..19f515eeeac 100644 --- a/src/renderer/src/components/automations/AutomationRunDetailsPage.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunDetailsPage.test.tsx @@ -2,11 +2,20 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AutomationRun } from '../../../../shared/automations-types' import { AutomationRunDetailsPage } from './AutomationRunDetailsPage' import type { AutomationRunViewState } from './automation-run-view-state' +// These fixtures exercise markdown without rendering diagrams. +const boundaryMocks = vi.hoisted(() => ({ + mermaid: vi.fn(() => { + throw new Error('These fixtures must not render Mermaid diagrams') + }) +})) + +vi.mock('@/components/sidebar/CommentMermaidBlock', () => ({ default: boundaryMocks.mermaid })) + const run: AutomationRun = { id: 'run-1', automationId: 'automation-1', @@ -47,7 +56,14 @@ function renderPage(viewState: AutomationRunViewState): void { } describe('AutomationRunDetailsPage', () => { - afterEach(cleanup) + afterEach(() => { + cleanup() + try { + expect(boundaryMocks.mermaid).not.toHaveBeenCalled() + } finally { + boundaryMocks.mermaid.mockClear() + } + }) it('shows the open action when it can open something', () => { renderPage({ diff --git a/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx b/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx index 68c8740eda6..387078a6971 100644 --- a/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx @@ -38,6 +38,8 @@ vi.mock('@monaco-editor/react', () => ({ }, loader: { config: vi.fn() } })) +// Why: these wrapper contracts never mount Monaco. +vi.mock('@/lib/monaco-setup', () => ({})) vi.mock('@/store', () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts index 839c625d56d..4c7438353a3 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts @@ -12,6 +12,15 @@ import { setRichMarkdownImageResolverContext } from './rich-markdown-image-conte vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn() } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +// These paths use supplied file context without resolving its editor connection. +const boundaryMocks = vi.hoisted(() => ({ + getConnectionId: vi.fn(() => { + throw new Error('These fixtures must not resolve editor connections') + }) +})) + +vi.mock('@/lib/connection-context', () => ({ getConnectionId: boundaryMocks.getConnectionId })) + const editors: Editor[] = [] const largeText = 'X'.repeat(70 * 1024) @@ -65,8 +74,13 @@ function textPosition(editor: Editor, text: string): number { afterEach(() => { editors.splice(0).forEach((editor) => editor.destroy()) document.body.replaceChildren() - vi.restoreAllMocks() - vi.clearAllMocks() + try { + expect(boundaryMocks.getConnectionId).not.toHaveBeenCalled() + } finally { + boundaryMocks.getConnectionId.mockClear() + vi.restoreAllMocks() + vi.clearAllMocks() + } }) describe('large Markdown paste selection during yields', () => { diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts index bf904cc1eed..6300dc706f5 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.test.ts @@ -14,6 +14,15 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +// These paths use supplied file context without resolving its editor connection. +const boundaryMocks = vi.hoisted(() => ({ + getConnectionId: vi.fn(() => { + throw new Error('These fixtures must not resolve editor connections') + }) +})) + +vi.mock('@/lib/connection-context', () => ({ getConnectionId: boundaryMocks.getConnectionId })) + const editors: Editor[] = [] function makePasteEvent(text: string, html = ''): ClipboardEvent { @@ -68,8 +77,13 @@ async function flushPromises(count = 12): Promise { afterEach(() => { editors.splice(0).forEach((editor) => editor.destroy()) document.body.replaceChildren() - vi.restoreAllMocks() - vi.clearAllMocks() + try { + expect(boundaryMocks.getConnectionId).not.toHaveBeenCalled() + } finally { + boundaryMocks.getConnectionId.mockClear() + vi.restoreAllMocks() + vi.clearAllMocks() + } }) describe('rich markdown large text paste', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index f023b17f9c4..e49929fe06c 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -6,7 +6,10 @@ import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPre import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' const mocks = vi.hoisted(() => ({ - useLocalImageSrc: vi.fn() + useLocalImageSrc: vi.fn(), + getConnectionIdForFile: vi.fn(() => { + throw new Error('Image preview fixtures must not resolve editor file connections') + }) })) vi.mock('@/i18n/i18n', () => ({ @@ -14,6 +17,10 @@ vi.mock('@/i18n/i18n', () => ({ fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => options?.[name] ?? '') })) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: mocks.getConnectionIdForFile +})) + vi.mock('@/components/editor/useLocalImageSrc', () => ({ useLocalImageSrc: mocks.useLocalImageSrc })) @@ -22,6 +29,8 @@ afterEach(() => { cleanup() vi.unstubAllGlobals() mocks.useLocalImageSrc.mockReset() + expect(mocks.getConnectionIdForFile).not.toHaveBeenCalled() + mocks.getConnectionIdForFile.mockClear() }) function renderPreview( diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.code-wrap.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.code-wrap.test.tsx index b19f059b83b..5b753a5789f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.code-wrap.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.code-wrap.test.tsx @@ -2,6 +2,19 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() +}) import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatMarkdown } from './NativeChatMarkdown' import { diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx index 8667044424c..8f7d31f4c6c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx @@ -3,6 +3,19 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() +}) + const mermaid = vi.hoisted(() => ({ initialize: vi.fn(), render: vi.fn() })) const theme = vi.hoisted(() => ({ value: 'light', listeners: new Set<() => void>() })) vi.mock('mermaid', () => ({ default: mermaid })) diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.test.tsx index 8b189102ee3..509a3880dc4 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.test.tsx @@ -1,7 +1,20 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() +}) import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { NativeChatMarkdown } from './NativeChatMarkdown' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx index 218f66e2d37..2e841c63e38 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { NativeChatMessageList } from './NativeChatMessageList' import { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx index 478e0c85a2f..8bf607c9644 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.older-history.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { NativeChatMessageList } from './NativeChatMessageList' import type { NativeChatOlderPageResult } from './native-chat-pagination' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx index 8fef8febbe0..1b81ce88f2e 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.opening-send.test.tsx @@ -6,6 +6,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx index c50397706f6..7a36f6886c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { AgentJournalItemBody, AgentJournalRenderItem, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx index f4d141ea388..f147eff5f1a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx @@ -5,6 +5,26 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { useCallback, useMemo, useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { AgentJournalItemBody, AgentJournalRenderItem diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx index d777dd72d83..db958b6e19c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx @@ -5,6 +5,26 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' import { NativeChatMessageList } from './NativeChatMessageList' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx index c6d90d0298f..c4ee6d37416 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import { agentJournalItemKey, agentJournalSubmissionKey diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx index beda7f0453c..1fb38fed9a4 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type * as NativeChatProseModule from '../../../../shared/native-chat-prose' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx index bc1e583c1cd..f77dacbbbe3 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx @@ -6,6 +6,26 @@ import { cleanup, fireEvent, render, screen, within, act } from '@testing-librar import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) + import type { AgentJournalItemBody, AgentJournalProducerLinkage, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index a31c831b10a..e79c98c8e7a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -6,6 +6,26 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) + import { subagentGroupFallbackText } from '../../../../shared/native-chat-subagent-summary' import type { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx index bebcab390d6..ed2c0328bef 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx @@ -1,6 +1,26 @@ // @vitest-environment happy-dom import { cleanup, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type * as EditNormalization from '../../../../shared/native-chat-edit-normalize' import type { NativeChatLiveSession } from './use-native-chat-live-session' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index 5887b9a929e..beb8be27d95 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx index da01701f637..b0b325951c9 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx @@ -6,6 +6,26 @@ import { cleanup, render, screen, fireEvent } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) + import { agentJournalItemKey, agentJournalSubmissionKey diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx index b9d3949300d..cc7c6c86f39 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-status.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import type { AgentJournalItemBody, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx index 9b2e1dc8ef9..d042db15882 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx @@ -8,6 +8,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { AgentJournalItemBody, AgentJournalRenderItem, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx index 489bd992d1c..8c6c7ddd02d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx @@ -4,6 +4,26 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { AgentJournalItemBody, AgentJournalRenderItem diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx index f73e96dfc1a..3919fded6b0 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.work-run-memo.test.tsx @@ -2,6 +2,26 @@ import { cleanup, render } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type * as NativeChatWorkRunModule from '../../../../shared/native-chat-work-run' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index e1cd980a791..46a064dc422 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -2,6 +2,26 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' import { TooltipProvider } from '@/components/ui/tooltip' diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx index 7452b7a9ed6..4653953e172 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.work-run.test.tsx @@ -1,7 +1,27 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Why: these fixtures have neither inline visual owners nor sender navigation. +const unusedChatLeaves = vi.hoisted(() => ({ + inlineVisual: vi.fn(() => { + throw new Error('Unexpected inline visual in transcript fixture') + }), + openSender: vi.fn(() => { + throw new Error('Unexpected sender navigation in transcript fixture') + }) +})) +vi.mock('./NativeChatInlineVisual', () => ({ + NativeChatInlineVisual: unusedChatLeaves.inlineVisual +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ + openAgentMessageSender: unusedChatLeaves.openSender +})) +afterEach(() => { + expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled() + expect(unusedChatLeaves.openSender).not.toHaveBeenCalled() +}) import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { MessageRow } from './NativeChatMessageRow' diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index 13382faa3c7..59f333dbfdf 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -13,6 +13,17 @@ import { import { chatImageAccess } from '@/lib/local-file-access' import { NativeChatImageAttachments } from './NativeChatTranscriptChrome' +// Chat images carry their runtime owner without resolving an editor connection. +const mocks = vi.hoisted(() => ({ + getConnectionIdForFile: vi.fn(() => { + throw new Error('Chat image fixtures must not resolve editor file connections') + }) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: mocks.getConnectionIdForFile +})) + globalThis.IS_REACT_ACT_ENVIRONMENT = true function runtimeContext(worktreeId: string): RuntimeFileOperationArgs { @@ -49,7 +60,12 @@ beforeEach(() => { afterEach(() => { resetLocalImageSrcStateForTests() vi.unstubAllGlobals() - vi.restoreAllMocks() + try { + expect(mocks.getConnectionIdForFile).not.toHaveBeenCalled() + } finally { + mocks.getConnectionIdForFile.mockClear() + vi.restoreAllMocks() + } }) describe('NativeChatImageAttachments', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx index 64599979575..bf56527f5d5 100644 --- a/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-visual-markdown-syntax.test.tsx @@ -1,7 +1,7 @@ import type { ComponentProps } from 'react' import type { ExtraProps } from 'react-markdown' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import CommentMarkdown, { type CommentMarkdownExtension } from '@/components/sidebar/CommentMarkdown' @@ -10,6 +10,23 @@ import { remarkNativeChatVisuals } from './native-chat-visual-markdown-syntax' +// These fixtures exercise markdown without rendering diagrams. +const boundaryMocks = vi.hoisted(() => ({ + mermaid: vi.fn(() => { + throw new Error('These fixtures must not render Mermaid diagrams') + }) +})) + +vi.mock('@/components/sidebar/CommentMermaidBlock', () => ({ default: boundaryMocks.mermaid })) + +afterEach(() => { + try { + expect(boundaryMocks.mermaid).not.toHaveBeenCalled() + } finally { + boundaryMocks.mermaid.mockClear() + } +}) + const NONCE = 'nonce-123' function Probe(props: ComponentProps<'div'> & ExtraProps): React.JSX.Element { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index 84976d1d465..ba0371e4372 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -7,7 +7,7 @@ // and no draft text ever rides an answer. A host-held draft is a card above // the composer, never a transcript bubble. -import { act, renderHook } from '@testing-library/react' +import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem, @@ -176,6 +176,7 @@ beforeEach(() => { }) afterEach(() => { + cleanup() setLocalRuntimeCapabilitiesForTests(null) }) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx index 6959798e9b1..1575cf2528a 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx @@ -5,7 +5,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' import CommentMarkdown from './CommentMarkdown' -afterEach(cleanup) +// These fixtures exercise markdown without rendering diagrams. +const boundaryMocks = vi.hoisted(() => ({ + mermaid: vi.fn(() => { + throw new Error('These fixtures must not render Mermaid diagrams') + }) +})) + +vi.mock('./CommentMermaidBlock', () => ({ default: boundaryMocks.mermaid })) + +afterEach(() => { + cleanup() + try { + expect(boundaryMocks.mermaid).not.toHaveBeenCalled() + } finally { + boundaryMocks.mermaid.mockClear() + } +}) describe('CommentMarkdown host-confirmed file cases', () => { it.each([ diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx index 49bf77d7e7c..7b8628730a8 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.github-attachment-image.test.tsx @@ -2,9 +2,18 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import CommentMarkdown from './CommentMarkdown' +// These fixtures exercise prose, links and images without rendering diagrams. +const mocks = vi.hoisted(() => ({ + mermaid: vi.fn(() => { + throw new Error('These fixtures must not render Mermaid diagrams') + }) +})) + +vi.mock('./CommentMermaidBlock', () => ({ default: mocks.mermaid })) + const attachmentUrl = 'https://github.com/user-attachments/assets/ce11040a-fb66-4289-927f-547b16dfc488' @@ -33,6 +42,8 @@ describe('CommentMarkdown GitHub attachment images', () => { document.body.replaceChildren() root = null container = null + expect(mocks.mermaid).not.toHaveBeenCalled() + mocks.mermaid.mockClear() }) it('renders GitHub user attachment document images as openable links', () => { diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.growing.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.growing.test.tsx index a0e7f06ede1..40aa64fb8a3 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.growing.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.growing.test.tsx @@ -4,7 +4,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import CommentMarkdown from './CommentMarkdown' import type { DocumentCodeBlockRenderer } from './comment-markdown-element-renderers' -afterEach(cleanup) +// These fixtures exercise prose, links and images without rendering diagrams. +const mocks = vi.hoisted(() => ({ + mermaid: vi.fn(() => { + throw new Error('These fixtures must not render Mermaid diagrams') + }) +})) + +vi.mock('./CommentMermaidBlock', () => ({ default: mocks.mermaid })) + +afterEach(() => { + cleanup() + expect(mocks.mermaid).not.toHaveBeenCalled() + mocks.mermaid.mockClear() +}) /** The rendered blocks, without the line breaks the renderer puts between them, which lay out as nothing. */ function blocksOf(container: HTMLElement): string[] { diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx index b5349dcff4d..eafa7504a5f 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.link-click.test.tsx @@ -10,6 +10,15 @@ import { import CommentMarkdown from './CommentMarkdown' import type { FileLinkExists } from './comment-markdown-native-chat-file-links' +// These fixtures exercise prose, links and images without rendering diagrams. +const mocks = vi.hoisted(() => ({ + mermaid: vi.fn(() => { + throw new Error('These fixtures must not render Mermaid diagrams') + }) +})) + +vi.mock('./CommentMermaidBlock', () => ({ default: mocks.mermaid })) + const everyPathExists: FileLinkExists = () => true /** The host's files, by the path text a reply names them with. */ @@ -29,6 +38,8 @@ describe('CommentMarkdown link click handler', () => { container?.remove() root = null container = null + expect(mocks.mermaid).not.toHaveBeenCalled() + mocks.mermaid.mockClear() }) it('lets callers intercept rendered document links', () => { diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.word-fade.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.word-fade.test.tsx index 7ab34b0cf9a..403eb141fad 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.word-fade.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.word-fade.test.tsx @@ -3,9 +3,20 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import CommentMarkdown from './CommentMarkdown' +// These fixtures exercise prose, links and images without rendering diagrams. +const mocks = vi.hoisted(() => ({ + mermaid: vi.fn(() => { + throw new Error('These fixtures must not render Mermaid diagrams') + }) +})) + +vi.mock('./CommentMermaidBlock', () => ({ default: mocks.mermaid })) + afterEach(() => { cleanup() vi.useRealTimers() + expect(mocks.mermaid).not.toHaveBeenCalled() + mocks.mermaid.mockClear() }) it('removes settled word spans while keeping new words animated', () => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts index 6c12277f2c0..705d5ebb96e 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-connect-failure-recovery.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks @@ -8,6 +8,19 @@ import { REMOTE_RUNTIME_RECOVERY_ATTEMPT_BUDGET_MS } from './remote-runtime-pty-recovery-state' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace-retry.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace-retry.test.ts index 30079407f97..7080376ad31 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace-retry.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-disconnected-input-grace-retry.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -11,6 +11,19 @@ import { import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' import { REMOTE_RUNTIME_DISCONNECTED_INPUT_GRACE_MS } from './remote-runtime-disconnected-input-grace' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts index 6fcb40f606e..30aab7651aa 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -12,6 +12,19 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts index ae75eae585b..5982b63a897 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, encodeTerminalStreamFrame @@ -8,6 +8,19 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts index c5c85139b46..0106abac2ec 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-end-verdict.test.ts @@ -1,9 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts index b6f3b9802e7..cd30136edc7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts @@ -1,9 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts index 2d93fda7b21..719aebfe795 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-absence-evidence.test.ts @@ -1,9 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts index ad9ef82a06d..f70d0456897 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -9,6 +9,19 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts index b77f0b26e82..8a98e2144bc 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -15,6 +15,19 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts index b567c0f16dc..7b9de377178 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame @@ -8,6 +8,19 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-replay.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-replay.test.ts index 20880c0d9f8..6c73c6ea617 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-replay.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -21,6 +21,19 @@ import { REMOTE_RUNTIME_INPUT_RESEND_BACKOFF_MS } from './remote-runtime-input-journal' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts index 64f954ff9dc..e2aade8c57c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-binding-ownership.test.ts @@ -1,10 +1,23 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, readyHostSessionInventoryResponse, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts index ce3f4445f32..7ac66c97737 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pane-handle-resolution.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -9,6 +9,19 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts index 69c8d5c437c..86d640a392a 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-pending-host-surface-attach.test.ts @@ -5,6 +5,19 @@ import { } from './remote-runtime-pty-transport-test-harness' import type { PtyTransportRecoveryState } from './pty-transport-types' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts index 2ee83ace72b..d3bab9009b3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' import { TerminalStreamOpcode, @@ -11,6 +11,19 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts index a0d95593bc2..e27a981e23b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-split-leaf-activation.test.ts @@ -1,9 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts index cec52958852..9034e709ff0 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, readyHostSessionInventoryResponse, @@ -6,6 +6,19 @@ import { } from './remote-runtime-pty-transport-test-harness' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts index ce689695bc0..e832a24d27f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -11,6 +11,19 @@ import { } from './remote-runtime-pty-transport-test-harness' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts index 3f5dd243b44..a0dc82f3b5a 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -11,6 +11,19 @@ import { } from './remote-runtime-pty-transport-test-harness' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unmounted-host-pane.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unmounted-host-pane.test.ts index cdce2d020d8..103ed570027 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unmounted-host-pane.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unmounted-host-pane.test.ts @@ -1,10 +1,23 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unpublished-host-graph.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unpublished-host-graph.test.ts index 608b8d4217f..1905efb1d90 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unpublished-host-graph.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-unpublished-host-graph.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, readyHostSessionInventoryResponse, @@ -6,6 +6,19 @@ import { } from './remote-runtime-pty-transport-test-harness' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts index 784ed9d8768..6c3af570701 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-web-mirror-recovery.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createRemoteRuntimeTransportMocks, type MultiplexSubscriptionCallbacks @@ -8,6 +8,19 @@ import { REMOTE_RUNTIME_RECOVERY_DELAYS_MS } from './remote-runtime-pty-recovery-state' +// These transport fixtures never read abandoned-launch ownership from the app store. +const appStore = vi.hoisted(() => ({ + getState: vi.fn(() => { + throw new Error('These transport fixtures must not read app-store ownership') + }) +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: appStore.getState } })) + +afterEach(() => { + expect(appStore.getState).not.toHaveBeenCalled() +}) + let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' diff --git a/src/renderer/src/lib/user-opened-local-path.test.ts b/src/renderer/src/lib/user-opened-local-path.test.ts index 1a0eca26ceb..c391e2448f2 100644 --- a/src/renderer/src/lib/user-opened-local-path.test.ts +++ b/src/renderer/src/lib/user-opened-local-path.test.ts @@ -1,7 +1,18 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +// These paths use supplied file context without resolving its editor connection. +const boundaryMocks = vi.hoisted(() => ({ + getConnectionIdForFile: vi.fn(() => { + throw new Error('These fixtures must not resolve editor connections') + }) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: boundaryMocks.getConnectionIdForFile +})) + const mocks = vi.hoisted(() => ({ statRuntimePath: vi.fn() })) vi.mock('@/runtime/runtime-file-routing', () => ({ isRemoteRuntimeFileOperation: () => false })) @@ -33,6 +44,14 @@ function answer(containedOk: boolean, userNamedOk: boolean, isDirectory = false) ) } +afterEach(() => { + try { + expect(boundaryMocks.getConnectionIdForFile).not.toHaveBeenCalled() + } finally { + boundaryMocks.getConnectionIdForFile.mockClear() + } +}) + describe('statUserOpenedPath', () => { beforeEach(() => { mocks.statRuntimePath.mockReset() From ab8d33be8e792e46bbb750e0b56eb734e53e8e01 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:24:42 -0700 Subject: [PATCH 10/17] [CI] Reuse automation publication test imports (#28910) --- .../automation-change-publication.test.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/automation-change-publication.test.ts b/src/main/runtime/automation-change-publication.test.ts index a63d56efb9b..60c602a87af 100644 --- a/src/main/runtime/automation-change-publication.test.ts +++ b/src/main/runtime/automation-change-publication.test.ts @@ -9,7 +9,7 @@ import { closeTestStores, createSqliteTestStore } from '../persistence-test-harn * are the single publication site for every transport (local IPC and remote RPC). */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -19,8 +19,16 @@ import type { Repo } from '../../shared/repo-types' import type { SshTarget } from '../../shared/ssh-types' import { getDefaultPersistedState } from '../../shared/constants' import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' +import { Store, initDataPath } from '../persistence' +import { OrcaRuntimeService } from './orca-runtime' +import * as worktreeChangeInvalidators from '../ipc/worktree-change-invalidators' +import { _resetTerminalModelQueryAuthorityForTest } from './terminal-model-query-authority' +import { _resetTerminalViewAttributesForTest } from './terminal-view-attribute-store' const testState = { dir: '' } +let registrationSpy: + | MockInstance + | undefined vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -104,11 +112,8 @@ async function makeRuntime() { }), 'utf-8' ) - vi.resetModules() - const { Store, initDataPath } = await import('../persistence') initDataPath() const store = createSqliteTestStore(Store, { dataFile: join(testState.dir, 'orca-data.json') }) - const { OrcaRuntimeService } = await import('./orca-runtime') const runtime = new OrcaRuntimeService(store as never) const published: AutomationsChangedPayload[] = [] vi.spyOn(runtime, 'notifyAutomationsChanged').mockImplementation( @@ -126,11 +131,28 @@ beforeEach(() => { installFakeAppEnvironment({ getPath: (name) => (name === 'userData' ? testState.dir : tmpdir()) }) + registrationSpy = vi.spyOn(worktreeChangeInvalidators, 'registerWorktreeChangeInvalidator') }) afterEach(async () => { - await closeTestStores() - rmSync(testState.dir, { recursive: true, force: true }) + try { + await closeTestStores() + } finally { + try { + // Runtime callbacks must end with their fixture. + for (const result of registrationSpy?.mock.results ?? []) { + if (result.type === 'return') { + result.value() + } + } + } finally { + registrationSpy?.mockRestore() + registrationSpy = undefined + _resetTerminalModelQueryAuthorityForTest() + _resetTerminalViewAttributesForTest() + rmSync(testState.dir, { recursive: true, force: true }) + } + } }) describe('scoped automationsChanged publication', () => { From 64c63062c445f6f63f76be894be2ad698a517875 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:30:46 -0700 Subject: [PATCH 11/17] Fix WSL upload readiness fixture transport (#28911) --- src/main/wsl/wsl-directory-upload.test.ts | 50 ++++++++++++++++------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/main/wsl/wsl-directory-upload.test.ts b/src/main/wsl/wsl-directory-upload.test.ts index a7240ac3823..5d6c78d6ff5 100644 --- a/src/main/wsl/wsl-directory-upload.test.ts +++ b/src/main/wsl/wsl-directory-upload.test.ts @@ -14,8 +14,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { spawnProcess } from '@orca/process-host' +import type { PipedChildProcess } from '@orca/process-host/process-spec' import { uploadDirectoryIntoWsl, wslGuestSourcePath } from './wsl-directory-upload' -import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { createWslOrcadHostTransport, type WslOrcadHostTransport } from './wsl-orcad-host-transport' function hasSha256sum(): boolean { try { @@ -56,22 +57,39 @@ describe.skipIf(process.platform === 'win32' || !hasSha256sum())('uploadDirector let source: string let dest: string let commands: string[] + const transports: WslOrcadHostTransport[] = [] + const children: PipedChildProcess[] = [] - const transport = createWslOrcadHostTransport({ - target: { kind: 'wsl', id: 'env-test', distro: 'test', label: 'test' }, - spawn: (spec) => { - const args = spec.args ?? [] - const command = args[args.indexOf('--exec') + 3] ?? '' - commands.push(command) - return spawnProcess({ program: 'sh', args: ['-c', command], stdio: 'pipe' }) + const getTransport = (): WslOrcadHostTransport => { + const transport = transports.at(-1) + if (!transport) { + throw new Error('WSL test transport was not initialized') } - }) + return transport + } beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'wsl-upload-')) source = join(root, 'source') dest = join(root, 'dest') commands = [] + const transport = createWslOrcadHostTransport({ + target: { kind: 'wsl', id: 'env-test', distro: 'test', label: 'test' }, + spawn: (spec) => { + const args = spec.args ?? [] + const execIndex = args.indexOf('--exec') + const command = args[execIndex + 3] ?? '' + commands.push(command) + const child = spawnProcess({ + program: 'sh', + args: args.slice(execIndex + 2), + stdio: 'pipe' + }) + children.push(child) + return child + } + }) + transports.push(transport) mkdirSync(join(source, 'bin'), { recursive: true }) mkdirSync(join(source, 'empty'), { recursive: true }) writeFileSync(join(source, 'bin', 'tool'), '#!/bin/sh\necho ran\n') @@ -81,6 +99,8 @@ describe.skipIf(process.platform === 'win32' || !hasSha256sum())('uploadDirector }) afterEach(() => { + transports.splice(0).forEach((transport) => transport.dispose()) + children.splice(0).forEach((child) => child.kill()) rmSync(root, { recursive: true, force: true }) }) @@ -94,19 +114,19 @@ describe.skipIf(process.platform === 'win32' || !hasSha256sum())('uploadDirector } it('streams a tar when the guest cannot see the source', async () => { - await uploadDirectoryIntoWsl(transport, source, null, dest) + await uploadDirectoryIntoWsl(getTransport(), source, null, dest) expectUploaded() expect(commands.some((command) => command.includes('tar -x'))).toBe(true) }) it('copies from the guest-visible path without a tar stream', async () => { - await uploadDirectoryIntoWsl(transport, source, source, dest) + await uploadDirectoryIntoWsl(getTransport(), source, source, dest) expectUploaded() expect(commands.some((command) => command.includes('tar -x'))).toBe(false) }) it('falls back to the tar stream when the mount is missing', async () => { - await uploadDirectoryIntoWsl(transport, source, join(root, 'not-mounted'), dest) + await uploadDirectoryIntoWsl(getTransport(), source, join(root, 'not-mounted'), dest) expectUploaded() expect(commands.some((command) => command.includes('tar -x'))).toBe(true) }) @@ -115,7 +135,7 @@ describe.skipIf(process.platform === 'win32' || !hasSha256sum())('uploadDirector mkdirSync(dest, { recursive: true }) writeFileSync(join(dest, 'keep.txt'), 'kept') writeFileSync(join(dest, 'large.bin'), 'stale') - await uploadDirectoryIntoWsl(transport, source, null, dest) + await uploadDirectoryIntoWsl(getTransport(), source, null, dest) expectUploaded() expect(readFileSync(join(dest, 'keep.txt'), 'utf8')).toBe('kept') }) @@ -124,7 +144,7 @@ describe.skipIf(process.platform === 'win32' || !hasSha256sum())('uploadDirector const other = join(root, 'other') execFileSync('cp', ['-R', source, other]) writeFileSync(join(other, 'large.bin'), 'tampered') - await expect(uploadDirectoryIntoWsl(transport, source, other, dest)).rejects.toThrow( + await expect(uploadDirectoryIntoWsl(getTransport(), source, other, dest)).rejects.toThrow( /digest check/ ) expect(existsSync(join(dest, 'large.bin'))).toBe(false) @@ -132,7 +152,7 @@ describe.skipIf(process.platform === 'win32' || !hasSha256sum())('uploadDirector }) it('writes a file atomically through stdin', async () => { - await transport.writeFile(join(dest, 'nested', 'record.json'), '{"ok":true}') + await getTransport().writeFile(join(dest, 'nested', 'record.json'), '{"ok":true}') expect(readFileSync(join(dest, 'nested', 'record.json'), 'utf8')).toBe('{"ok":true}') }) }) From eeb4311b814cc24b95267a238fb6096a1fad1f8e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:40:32 -0700 Subject: [PATCH 12/17] test: narrow editor model lifetime fixture (#28916) --- .../editor/editor-model-lifetime-fixture.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/editor/editor-model-lifetime-fixture.ts b/src/renderer/src/components/editor/editor-model-lifetime-fixture.ts index 5ec2466f452..a6821baf128 100644 --- a/src/renderer/src/components/editor/editor-model-lifetime-fixture.ts +++ b/src/renderer/src/components/editor/editor-model-lifetime-fixture.ts @@ -1,7 +1,8 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' import { vi } from 'vitest' import { createEditorModelRegistry } from '@/lib/editor-model-registry' -import { createTestStore, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { createEditorTabsStore } from '@/store/slices/editor-slice-test-harness' +import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' import { createStoreSessionMockApi } from '@/store/slices/store-session-test-harness' import type { OpenFile } from '@/store/slices/editor' import { attachClosedEditorTabCleanup } from './closed-editor-tab-controller' @@ -22,8 +23,16 @@ export function createModelLifetimeFixture(register = true) { const api = createStoreSessionMockApi() Reflect.set(globalThis, 'window', domWindow) Reflect.set(domWindow, 'api', api) - const store = createTestStore() + const store = createEditorTabsStore() store.setState({ + settings: null, + detectedWorktreesByRepo: {}, + activeWorkspaceExecutionHostId: null, + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: false, + removedRuntimeEnvironmentIds: new Set(), + recentlyClosedTabKindsByWorktree: {}, repos: [{ ...TEST_REPO, id: 'model-fixture', path: '/fixture/workspace' }], worktreesByRepo: { 'model-fixture': [ From 8df0f67c8282bfa6dde74167d255e5c235eb03a5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:48:13 -0700 Subject: [PATCH 13/17] ci: skip redundant PR platform warmers (#28917) --- .../ci-cache-warmup-platform-jobs.yml | 2 + .../workflows/ci-cache-warmup-platforms.yml | 1 - .github/workflows/ci-cache-warmup.yml | 2 - .../ci-dependency-download-cache.test.mjs | 111 ++++++++++++++++++ .../linux-package-fixture-cache.test.mjs | 44 +++++++ 5 files changed, 157 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-cache-warmup-platform-jobs.yml b/.github/workflows/ci-cache-warmup-platform-jobs.yml index 07fb93918b8..f623fb74a1c 100644 --- a/.github/workflows/ci-cache-warmup-platform-jobs.yml +++ b/.github/workflows/ci-cache-warmup-platform-jobs.yml @@ -8,6 +8,7 @@ permissions: jobs: warm-linux-arm: + if: github.event_name != 'pull_request' concurrency: group: ci-cache-warmup-linux-arm-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name != 'schedule' }} @@ -85,6 +86,7 @@ jobs: arches: arm64 warm-linux-package-fixtures: + if: github.event_name != 'pull_request' concurrency: group: ci-cache-warmup-linux-package-fixtures-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name != 'schedule' }} diff --git a/.github/workflows/ci-cache-warmup-platforms.yml b/.github/workflows/ci-cache-warmup-platforms.yml index e8f37a09c78..857fbfaa21e 100644 --- a/.github/workflows/ci-cache-warmup-platforms.yml +++ b/.github/workflows/ci-cache-warmup-platforms.yml @@ -45,7 +45,6 @@ on: - 'resources/build/icon.ico' pull_request: paths: - - '.github/workflows/ci-cache-warmup.yml' - '.github/workflows/ci-cache-warmup-platforms.yml' - '.github/workflows/ci-cache-warmup-platform-jobs.yml' diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 8487d95ef34..882dfac0bab 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -50,8 +50,6 @@ on: pull_request: paths: - '.github/workflows/ci-cache-warmup.yml' - - '.github/workflows/ci-cache-warmup-platform-jobs.yml' - - '.github/workflows/ci-cache-warmup-platforms.yml' - '.github/actions/prepare-headless-compiler/**' - 'config/scripts/headless-detector-compiler-cache.mjs' - '.github/actions/prepare-wayland-clipboard/**' diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index 8db466b4637..0bcfefa5edb 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs' import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { classifyPrJobs } from './pr-code-change-scope.mjs' const read = (path) => parse(readFileSync(path, 'utf8')) const workflow = (name) => read(`.github/workflows/${name}.yml`) @@ -300,6 +301,116 @@ describe('CI dependency download caches', () => { } }) + it('keeps PR warmer validation within the workflow family that runs it', () => { + const shared = workflow('ci-cache-warmup') + const platforms = workflow('ci-cache-warmup-platforms') + expect(shared.on.pull_request.paths).toEqual([ + '.github/workflows/ci-cache-warmup.yml', + '.github/actions/prepare-headless-compiler/**', + 'config/scripts/headless-detector-compiler-cache.mjs', + '.github/actions/prepare-wayland-clipboard/**' + ]) + expect(platforms.on.pull_request.paths).toEqual([ + '.github/workflows/ci-cache-warmup-platforms.yml', + '.github/workflows/ci-cache-warmup-platform-jobs.yml' + ]) + expect(shared.jobs['warm-platforms'].uses).toBe(platforms.jobs['warm-platforms'].uses) + expect(shared.on.schedule).toEqual([{ cron: '41 */6 * * *' }]) + expect(shared.on).toHaveProperty('workflow_dispatch') + for (const producer of [shared, platforms]) { + expect(producer.on.push.branches).toEqual(['main']) + expect(producer.on.push.paths).toEqual( + expect.arrayContaining([ + '.github/workflows/ci-cache-warmup.yml', + '.github/workflows/ci-cache-warmup-platforms.yml', + '.github/workflows/ci-cache-warmup-platform-jobs.yml', + '.github/actions/install-node-dependencies/**', + '.github/actions/prepare-native-runtime/**', + 'pnpm-lock.yaml' + ]) + ) + } + }) + + it.each([ + ['pull_request', false, false], + ['push', true, false], + ['schedule', true, true], + ['workflow_dispatch', true, true] + ])('retains cache producers and Windows validation on %s', (event, armRuns, sharedPlatforms) => { + const shared = workflow('ci-cache-warmup') + const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses) + const context = { github: { event_name: event } } + expect(runInNewContext(platforms.jobs['warm-linux-arm'].if, context)).toBe(armRuns) + expect(runInNewContext(shared.jobs['warm-platforms'].if, context)).toBe(sharedPlatforms) + expect(shared.jobs.warm.if).toBeUndefined() + expect(platforms.jobs['warm-windows'].if).toBeUndefined() + expect(platforms.jobs['warm-windows'].strategy.matrix.os).toEqual([ + 'windows-2022', + 'windows-11-arm' + ]) + }) + + it('validates the omitted ARM warmer preparation in the real PR consumers', () => { + const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses) + const warm = platforms.jobs['warm-linux-arm'] + const pr = workflow('pr') + const units = workflow('unit-tests').jobs.test + const installer = './.github/actions/install-node-dependencies' + const warmInstall = warm.steps.find((step) => step.uses === installer) + const unitInstall = units.steps.find((step) => step.uses === installer) + expect(warm['runs-on']).toBe('ubuntu-24.04-arm') + expect(pr.jobs.preflight['runs-on']).toBe(warm['runs-on']) + expect(pr.jobs.test.with.runner).toBe(warm['runs-on']) + expect(units['runs-on']).toBe('${{ inputs.runner }}') + expect(JSON.parse(pr.jobs.test.with.node_versions)).toEqual(['24']) + expect(warmInstall.if).toBeUndefined() + expect(unitInstall.if).toBeUndefined() + expect(warmInstall.with['native-runtime']).toBe('node') + expect(unitInstall.with['native-runtime']).toBe(warmInstall.with['native-runtime']) + expect(warmInstall.with['node-version']).toBe('24') + expect(unitInstall.with['node-version']).toBe('${{ matrix.node }}') + expect(unitInstall.with['cache-electron-package']).toBe( + warmInstall.with['cache-electron-package'] + ) + const preflightInstalls = pr.jobs.preflight.steps.filter((step) => step.uses === installer) + expect(preflightInstalls.map((step) => step.if)).toEqual([ + "needs.code_paths.outputs.mobile_dependencies != 'true'", + "needs.code_paths.outputs.mobile_dependencies == 'true'" + ]) + for (const step of preflightInstalls) { + expect(step.with['native-runtime']).toBe('node') + expect(step.with['node-version']).toBe('24') + } + const populate = 'node config/scripts/install-electron-package-binary.mjs' + const unitPopulate = units.steps.find((step) => step.run === populate) + expect(unitPopulate.if).toBeUndefined() + expect(warm.steps.map((step) => step.uses ?? step.run)).toEqual([ + 'actions/checkout@v6', + installer, + populate, + 'node config/scripts/ensure-native-runtime.mjs --check-only' + ]) + const native = read('.github/actions/prepare-native-runtime/action.yml') + const identity = native.runs.steps.find((step) => step.id === 'native-cache-scope') + const inputs = [...identity.env.NATIVE_SOURCE_HASH.matchAll(/'([^']+)'/g)].map( + (match) => match[1] + ) + for (const path of [ + ...inputs, + '.github/actions/install-node-dependencies/action.yml', + ...workflow('ci-cache-warmup-platforms').on.pull_request.paths, + '.github/workflows/ci-cache-warmup.yml' + ]) { + expect(classifyPrJobs([path], { allowMobileCiTools: true }), path).toMatchObject({ + static_analysis: true, + typecheck: true, + test: true, + mobile_ci_tools: false + }) + } + }) + it('restores Windows packaging downloads from the release cache without a PR upload', () => { const packaging = workflow('pr').jobs.package_windows const restore = packaging.steps.find((step) => step.name === 'Cache electron-builder downloads') diff --git a/config/scripts/linux-package-fixture-cache.test.mjs b/config/scripts/linux-package-fixture-cache.test.mjs index 6c677f0abc8..81465a38a29 100644 --- a/config/scripts/linux-package-fixture-cache.test.mjs +++ b/config/scripts/linux-package-fixture-cache.test.mjs @@ -1,9 +1,11 @@ import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' +import { runInNewContext } from 'node:vm' import { delimiter, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' import { runProcessSync } from '@orca/process-host' +import { classifyPrJobs } from './pr-code-change-scope.mjs' const action = parse( readFileSync('.github/actions/prepare-linux-package-fixture/action.yml', 'utf8') @@ -132,3 +134,45 @@ it('uses exact context/platform keys and the same restore-only action in package .every((step) => step.with['save-cache'] === "${{ github.event_name != 'pull_request' }}") ).toBe(true) }) + +it.each([ + ['pull_request', false], + ['push', true], + ['schedule', true], + ['workflow_dispatch', true] +])('keeps fixture seeding available without a redundant PR runner on %s', (event, runs) => { + const source = workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses + const warm = parse(readFileSync(source, 'utf8')).jobs['warm-linux-package-fixtures'] + const context = { github: { event_name: event } } + expect(warm['runs-on']).toBe('ubuntu-latest') + expect(warm.steps.map((step) => step.uses)).toEqual([ + 'actions/checkout@v6', + './.github/actions/prepare-linux-package-fixture', + './.github/actions/prepare-linux-package-fixture', + './.github/actions/prepare-linux-package-fixture' + ]) + expect(runInNewContext(warm.if, context)).toBe(runs) + for (const step of warm.steps.slice(1)) { + expect(runInNewContext(step.with['save-cache'].slice(3, -2), context)).toBe(runs) + } + const consumers = workflow('pr').jobs.package.steps.filter((step) => + step.uses?.endsWith('/prepare-linux-package-fixture') + ) + expect(consumers.every((step) => step.with['save-cache'] === undefined)).toBe(true) + expect(consumers.map((step) => step.with.fixture)).toEqual( + warm.steps.slice(1).map((step) => step.with.fixture) + ) +}) + +it.each([ + '.github/actions/prepare-linux-package-fixture/action.yml', + 'config/docker/daemon-shutdown-descendants/Dockerfile', + 'config/docker/headless-serve-shutdown/Dockerfile', + 'config/docker/cli-launch-contract/Dockerfile' +])('validates a changed fixture in Linux packaging: %s', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ + test: true, + package: true, + package_windows: false + }) +}) From d3fc80292be694f454717e18f82fdc19dbe353de Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:53:16 -0700 Subject: [PATCH 14/17] test: isolate inactive browser pane leaves (#28918) --- .../browser-workspace-pane.adoption-chrome.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx index b1c4a68c3f5..627385bc2cb 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx @@ -14,6 +14,18 @@ const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(async () => ({ url: 'about:blank', title: 'New Tab' })) })) +vi.mock('./browser-page-pane', () => ({ + BrowserPagePane: () => { + throw new Error('Adoption chrome tests must not render the local browser pane') + } +})) + +vi.mock('../workspace-doc/workspace-doc-page-pane', () => ({ + WorkspaceDocPagePane: () => { + throw new Error('Adoption chrome tests must not render a workspace document pane') + } +})) + vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn(), message: vi.fn() } })) From 7cf09809f684e25065a2e068ca80f99d37724f7c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:04:41 -0400 Subject: [PATCH 15/17] fix(mobile): group by Project with the desktop sidebar's own code (#28850) * fix(mobile): phone server rows follow the desktop's other-client and sleeping filters A phone created through a desktop now records whom it acted for, so the desktop's Hide other-client workspaces filter keeps the user's own phone workspaces (locally and on servers) and hides only other people's. The desktop relay marks each server row its sidebar would hide, and the rows its own browser or chat tabs keep awake; the phone applies both. * test(e2e): dedupe sidebar row ids in the visibility spec * fix(mobile): address review: owner naming, old-server relayed phones, scope trim * refactor: move the sidebar's project section grouping into shared The project sections the desktop sidebar draws under Group by Project are built from the project/setup projection, the per-host catalog fold, the endpoint host stamp, and the section labelling and ordering rules. Move the pure parts of each into src/shared (no behaviour change) so the paired phone can call the same code instead of guessing the grouping. * fix(mobile): group by Project with the desktop sidebar's own sections The phone grouped Project sections by repo display name, so it merged checkouts the desktop keeps apart (several local stablyai/orca checkouts, or a server's copy) and split what the desktop merges. The phone now reads each host's repo.list, project.list and projectHostSetup.list (the desktop's own and each server's through the relay), folds them with the renderer's shared catalog fold and projection merge, and builds sections with the desktop's shared grouping, labelling and ordering code. Keys, labels and Recent order equal the sidebar's; a host that predates the project lists gets the repo-derived projection, as the desktop does. Header colour and icon come from the section's anchor repo and rows from their own repo by id, as on the desktop. The repo filter lists every catalog repo by id per host instead of merging same-named repos. Group-by labels read Project and None. project.list and projectHostSetup.list are routed for paired phones (read-only, workspace permission). * refactor(mobile): record a phone's owner as creator and fold desktop tab activity into the host flag A phone now records its owner as the creator (this host for a directly paired phone, the relaying desktop for a delegated one), so the shared rule is unchanged and onBehalfOf is gone. The relay ORs the desktop's saved browser/chat tabs into hasHostSidebarActivity, so the phone's status dot follows too, and local worktree.ps counts the same signals. The old-server fallback is removed. * test(e2e): poll the phone and the sidebar together in the visibility spec * fix(mobile): one Project-section builder for desktop and phone, with the desktop's repo order Review fixes. The sidebar's buildOrderedGroups now calls the shared buildProjectSections for Group by Project (notice and pending repos are optional inputs), so desktop and phone run one function. The phone ranks manual header order by its folded catalog order, which is each host's repo.list order with the desktop's own repos first, as the renderer loads them; only the desktop's cross-host manualRepoOrder (pairing-local) is unknown to it. The projection selector is now a memo over the shared resolver. A transient project-list failure keeps that host's last catalog; only forbidden or method_not_found falls back to the repo-derived projection. Catalog rows are checked on every member grouping reads, and the parity test answers the renderer's real catalog fetch, includes a server's own project rows, and fixes Manual and Recent orders that differ. * fix(mobile): use the repo-derived catalog until a host's project lists first answer * fix(mobile): the desktop serves its sidebar's Project catalog instead of the phone fetching it The phone fetched repo.list, project.list and projectHostSetup.list from the desktop and every relay-ready server each minute and folded them itself, so rows from offline or older servers fell back to repo sections and keys moved on cold start. The desktop renderer now publishes the inputs its own Project headers use (state.repos in sidebar order, the projection selector's projects and setups, projectOrderBy) on the runtime window graph it already sends to main, only when they change. Main keeps the last one and answers it on a new paired-desktop read, mobileRelay.projectSections. The phone reads it with its repo metadata and groups with the shared builder; a desktop that predates the read (forbidden or method_not_found) leaves the phone grouping rows by repo from the desktop's repo.list, and a transient failure keeps the last catalog. The payload carries only grouping and label fields, no hooks, credentials or uploaded icons. The phone's own catalog fetching and its project.list/projectHostSetup.list routes are removed. * fix(mobile): keep uploaded repo icons on the phone, and isolate the snapshot tests * test(e2e): the desktop window serves its sidebar's Project catalog to the phone * chore(i18n): regenerate the runtime English catalog (drift already on main) * fix(mobile): read the Project catalog as its own request, outside the recorded repo-metadata flow The extra read inside the repo-metadata refresh stalled its recorded RPC sequence. It now runs beside it with its own throttle. The repo-metadata goldens are re-recorded for the intended state change: the name-keyed colour, icon and id maps became desktopRepos. * fix(mobile): fetch the Project catalog at once when the screen moves to another host The refresh window is now kept per client, so a host screen reused for another desktop doesn't wait out the previous host's 60s window before showing its Project headers. --- config/scripts/ci-shard-timings.json | 2 +- ...ettings.repo-metadata-host.platform-1.json | 976 ++++++++-------- ...ix-settings.repo-metadata-repo.list-1.json | 459 ++++---- ...settings.repo-metadata-settings.get-1.json | 1021 ++++++++--------- ...po-metadata-ssh.listtargetsummaries-1.json | 952 ++++++++------- ...ules-settings-repo-metadata-fulfilled.json | 910 ++++++++------- .../goldens/settings-repo-cache-expiry.json | 445 ++++--- .../settings-repo-metadata-fulfilled.json | 357 +++--- .../goldens/settings-repo-metadata-icons.json | 502 ++++---- ...tings-repo-metadata-refuse-after-data.json | 668 ++++++----- .../settings-repo-metadata-refused.json | 413 +++---- .../settings-repo-metadata-single-host.json | 348 +++--- ...ettings-repo-metadata-transport-error.json | 419 +++---- mobile/src/host-screen/host-repo-looks.ts | 18 + .../src/host-screen/host-screen-overlays.tsx | 2 +- .../host-screen/host-screen-reply-schema.ts | 29 +- .../host-show-pinned-in-groups.test.ts | 4 +- .../host-workspace-list-action-error.test.tsx | 3 +- .../host-workspace-list-row-memo.test.tsx | 5 +- .../src/host-screen/host-workspace-list.tsx | 18 +- .../use-host-project-sections.test.tsx | 51 + .../host-screen/use-host-project-sections.ts | 56 + .../src/host-screen/use-host-repo-metadata.ts | 38 +- .../host-screen/use-host-screen-controller.ts | 29 +- .../host-screen/use-host-screen-identity.ts | 8 +- .../src/host-screen/use-host-screen-state.ts | 23 +- .../worktree/project-section-catalog.test.ts | 37 + .../src/worktree/project-section-catalog.ts | 38 + .../worktree/project-sections-operations.ts | 104 ++ mobile/src/worktree/use-workspace-sections.ts | 51 +- .../worktree/workspace-filter-repos.test.ts | 66 ++ mobile/src/worktree/workspace-filter-repos.ts | 39 + .../worktree/workspace-list-picker-options.ts | 4 +- .../workspace-list-pinned-lineage.test.ts | 6 +- ...space-list-sections-desktop-parity.test.ts | 147 +++ .../worktree/workspace-list-sections.test.ts | 41 +- .../src/worktree/workspace-list-sections.ts | 111 +- mobile/src/worktree/workspace-list-types.ts | 14 +- .../worktree-list-completeness.test.ts | 44 +- src/main/ipc/runtime.test.ts | 42 + src/main/ipc/runtime.ts | 8 +- .../mobile-project-sections-snapshot.ts | 16 + src/main/runtime/rpc/methods/index.ts | 2 + .../methods/mobile-project-sections.test.ts | 47 + .../rpc/methods/mobile-project-sections.ts | 12 + ...ntime-rpc-mobile-paired-desktop-methods.ts | 2 + .../src/app-shell/use-runtime-graph-sync.ts | 16 + .../cmd-j/palette-filter-options.ts | 9 +- .../cmd-j/palette-project-results.ts | 4 +- .../components/repo/NestedRepoChecklist.tsx | 5 +- .../settings/settings-project-list.ts | 2 +- .../settings-project-regrouping.test.ts | 2 +- .../sidebar/project-header-drag-commit.ts | 2 +- .../sidebar/project-header-drop.test.ts | 2 +- .../components/sidebar/project-header-drop.ts | 14 - .../rendered-sidebar-worktree-order.ts | 2 +- ...worktree-list-groups-mobile-parity.test.ts | 195 ++++ ...ree-list-groups-notice-host-labels.test.ts | 2 +- .../worktree-list/grouping/build-rows.ts | 7 +- .../worktree-list/grouping/group-sections.ts | 3 +- .../worktree-list/grouping/host-labels.ts | 6 +- .../grouping/project-group-sections.ts | 4 +- .../grouping/project-grouping.ts | 177 --- .../recent-project-rank-scan-budget.test.ts | 4 +- .../worktree-list/grouping/section-order.ts | 160 +-- .../grouping/worktree-group-entry.ts | 11 + .../grouping/worktree-group-keys.ts | 7 +- .../grouping/worktree-grouping.ts | 167 +-- .../listing/use-collapsed-groups.ts | 2 +- .../worktree-list/listing/use-section-rows.ts | 4 +- .../navigation/pending-reveal-inputs.ts | 2 +- .../navigation/reveal-ancestors.ts | 2 +- .../navigation/worktree-reveal-group-keys.ts | 2 +- .../worktree-list/viewport/viewport-props.ts | 2 +- .../settings-navigation-build-options.ts | 2 +- .../hooks/useSettingsNavigationMetadata.ts | 2 +- ...ync-runtime-graph-project-sections.test.ts | 110 ++ .../sync-runtime-graph/graph-publication.ts | 7 + .../runtime/sync-runtime-graph/graph-state.ts | 9 +- .../project-section-catalog.ts | 44 + src/renderer/src/store/adopt-from-endpoint.ts | 43 +- .../store/github/work-item-fetch-actions.ts | 2 +- ...oject-host-setup-selector-normalization.ts | 51 - .../src/store/project-host-setup-selector.ts | 247 +--- .../store/projects/project-host-routing.ts | 33 +- .../src/store/projects/project-update.ts | 2 +- .../repos/all-host-repo-catalog-actions.ts | 11 +- .../src/store/repos/repo-catalog-actions.ts | 17 +- .../src/store/repos/repo-catalog-identity.ts | 4 +- .../src/store/repos/repo-catalog-merge.ts | 12 - .../repos/runtime-repo-catalog-actions.ts | 11 +- .../store/slices/repo-identity-reconcile.ts | 27 +- .../src/store/slices/runtime-status.ts | 2 +- src/shared/catalog-row-reconcile.ts | 27 + src/shared/endpoint-catalog-adoption.ts | 68 ++ .../mobile-project-sections-contract.ts | 64 ++ .../project-compatibility-host-merge.ts | 41 +- .../project-compatibility-merge.ts} | 27 +- src/shared/project-host-setup-hydration.ts | 120 ++ src/shared/project-section-grouping.ts | 282 +++++ src/shared/project-section-order.ts | 201 ++++ .../project-section-parity.test-fixture.ts | 248 ++++ .../repo-display-labels.test.ts | 0 .../src/lib => shared}/repo-display-labels.ts | 2 +- .../rpc-params-catalog.generated.ts | 1 + src/shared/runtime-session-contracts.ts | 3 + ...esktop-mirror-workspace-visibility.spec.ts | 19 + 107 files changed, 6293 insertions(+), 4848 deletions(-) create mode 100644 mobile/src/host-screen/host-repo-looks.ts create mode 100644 mobile/src/host-screen/use-host-project-sections.test.tsx create mode 100644 mobile/src/host-screen/use-host-project-sections.ts create mode 100644 mobile/src/worktree/project-section-catalog.test.ts create mode 100644 mobile/src/worktree/project-section-catalog.ts create mode 100644 mobile/src/worktree/project-sections-operations.ts create mode 100644 mobile/src/worktree/workspace-filter-repos.test.ts create mode 100644 mobile/src/worktree/workspace-filter-repos.ts create mode 100644 mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts create mode 100644 src/main/runtime/mobile-project-sections-snapshot.ts create mode 100644 src/main/runtime/rpc/methods/mobile-project-sections.test.ts create mode 100644 src/main/runtime/rpc/methods/mobile-project-sections.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts delete mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-entry.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts delete mode 100644 src/renderer/src/store/project-host-setup-selector-normalization.ts create mode 100644 src/shared/catalog-row-reconcile.ts create mode 100644 src/shared/endpoint-catalog-adoption.ts create mode 100644 src/shared/mobile-project-sections-contract.ts rename src/{renderer/src/store/projects => shared}/project-compatibility-host-merge.ts (87%) rename src/{renderer/src/store/projects/project-compatibility-core.ts => shared/project-compatibility-merge.ts} (92%) create mode 100644 src/shared/project-host-setup-hydration.ts create mode 100644 src/shared/project-section-grouping.ts create mode 100644 src/shared/project-section-order.ts create mode 100644 src/shared/project-section-parity.test-fixture.ts rename src/{renderer/src/lib => shared}/repo-display-labels.test.ts (100%) rename src/{renderer/src/lib => shared}/repo-display-labels.ts (96%) diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 19d51b803bb..bfeb2370e20 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -10074,7 +10074,7 @@ "src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 137, "src/renderer/src/lib/renderer-app-platform.test.ts": 177, "src/renderer/src/lib/renderer-memory-profile.test.ts": 64, - "src/renderer/src/lib/repo-display-labels.test.ts": 50, + "src/shared/repo-display-labels.test.ts": 50, "src/renderer/src/lib/repo-runtime-owner.test.ts": 61, "src/renderer/src/lib/repo-search.test.ts": 43, "src/renderer/src/lib/repo-slug-cache.test.ts": 37, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json index 27eda6c704c..d5e14ee56df 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json @@ -4,9 +4,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { + "08c11e02c691": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -33,14 +33,15 @@ "id": "frame-4", "ok": true, "result": { - "platform": "linux" + "error": "inner refused", + "ok": false } } } }, - "0c4296f215a4": { + "18762ff064ed": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -78,9 +79,92 @@ } } }, - "0d7d9425dc1a": { + "19b2979d7fc2": { + "name": "repo.list#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, + "1e027d0127f3": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "1f909a505b54": { + "name": "hostPlatform", + "ordinal": 13, + "value": { + "$rpc": "null" + } + }, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } + } + }, + "371d2b84193c": { + "name": "host.platform#1", + "ordinal": 7, "args": [ { "name": "method", @@ -104,18 +188,14 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "method_not_found", - "message": "Unknown method" - }, "id": "frame-4", - "ok": false + "ok": true } } }, - "0f2a66846ba6": { + "3d730d2842bc": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -147,79 +227,9 @@ } } }, - "19b2979d7fc2": { - "name": "repo.list#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "1a160fc2d137": { + "3e996af9e799": { "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "26d576339b0b": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "28cff8ea82f0": { - "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -245,16 +255,16 @@ "value": { "error": { "code": "refused", - "message": "outer refused" + "message": "" }, "id": "frame-4", "ok": false } } }, - "2c656b54bd5a": { + "443ebd7b9f5e": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -278,30 +288,15 @@ "startedAt": 0, "settledAt": 0, "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, "id": "frame-4", - "ok": true, - "result": { - "error": { - "message": "inner refused" - }, - "ok": false - } + "ok": false } } }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -349,78 +344,9 @@ } } }, - "50027661b715": { + "5248b37ec085": { "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" - }, - "53dea128b181": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } - } - }, - "5413fbee66a1": { - "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -447,40 +373,188 @@ "id": "frame-4", "ok": true, "result": { - "error": "inner refused", + "platform": "linux" + } + } + } + }, + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] + }, + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "8b618db22116": { + "name": "settings.get#1", + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "91e7f4ef9a77": { + "name": "hostPlatform", + "ordinal": 13, + "value": "linux" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "c294eaf242bb": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": { + "message": "inner refused" + }, "ok": false } } } }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] - }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "77aba92ac03c": { + "c2d611080e31": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -510,142 +584,9 @@ } } }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ] - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "96e0c54d86b0": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "9b82184a62fd": { + "c2d95318e127": { "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true - } - } - }, - "9d20695fdb34": { - "name": "hostPlatform", - "ordinal": 15, - "value": { - "$rpc": "null" - } - }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "bea13a4a2e87": { - "name": "hostLabelById", - "ordinal": 14, - "value": [["ssh:ssh-1", "SSH"]] - }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } - }, - "d77076db12f0": { - "name": "host.platform#1", - "ordinal": 9, "args": [ { "name": "method", @@ -671,31 +612,120 @@ "value": { "error": { "code": "refused", - "message": "" + "message": "outer refused" }, "id": "frame-4", "ok": false } } }, - "e841f87c65aa": { + "cc76dd9f9341": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "d88cba4e044f": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], "hostHealthById": [], "hostLabelById": [["ssh:ssh-1", "SSH"]], "hostPlatform": { "$rpc": "null" }, - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] + ] + }, + "d9c464d0dc78": { + "name": "hostLabelById", + "ordinal": 12, + "value": [["ssh:ssh-1", "SSH"]] + }, + "df14e89c3d1c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] ] }, "eb79a9b3682a": { @@ -705,24 +735,6 @@ "value": { "$rpc": "undefined" } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] } }, "recording": { @@ -731,265 +743,241 @@ { "id": "settings-repo-metadata-fulfilled.prelude:settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.prelude:cleanup", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "53dea128b181"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "1e027d0127f3"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "6134b73f18d0", + "state": "68916f125c3c", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.normal:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.result-absent:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "9b82184a62fd"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "371d2b84193c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.result-null:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "0f2a66846ba6"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "3d730d2842bc"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-ok-missing:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "26d576339b0b"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "cc76dd9f9341"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-string-error:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "5413fbee66a1"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "08c11e02c691"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-object-error:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "2c656b54bd5a"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "c294eaf242bb"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "28cff8ea82f0"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "c2d95318e127"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused-no-message:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "d77076db12f0"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "3e996af9e799"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.method-not-found:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "0d7d9425dc1a"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "443ebd7b9f5e"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "77aba92ac03c"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "c2d611080e31"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection-no-message:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "50027661b715"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "df14e89c3d1c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } } diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json index bd38ed25958..14c5c95ee04 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json @@ -4,80 +4,6 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "linux" - } - } - } - }, - "0c4296f215a4": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "settings": { - "defaultTuiAgent": "codex", - "disabledTuiAgents": ["claude"], - "hostSettingOverrides": {}, - "prBotAuthorOverrides": ["bot-user"], - "visibleTaskProviders": ["github", "linear"] - } - } - } - } - }, "12e70439f294": { "name": "repo.list#1", "ordinal": 1, @@ -145,18 +71,13 @@ } } }, - "19b2979d7fc2": { - "name": "repo.list#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, + "18762ff064ed": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", - "value": "host.platform" + "value": "settings.get" }, { "name": "params", @@ -172,10 +93,29 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-3", + "ok": true, + "result": { + "settings": { + "defaultTuiAgent": "codex", + "disabledTuiAgents": ["claude"], + "hostSettingOverrides": {}, + "prBotAuthorOverrides": ["bot-user"], + "visibleTaskProviders": ["github", "linear"] + } + } + } } }, + "19b2979d7fc2": { + "name": "repo.list#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, "1b23dab83fcf": { "name": "repo.list#1", "ordinal": 1, @@ -210,23 +150,44 @@ } } }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } + } }, "44136fa355b3": {}, "4526bff18b74": { @@ -310,30 +271,39 @@ } } }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" - }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] - }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + "5248b37ec085": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "linux" + } + } + } }, "624ec4e5082c": { "name": "repo.list#1", @@ -370,10 +340,66 @@ } } }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] + }, + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] }, "747c556da67a": { "name": "repo.list#1", @@ -406,13 +432,31 @@ } } }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } }, "8a72d3d14d44": { "name": "repo.list#1", @@ -449,21 +493,14 @@ } } }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ] - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "96e0c54d86b0": { + "8b618db22116": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -487,54 +524,24 @@ "startedAt": 0 } }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "91e7f4ef9a77": { + "name": "hostPlatform", + "ordinal": 13, + "value": "linux" }, - "bea13a4a2e87": { - "name": "hostLabelById", - "ordinal": 14, - "value": [["ssh:ssh-1", "SSH"]] + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 }, - "c752e67787f9": { + "c1246f5792ee": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" }, "d68475063b62": { "name": "repo.list#1", @@ -573,6 +580,11 @@ } } }, + "d9c464d0dc78": { + "name": "hostLabelById", + "ordinal": 12, + "value": [["ssh:ssh-1", "SSH"]] + }, "dae756300589": { "name": "repo.list#1", "ordinal": 1, @@ -605,6 +617,19 @@ } } }, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -647,24 +672,6 @@ "ok": false } } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] } }, "recording": { @@ -673,34 +680,32 @@ { "id": "settings-repo-metadata-fulfilled.normal:settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.normal:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json index 423f3062147..46d391c5870 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json @@ -4,13 +4,45 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, + "010dfaf4d076": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", - "value": "host.platform" + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "156e191c4a8b": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" }, { "name": "params", @@ -30,17 +62,18 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-4", + "id": "frame-3", "ok": true, "result": { - "platform": "linux" + "error": "inner refused", + "ok": false } } } }, - "0c4296f215a4": { + "18762ff064ed": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -78,9 +111,124 @@ } } }, - "0e736afe9db0": { + "19b2979d7fc2": { + "name": "repo.list#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, + "1e027d0127f3": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "1f909a505b54": { + "name": "hostPlatform", + "ordinal": 13, + "value": { + "$rpc": "null" + } + }, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } + } + }, + "302f82cb9a68": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "33781e3dd8ff": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -107,95 +255,11 @@ "id": "frame-3", "ok": true, "result": { - "$rpc": "null" + "error": "refused" } } } }, - "191d1c98ca5c": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-3", - "ok": false - } - } - }, - "19b2979d7fc2": { - "name": "repo.list#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -243,9 +307,43 @@ } } }, - "4f9cd81446d9": { + "5248b37ec085": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "linux" + } + } + } + }, + "597c187eb03e": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -270,22 +368,78 @@ "settledAt": 0, "value": { "error": { - "code": "method_not_found", - "message": "Unknown method" + "code": "refused", + "message": "" }, "id": "frame-3", "ok": false } } }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] }, - "53dea128b181": { + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "7c508088602c": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -304,232 +458,14 @@ } } ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } - } - }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] - }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "628a0201bdef": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "80c010782ff2": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "8eda68e753fa": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ] - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "96e0c54d86b0": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], "settlement": { "status": "pending", "startedAt": 0 } }, - "9d20695fdb34": { - "name": "hostPlatform", - "ordinal": 15, - "value": { - "$rpc": "null" - } - }, - "ae308dad876a": { + "80c9ec66b537": { "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true - } - } - }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "bba1cbe66ba5": { - "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -562,18 +498,53 @@ } } }, - "bea13a4a2e87": { - "name": "hostLabelById", - "ordinal": 14, - "value": [["ssh:ssh-1", "SSH"]] + "8b618db22116": { + "name": "settings.get#1", + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", - "value": "ssh.listTargetSummaries" + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "91e7f4ef9a77": { + "name": "hostPlatform", + "ordinal": 13, + "value": "linux" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "a58fc81113a0": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" }, { "name": "params", @@ -593,22 +564,62 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-2", + "id": "frame-3", "ok": true, "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] + "$rpc": "null" } } } }, - "da3e3c335005": { + "aec686ccee7e": { "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "method_not_found", + "message": "Unknown method" + }, + "id": "frame-3", + "ok": false + } + } + }, + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "d82cff5cd531": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -643,41 +654,14 @@ } } }, - "dd3998116e8e": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } - } + "d9c464d0dc78": { + "name": "hostLabelById", + "ordinal": 12, + "value": [["ssh:ssh-1", "SSH"]] }, - "e44262c95a88": { + "dd998f128ff4": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -702,14 +686,23 @@ "settledAt": 0, "value": { "id": "frame-3", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } + "ok": true } } }, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, "eb79a9b3682a": { "status": "fulfilled", "startedAt": 0, @@ -718,23 +711,37 @@ "$rpc": "undefined" } }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + "f18e81499208": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } } }, "recording": { @@ -743,265 +750,241 @@ { "id": "settings-repo-metadata-fulfilled.prelude:settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.prelude:cleanup", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "dd3998116e8e", "53dea128b181"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "302f82cb9a68", "1e027d0127f3"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "6134b73f18d0", + "state": "68916f125c3c", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.normal:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.result-absent:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "ae308dad876a", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "dd998f128ff4", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.result-null:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0e736afe9db0", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "a58fc81113a0", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-ok-missing:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "80c010782ff2", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "33781e3dd8ff", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-string-error:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "e44262c95a88", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "156e191c4a8b", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-object-error:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "da3e3c335005", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "d82cff5cd531", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "bba1cbe66ba5", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "80c9ec66b537", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused-no-message:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "191d1c98ca5c", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "597c187eb03e", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.method-not-found:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "4f9cd81446d9", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "aec686ccee7e", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "628a0201bdef", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "010dfaf4d076", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection-no-message:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "8eda68e753fa", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "f18e81499208", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } } diff --git a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json index eaf4e75fb05..b6896a08bd6 100644 --- a/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json +++ b/mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json @@ -4,109 +4,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "linux" - } - } - } - }, - "069215767aa7": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "08d05f42e4a2": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "0c4296f215a4": { + "18762ff064ed": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -149,35 +49,9 @@ "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "1c0fba41a128": { + "299d571abf13": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "ordinal": 5, "args": [ { "name": "method", @@ -202,50 +76,21 @@ "settledAt": 0, "value": { "id": "frame-2", - "ok": true + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } } } }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "25c61ac0428d": { + "35dd46bd7f86": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "2946dad921bd": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "ordinal": 5, "args": [ { "name": "method", @@ -278,18 +123,10 @@ } } }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", + "379c0eb6dc14": { + "name": "hostLabelById", "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + "value": [] }, "4526bff18b74": { "name": "repo.list#1", @@ -338,9 +175,9 @@ } } }, - "4e65ef8c0641": { + "4c7b73c1c4f4": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "ordinal": 5, "args": [ { "name": "method", @@ -367,15 +204,135 @@ "id": "frame-2", "ok": true, "result": { - "error": "inner refused", - "ok": false + "$rpc": "null" } } } }, - "4ffa98ab8ca7": { - "name": "ssh.listTargetSummaries#1", + "5248b37ec085": { + "name": "host.platform#1", "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "linux" + } + } + } + }, + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] + }, + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "887fb09573e8": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, "args": [ { "name": "method", @@ -410,37 +367,49 @@ } } }, - "523753167e39": { - "hostHealthById": [], - "hostLabelById": [], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] + "8b618db22116": { + "name": "settings.get#1", + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, - "53226cb82ec3": { + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "91e7f4ef9a77": { "name": "hostPlatform", - "ordinal": 15, + "ordinal": 13, "value": "linux" }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 }, - "60676e66d4b6": { + "943ef4ae0f6c": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "ordinal": 5, "args": [ { "name": "method", @@ -473,53 +442,147 @@ } } }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + "99367b73f33f": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], + "hostHealthById": [], + "hostLabelById": [], + "hostPlatform": "linux", "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] ] }, - "668a7e313975": { + "9ad75b2a47cd": { "name": "ssh.listTargetSummaries#1", - "ordinal": 10, + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "a7a5b2905c3e": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "a8b44d1f7b7f": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } + } + } + }, + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 8, "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" }, - "7e772c1c111d": { - "name": "repoIdsByName", + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "cda9ce953e6b": { + "name": "ssh.listTargetSummaries#1", "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ] - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "96e0c54d86b0": { - "name": "settings.get#1", - "ordinal": 8, "args": [ { "name": "method", - "value": "settings.get" + "value": "ssh.listTargetSummaries" }, { "name": "params", @@ -535,13 +598,19 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } } }, - "ac84a93b5ea0": { + "cea6f15efed0": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "ordinal": 5, "args": [ { "name": "method", @@ -566,96 +635,31 @@ "settledAt": 0, "value": { "id": "frame-2", - "ok": true, - "result": { - "$rpc": "null" - } + "ok": true } } }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "bea13a4a2e87": { + "d9c464d0dc78": { "name": "hostLabelById", - "ordinal": 14, + "ordinal": 12, "value": [["ssh:ssh-1", "SSH"]] }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } - }, - "daecdc0e521b": { - "name": "hostLabelById", - "ordinal": 14, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, "value": [] }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] ] }, - "fa2cb04c517b": { + "e9b2f982bd10": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "ordinal": 5, "args": [ { "name": "method", @@ -687,6 +691,14 @@ "ok": false } } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } } }, "recording": { @@ -695,374 +707,352 @@ { "id": "settings-repo-metadata-fulfilled.normal:settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.normal:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.result-absent:settings-pending", "observation": { - "sender": ["4526bff18b74", "1c0fba41a128", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "cea6f15efed0", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.result-absent:settled", "observation": { - "sender": ["4526bff18b74", "1c0fba41a128", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "cea6f15efed0", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.result-null:settings-pending", "observation": { - "sender": ["4526bff18b74", "ac84a93b5ea0", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "4c7b73c1c4f4", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.result-null:settled", "observation": { - "sender": ["4526bff18b74", "ac84a93b5ea0", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "4c7b73c1c4f4", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-ok-missing:settings-pending", "observation": { - "sender": ["4526bff18b74", "069215767aa7", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "a7a5b2905c3e", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.inner-ok-missing:settled", "observation": { - "sender": ["4526bff18b74", "069215767aa7", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "a7a5b2905c3e", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-string-error:settings-pending", "observation": { - "sender": ["4526bff18b74", "4e65ef8c0641", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "a8b44d1f7b7f", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-string-error:settled", "observation": { - "sender": ["4526bff18b74", "4e65ef8c0641", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "a8b44d1f7b7f", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-object-error:settings-pending", "observation": { - "sender": ["4526bff18b74", "4ffa98ab8ca7", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "887fb09573e8", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.inner-false-object-error:settled", "observation": { - "sender": ["4526bff18b74", "4ffa98ab8ca7", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "887fb09573e8", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused:settings-pending", "observation": { - "sender": ["4526bff18b74", "2946dad921bd", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "35dd46bd7f86", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused:settled", "observation": { - "sender": ["4526bff18b74", "2946dad921bd", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "35dd46bd7f86", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused-no-message:settings-pending", "observation": { - "sender": ["4526bff18b74", "fa2cb04c517b", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "e9b2f982bd10", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.outer-refused-no-message:settled", "observation": { - "sender": ["4526bff18b74", "fa2cb04c517b", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "e9b2f982bd10", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.method-not-found:settings-pending", "observation": { - "sender": ["4526bff18b74", "60676e66d4b6", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "943ef4ae0f6c", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.method-not-found:settled", "observation": { - "sender": ["4526bff18b74", "60676e66d4b6", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "943ef4ae0f6c", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection:settings-pending", "observation": { - "sender": ["4526bff18b74", "25c61ac0428d", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "cda9ce953e6b", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection:settled", "observation": { - "sender": ["4526bff18b74", "25c61ac0428d", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "cda9ce953e6b", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection-no-message:settings-pending", "observation": { - "sender": ["4526bff18b74", "08d05f42e4a2", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "9ad75b2a47cd", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.transport-rejection-no-message:settled", "observation": { - "sender": ["4526bff18b74", "08d05f42e4a2", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "9ad75b2a47cd", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "523753167e39", + "state": "99367b73f33f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "daecdc0e521b", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "379c0eb6dc14", + "91e7f4ef9a77" ] } } diff --git a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json index a81b7772b49..2a1528af935 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -4,13 +4,13 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "031c2df78b67": { - "name": "host.platform#1", - "ordinal": 9, + "0603f3cff57a": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", - "value": "host.platform" + "value": "settings.get" }, { "name": "params", @@ -41,9 +41,9 @@ } } }, - "03c9a2ba60b9": { + "0788f40c85d2": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -63,21 +63,19 @@ } ], "settlement": { - "status": "fulfilled", + "status": "rejected", "startedAt": 0, "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "linux" - } + "error": { + "category": "Error", + "message": "Connection lost", + "isRpcDeliveryUnknown": true } } }, - "0c4296f215a4": { + "18762ff064ed": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -115,14 +113,46 @@ } } }, + "19172e0dbeb5": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "settings.get#1 rejected", + "isRpcDeliveryUnknown": true + } + } + }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "1a160fc2d137": { + "1e027d0127f3": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -142,26 +172,166 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } } }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] + "1f909a505b54": { + "name": "hostPlatform", + "ordinal": 13, + "value": { + "$rpc": "null" + } }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "32bd1f0384a8": { + "271940c15814": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 30000, + "error": { + "category": "Error", + "message": "Request timed out: settings.get", + "isRpcDeliveryUnknown": true + } + } + }, + "278a60f78954": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "LogicalClientCutoverError", + "message": "RPC interrupted by connection migration", + "isRpcDeliveryUnknown": true, + "cause": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + } + }, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } + } + }, + "335f5d729e86": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "host.platform#1 rejected", + "isRpcDeliveryUnknown": true + } + } + }, + "3af207882a60": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -191,11 +361,6 @@ } } }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -243,271 +408,13 @@ } } }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" - }, - "53dea128b181": { + "5248b37ec085": { "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } - } - }, - "5b781918b903": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection lost", - "isRpcDeliveryUnknown": true - } - } - }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] - }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "6b33c36aba17": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "LogicalClientCutoverError", - "message": "RPC interrupted by connection migration", - "isRpcDeliveryUnknown": true, - "cause": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } - } - } - }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "823fd36df8ac": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 30000, - "error": { - "category": "Error", - "message": "Request timed out: settings.get", - "isRpcDeliveryUnknown": true - } - } - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ] - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "96e0c54d86b0": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "9ae54f671699": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "host.platform#1 rejected", - "isRpcDeliveryUnknown": true - } - } - }, - "9d20695fdb34": { - "name": "hostPlatform", - "ordinal": 15, - "value": { - "$rpc": "null" - } - }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "bea13a4a2e87": { - "name": "hostLabelById", - "ordinal": 14, - "value": [["ssh:ssh-1", "SSH"]] - }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", "ordinal": 7, "args": [ { "name": "method", - "value": "ssh.listTargetSummaries" + "value": "host.platform" }, { "name": "params", @@ -527,30 +434,17 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-2", + "id": "frame-4", "ok": true, "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] + "platform": "linux" } } } }, - "c7f150c7a054": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 30000, - "value": { - "$rpc": "undefined" - } - }, - "df9bd2cc2b3c": { + "62384d41a9c4": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -580,9 +474,101 @@ } } }, - "e03d2292c377": { + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] + }, + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "8b618db22116": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -602,34 +588,78 @@ } ], "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "settings.get#1 rejected", - "isRpcDeliveryUnknown": true - } + "status": "pending", + "startedAt": 0 } }, - "e841f87c65aa": { + "91e7f4ef9a77": { + "name": "hostPlatform", + "ordinal": 13, + "value": "linux" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "c7f150c7a054": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 30000, + "value": { + "$rpc": "undefined" + } + }, + "d88cba4e044f": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], "hostHealthById": [], "hostLabelById": [["ssh:ssh-1", "SSH"]], "hostPlatform": { "$rpc": "null" }, - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] + ] + }, + "d9c464d0dc78": { + "name": "hostLabelById", + "ordinal": 12, + "value": [["ssh:ssh-1", "SSH"]] + }, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] ] }, "eb79a9b3682a": { @@ -639,24 +669,6 @@ "value": { "$rpc": "undefined" } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] } }, "recording": { @@ -665,248 +677,232 @@ { "id": "settings-repo-metadata-fulfilled.forward:sibling-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.forward:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.reverse:sibling-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.reverse:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "settings-repo-metadata-fulfilled.both-reject-forward:sibling-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "e03d2292c377", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "19172e0dbeb5", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.both-reject-forward:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "e03d2292c377", "9ae54f671699"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "19172e0dbeb5", "335f5d729e86"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.both-reject-reverse:sibling-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "9ae54f671699"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "335f5d729e86"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.both-reject-reverse:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "e03d2292c377", "9ae54f671699"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "19172e0dbeb5", "335f5d729e86"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.reject-peer-pending:sibling-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "e03d2292c377", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "19172e0dbeb5", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.reject-peer-pending:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "e03d2292c377", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "19172e0dbeb5", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settings-repo-metadata-fulfilled.reject-peer-pending:cleanup", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "e03d2292c377", "53dea128b181"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "19172e0dbeb5", "1e027d0127f3"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "6134b73f18d0", + "state": "68916f125c3c", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.timeout:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "823fd36df8ac", "df9bd2cc2b3c"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "271940c15814", "62384d41a9c4"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "c7f150c7a054" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.disconnect:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "32bd1f0384a8", "5b781918b903"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "3af207882a60", "0788f40c85d2"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "disconnect": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } }, { "id": "settings-repo-metadata-fulfilled.client-cutover:settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "6b33c36aba17", "031c2df78b67"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "0603f3cff57a", "278a60f78954"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "cutover": "eb79a9b3682a" }, - "state": "e841f87c65aa", + "state": "d88cba4e044f", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "9d20695fdb34" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "1f909a505b54" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index 57c042b30b8..f7445955a8e 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -4,43 +4,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "linux" - } - } - } - }, - "0c4296f215a4": { + "18762ff064ed": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -83,13 +49,13 @@ "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, "args": [ { "name": "method", - "value": "host.platform" + "value": "ssh.listTargetSummaries" }, { "name": "params", @@ -105,33 +71,28 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } } }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "2053629911e7": { + "35f30397fcb2": { "name": "repo.list#2", - "ordinal": 17, + "ordinal": 15, "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -179,59 +140,135 @@ } } }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" + "5248b37ec085": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "linux" + } + } + } }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] ] }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } }, - "96e0c54d86b0": { + "8b618db22116": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -255,23 +292,9 @@ "startedAt": 0 } }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "ba8a19e3e2b1": { - "status": "pending", - "startedAt": 60000 - }, - "bea13a4a2e87": { - "name": "hostLabelById", - "ordinal": 14, - "value": [["ssh:ssh-1", "SSH"]] - }, - "c402c05b8b96": { + "9157a9ad3cbd": { "name": "repo.list#2", - "ordinal": 16, + "ordinal": 14, "args": [ { "name": "method", @@ -295,44 +318,46 @@ "startedAt": 60000 } }, - "c752e67787f9": { + "91e7f4ef9a77": { + "name": "hostPlatform", + "ordinal": 13, + "value": "linux" + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "ba8a19e3e2b1": { + "status": "pending", + "startedAt": 60000 + }, + "c1246f5792ee": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "d9c464d0dc78": { + "name": "hostLabelById", + "ordinal": 12, + "value": [["ssh:ssh-1", "SSH"]] + }, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -349,24 +374,6 @@ "value": { "$rpc": "undefined" } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] } }, "recording": { @@ -375,79 +382,73 @@ { "id": "settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "cache-hit", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "cached-query": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "cache-warm", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "cached-query": "eb79a9b3682a", "warm-query": "f43afee17848" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, @@ -456,17 +457,17 @@ "observation": { "sender": [ "4526bff18b74", - "c752e67787f9", - "0c4296f215a4", - "03c9a2ba60b9", - "c402c05b8b96" + "299d571abf13", + "18762ff064ed", + "5248b37ec085", + "9157a9ad3cbd" ], "payloads": [ "19b2979d7fc2", - "668a7e313975", - "b428bce85ee7", - "36a96ddbd396", - "2053629911e7" + "c1246f5792ee", + "8b618db22116", + "c1d6fc758acc", + "35f30397fcb2" ], "settlements": { "mount": "eb79a9b3682a", @@ -475,15 +476,13 @@ "warm-query": "f43afee17848", "expired-query": "ba8a19e3e2b1" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index e3be4ab006b..bf3e74423bf 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -4,43 +4,9 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "linux" - } - } - } - }, - "0c4296f215a4": { + "18762ff064ed": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -83,13 +49,13 @@ "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, "args": [ { "name": "method", - "value": "host.platform" + "value": "ssh.listTargetSummaries" }, { "name": "params", @@ -105,28 +71,23 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } } }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -174,59 +135,135 @@ } } }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" + "5248b37ec085": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "linux" + } + } + } }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] ] }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } }, - "96e0c54d86b0": { + "8b618db22116": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -250,54 +287,42 @@ "startedAt": 0 } }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "91e7f4ef9a77": { + "name": "hostPlatform", + "ordinal": 13, + "value": "linux" }, - "bea13a4a2e87": { + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "d9c464d0dc78": { "name": "hostLabelById", - "ordinal": 14, + "ordinal": 12, "value": [["ssh:ssh-1", "SSH"]] }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -306,24 +331,6 @@ "value": { "$rpc": "undefined" } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] } }, "recording": { @@ -332,34 +339,32 @@ { "id": "settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index dcd54acfef5..1fd076e0b98 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -4,27 +4,68 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { + "0fd72e1aba93": { + "desktopRepos": [ + { + "badgeColor": "#123456", + "displayName": "Lucide", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1", + "repoIcon": { + "name": "Rocket", + "type": "lucide" + } + }, + { + "displayName": "Emoji", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2", + "repoIcon": { + "emoji": "🐳", + "type": "emoji" + } + }, + { + "displayName": "Avatar", + "executionHostId": "ssh:ssh-1", + "id": "repo-3", + "path": "repo-3", + "repoIcon": { + "label": "acme/orca", + "source": "github", + "src": "https://github.com/acme.png?size=64", + "type": "image" + } + } + ], + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "darwin", + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"], + ["repo-3", "ssh:ssh-1"] + ] + }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "19bf6d2469a6": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Lucide", "#123456"], - ["Emoji", "#f97316"], - ["Avatar", "#f97316"] - ] + "1c76208c9beb": { + "name": "hostPlatform", + "ordinal": 13, + "value": "darwin" }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, "args": [ { "name": "method", - "value": "host.platform" + "value": "ssh.listTargetSummaries" }, { "name": "params", @@ -40,70 +81,26 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } } }, - "25b44bca9e45": { - "name": "hostPlatform", - "ordinal": 15, - "value": "darwin" - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] - }, - "6338d57f282f": { - "repoColorsByName": [ - ["Lucide", "#123456"], - ["Emoji", "#f97316"], - ["Avatar", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"], - ["repo-3", "ssh:ssh-1"] - ], - "repoIconsByName": [ - [ - "Lucide", - { - "name": "Rocket", - "type": "lucide" - } - ], - [ - "Emoji", - { - "emoji": "🐳", - "type": "emoji" - } - ], - [ - "Avatar", - { - "label": "acme/orca", - "source": "github", - "src": "https://github.com/acme.png?size=64", - "type": "image" - } - ] - ], - "repoIdsByName": [ - ["Lucide", "repo-1"], - ["Emoji", "repo-2"], - ["Avatar", "repo-3"] - ] - }, - "64f5522cc303": { + "466c0317a24a": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -139,36 +136,40 @@ } } }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } }, - "6bc5e5b7a259": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"], - ["repo-3", "ssh:ssh-1"] - ] - }, - "77cbfd1f087c": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Lucide", "repo-1"], - ["Emoji", "repo-2"], - ["Avatar", "repo-3"] - ] - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "96e0c54d86b0": { + "8b618db22116": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -192,6 +193,86 @@ "startedAt": 0 } }, + "910617d262f4": { + "desktopRepos": [ + { + "badgeColor": "#123456", + "displayName": "Lucide", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1", + "repoIcon": { + "name": "Rocket", + "type": "lucide" + } + }, + { + "displayName": "Emoji", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2", + "repoIcon": { + "emoji": "🐳", + "type": "emoji" + } + }, + { + "displayName": "Avatar", + "executionHostId": "ssh:ssh-1", + "id": "repo-3", + "path": "repo-3", + "repoIcon": { + "label": "acme/orca", + "source": "github", + "src": "https://github.com/acme.png?size=64", + "type": "image" + } + } + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"], + ["repo-3", "ssh:ssh-1"] + ] + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "9bc91d02ce24": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "darwin" + } + } + } + }, "a9add325bb0a": { "name": "repo.list#1", "ordinal": 1, @@ -259,128 +340,34 @@ } } }, - "ae27a5b525fa": { - "name": "repoIconsByName", + "b4ad28ffe331": { + "name": "repoHostIdByRepoId", "ordinal": 4, "value": [ - [ - "Lucide", - { - "name": "Rocket", - "type": "lucide" - } - ], - [ - "Emoji", - { - "emoji": "🐳", - "type": "emoji" - } - ], - [ - "Avatar", - { - "label": "acme/orca", - "source": "github", - "src": "https://github.com/acme.png?size=64", - "type": "image" - } - ] - ] - }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "bea13a4a2e87": { - "name": "hostLabelById", - "ordinal": 14, - "value": [["ssh:ssh-1", "SSH"]] - }, - "c020f709d835": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "darwin", - "repoColorsByName": [ - ["Lucide", "#123456"], - ["Emoji", "#f97316"], - ["Avatar", "#f97316"] - ], - "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"], ["repo-3", "ssh:ssh-1"] - ], - "repoIconsByName": [ - [ - "Lucide", - { - "name": "Rocket", - "type": "lucide" - } - ], - [ - "Emoji", - { - "emoji": "🐳", - "type": "emoji" - } - ], - [ - "Avatar", - { - "label": "acme/orca", - "source": "github", - "src": "https://github.com/acme.png?size=64", - "type": "image" - } - ] - ], - "repoIdsByName": [ - ["Lucide", "repo-1"], - ["Emoji", "repo-2"], - ["Avatar", "repo-3"] ] }, - "c752e67787f9": { + "c1246f5792ee": { "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "d9c464d0dc78": { + "name": "hostLabelById", + "ordinal": 12, + "value": [["ssh:ssh-1", "SSH"]] + }, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] }, "eb79a9b3682a": { "status": "fulfilled", @@ -390,39 +377,44 @@ "$rpc": "undefined" } }, - "ee8e16df5187": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ + "f9217d79f53e": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "badgeColor": "#123456", + "displayName": "Lucide", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1", + "repoIcon": { + "name": "Rocket", + "type": "lucide" } }, { - "name": "options", - "value": { - "$rpc": "absent" + "displayName": "Emoji", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2", + "repoIcon": { + "emoji": "🐳", + "type": "emoji" + } + }, + { + "displayName": "Avatar", + "executionHostId": "ssh:ssh-1", + "id": "repo-3", + "path": "repo-3", + "repoIcon": { + "label": "acme/orca", + "source": "github", + "src": "https://github.com/acme.png?size=64", + "type": "image" } } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "darwin" - } - } - } + ] } }, "recording": { @@ -431,34 +423,32 @@ { "id": "settings-pending", "observation": { - "sender": ["a9add325bb0a", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["a9add325bb0a", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6338d57f282f", - "effects": ["19bf6d2469a6", "ae27a5b525fa", "77cbfd1f087c", "6bc5e5b7a259"] + "state": "910617d262f4", + "effects": ["f9217d79f53e", "b4ad28ffe331"] } }, { "id": "settled", "observation": { - "sender": ["a9add325bb0a", "c752e67787f9", "64f5522cc303", "ee8e16df5187"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["a9add325bb0a", "299d571abf13", "466c0317a24a", "9bc91d02ce24"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "c020f709d835", + "state": "0fd72e1aba93", "effects": [ - "19bf6d2469a6", - "ae27a5b525fa", - "77cbfd1f087c", - "6bc5e5b7a259", - "6063e534d9db", - "bea13a4a2e87", - "25b44bca9e45" + "f9217d79f53e", + "b4ad28ffe331", + "e2881f748a46", + "d9c464d0dc78", + "1c76208c9beb" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json index 53182455b8d..0b7ad2b8038 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json @@ -4,43 +4,14 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "linux" - } - } - } + "00d007a90b2c": { + "name": "hostPlatform", + "ordinal": 26, + "value": "linux" }, - "0c4296f215a4": { + "18762ff064ed": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 6, "args": [ { "name": "method", @@ -78,19 +49,14 @@ } } }, - "0ef3d910f42e": { - "name": "hostLabelById", - "ordinal": 29, - "value": [["ssh:ssh-1", "SSH"]] - }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, + "1e4a3aa71af5": { + "name": "host.platform#2", + "ordinal": 20, "args": [ { "name": "method", @@ -110,37 +76,79 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-8", + "ok": true, + "result": { + "platform": "linux" + } + } } }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } + } + } }, - "2053629911e7": { - "name": "repo.list#2", - "ordinal": 17, - "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, + "306fcf2b8536": { + "name": "desktopRepos", + "ordinal": 16, "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ] }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, - "376d3874239c": { - "name": "repoIconsByName", - "ordinal": 19, - "value": [] + "35f30397fcb2": { + "name": "repo.list#2", + "ordinal": 15, + "json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, "4526bff18b74": { "name": "repo.list#1", @@ -189,44 +197,9 @@ } } }, - "455855a85ab4": { - "name": "settings.get#2", - "ordinal": 23, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "settings refused" - }, - "id": "frame-7", - "ok": false - } - } - }, - "4771e3dbbbcf": { - "name": "host.platform#2", - "ordinal": 24, + "5248b37ec085": { + "name": "host.platform#1", + "ordinal": 7, "args": [ { "name": "method", @@ -250,7 +223,7 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-8", + "id": "frame-4", "ok": true, "result": { "platform": "linux" @@ -258,64 +231,106 @@ } } }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] - }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] + ] + }, + "69f575e15e15": { + "name": "settings.get#2", + "ordinal": 22, + "json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "8c5c812580ad": { - "name": "host.platform#2", - "ordinal": 27, - "json": "{\"id\":\"frame-8\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] ] }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } }, - "96e0c54d86b0": { + "8b618db22116": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -339,78 +354,33 @@ "startedAt": 0 } }, - "a67416f78b93": { - "name": "hostHealthById", - "ordinal": 28, - "value": [] - }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "bea13a4a2e87": { - "name": "hostLabelById", - "ordinal": 14, - "value": [["ssh:ssh-1", "SSH"]] - }, - "c497d2d846c9": { - "name": "ssh.listTargetSummaries#2", - "ordinal": 25, - "json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } - }, - "ceef481be25c": { + "91e7f4ef9a77": { "name": "hostPlatform", - "ordinal": 30, + "ordinal": 13, "value": "linux" }, - "d51254d2537f": { - "name": "settings.get#2", - "ordinal": 26, - "json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 }, - "d569b5b2500f": { + "9b915e5b8831": { + "name": "host.platform#2", + "ordinal": 23, + "json": "{\"id\":\"frame-8\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "a465a07eb4b0": { + "name": "hostLabelById", + "ordinal": 25, + "value": [["ssh:ssh-1", "SSH"]] + }, + "af3366c70550": { "name": "ssh.listTargetSummaries#2", - "ordinal": 22, + "ordinal": 21, + "json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "bc879379ce13": { + "name": "ssh.listTargetSummaries#2", + "ordinal": 18, "args": [ { "name": "method", @@ -447,17 +417,63 @@ } } }, - "da7cf9752395": { - "name": "repoColorsByName", - "ordinal": 18, + "bd0d18cd2160": { + "name": "repoHostIdByRepoId", + "ordinal": 17, "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] ] }, - "df78d2202264": { + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "d9c464d0dc78": { + "name": "hostLabelById", + "ordinal": 12, + "value": [["ssh:ssh-1", "SSH"]] + }, + "df3bf9bbb7bf": { "name": "repo.list#2", - "ordinal": 16, + "ordinal": 14, + "args": [ + { + "name": "method", + "value": "repo.list" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e3e3d558d06c": { + "name": "repo.list#2", + "ordinal": 14, "args": [ { "name": "method", @@ -502,55 +518,26 @@ } } }, - "ea2ced0895f3": { + "e49da3e1abca": { "name": "repoHostIdByRepoId", - "ordinal": 21, + "ordinal": 4, "value": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] ] }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } + "e7d590a1a17e": { + "name": "hostHealthById", + "ordinal": 24, + "value": [] }, - "eb885d2ceee5": { - "name": "repoIdsByName", - "ordinal": 20, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "fa585aceeec2": { - "name": "repo.list#2", - "ordinal": 16, + "eab4940c8ca3": { + "name": "settings.get#2", + "ordinal": 19, "args": [ { "name": "method", - "value": "repo.list" + "value": "settings.get" }, { "name": "params", @@ -566,8 +553,25 @@ } ], "settlement": { - "status": "pending", - "startedAt": 0 + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "settings refused" + }, + "id": "frame-7", + "ok": false + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" } } }, @@ -577,55 +581,51 @@ { "id": "settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, { "id": "data-present", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, @@ -634,32 +634,30 @@ "observation": { "sender": [ "4526bff18b74", - "c752e67787f9", - "0c4296f215a4", - "03c9a2ba60b9", - "fa585aceeec2" + "299d571abf13", + "18762ff064ed", + "5248b37ec085", + "df3bf9bbb7bf" ], "payloads": [ "19b2979d7fc2", - "668a7e313975", - "b428bce85ee7", - "36a96ddbd396", - "2053629911e7" + "c1246f5792ee", + "8b618db22116", + "c1d6fc758acc", + "35f30397fcb2" ], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "reload": "9270aeb7d9c6" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } }, @@ -668,45 +666,41 @@ "observation": { "sender": [ "4526bff18b74", - "c752e67787f9", - "0c4296f215a4", - "03c9a2ba60b9", - "df78d2202264", - "d569b5b2500f", - "455855a85ab4", - "4771e3dbbbcf" + "299d571abf13", + "18762ff064ed", + "5248b37ec085", + "e3e3d558d06c", + "bc879379ce13", + "eab4940c8ca3", + "1e4a3aa71af5" ], "payloads": [ "19b2979d7fc2", - "668a7e313975", - "b428bce85ee7", - "36a96ddbd396", - "2053629911e7", - "c497d2d846c9", - "d51254d2537f", - "8c5c812580ad" + "c1246f5792ee", + "8b618db22116", + "c1d6fc758acc", + "35f30397fcb2", + "af3366c70550", + "69f575e15e15", + "9b915e5b8831" ], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a", "reload": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3", - "da7cf9752395", - "376d3874239c", - "eb885d2ceee5", - "ea2ced0895f3", - "a67416f78b93", - "0ef3d910f42e", - "ceef481be25c" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77", + "306fcf2b8536", + "bd0d18cd2160", + "e7d590a1a17e", + "a465a07eb4b0", + "00d007a90b2c" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 17e304bc7bc..7c884f9d9e4 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -4,83 +4,18 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "platform": "linux" - } - } - } - }, "19b2979d7fc2": { "name": "repo.list#1", "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, "args": [ { "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "2e2ca6f7dc3f": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" + "value": "ssh.listTargetSummaries" }, { "name": "params", @@ -100,28 +35,19 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "refused", - "message": "settings refused" - }, - "id": "frame-3", - "ok": false + "id": "frame-2", + "ok": true, + "result": { + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] + } } } }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -169,59 +95,170 @@ } } }, - "53226cb82ec3": { - "name": "hostPlatform", - "ordinal": 15, - "value": "linux" + "5248b37ec085": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "linux" + } + } + } }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] + "61b27dbdf3a7": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "settings refused" + }, + "id": "frame-3", + "ok": false + } + } }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] + }, + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ ["repo-1", "local"], ["repo-2", "ssh:ssh-1"] ] }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } }, - "96e0c54d86b0": { + "8b618db22116": { "name": "settings.get#1", - "ordinal": 8, + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -245,54 +282,42 @@ "startedAt": 0 } }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "91e7f4ef9a77": { + "name": "hostPlatform", + "ordinal": 13, + "value": "linux" }, - "bea13a4a2e87": { + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "d9c464d0dc78": { "name": "hostLabelById", - "ordinal": 14, + "ordinal": 12, "value": [["ssh:ssh-1", "SSH"]] }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -301,24 +326,6 @@ "value": { "$rpc": "undefined" } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] } }, "recording": { @@ -327,34 +334,32 @@ { "id": "settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "2e2ca6f7dc3f", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "61b27dbdf3a7", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json index 277522dfed5..a05b52f8cc6 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -9,9 +9,9 @@ "ordinal": 2, "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" }, - "1a160fc2d137": { + "1e027d0127f3": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -30,35 +30,118 @@ } } ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "1f909a505b54": { + "name": "hostPlatform", + "ordinal": 13, + "value": { + "$rpc": "null" + } + }, + "2207323d0bd5": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "302f82cb9a68": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Connection closed", + "isRpcDeliveryUnknown": true + } + } + }, + "35960f0ff1b9": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, + "args": [ + { + "name": "method", + "value": "ssh.listTargetSummaries" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], "settlement": { "status": "pending", "startedAt": 0 } }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "2bd489a9fa29": { - "repoColorsByName": [ - ["Remote", "#f97316"], - ["Remote folder", "#ec4899"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "ssh:ssh-1"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Remote", "repo-1"], - ["Remote folder", "repo-2"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", + "379c0eb6dc14": { + "name": "hostLabelById", "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + "value": [] }, "4b6956e45bac": { "name": "repo.list#1", @@ -105,41 +188,27 @@ } } }, - "4bf1ed33de14": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ + "6e0383a0869e": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ { - "name": "method", - "value": "ssh.listTargetSummaries" + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-1", + "path": "repo-1" }, { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } + "displayName": "Remote folder", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } - } + ] }, - "53dea128b181": { + "7c508088602c": { "name": "host.platform#1", - "ordinal": 9, + "ordinal": 7, "args": [ { "name": "method", @@ -159,33 +228,22 @@ } ], "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } + "status": "pending", + "startedAt": 0 } }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] + "8b618db22116": { + "name": "settings.get#1", + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "6a227ad27189": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", - "value": "ssh.listTargetSummaries" + "value": "settings.get" }, { "name": "params", @@ -209,96 +267,48 @@ "status": "pending", "startedAt": 0 }, - "937107a86348": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Remote", "repo-1"], - ["Remote folder", "repo-2"] - ] - }, - "96e0c54d86b0": { - "name": "settings.get#1", + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", "ordinal": 8, - "args": [ + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "d23c5c4cde9a": { + "desktopRepos": [ { - "name": "method", - "value": "settings.get" + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-1", + "path": "repo-1" }, { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } + "displayName": "Remote folder", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" } ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "9d20695fdb34": { - "name": "hostPlatform", - "ordinal": 15, - "value": { - "$rpc": "null" - } - }, - "a086cfd03185": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ + "repoHostIdByRepoId": [ ["repo-1", "ssh:ssh-1"], ["repo-2", "ssh:ssh-1"] ] }, - "b428bce85ee7": { - "name": "settings.get#1", + "e2881f748a46": { + "name": "hostHealthById", "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "daecdc0e521b": { - "name": "hostLabelById", - "ordinal": 14, "value": [] }, - "dd3998116e8e": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "Connection closed", - "isRpcDeliveryUnknown": true - } - } + "e7dd8e429107": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "ssh:ssh-1"], + ["repo-2", "ssh:ssh-1"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -307,14 +317,6 @@ "value": { "$rpc": "undefined" } - }, - "fc0785cc0b08": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Remote", "#f97316"], - ["Remote folder", "#ec4899"] - ] } }, "recording": { @@ -323,34 +325,32 @@ { "id": "single-host-without-label-lookups", "observation": { - "sender": ["4b6956e45bac", "6a227ad27189", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4b6956e45bac", "35960f0ff1b9", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "2bd489a9fa29", - "effects": ["fc0785cc0b08", "1ff13ee8621c", "937107a86348", "a086cfd03185"] + "state": "d23c5c4cde9a", + "effects": ["6e0383a0869e", "e7dd8e429107"] } }, { "id": "cleanup", "observation": { - "sender": ["4b6956e45bac", "4bf1ed33de14", "dd3998116e8e", "53dea128b181"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4b6956e45bac", "2207323d0bd5", "302f82cb9a68", "1e027d0127f3"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "2bd489a9fa29", + "state": "d23c5c4cde9a", "effects": [ - "fc0785cc0b08", - "1ff13ee8621c", - "937107a86348", - "a086cfd03185", - "6063e534d9db", - "daecdc0e521b", - "9d20695fdb34" + "6e0383a0869e", + "e7dd8e429107", + "e2881f748a46", + "379c0eb6dc14", + "1f909a505b54" ] } } diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json index 965d2f81320..479a4146191 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -4,13 +4,18 @@ "family": "settings.repo-metadata", "namedDeltas": [], "values": { - "03c9a2ba60b9": { - "name": "host.platform#1", - "ordinal": 9, + "19b2979d7fc2": { + "name": "repo.list#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" + }, + "299d571abf13": { + "name": "ssh.listTargetSummaries#1", + "ordinal": 5, "args": [ { "name": "method", - "value": "host.platform" + "value": "ssh.listTargetSummaries" }, { "name": "params", @@ -30,63 +35,19 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-4", + "id": "frame-2", "ok": true, "result": { - "platform": "linux" + "targets": [ + { + "id": "ssh-1", + "label": "SSH" + } + ] } } } }, - "19b2979d7fc2": { - "name": "repo.list#1", - "ordinal": 2, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "1a160fc2d137": { - "name": "host.platform#1", - "ordinal": 9, - "args": [ - { - "name": "method", - "value": "host.platform" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "1ff13ee8621c": { - "name": "repoIconsByName", - "ordinal": 4, - "value": [] - }, - "2f1b95eac8f1": { - "name": "repoColorsByName", - "ordinal": 3, - "value": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ] - }, - "36a96ddbd396": { - "name": "host.platform#1", - "ordinal": 12, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" - }, "4526bff18b74": { "name": "repo.list#1", "ordinal": 1, @@ -134,14 +95,180 @@ } } }, - "53226cb82ec3": { + "5248b37ec085": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-4", + "ok": true, + "result": { + "platform": "linux" + } + } + } + }, + "661b4721aec6": { + "name": "desktopRepos", + "ordinal": 3, + "value": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ] + }, + "68916f125c3c": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "6d43117882f3": { + "desktopRepos": [ + { + "displayName": "Local", + "executionHostId": "local", + "id": "repo-1", + "path": "repo-1" + }, + { + "displayName": "Remote", + "executionHostId": "ssh:ssh-1", + "id": "repo-2", + "path": "repo-2" + } + ], + "hostHealthById": [], + "hostLabelById": [["ssh:ssh-1", "SSH"]], + "hostPlatform": "linux", + "repoHostIdByRepoId": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] + }, + "7c508088602c": { + "name": "host.platform#1", + "ordinal": 7, + "args": [ + { + "name": "method", + "value": "host.platform" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "8b618db22116": { + "name": "settings.get#1", + "ordinal": 9, + "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + }, + "902c0b2ed3ce": { + "name": "settings.get#1", + "ordinal": 6, + "args": [ + { + "name": "method", + "value": "settings.get" + }, + { + "name": "params", + "value": { + "$rpc": "absent" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "91e7f4ef9a77": { "name": "hostPlatform", - "ordinal": 15, + "ordinal": 13, "value": "linux" }, - "6021c58b2fd2": { - "name": "settings.get#1", + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "c1246f5792ee": { + "name": "ssh.listTargetSummaries#1", "ordinal": 8, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" + }, + "c1d6fc758acc": { + "name": "host.platform#1", + "ordinal": 10, + "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}" + }, + "ce32fe5b8377": { + "name": "settings.get#1", + "ordinal": 6, "args": [ { "name": "method", @@ -171,125 +298,23 @@ } } }, - "6063e534d9db": { - "name": "hostHealthById", - "ordinal": 13, - "value": [] - }, - "6134b73f18d0": { - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "668a7e313975": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 10, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}" - }, - "7e772c1c111d": { - "name": "repoIdsByName", - "ordinal": 5, - "value": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] - }, - "8ef3b348c179": { - "name": "repoHostIdByRepoId", - "ordinal": 6, - "value": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ] - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "96e0c54d86b0": { - "name": "settings.get#1", - "ordinal": 8, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "b428bce85ee7": { - "name": "settings.get#1", - "ordinal": 11, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" - }, - "bea13a4a2e87": { + "d9c464d0dc78": { "name": "hostLabelById", - "ordinal": 14, + "ordinal": 12, "value": [["ssh:ssh-1", "SSH"]] }, - "c752e67787f9": { - "name": "ssh.listTargetSummaries#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "ssh.listTargetSummaries" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "targets": [ - { - "id": "ssh-1", - "label": "SSH" - } - ] - } - } - } + "e2881f748a46": { + "name": "hostHealthById", + "ordinal": 11, + "value": [] + }, + "e49da3e1abca": { + "name": "repoHostIdByRepoId", + "ordinal": 4, + "value": [ + ["repo-1", "local"], + ["repo-2", "ssh:ssh-1"] + ] }, "eb79a9b3682a": { "status": "fulfilled", @@ -298,24 +323,6 @@ "value": { "$rpc": "undefined" } - }, - "f4667dda0187": { - "hostHealthById": [], - "hostLabelById": [["ssh:ssh-1", "SSH"]], - "hostPlatform": "linux", - "repoColorsByName": [ - ["Local", "#6366f1"], - ["Remote", "#f97316"] - ], - "repoHostIdByRepoId": [ - ["repo-1", "local"], - ["repo-2", "ssh:ssh-1"] - ], - "repoIconsByName": [], - "repoIdsByName": [ - ["Local", "repo-1"], - ["Remote", "repo-2"] - ] } }, "recording": { @@ -324,34 +331,32 @@ { "id": "settings-pending", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "9270aeb7d9c6" }, - "state": "6134b73f18d0", - "effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"] + "state": "68916f125c3c", + "effects": ["661b4721aec6", "e49da3e1abca"] } }, { "id": "settled", "observation": { - "sender": ["4526bff18b74", "c752e67787f9", "6021c58b2fd2", "03c9a2ba60b9"], - "payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"], + "sender": ["4526bff18b74", "299d571abf13", "ce32fe5b8377", "5248b37ec085"], + "payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"], "settlements": { "mount": "eb79a9b3682a", "load": "eb79a9b3682a" }, - "state": "f4667dda0187", + "state": "6d43117882f3", "effects": [ - "2f1b95eac8f1", - "1ff13ee8621c", - "7e772c1c111d", - "8ef3b348c179", - "6063e534d9db", - "bea13a4a2e87", - "53226cb82ec3" + "661b4721aec6", + "e49da3e1abca", + "e2881f748a46", + "d9c464d0dc78", + "91e7f4ef9a77" ] } } diff --git a/mobile/src/host-screen/host-repo-looks.ts b/mobile/src/host-screen/host-repo-looks.ts new file mode 100644 index 00000000000..9fe08ea5ca1 --- /dev/null +++ b/mobile/src/host-screen/host-repo-looks.ts @@ -0,0 +1,18 @@ +import type { SectionRepo } from '../worktree/workspace-list-types' +import { sectionRepoColor } from '../worktree/workspace-filter-repos' +import { readMobileRepoIcon, type MobileHostRepoIcon } from './host-screen-reply-schema' + +/** The colour and icon a repo's header and rows draw. */ +export type RepoLook = { color: string; icon: MobileHostRepoIcon | null } + +/** Decoded once per catalog, so a row's props keep their identity across polls. */ +export function buildRepoLookById( + repoById: ReadonlyMap +): ReadonlyMap { + return new Map( + [...repoById].map(([id, repo]) => [ + id, + { color: sectionRepoColor(repo), icon: readMobileRepoIcon(repo.repoIcon) } + ]) + ) +} diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index f02078e0e3a..ce65935f9e6 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -95,7 +95,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr Repositories {controller.sectionsResult.uniqueRepos.map((repo, i) => ( - + {i > 0 && } > +const mobileRepoIconSchema = z.union([ + repoIconBranches.lucide, + repoIconBranches.emoji, + repoIconBranches.image +]) + +/** A catalog repo's icon as MobileRepoIcon draws it; null for an arm this build cannot read. */ +export function readMobileRepoIcon(value: unknown): MobileHostRepoIcon | null { + const parsed = mobileRepoIconSchema.safeParse(value) + return parsed.success ? parsed.data : null +} // What the host screen reads to label its rows and to mirror the desktop's workspace view store. // Checked against src/main/runtime/rpc/methods/repo.ts:29, ssh.ts:55, host-capabilities.ts:8 and @@ -37,12 +48,12 @@ const repoIconBranches = { // PersistedUIState's workspace-view subset in mobile/src/worktree/workspace-view-settings.ts:14. /** - * The host's repo catalog, narrowed to what the label maps are built from. + * The host's repo catalog, narrowed to what the host-label maps and the repo cache are built from. * - * `id` and `displayName` are required and a row without either drops: `displayName` is the key of - * four Maps and the argument `repoColor` hashes with `name.charCodeAt` — a row without one was a - * TypeError that took the whole metadata refresh with it — and `id` is the Map value the workspace - * rows resolve their host through. + * `id` and `displayName` are required and a row without either drops: `displayName` is what the + * new-workspace picker shows from the repo cache, and `id` is the Map key the workspace rows + * resolve their host through. `path` labels an older desktop's repo sections (the newer one serves + * project-sections-operations.ts instead). * * `badgeColor` sits behind main's own `||` fallback to `repoColor`, so a salvaged member draws the * same swatch. `repoIcon` is declared as the three arms MobileRepoIcon renders, and an arm this @@ -64,11 +75,9 @@ export const hostRepoCatalogSchema = z z.looseObject({ id: z.string(), displayName: z.string(), + path: salvagedOptional('path', z.string()), badgeColor: salvagedOptional('badgeColor', z.string()), - repoIcon: salvagedOptional( - 'repoIcon', - z.union([repoIconBranches.lucide, repoIconBranches.emoji, repoIconBranches.image]) - ), + repoIcon: salvagedOptional('repoIcon', mobileRepoIconSchema), connectionId: salvagedOptional('connectionId', z.string().nullable()), executionHostId: salvagedOptional('executionHostId', z.string().nullable()) }) @@ -184,6 +193,8 @@ export const hostViewSettingsSchema = z */ export const hostScreenUnreadReplySchema = z.unknown() +export type HostRepoCatalogRow = z.output[number] + /** One decoded catalog icon: the members MobileRepoIcon reads, with the rest passed through. */ export type MobileHostRepoIcon = NonNullable< z.output[number]['repoIcon'] diff --git a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts index a225a1a48bd..ab05b44ab9a 100644 --- a/mobile/src/host-screen/host-show-pinned-in-groups.test.ts +++ b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts @@ -48,6 +48,7 @@ function screenState(clientRef: ClientRef, applied: boolean[]) { [ 'setActionError', 'setCatalogError', + 'setDesktopRepos', 'setError', 'setHostLabelById', 'setHostHealthById', @@ -57,9 +58,8 @@ function screenState(clientRef: ClientRef, applied: boolean[]) { 'setLastKnownWorktrees', 'setOptimisticActiveWorktreeIdentity', 'setPinnedIds', - 'setRepoColorsByName', 'setRepoHostIdByRepoId', - 'setRepoIconsByName', + 'setServedProjectSections', 'setSleptIds', 'setWorktrees', 'setWorktreesLoaded' diff --git a/mobile/src/host-screen/host-workspace-list-action-error.test.tsx b/mobile/src/host-screen/host-workspace-list-action-error.test.tsx index ec2d7af68e4..90b5677f374 100644 --- a/mobile/src/host-screen/host-workspace-list-action-error.test.tsx +++ b/mobile/src/host-screen/host-workspace-list-action-error.test.tsx @@ -60,7 +60,7 @@ function listWith(fields: { actionError: string; setActionError: (value: string) relayRecovery: { pairingRejected: false }, routeNotice: '', router: { push: () => {} }, - sectionsResult: { rawSections: [], sections: [], uniqueRepoColors: new Map() }, + sectionsResult: { rawSections: [], sections: [], repoById: new Map() }, setDismissedNotice: () => {}, settings: { activeFilterCount: 0 }, state: { @@ -69,7 +69,6 @@ function listWith(fields: { actionError: string; setActionError: (value: string) catalogError: null, collapsedGroups: new Set(), groupMode: 'none', - repoIconsByName: new Map(), search: '', showSearch: false, worktreesLoaded: true diff --git a/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx b/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx index 892e782420f..3ca99182a57 100644 --- a/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx +++ b/mobile/src/host-screen/host-workspace-list-row-memo.test.tsx @@ -68,7 +68,7 @@ function worktree(id: string, overrides: Partial = {}): Worktree { } const noop = () => {} -const stable = { ref: { current: null }, repoColors: new Map(), repoIcons: new Map() } +const stable = { ref: { current: null }, repoById: new Map() } function listProps(worktrees: Worktree[]) { // Each poll re-parses the payload, so rows are fresh objects even when nothing changed. @@ -91,13 +91,12 @@ function listProps(worktrees: Worktree[]) { now: 1_000, reconnectAttempts: 0, relayRecovery: { pairingRejected: false }, - sectionsResult: { rawSections: sections, sections, uniqueRepoColors: stable.repoColors }, + sectionsResult: { rawSections: sections, sections, repoById: stable.repoById }, settings: { activeFilterCount: 0, toggleWorktreeLineage: noop }, state: { actionError: '', setActionTarget: noop, groupMode: 'none', - repoIconsByName: stable.repoIcons, search: '', showSearch: false, worktreesLoaded: true diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index 6f47fc6415b..fc6b2180505 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { Platform, Pressable, RefreshControl, SectionList, Text, View } from 'react-native' import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native' import { AuthFailedBanner } from '../components/AuthFailedBanner' @@ -13,6 +14,7 @@ import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states' import { getWorktreeStatus } from '../worktree/workspace-list-sections' import { repoColor } from '../worktree/repo-color' import { hostScreenStyles as styles } from './host-screen-styles' +import { buildRepoLookById } from './host-repo-looks' import type { HostScreenController } from './use-host-screen-controller' export function HostWorkspaceList({ controller }: { controller: HostScreenController }) { @@ -40,7 +42,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro settings, state } = controller - const { rawSections, sections, uniqueRepoColors } = sectionsResult + const { rawSections, sections, repoById } = sectionsResult + const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById]) return ( <> @@ -140,10 +143,11 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro const isCollapsed = state.collapsedGroups.has(section.key) const rawSection = rawSections.find((s) => s.key === section.key) const count = rawSection?.data.length ?? 0 - const repoSectionColor = - state.groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null - const repoSectionIcon = - state.groupMode === 'repo' ? state.repoIconsByName.get(section.title) : null + // Why: the desktop's header draws its anchor repo's colour and icon. + const repoSectionLook = + state.groupMode === 'repo' && section.repo ? repoLookById.get(section.repo.id) : null + const repoSectionColor = repoSectionLook?.color ?? null + const repoSectionIcon = repoSectionLook?.icon ?? null return ( vi.fn()) + +vi.mock('../worktree/project-sections-operations', () => ({ + projectSectionsRead: { request }, + readServedProjectSections: () => ({ kind: 'answered', value: null }) +})) + +import type { RpcClient } from '../transport/rpc-client' +import { useHostProjectSections } from './use-host-project-sections' + +function clientDouble(): RpcClient { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only compares the client by identity and hands it to the mocked read. + return {} as RpcClient +} + +describe('useHostProjectSections', () => { + it('fetches the catalog at once when the screen moves to another host client', async () => { + request.mockResolvedValue({ ok: true }) + const clientRef: { current: RpcClient | null } = { current: null } + let refresh: (options?: { force?: boolean }) => Promise = async () => {} + function Probe({ client, hostId }: { client: RpcClient; hostId: string }) { + refresh = useHostProjectSections({ + client, + connState: 'connected', + hostId, + state: { clientRef, setServedProjectSections: () => {} } + }) + return null + } + const first = clientDouble() + const second = clientDouble() + clientRef.current = first + let tree!: ReturnType + act(() => { + tree = create(createElement(Probe, { client: first, hostId: 'a' })) + }) + await act(async () => refresh()) + await act(async () => refresh()) + expect(request).toHaveBeenCalledTimes(1) + + clientRef.current = second + act(() => tree.update(createElement(Probe, { client: second, hostId: 'b' }))) + await act(async () => refresh()) + expect(request).toHaveBeenCalledTimes(2) + expect(request).toHaveBeenLastCalledWith(second) + }) +}) diff --git a/mobile/src/host-screen/use-host-project-sections.ts b/mobile/src/host-screen/use-host-project-sections.ts new file mode 100644 index 00000000000..c775009186a --- /dev/null +++ b/mobile/src/host-screen/use-host-project-sections.ts @@ -0,0 +1,56 @@ +import { useCallback, useRef } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { + projectSectionsRead, + readServedProjectSections +} from '../worktree/project-sections-operations' +import type { HostScreenState } from './use-host-screen-state' + +const PROJECT_SECTIONS_REFRESH_MS = 60_000 + +/** + * The desktop sidebar's Project catalog, refreshed with repo metadata but as its own read, so a + * slow or missing answer never holds the repo labels back. + */ +export function useHostProjectSections(args: { + client: RpcClient | null + connState: ConnectionState + hostId: string | undefined + state: Pick +}): (options?: { force?: boolean }) => Promise { + const { client, connState, hostId, state } = args + const { clientRef, setServedProjectSections } = state + const inFlight = useRef(new WeakSet()) + // Per client, so a reused screen for another host fetches its catalog at once. + const fetchedAt = useRef(new WeakMap()) + + return useCallback( + async (options: { force?: boolean } = {}) => { + if (!client || connState !== 'connected' || !hostId || inFlight.current.has(client)) { + return + } + if ( + !options.force && + Date.now() - (fetchedAt.current.get(client) ?? 0) < PROJECT_SECTIONS_REFRESH_MS + ) { + return + } + inFlight.current.add(client) + try { + const reply = await projectSectionsRead.request(client).catch(() => null) + if (clientRef.current !== client) { + return + } + const sections = readServedProjectSections(reply) + if (sections.kind === 'answered') { + fetchedAt.current.set(client, Date.now()) + setServedProjectSections(sections.value) + } + } finally { + inFlight.current.delete(client) + } + }, + [client, clientRef, connState, hostId, setServedProjectSections] + ) +} diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index 892e90ff512..550b0eefed3 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -5,12 +5,13 @@ import { setCachedRepos } from '../cache/repo-cache' import type { RpcAcceptedResult } from '../transport/rpc-accepted-result' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcResponse } from '../transport/types' -import { repoColor } from '../worktree/repo-color' import { buildHostLabelById, buildRepoHostIdByRepoId } from '../worktree/worktree-host-context-labels' import { buildSshHostHealthById } from '../worktree/worktree-host-health' +import type { SectionRepo } from '../worktree/workspace-list-types' +import type { HostRepoCatalogRow } from './host-screen-reply-schema' import { hostPlatformRead, hostRepoCatalogRead, @@ -41,6 +42,18 @@ function acceptedMetadata( return verdict.accepted ? verdict.value : null } +/** A desktop repo.list row as Project sections read it, for a desktop that serves no catalog. */ +function desktopSectionRepo(repo: HostRepoCatalogRow): SectionRepo { + return { + id: repo.id, + displayName: repo.displayName, + path: repo.path ?? repo.id, + executionHostId: getRepoExecutionHostId(repo), + ...(repo.badgeColor ? { badgeColor: repo.badgeColor } : {}), + ...(repo.repoIcon ? { repoIcon: repo.repoIcon } : {}) + } +} + function readHostSettingOverrides(result: unknown): unknown { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. return (result as { hostSettingOverrides?: unknown } | null)?.hostSettingOverrides @@ -60,11 +73,9 @@ export function useHostRepoMetadata(args: { repoMetadataFetchedAtRef, setHostLabelById, setHostHealthById, + setDesktopRepos, setHostPlatform, - setRepoColorsByName, - setRepoHostIdByRepoId, - setRepoIconsByName, - setRepoIdsByName + setRepoHostIdByRepoId } = state const fetchRepoMetadata = useCallback( @@ -101,22 +112,7 @@ export function useHostRepoMetadata(args: { const catalog = repos.value repoMetadataFetchedAtRef.current = Date.now() setCachedRepos(requestHostId, catalog) - setRepoColorsByName( - new Map( - catalog.map((repo) => [ - repo.displayName, - repo.badgeColor || repoColor(repo.displayName) - ]) - ) - ) - setRepoIconsByName( - new Map( - catalog.flatMap((repo) => - repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : [] - ) - ) - ) - setRepoIdsByName(new Map(catalog.map((repo) => [repo.displayName, repo.id]))) + setDesktopRepos(catalog.map(desktopSectionRepo)) setRepoHostIdByRepoId(buildRepoHostIdByRepoId(catalog)) // Why: rows name their host when the list spans hosts or an SSH host may be unhealthy, // so a local-only catalog never pays for the label lookups. Counted over repos, not the diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 23eb64e99b1..2e3075b5899 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useLocalSearchParams, usePathname } from 'expo-router' import { useRouteHandoff } from '../navigation/route-handoff' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -16,7 +16,9 @@ import { } from '../transport/client-context-connection-metrics' import { useWorkspaceSections } from '../worktree/use-workspace-sections' import { useHostDisplayWorktrees } from './use-host-display-worktrees' -import { useHostRepoMetadata } from './use-host-repo-metadata' +import { buildProjectSectionCatalog } from '../worktree/project-section-catalog' +import { useHostProjectSections } from './use-host-project-sections' +import { useHostRepoMetadata, type FetchHostRepoMetadata } from './use-host-repo-metadata' import { useHostScreenIdentity } from './use-host-screen-identity' import { useHostScreenState } from './use-host-screen-state' import { useHostViewSettings } from './use-host-view-settings' @@ -72,7 +74,15 @@ export function useHostScreenController({ ) useHostScreenIdentity({ client, hostId, state }) - const fetchRepoMetadata = useHostRepoMetadata({ client, connState, hostId, state }) + const fetchRepoMetadataOnly = useHostRepoMetadata({ client, connState, hostId, state }) + const fetchProjectSections = useHostProjectSections({ client, connState, hostId, state }) + const fetchRepoMetadata = useCallback( + (options) => { + void fetchProjectSections(options) + return fetchRepoMetadataOnly(options) + }, + [fetchProjectSections, fetchRepoMetadataOnly] + ) const catalog = useHostWorktreeCatalog({ client, connState, @@ -104,6 +114,15 @@ export function useHostScreenController({ const showNewWorktree = resolvedRouteActionState.showNewWorktree const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId) + const projectCatalog = useMemo( + () => + buildProjectSectionCatalog( + state.servedProjectSections, + state.desktopRepos, + state.visibleHostIds + ), + [state.servedProjectSections, state.desktopRepos, state.visibleHostIds] + ) const sectionsResult = useWorkspaceSections({ displayWorktrees, sortMode: state.sortMode, @@ -112,8 +131,8 @@ export function useHostScreenController({ groupMode: state.groupMode, pinnedIds: state.pinnedIds, showPinnedInGroups: state.showPinnedInGroups, - repoIdsByName: state.repoIdsByName, - repoColorsByName: state.repoColorsByName, + projectCatalog, + filterCatalogRepos: state.servedProjectSections?.repos ?? state.desktopRepos, collapsedGroups: state.collapsedGroups, workspaceStatuses: state.workspaceStatuses }) diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/use-host-screen-identity.ts index 6f675b8457f..9e482ec124b 100644 --- a/mobile/src/host-screen/use-host-screen-identity.ts +++ b/mobile/src/host-screen/use-host-screen-identity.ts @@ -18,15 +18,15 @@ export function useHostScreenIdentity(args: { setCatalogError, setError, setHostLabelById, + setServedProjectSections, + setDesktopRepos, setHostHealthById, setHostName, setHostPlatform, setHostStoredDescriptor, setLastKnownWorktrees, setPinnedIds, - setRepoColorsByName, setRepoHostIdByRepoId, - setRepoIconsByName, setShowPinnedInGroups, setWorktrees, setWorktreesLoaded @@ -59,10 +59,10 @@ export function useHostScreenIdentity(args: { setHostName('') setHostStoredDescriptor(null) setError('') - setRepoColorsByName(new Map()) - setRepoIconsByName(new Map()) setRepoHostIdByRepoId(new Map()) setHostLabelById(new Map()) + setServedProjectSections(null) + setDesktopRepos([]) setHostHealthById(new Map()) setHostPlatform(null) setShowPinnedInGroups(false) diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index 19b7003581d..d24f4bc942d 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -13,7 +13,8 @@ import type { MobileViewState } from '../worktree/workspace-view-settings' import type { FilterState, Worktree } from '../worktree/workspace-list-sections' -import type { MobileHostRepoIcon } from './host-screen-reply-schema' +import type { ServedProjectSections } from '../worktree/project-sections-operations' +import type { SectionRepo } from '../worktree/workspace-list-types' import { NO_SERVER_WORKSPACES, type ServerWorkspaces } from '../worktree/server-workspaces' export function useHostScreenState(hostId: string | undefined, action: string | undefined) { @@ -39,8 +40,6 @@ export function useHostScreenState(hostId: string | undefined, action: string | const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState< string | null >(null) - const [repoColorsByName, setRepoColorsByName] = useState>(new Map()) - const [repoIconsByName, setRepoIconsByName] = useState>(new Map()) const [hostName, setHostName] = useState('') // The stored name identity, loaded with the name so the header can label an offline host. const [hostStoredDescriptor, setHostStoredDescriptor] = useState<{ @@ -66,8 +65,12 @@ export function useHostScreenState(hostId: string | undefined, action: string | const [workspaceStatuses, setWorkspaceStatuses] = useState( DEFAULT_MOBILE_WORKSPACE_STATUSES ) - // displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two. - const [repoIdsByName, setRepoIdsByName] = useState>(new Map()) + // The desktop sidebar's Project grouping inputs; null for a desktop that serves none. + const [servedProjectSections, setServedProjectSections] = useState( + null + ) + // The desktop's own repos from repo.list: an older desktop's rows group by these. + const [desktopRepos, setDesktopRepos] = useState([]) // Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform. const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState>( new Map() @@ -133,10 +136,9 @@ export function useHostScreenState(hostId: string | undefined, action: string | newWorktreeModalVisibleRef, optimisticActiveWorktreeIdentity, pinnedIds, - repoColorsByName, repoHostIdByRepoId, - repoIconsByName, - repoIdsByName, + servedProjectSections, + desktopRepos, repoMetadataFetchedAtRef, routeActionState, search, @@ -159,10 +161,9 @@ export function useHostScreenState(hostId: string | undefined, action: string | setLastKnownWorktrees, setOptimisticActiveWorktreeIdentity, setPinnedIds, - setRepoColorsByName, setRepoHostIdByRepoId, - setRepoIconsByName, - setRepoIdsByName, + setServedProjectSections, + setDesktopRepos, setRouteActionState, setSearch, setServerNotice, diff --git a/mobile/src/worktree/project-section-catalog.test.ts b/mobile/src/worktree/project-section-catalog.test.ts new file mode 100644 index 00000000000..b08fd27c50e --- /dev/null +++ b/mobile/src/worktree/project-section-catalog.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { buildProjectSectionCatalog } from './project-section-catalog' +import type { ServedProjectSections } from './project-sections-operations' + +const uploaded = { type: 'image', src: 'data:image/png;base64,AAAA' } + +type ServedRepo = ServedProjectSections['repos'][number] + +function repo( + id: string, + executionHostId?: ServedRepo['executionHostId'], + repoIcon?: unknown +): ServedRepo { + return { + id, + displayName: 'orca', + path: `/repos/${id}`, + badgeColor: undefined, + addedAt: undefined, + connectionId: undefined, + executionHostId, + ...(repoIcon ? { repoIcon } : {}) + } +} + +describe('buildProjectSectionCatalog', () => { + it("restores an uploaded icon from the desktop's repo.list only for the same host's repo", () => { + const served: ServedProjectSections = { + repos: [repo('r1'), repo('r1', 'runtime:m4air')], + projects: [], + setups: [], + projectOrderBy: 'manual' + } + const catalog = buildProjectSectionCatalog(served, [repo('r1', undefined, uploaded)], null) + expect(catalog.repos.map((entry) => entry.repoIcon)).toEqual([uploaded, undefined]) + }) +}) diff --git a/mobile/src/worktree/project-section-catalog.ts b/mobile/src/worktree/project-section-catalog.ts new file mode 100644 index 00000000000..79140cc3564 --- /dev/null +++ b/mobile/src/worktree/project-section-catalog.ts @@ -0,0 +1,38 @@ +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' +import { getLogicalRepoOrderRankById } from '../../../src/shared/project-section-order' +import type { ServedProjectSections } from './project-sections-operations' +import { NO_PROJECT_SECTION_CATALOG, type ProjectSectionCatalog } from './workspace-list-sections' +import type { SectionRepo } from './workspace-list-types' + +/** + * Project grouping's inputs: the desktop sidebar's own catalog when it serves one, else (an older + * desktop) its own repos with no projects, so rows group by repo. Only shown hosts' repos open + * empty sections; the grouping itself spans every host, as the desktop's does. + */ +export function buildProjectSectionCatalog( + served: ServedProjectSections | null, + desktopRepos: readonly SectionRepo[], + visibleHostIds: ReadonlySet | null +): ProjectSectionCatalog { + const shown = (repos: readonly SectionRepo[]) => + visibleHostIds + ? repos.filter((repo) => visibleHostIds.has(getRepoExecutionHostId(repo))) + : repos + if (!served) { + return { ...NO_PROJECT_SECTION_CATALOG, repos: shown(desktopRepos) } + } + // Why: the desktop withholds uploaded image icons from the snapshot; its repo.list still carries them. + const iconKey = (repo: SectionRepo) => `${getRepoExecutionHostId(repo)}|${repo.id}` + const iconByKey = new Map(desktopRepos.map((repo) => [iconKey(repo), repo.repoIcon])) + const repos = served.repos.map((repo) => { + const icon = repo.repoIcon ? undefined : iconByKey.get(iconKey(repo)) + return icon ? { ...repo, repoIcon: icon } : repo + }) + return { + repos: shown(repos), + grouping: { projects: served.projects, projectHostSetups: served.setups }, + // Why: served repos come in the sidebar's header order, cross-host manual order included. + repoOrder: getLogicalRepoOrderRankById(served.repos.map((repo) => repo.id)), + projectOrderBy: served.projectOrderBy + } +} diff --git a/mobile/src/worktree/project-sections-operations.ts b/mobile/src/worktree/project-sections-operations.ts new file mode 100644 index 00000000000..21bbf9270a3 --- /dev/null +++ b/mobile/src/worktree/project-sections-operations.ts @@ -0,0 +1,104 @@ +import { z } from 'zod' +import { MOBILE_PROJECT_SECTIONS_METHOD } from '../../../src/shared/mobile-project-sections-contract' +import { normalizeExecutionHostId } from '../../../src/shared/execution-host' +import type { ProjectHostSetupMethod } from '../../../src/shared/project-types' +import type { RepoKind } from '../../../src/shared/repo-types' +import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types' +import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' +import type { RpcResponse } from '../transport/types' +import type { SectionRepo } from './workspace-list-types' + +const SETUP_METHOD_ARMS = hostUnionArms({ + 'legacy-repo': true, + 'imported-existing-folder': true, + cloned: true, + provisioned: true +}) +const REPO_KIND_ARMS = hostUnionArms({ git: true, folder: true }) +const PROJECT_ORDER_BY_ARMS = hostUnionArms({ manual: true, recent: true }) + +// A host id the desktop spells; normalized, so a spelling this build cannot read drops. +const hostIdSchema = z.string().transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'unknown host id' }) + return z.NEVER + } + return hostId +}) + +const repoSchema = z.object({ + id: z.string(), + displayName: z.string(), + path: z.string(), + badgeColor: salvagedOptional('badgeColor', z.string()), + addedAt: salvagedOptional('addedAt', z.number()), + connectionId: salvagedOptional('connectionId', z.string().nullable()), + executionHostId: salvagedOptional('executionHostId', hostIdSchema), + // Decoded where drawn (readMobileRepoIcon), so an arm this build lacks still keeps the row. + repoIcon: z.unknown().optional() +}) satisfies z.ZodType + +const setupSchema = z.object({ + id: z.string(), + projectId: z.string(), + hostId: hostIdSchema, + repoId: z.string(), + path: z.string(), + displayName: z.string(), + // Why the fallback: only 'provisioned' changes grouping, so a newer arm groups as a checkout. + setupMethod: z.enum(SETUP_METHOD_ARMS).catch('legacy-repo'), + kind: salvagedOptional('kind', z.enum(REPO_KIND_ARMS)), + connectionId: salvagedOptional('connectionId', z.string().nullable()), + executionHostId: salvagedOptional('executionHostId', hostIdSchema) +}) + +const catalogSchema = z.object({ + repos: salvagingArray(repoSchema), + projects: salvagingArray(z.object({ id: z.string(), displayName: z.string() })), + setups: salvagingArray(setupSchema), + projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual') +}) + +/** The desktop sidebar's Project grouping inputs, as this phone reads them. */ +export type ServedProjectSections = z.output + +/** The sidebar's Project grouping inputs; null until a desktop window has published them. */ +export const projectSectionsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'mobileRelay.projectSections-or-skip', + method: MOBILE_PROJECT_SECTIONS_METHOD, + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: rpcResultVariant( + 'project-sections', + z.object({ catalog: catalogSchema.nullable() }).transform(({ catalog }) => catalog) + ) + }) +) + +/** + * The desktop's sidebar catalog: absent (null) on a desktop that predates the read, and left + * as it was after a transient failure, so a dropped reply never regroups the list. + */ +export function readServedProjectSections( + reply: RpcResponse | null +): { kind: 'answered'; value: ServedProjectSections | null } | { kind: 'kept' } { + if (!reply) { + return { kind: 'kept' } + } + if (!reply.ok) { + return isMobileMethodUnavailableError(reply.error.code, reply.error.message) + ? { kind: 'answered', value: null } + : { kind: 'kept' } + } + try { + const verdict = projectSectionsRead.interpret(reply) + return verdict.accepted ? { kind: 'answered', value: verdict.value } : { kind: 'kept' } + } catch { + return { kind: 'kept' } + } +} diff --git a/mobile/src/worktree/use-workspace-sections.ts b/mobile/src/worktree/use-workspace-sections.ts index 0183efb78a2..0611620f9f8 100644 --- a/mobile/src/worktree/use-workspace-sections.ts +++ b/mobile/src/worktree/use-workspace-sections.ts @@ -2,18 +2,15 @@ import { useMemo } from 'react' import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' import { + buildSectionRepoMap, buildSections, type FilterState, + type ProjectSectionCatalog, type Section, + type SectionRepo, type Worktree } from './workspace-list-sections' -import { repoColor } from './repo-color' - -export type WorkspaceSectionRepo = { - name: string - id: string - color: string -} +import { buildFilterRepos, type WorkspaceSectionRepo } from './workspace-filter-repos' export function useWorkspaceSections(args: { displayWorktrees: Worktree[] @@ -23,15 +20,17 @@ export function useWorkspaceSections(args: { groupMode: MobileGroupMode pinnedIds: Set showPinnedInGroups: boolean - repoIdsByName: Map - repoColorsByName: Map + projectCatalog: ProjectSectionCatalog + /** Every catalog repo on every host, which is what the desktop's filter lists. */ + filterCatalogRepos: readonly SectionRepo[] collapsedGroups: Set workspaceStatuses: readonly WorkspaceStatusDefinition[] }): { sections: Section[] rawSections: Section[] uniqueRepos: WorkspaceSectionRepo[] - uniqueRepoColors: Map + /** Each row's repo by id, as the desktop's cards look theirs up for colour and icon. */ + repoById: ReadonlyMap } { const { displayWorktrees, @@ -41,28 +40,20 @@ export function useWorkspaceSections(args: { groupMode, pinnedIds, showPinnedInGroups, - repoIdsByName, - repoColorsByName, + projectCatalog, + filterCatalogRepos, collapsedGroups, workspaceStatuses } = args - const uniqueRepos = useMemo(() => { - const repos = new Map() - for (const w of displayWorktrees) { - if (!repos.has(w.repo)) { - repos.set(w.repo, { - id: repoIdsByName.get(w.repo) ?? w.repoId, - color: repoColorsByName.get(w.repo) ?? repoColor(w.repo) - }) - } - } - return [...repos.entries()].map(([name, { id, color }]) => ({ name, id, color })) - }, [displayWorktrees, repoColorsByName, repoIdsByName]) + const uniqueRepos = useMemo( + () => buildFilterRepos(filterCatalogRepos, displayWorktrees), + [filterCatalogRepos, displayWorktrees] + ) - const uniqueRepoColors = useMemo( - () => new Map(uniqueRepos.map((repo) => [repo.name, repo.color])), - [uniqueRepos] + const repoById = useMemo( + () => buildSectionRepoMap(projectCatalog.repos, displayWorktrees), + [projectCatalog.repos, displayWorktrees] ) const rawSections = useMemo( @@ -74,7 +65,7 @@ export function useWorkspaceSections(args: { search, groupMode, pinnedIds, - repoIdsByName, + projectCatalog, workspaceStatuses, collapsedGroups, showPinnedInGroups @@ -86,7 +77,7 @@ export function useWorkspaceSections(args: { search, groupMode, pinnedIds, - repoIdsByName, + projectCatalog, workspaceStatuses, collapsedGroups, showPinnedInGroups @@ -102,5 +93,5 @@ export function useWorkspaceSections(args: { [rawSections, collapsedGroups] ) - return { sections, rawSections, uniqueRepos, uniqueRepoColors } + return { sections, rawSections, uniqueRepos, repoById } } diff --git a/mobile/src/worktree/workspace-filter-repos.test.ts b/mobile/src/worktree/workspace-filter-repos.test.ts new file mode 100644 index 00000000000..83027127c24 --- /dev/null +++ b/mobile/src/worktree/workspace-filter-repos.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { buildFilterRepos } from './workspace-filter-repos' +import { filterWorktrees, type Worktree } from './workspace-list-sections' + +function row(worktreeId: string, repoId: string, hostId: Worktree['hostId']): Worktree { + return { + worktreeId, + repoId, + hostId, + repo: 'orca', + branch: 'feature', + displayName: worktreeId, + path: `/tmp/${worktreeId}`, + liveTerminalCount: 0, + hasAttachedPty: false, + unread: false, + isPinned: false, + linkedPR: null + } +} + +describe('the repo filter', () => { + it("lists each host's same-named repo separately, by id, as the desktop's filter does", () => { + const repos = buildFilterRepos( + [ + { id: 'local-orca', displayName: 'orca', path: '/Users/me/orca', badgeColor: '#111111' }, + { + id: 'm4-orca', + displayName: 'orca', + path: '/Users/m4air/orca/orca', + executionHostId: 'runtime:m4air' + }, + { id: 'assets', displayName: 'design-assets', path: '/Users/me/design-assets' } + ], + [] + ) + + expect(repos.map(({ key, id, name }) => ({ key, id, name }))).toEqual([ + { key: 'local|local-orca', id: 'local-orca', name: 'orca' }, + { key: 'runtime:m4air|m4-orca', id: 'm4-orca', name: 'orca' }, + { key: 'local|assets', id: 'assets', name: 'design-assets' } + ]) + expect(repos[0]?.color).toBe('#111111') + }) + + it('lists the repos the rows name until a catalog answers, still one per repo id', () => { + const repos = buildFilterRepos( + [], + [row('a', 'local-orca', 'local'), row('b', 'm4-orca', 'runtime:m4air')] + ) + + expect(repos.map((repo) => repo.id)).toEqual(['local-orca', 'm4-orca']) + }) + + it('filters by the picked repo id, leaving the same-named repo on another host out', () => { + const rows = [row('a', 'local-orca', 'local'), row('b', 'm4-orca', 'runtime:m4air')] + + expect( + filterWorktrees( + rows, + { filterRepoIds: new Set(['m4-orca']), hideSleeping: false, hideDefaultBranch: false }, + '' + ).map((w) => w.worktreeId) + ).toEqual(['b']) + }) +}) diff --git a/mobile/src/worktree/workspace-filter-repos.ts b/mobile/src/worktree/workspace-filter-repos.ts new file mode 100644 index 00000000000..618c4e5cb24 --- /dev/null +++ b/mobile/src/worktree/workspace-filter-repos.ts @@ -0,0 +1,39 @@ +import { getRepoExecutionHostId } from '../../../src/shared/execution-host' +import { repoColor } from './repo-color' +import { buildSectionRepoMap, type SectionRepo, type Worktree } from './workspace-list-sections' + +export type WorkspaceSectionRepo = { + /** Unique per host: two hosts can each hold a repo of the same name. */ + key: string + name: string + id: string + color: string +} + +/** A repo's badge colour, else the colour its name hashes to. */ +export function sectionRepoColor(repo: Pick): string { + return (typeof repo.badgeColor === 'string' && repo.badgeColor) || repoColor(repo.displayName) +} + +/** + * The repos the filter lists, as the desktop's project filter lists them: every catalog repo by + * id, each host's separately. Before any catalog answers, the repos the rows name. + */ +export function buildFilterRepos( + filterCatalogRepos: readonly SectionRepo[], + worktrees: readonly Worktree[] +): WorkspaceSectionRepo[] { + const repos = + filterCatalogRepos.length > 0 + ? filterCatalogRepos + : [...buildSectionRepoMap([], worktrees).values()] + const seen = new Set() + return repos.flatMap((repo) => { + const key = `${getRepoExecutionHostId(repo)}|${repo.id}` + if (seen.has(key)) { + return [] + } + seen.add(key) + return [{ key, id: repo.id, name: repo.displayName, color: sectionRepoColor(repo) }] + }) +} diff --git a/mobile/src/worktree/workspace-list-picker-options.ts b/mobile/src/worktree/workspace-list-picker-options.ts index ca597ed38cc..5a9f4886443 100644 --- a/mobile/src/worktree/workspace-list-picker-options.ts +++ b/mobile/src/worktree/workspace-list-picker-options.ts @@ -18,8 +18,8 @@ export const WORKSPACE_SORT_OPTIONS: PickerOption[] = [ ] export const WORKSPACE_GROUP_OPTIONS: PickerOption[] = [ - { value: 'none', label: 'No Grouping' }, + { value: 'none', label: 'None' }, { value: 'workspaceStatus', label: 'Status' }, - { value: 'repo', label: 'Repository' }, + { value: 'repo', label: 'Project' }, { value: 'prStatus', label: 'PR Status' } ] diff --git a/mobile/src/worktree/workspace-list-pinned-lineage.test.ts b/mobile/src/worktree/workspace-list-pinned-lineage.test.ts index 2b64872d9ac..d5d3dd58b64 100644 --- a/mobile/src/worktree/workspace-list-pinned-lineage.test.ts +++ b/mobile/src/worktree/workspace-list-pinned-lineage.test.ts @@ -1,7 +1,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { Worktree } from './workspace-list-sections' -import { buildSections } from './workspace-list-sections' +import { NO_PROJECT_SECTION_CATALOG, buildSections } from './workspace-list-sections' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses' function worktree(overrides: Partial = {}): Worktree { @@ -41,7 +41,7 @@ describe('a pinned parent with an unpinned child', () => { '', 'repo', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES, new Set(), showPinnedInGroups @@ -64,7 +64,7 @@ describe('a pinned parent with an unpinned child', () => { it('nests the child under its parent in both sections when the desktop setting is on', () => { expect(nesting(true)).toEqual([ { key: 'pinned', rows: nested }, - { key: 'repo:orca', rows: nested } + { key: 'repo:repo-1', rows: nested } ]) }) }) diff --git a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts new file mode 100644 index 00000000000..f0e03d8fa89 --- /dev/null +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { getRepoExecutionHostId } from '../../../src/shared/execution-host' +import { + PROJECT_SECTION_PARITY_HEADERS, + PROJECT_SECTION_PARITY_ROWS, + PROJECT_SECTION_PARITY_SERVED +} from '../../../src/shared/project-section-parity.test-fixture' +import { buildProjectSectionCatalog } from './project-section-catalog' +import { + projectSectionsRead, + readServedProjectSections, + type ServedProjectSections +} from './project-sections-operations' +import { buildSections, type Worktree } from './workspace-list-sections' + +// The desktop half lives in src/renderer/src/components/sidebar/ +// worktree-list-groups-mobile-parity.test.ts: buildRows renders these headers, and main serves +// PROJECT_SECTION_PARITY_SERVED for the same host answers. + +function served(projectOrderBy: 'manual' | 'recent'): ServedProjectSections | null { + const verdict = projectSectionsRead.interpret({ + id: 'sections', + ok: true, + result: { catalog: { ...PROJECT_SECTION_PARITY_SERVED, projectOrderBy } } + }) + return verdict.accepted ? verdict.value : null +} + +function rows(): Worktree[] { + const repoById = new Map(PROJECT_SECTION_PARITY_SERVED.repos.map((repo) => [repo.id, repo])) + return PROJECT_SECTION_PARITY_ROWS.flatMap((row) => { + const repo = repoById.get(row.repoId) + return repo + ? [ + { + worktreeId: row.id, + repoId: row.repoId, + hostId: getRepoExecutionHostId(repo), + repo: repo.displayName, + branch: 'feature', + displayName: row.id, + path: `${repo.path}-${row.id}`, + lastActivityAt: row.lastActivityAt, + liveTerminalCount: 0, + hasAttachedPty: false, + unread: false, + isPinned: false, + linkedPR: null + } + ] + : [] + }) +} + +function headers(catalog: ReturnType) { + return buildSections( + rows(), + 'recent', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'repo', + new Set(), + catalog + ).map((section) => ({ key: section.key, label: section.title })) +} + +describe('Project grouping on the phone', () => { + it.each(['recent', 'manual'] as const)( + "renders the desktop sidebar's %s headers from the catalog the desktop serves", + (projectOrderBy) => { + expect(rows()).toHaveLength(PROJECT_SECTION_PARITY_ROWS.length) + expect(headers(buildProjectSectionCatalog(served(projectOrderBy), [], null))).toEqual( + PROJECT_SECTION_PARITY_HEADERS[projectOrderBy] + ) + } + ) + + it("groups by repo from an older desktop's own repos when it serves no catalog", () => { + const desktopRepos = PROJECT_SECTION_PARITY_SERVED.repos.filter( + (repo) => repo.executionHostId === 'local' + ) + + expect(headers(buildProjectSectionCatalog(null, desktopRepos, null))).toEqual([ + { key: 'repo:m4-orca-two', label: 'orca' }, + { key: 'repo:win-orca', label: 'orca' }, + { key: 'repo:local-workspace', label: 'Users/jinwoo/orca/workspaces/orca/orca' }, + { key: 'repo:local-stably', label: 'Users/jinwoo/stably/orca' }, + { key: 'repo:m4-orca', label: 'orca' }, + { key: 'repo:local-assets', label: 'design-assets' } + ]) + }) + + it('drops a served setup whose host id this build cannot read, keeping the rest', () => { + const verdict = projectSectionsRead.interpret({ + id: 'sections', + ok: true, + result: { + catalog: { + ...PROJECT_SECTION_PARITY_SERVED, + setups: [ + { ...PROJECT_SECTION_PARITY_SERVED.setups[0], hostId: 'nonsense' }, + ...PROJECT_SECTION_PARITY_SERVED.setups.slice(1) + ] + } + } + }) + + expect(verdict.accepted && verdict.value?.setups).toHaveLength( + PROJECT_SECTION_PARITY_SERVED.setups.length - 1 + ) + }) +}) + +describe('readServedProjectSections', () => { + it('reads a served catalog, and a desktop with no published window as none', () => { + expect( + readServedProjectSections({ + id: 'sections', + ok: true, + result: { catalog: PROJECT_SECTION_PARITY_SERVED } + }) + ).toMatchObject({ kind: 'answered', value: { repos: expect.any(Array) } }) + expect( + readServedProjectSections({ id: 'sections', ok: true, result: { catalog: null } }) + ).toEqual({ kind: 'answered', value: null }) + }) + + it.each(['forbidden', 'method_not_found'])( + 'reads an older desktop that answers %s as serving none', + (code) => { + expect( + readServedProjectSections({ id: 'sections', ok: false, error: { code, message: code } }) + ).toEqual({ kind: 'answered', value: null }) + } + ) + + it('keeps the last catalog after a timeout or a dropped reply', () => { + expect( + readServedProjectSections({ + id: 'sections', + ok: false, + error: { code: 'timeout', message: 'timed out' } + }) + ).toEqual({ kind: 'kept' }) + expect(readServedProjectSections(null)).toEqual({ kind: 'kept' }) + }) +}) diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index f5dfe3f52c6..1d6c155776d 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import type { Worktree } from './workspace-list-sections' import { CREATE_GRACE_MS, + NO_PROJECT_SECTION_CATALOG, buildSections, filterWorktrees, getWorktreeStatus, @@ -32,8 +33,16 @@ function worktree(overrides: Partial = {}): Worktree { } } +// A catalog of [displayName, id] repos with no project rows, as an old desktop answers. +function catalogOf(repos: readonly (readonly [string, string])[]) { + return { + ...NO_PROJECT_SECTION_CATALOG, + repos: repos.map(([displayName, id]) => ({ id, displayName, path: `/repos/${id}` })) + } +} + function withoutSectionListKeys(sections: ReturnType) { - return sections.map((section) => ({ + return sections.map(({ repo: _repo, ...section }) => ({ ...section, data: section.data.map( ({ @@ -293,7 +302,7 @@ describe('buildSections', () => { '', 'workspaceStatus', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES ) @@ -339,7 +348,7 @@ describe('buildSections', () => { '', 'workspaceStatus', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES ) @@ -525,7 +534,7 @@ describe('buildSections', () => { '', 'repo', new Set(), - new Map([['orca', 'repo-1']]), + catalogOf([['orca', 'repo-1']]), DEFAULT_MOBILE_WORKSPACE_STATUSES ) @@ -556,7 +565,7 @@ describe('buildSections', () => { '', 'repo', new Set(), - new Map([['orca', 'repo-1']]) + catalogOf([['orca', 'repo-1']]) ) expect(sections[0]?.data.map((item) => item.worktreeId)).toEqual(['main', 'child']) @@ -570,7 +579,7 @@ describe('buildSections', () => { '', 'repo', new Set(), - new Map([ + catalogOf([ ['orca', 'repo-1'], ['zoom-img', 'repo-missing'] ]) @@ -594,7 +603,7 @@ describe('buildSections', () => { '', 'none', new Set(), - new Map([['zoom-img', 'repo-missing']]) + catalogOf([['zoom-img', 'repo-missing']]) ) expect(withoutSectionListKeys(sections)).toEqual([]) @@ -612,7 +621,7 @@ describe('buildSections', () => { 'zoom', 'repo', new Set(), - new Map([ + catalogOf([ ['zoom-img', 'repo-matching'], ['repo', 'repo-hidden'], ['zoom-hidden', 'repo-unfiltered'] @@ -637,7 +646,7 @@ describe('buildSections', () => { '', 'repo', new Set(), - new Map([ + catalogOf([ ['sleeping-repo', 'repo-sleeping'], ['empty-repo', 'repo-empty'] ]) @@ -667,7 +676,7 @@ describe('buildSections', () => { '', 'workspaceStatus', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES ) @@ -691,7 +700,7 @@ describe('buildSections', () => { '', 'workspaceStatus', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, [] ) @@ -720,7 +729,7 @@ describe('buildSections', () => { '', groupMode, new Set(['local-pinned']), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES, new Set(), showPinnedInGroups @@ -759,7 +768,7 @@ describe('buildSections', () => { '', 'repo', new Set(), - new Map([['orca', 'repo-1']]) + catalogOf([['orca', 'repo-1']]) ) expect(sections.map((section) => section.key)).toEqual(['pinned']) @@ -786,7 +795,7 @@ describe('buildSections', () => { '', 'none', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES ) @@ -817,7 +826,7 @@ describe('buildSections', () => { '', 'workspaceStatus', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES ) @@ -845,7 +854,7 @@ describe('buildSections', () => { '', 'workspaceStatus', new Set(), - new Map(), + NO_PROJECT_SECTION_CATALOG, DEFAULT_MOBILE_WORKSPACE_STATUSES, new Set([getMobileWorkspaceLineageGroupKey(parent)]) ) diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index 9f3b480d4b3..ed53ac550ff 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -10,12 +10,57 @@ import { getMobileWorkspaceLineageChildren } from './mobile-workspace-lineage' import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-status-groups' -import type { FilterState, Section, Worktree } from './workspace-list-types' +import type { FilterState, Section, SectionRepo, Worktree } from './workspace-list-types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' import { sortWorktrees } from './workspace-list-ordering' import { getWorktreeRowIdentity } from './worktree-host-row-identity' +import { + buildProjectGroupingIndex, + buildProjectSections, + type ProjectSectionModel +} from '../../../src/shared/project-section-grouping' +import { withRepoSectionDisplayLabels } from '../../../src/shared/project-section-order' +import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types' -export type { FilterState, Section, Worktree } from './workspace-list-types' +export type { FilterState, Section, SectionRepo, Worktree } from './workspace-list-types' + +/** What Project grouping reads beyond the rows: the desktop's all-host catalog and header order. */ +export type ProjectSectionCatalog = { + /** Catalog repos of the hosts the list shows; each without rows gets an empty section. */ + repos: readonly SectionRepo[] + /** Null until the desktop's catalog answers; rows then group by repo, as the desktop's would. */ + grouping: ProjectSectionModel | null + /** Manual header rank; undefined keeps first-row order. */ + repoOrder: ReadonlyMap | undefined + projectOrderBy: ProjectOrderBy +} + +export const NO_PROJECT_SECTION_CATALOG: ProjectSectionCatalog = { + repos: [], + grouping: null, + repoOrder: undefined, + projectOrderBy: 'manual' +} + +/** Catalog repos by id, plus a stand-in from the row for a repo its host's catalog lacks. */ +export function buildSectionRepoMap( + catalogRepos: readonly SectionRepo[], + worktrees: readonly Worktree[] +): Map { + const repoMap = new Map(catalogRepos.map((repo) => [repo.id, repo])) + for (const w of worktrees) { + if (!repoMap.has(w.repoId)) { + repoMap.set(w.repoId, { + id: w.repoId, + displayName: w.repo || 'Unknown', + // Why: labels key on host + path, so a shared '' would let one stand-in rename another. + path: w.repoId, + executionHostId: w.hostId + }) + } + } + return repoMap +} export { CREATE_GRACE_MS, getWorktreeStatus, sortWorktrees } from './workspace-list-ordering' function makeSection( @@ -23,13 +68,14 @@ function makeSection( title: string, data: Worktree[], collapsedGroups: ReadonlySet, - icon?: 'pin' + decoration: { icon?: 'pin'; repo?: SectionRepo } = {} ): Section { const rows = applyMobileWorkspaceLineage(data, collapsedGroups) return { key, title, - ...(icon ? { icon } : {}), + ...(decoration.icon ? { icon: decoration.icon } : {}), + ...(decoration.repo ? { repo: decoration.repo } : {}), data: rows.map((worktree) => ({ ...worktree, sectionListKey: `${key}:${getWorktreeRowIdentity(worktree)}` @@ -149,7 +195,7 @@ export function buildSections( search: string, groupMode: MobileGroupMode, pinnedIds: Set, - repoIdsByName: ReadonlyMap = new Map(), + projectCatalog: ProjectSectionCatalog = NO_PROJECT_SECTION_CATALOG, workspaceStatuses: readonly WorkspaceStatusDefinition[] = DEFAULT_MOBILE_WORKSPACE_STATUSES, collapsedGroups: ReadonlySet = new Set(), showPinnedInGroups = false @@ -164,7 +210,7 @@ export function buildSections( const sections: Section[] = [] if (pinned.length > 0) { - sections.push(makeSection('pinned', 'Pinned', pinned, collapsedGroups, 'pin')) + sections.push(makeSection('pinned', 'Pinned', pinned, collapsedGroups, { icon: 'pin' })) } if (groupMode === 'none') { @@ -172,35 +218,34 @@ export function buildSections( sections.push(makeSection('all', 'All', canonicalGroupWorktrees, collapsedGroups)) } } else if (groupMode === 'repo') { - const byRepo = new Map() - for (const w of canonicalGroupWorktrees) { - const key = w.repo || 'Unknown' - const list = byRepo.get(key) - if (list) { - list.push(w) - } else { - byRepo.set(key, [w]) - } - } const representedRepoIds = new Set(worktrees.map((w) => w.repoId)) const query = search.trim().toLowerCase() - for (const [displayName, id] of repoIdsByName) { - if (representedRepoIds.has(id)) { - continue - } - if (filters.filterRepoIds.size > 0 && !filters.filterRepoIds.has(id)) { - continue - } - if (query && !displayName.toLowerCase().includes(query)) { - continue - } - if (!byRepo.has(displayName)) { - byRepo.set(displayName, []) - } - } - for (const [repo, items] of byRepo) { - const key = `repo:${repoIdsByName.get(repo) ?? repo}` - sections.push(makeSection(key, repo, orderMainWorktreeFirst(items), collapsedGroups)) + const placeholderRepoIds = projectCatalog.repos + .filter( + (repo) => + !representedRepoIds.has(repo.id) && + (filters.filterRepoIds.size === 0 || filters.filterRepoIds.has(repo.id)) && + (!query || repo.displayName.toLowerCase().includes(query)) + ) + .map((repo) => repo.id) + // Why: the desktop sidebar's own grouping, so keys, labels and order match it. + const projectSections = withRepoSectionDisplayLabels( + buildProjectSections({ + items: canonicalGroupWorktrees, + repoIdOf: (w) => w.repoId, + repoMap: buildSectionRepoMap(projectCatalog.repos, worktrees), + projectIndex: buildProjectGroupingIndex(projectCatalog.grouping ?? undefined), + placeholderRepoIds, + repoOrder: projectCatalog.repoOrder, + projectOrderBy: projectCatalog.projectOrderBy + }) + ) + for (const [key, section] of projectSections) { + sections.push( + makeSection(key, section.label, orderMainWorktreeFirst(section.items), collapsedGroups, { + repo: section.repo + }) + ) } } else if (groupMode === 'workspaceStatus') { const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 7d9061ec2ca..0f6c499792b 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -1,6 +1,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import type { ProjectSectionRepo } from '../../../src/shared/project-section-grouping' export type Worktree = { sectionListKey?: string @@ -65,4 +66,15 @@ export type FilterState = { alwaysShowDefaultBranch?: boolean } -export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] } +/** A repo as Project sections read it: the desktop's catalog record, or one a row stands in for. */ +// repoIcon stays unread wire data until drawn (readMobileRepoIcon). +export type SectionRepo = ProjectSectionRepo & { badgeColor?: string; repoIcon?: unknown } + +export type Section = { + key: string + title: string + icon?: 'pin' + /** Under Project grouping, the repo whose colour and icon the header shows, as on the desktop. */ + repo?: SectionRepo + data: Worktree[] +} diff --git a/mobile/src/worktree/worktree-list-completeness.test.ts b/mobile/src/worktree/worktree-list-completeness.test.ts index dc4d97231d1..4ffd23bcda4 100644 --- a/mobile/src/worktree/worktree-list-completeness.test.ts +++ b/mobile/src/worktree/worktree-list-completeness.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildSections, type Worktree } from './workspace-list-sections' +import { NO_PROJECT_SECTION_CATALOG, buildSections, type Worktree } from './workspace-list-sections' import { applyDesktopViewSettings, type MobileViewState, @@ -47,11 +47,11 @@ const base: MobileViewState = { } // Reconstruct the list the host screen renders: apply the synced desktop -// settings, then build sections exactly as the screen does. `repoIdsByName` is -// the display-name-keyed map the screen builds from repo.list. +// settings, then build sections exactly as the screen does. `catalogRepos` are +// [displayName, id] rows of the repo.list catalog the screen groups by. function visibleWorktreeIds( worktrees: Worktree[], - repoIdsByName: Map, + catalogRepos: readonly (readonly [string, string])[], desktop: WorkspaceViewSettings = {} ): string[] { const state = applyDesktopViewSettings(base, desktop) @@ -66,7 +66,10 @@ function visibleWorktreeIds( '', state.groupMode, new Set(), - repoIdsByName, + { + ...NO_PROJECT_SECTION_CATALOG, + repos: catalogRepos.map(([displayName, id]) => ({ id, displayName, path: `/repos/${id}` })) + }, state.workspaceStatuses, new Set(state.collapsedGroups) ) @@ -81,9 +84,9 @@ describe('every worktree stays visible regardless of repo.list contents', () => worktree('orca-local', 'b'), worktree('orca-runtime', 'c') ] - const repoIdsByName = new Map([['orca-runtime', 'orca-runtime']]) + const catalogRepos = [['orca-runtime', 'orca-runtime']] as const - expect(visibleWorktreeIds(worktrees, repoIdsByName)).toEqual(['a', 'b', 'c']) + expect(visibleWorktreeIds(worktrees, catalogRepos)).toEqual(['a', 'b', 'c']) }) it('shows every worktree when the same repo name maps to two host-specific ids', () => { @@ -94,29 +97,26 @@ describe('every worktree stays visible regardless of repo.list contents', () => worktree('orca-local', 'local-2', 'orca'), worktree('orca-runtime', 'runtime-1', 'orca') ] - // Name-keyed map collapses "orca" to a single id, as the host screen builds it. - const repoIdsByName = new Map([['orca', 'orca-runtime']]) + // The catalog names only one of the two "orca" repos. + const catalogRepos = [['orca', 'orca-runtime']] as const - expect(visibleWorktreeIds(worktrees, repoIdsByName)).toEqual([ - 'local-1', - 'local-2', - 'runtime-1' - ]) + expect(visibleWorktreeIds(worktrees, catalogRepos)).toEqual(['local-1', 'local-2', 'runtime-1']) }) it('shows every worktree even when the desktop carries a host scope', () => { // Host scope must never reach the mobile list either (it has no mobile UI). const worktrees = [worktree('local-repo', 'x'), worktree('runtime-repo', 'y')] - const repoIdsByName = new Map([ + const catalogRepos = [ ['local-repo', 'local-repo'], ['runtime-repo', 'runtime-repo'] - ]) + ] as const - expect( - visibleWorktreeIds(worktrees, repoIdsByName, { - workspaceHostScope: 'runtime:devbox', - visibleWorkspaceHostIds: ['runtime:devbox'] - } as unknown as WorkspaceViewSettings) - ).toEqual(['x', 'y']) + // A variable, not a literal: `workspaceHostScope` is not a member the phone reads. + const desktopWithHostScope = { + workspaceHostScope: 'runtime:devbox', + visibleWorkspaceHostIds: ['runtime:devbox'] + } + + expect(visibleWorktreeIds(worktrees, catalogRepos, desktopWithHostScope)).toEqual(['x', 'y']) }) }) diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index b2d0d0fbb53..6e2df76c67b 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -23,6 +23,8 @@ vi.mock('electron', () => ({ import { registerRuntimeHandlers } from './runtime' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' +import { PROJECT_SECTION_PARITY_SERVED } from '../../shared/project-section-parity.test-fixture' +import { readMobileProjectSections } from '../runtime/mobile-project-sections-snapshot' function runtimeCallEvent() { const mainFrame = {} @@ -72,6 +74,46 @@ describe('registerRuntimeHandlers', () => { expect(result).toEqual({ graphStatus: 'ready' }) }) + it("serves the authoritative window's sidebar catalog to paired phones, and keeps it when omitted", () => { + const runtime = { syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }) } + registerRuntimeHandlers(runtime as never) + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'runtime:syncWindowGraph' + )![1] + const currentMainFrame = {} + const event = { sender: { mainFrame: currentMainFrame }, senderFrame: currentMainFrame } + fromWebContentsMock.mockReturnValue({ id: 17 }) + const graph = { tabs: [], leaves: [], rendererGeneration: 'renderer-1' } + + handler(event, { ...graph, projectSectionCatalog: PROJECT_SECTION_PARITY_SERVED }) + handler(event, graph) + + expect(readMobileProjectSections()).toEqual({ catalog: PROJECT_SECTION_PARITY_SERVED }) + }) + + it('does not serve a catalog from a graph the runtime refused', () => { + const refused = { ...PROJECT_SECTION_PARITY_SERVED, projectOrderBy: 'recent' as const } + const runtime = { + syncWindowGraph: vi.fn(() => { + throw new Error('Runtime graph publisher does not match the authoritative window') + }) + } + registerRuntimeHandlers(runtime as never) + const handler = handleMock.mock.calls.find( + ([channel]) => channel === 'runtime:syncWindowGraph' + )![1] + const currentMainFrame = {} + fromWebContentsMock.mockReturnValue({ id: 18 }) + + expect(() => + handler( + { sender: { mainFrame: currentMainFrame }, senderFrame: currentMainFrame }, + { tabs: [], leaves: [], rendererGeneration: 'renderer-2', projectSectionCatalog: refused } + ) + ).toThrow() + expect(readMobileProjectSections().catalog).not.toEqual(refused) + }) + it('rejects a graph publication queued by a superseded main frame', () => { const runtime = { syncWindowGraph: vi.fn(), diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 1736cbc6f60..45381da0e0b 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -15,6 +15,7 @@ import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { DESKTOP_RPC_CALLER } from '../runtime/rpc/rpc-caller-identity' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' +import { publishMobileProjectSections } from '../runtime/mobile-project-sections-snapshot' function boundTerminalFitRestore(pending: Promise): Promise { let timer: ReturnType | undefined @@ -49,7 +50,12 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { if (typeof graph.rendererGeneration !== 'string' || graph.rendererGeneration.length === 0) { throw new Error('Runtime graph sync requires a renderer generation') } - return runtime.syncWindowGraph(window.id, graph) + const result = runtime.syncWindowGraph(window.id, graph) + // Why after the sync: only the authoritative window's graph is accepted, so only its catalog is. + if (graph.projectSectionCatalog) { + publishMobileProjectSections(graph.projectSectionCatalog) + } + return result } ) diff --git a/src/main/runtime/mobile-project-sections-snapshot.ts b/src/main/runtime/mobile-project-sections-snapshot.ts new file mode 100644 index 00000000000..5c406f505f1 --- /dev/null +++ b/src/main/runtime/mobile-project-sections-snapshot.ts @@ -0,0 +1,16 @@ +import type { + MobileProjectSectionCatalog, + MobileProjectSectionsResult +} from '../../shared/mobile-project-sections-contract' + +// Why kept after the window closes: a stale sidebar catalog groups better than none. +let latestCatalog: MobileProjectSectionCatalog | null = null + +/** The authoritative desktop window's sidebar catalog, as its runtime graph last carried it. */ +export function publishMobileProjectSections(catalog: MobileProjectSectionCatalog): void { + latestCatalog = catalog +} + +export function readMobileProjectSections(): MobileProjectSectionsResult { + return { catalog: latestCatalog } +} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index db3786c3e2f..f0dca1d168b 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -49,6 +49,7 @@ import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { MOBILE_RELAY_HOSTS_METHODS } from './mobile-relay-hosts' +import { MOBILE_PROJECT_SECTIONS_METHODS } from './mobile-project-sections' import { UPDATER_METHODS } from './updater' import { AUTOMATIC_WORK_METHODS } from './automatic-work' import { AGENT_SESSION_METHODS } from './agent-session' @@ -125,6 +126,7 @@ export const ALL_RPC_METHODS = [ ...EMULATOR_METHODS, ...PAIRING_METHODS, ...MOBILE_RELAY_HOSTS_METHODS, + ...MOBILE_PROJECT_SECTIONS_METHODS, ...UPDATER_METHODS, ...AUTOMATIC_WORK_METHODS ] diff --git a/src/main/runtime/rpc/methods/mobile-project-sections.test.ts b/src/main/runtime/rpc/methods/mobile-project-sections.test.ts new file mode 100644 index 00000000000..22835c6068a --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-project-sections.test.ts @@ -0,0 +1,47 @@ +import '../unused-default-rpc-methods.test-fixture' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { MOBILE_PROJECT_SECTIONS_METHOD } from '../../../../shared/mobile-project-sections-contract' +import { PROJECT_SECTION_PARITY_SERVED } from '../../../../shared/project-section-parity.test-fixture' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the method reads no runtime member; the dispatcher only stamps the runtime id. +const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + +// Why fresh modules: the snapshot is process state, so each test starts with nothing published. +beforeEach(() => { + vi.resetModules() +}) + +async function sectionsDispatcher(): Promise { + const { MOBILE_PROJECT_SECTIONS_METHODS } = await import('./mobile-project-sections') + return new RpcDispatcher({ runtime, methods: MOBILE_PROJECT_SECTIONS_METHODS }) +} + +describe(MOBILE_PROJECT_SECTIONS_METHOD, () => { + it('answers no catalog before a desktop window has published one', async () => { + const response = await ( + await sectionsDispatcher() + ).dispatch({ + id: 'req-1', + authToken: 'tok', + method: MOBILE_PROJECT_SECTIONS_METHOD + }) + + expect(response).toMatchObject({ ok: true, result: { catalog: null } }) + }) + + it("answers the window's last published catalog", async () => { + const dispatcher = await sectionsDispatcher() + const { publishMobileProjectSections } = await import('../../mobile-project-sections-snapshot') + publishMobileProjectSections(PROJECT_SECTION_PARITY_SERVED) + + const response = await dispatcher.dispatch({ + id: 'req-2', + authToken: 'tok', + method: MOBILE_PROJECT_SECTIONS_METHOD + }) + + expect(response).toMatchObject({ ok: true, result: { catalog: PROJECT_SECTION_PARITY_SERVED } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-project-sections.ts b/src/main/runtime/rpc/methods/mobile-project-sections.ts new file mode 100644 index 00000000000..617ab54a768 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-project-sections.ts @@ -0,0 +1,12 @@ +import { defineMethod } from '../core' +import { MOBILE_PROJECT_SECTIONS_METHOD } from '../../../../shared/mobile-project-sections-contract' +import { readMobileProjectSections } from '../../mobile-project-sections-snapshot' + +export const MOBILE_PROJECT_SECTIONS_METHODS = [ + defineMethod({ + name: MOBILE_PROJECT_SECTIONS_METHOD, + permission: 'workspace', + params: null, + handler: () => readMobileProjectSections() + }) +] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index b175513b672..eab6f380498 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -16,6 +16,8 @@ export const PAIRED_DESKTOP_METHODS = [ // The configured servers the desktop shows, and their workspace lists as it last fetched them. 'mobileRelay.hosts.list', 'mobileRelay.hosts.worktrees', + // The sidebar's Project grouping inputs, as the desktop window last published them. + 'mobileRelay.projectSections', 'speech.dictation.cancel', 'speech.dictation.chunk', 'speech.dictation.finish', diff --git a/src/renderer/src/app-shell/use-runtime-graph-sync.ts b/src/renderer/src/app-shell/use-runtime-graph-sync.ts index 96b7b56d59c..8ee1b8f67be 100644 --- a/src/renderer/src/app-shell/use-runtime-graph-sync.ts +++ b/src/renderer/src/app-shell/use-runtime-graph-sync.ts @@ -51,6 +51,22 @@ export function useRuntimeGraphSync(): void { }) }, []) + useEffect( + () => + useAppStore.subscribe((state, previousState) => { + // Why: the graph carries the sidebar's Project catalog to paired phones; republish on change. + if ( + state.repos !== previousState.repos || + state.projects !== previousState.projects || + state.projectHostSetups !== previousState.projectHostSetups || + state.projectOrderBy !== previousState.projectOrderBy + ) { + scheduleRuntimeGraphSync() + } + }), + [] + ) + useEffect(() => { setRuntimeGraphSyncEnabled(workspaceSessionReady) return () => { diff --git a/src/renderer/src/components/cmd-j/palette-filter-options.ts b/src/renderer/src/components/cmd-j/palette-filter-options.ts index 21a06b8a24a..4cc74bf66f7 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-options.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-options.ts @@ -1,4 +1,7 @@ -import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import { + getRepoDisplayLabelKey, + getRepoDisplayLabelsByPath +} from '../../../../shared/repo-display-labels' import { getRepoExecutionHostId, getWorktreeExecutionHostId, @@ -8,8 +11,8 @@ import { import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { getProjectHeaderRevealTarget } from '../sidebar/worktree-list/grouping/project-grouping' -import type { ProjectGroupingModel } from '../sidebar/worktree-list/grouping/project-grouping' +import { getProjectHeaderRevealTarget } from '../../../../shared/project-section-grouping' +import type { ProjectGroupingModel } from '../../../../shared/project-section-grouping' import type { SidebarHostOption } from '../sidebar/sidebar-host-options' import { expandEquivalentExecutionHostIds, diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index c403607e48f..e4581e11659 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -12,8 +12,8 @@ import type { Project, ProjectHostSetup } from '../../../../shared/project-types import type { Repo } from '../../../../shared/repo-types' import { translate } from '@/i18n/i18n' import { getProjectGroupHeaderKey } from '../sidebar/worktree-list/grouping/group-keys' -import { getProjectHeaderRevealTarget } from '../sidebar/worktree-list/grouping/project-grouping' -import type { ProjectGroupingModel } from '../sidebar/worktree-list/grouping/project-grouping' +import { getProjectHeaderRevealTarget } from '../../../../shared/project-section-grouping' +import type { ProjectGroupingModel } from '../../../../shared/project-section-grouping' export type CmdJProjectGroupResult = { id: string diff --git a/src/renderer/src/components/repo/NestedRepoChecklist.tsx b/src/renderer/src/components/repo/NestedRepoChecklist.tsx index 77bb3378900..35e6f062a47 100644 --- a/src/renderer/src/components/repo/NestedRepoChecklist.tsx +++ b/src/renderer/src/components/repo/NestedRepoChecklist.tsx @@ -2,7 +2,10 @@ import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import { GitBranch } from 'lucide-react' import type { NestedRepoScanResult } from '../../../../shared/project-group-types' import { cn } from '@/lib/utils' -import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import { + getRepoDisplayLabelKey, + getRepoDisplayLabelsByPath +} from '../../../../shared/repo-display-labels' import { translate } from '@/i18n/i18n' function NestedRepoSelectAllRow({ diff --git a/src/renderer/src/components/settings/settings-project-list.ts b/src/renderer/src/components/settings/settings-project-list.ts index 9d2bc449abc..17a2b64fdc7 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -12,7 +12,7 @@ import { buildProjectGroupingIndex, isCheckoutScopedProjectSetup, type ProjectGroupingModel -} from '@/components/sidebar/worktree-list/grouping/project-grouping' +} from '../../../../shared/project-section-grouping' export type SettingsProject = { projectId: string diff --git a/src/renderer/src/components/settings/settings-project-regrouping.test.ts b/src/renderer/src/components/settings/settings-project-regrouping.test.ts index b583222090b..e2557dc0779 100644 --- a/src/renderer/src/components/settings/settings-project-regrouping.test.ts +++ b/src/renderer/src/components/settings/settings-project-regrouping.test.ts @@ -5,7 +5,7 @@ import { createUIStore } from '../../store/slices/ui-slice-test-harness' import { buildProjectGroupingIndex, getProjectGroupingForRepo -} from '../sidebar/worktree-list/grouping/project-grouping' +} from '../../../../shared/project-section-grouping' import { buildSettingsProjectList, getSettingsEntryHostSelection, diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 0a89742097b..5786391c253 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -1,6 +1,6 @@ +import { getLogicalRepoOrderRankById } from '../../../../shared/project-section-order' import { applyAllRepoInsertAt, - getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, mapSidebarRepoDropIndexToAllRepoInsertAt diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts index f5ee78e81e5..40e10f61e9d 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -1,10 +1,10 @@ // @vitest-environment happy-dom import { describe, expect, it } from 'vitest' +import { getLogicalRepoOrderRankById } from '../../../../shared/project-section-order' import { applyAllRepoInsertAt, computeProjectHeaderDropPreview, - getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, getProjectHeaderDragBucketKey, getSidebarOrderedRepoHeaderIdsByBucket, diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index 4cc23ddd9c6..171aa8261c8 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -44,20 +44,6 @@ export function getSidebarOrderedRepoHeaderIdsByBucket( return buckets } -export function getLogicalRepoOrderRankById( - orderedRepoIds: readonly string[] -): Map { - const rankById = new Map() - orderedRepoIds.forEach((repoId, index) => { - // Why: paired hosts can contribute the same logical project ID; its merged - // header must anchor to the first occurrence instead of whichever host loaded last. - if (!rankById.has(repoId)) { - rankById.set(repoId, index) - } - }) - return rankById -} - export function getProjectGroupOrderForSidebarDrop(args: { siblings: readonly Repo[] dropIndex: number diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index 290f44e2ef6..2d7b680a019 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -12,7 +12,7 @@ import { getPinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-typ import { addHostSectionRows, type HostSectionRow } from './host-section-rows' import { orderHostSectionOptions } from './host-section-order' import { buildSidebarHostOptions } from './sidebar-host-options' -import { getLogicalRepoOrderRankById } from './project-header-drop' +import { getLogicalRepoOrderRankById } from '../../../../shared/project-section-order' import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' import { selectWorktreeListReviewCacheInputs } from './worktree-list/listing/review-cache-inputs' import { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts new file mode 100644 index 00000000000..251236173bc --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' +import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' +import type { ProjectCatalogRows } from '../../../../shared/project-compatibility-merge' +import { + buildProjectGroupingIndex, + buildProjectSections +} from '../../../../shared/project-section-grouping' +import { + getLogicalRepoOrderRankById, + withRepoSectionDisplayLabels +} from '../../../../shared/project-section-order' +import { + PROJECT_SECTION_PARITY_HEADERS, + PROJECT_SECTION_PARITY_HOSTS, + PROJECT_SECTION_PARITY_ROWS, + PROJECT_SECTION_PARITY_SERVED +} from '../../../../shared/project-section-parity.test-fixture' +import type { Repo } from '../../../../shared/repo-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { ProjectOrderBy } from '../../../../shared/ui-chrome-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + fetchRepoCatalogForTarget, + filterSetupsForPrunedRepoRows, + mergeFetchedRepoCatalog, + reconcileSupersededSshRepos +} from '../../store/repos/repo-catalog-merge' +import { getProjectHostSetupProjectionFromState } from '../../store/project-host-setup-selector' +import { buildRows } from './worktree-list/grouping/build-rows' +import { worktree as worktreeFixture } from './worktree-list-groups-test-fixtures' +import type { MobileProjectSectionCatalog } from '../../../../shared/mobile-project-sections-contract' +import { getProjectSectionCatalogFromState } from '../../runtime/sync-runtime-graph/project-section-catalog' + +// Why: the phone's Project sections must be the desktop sidebar's by construction. The desktop +// half answers the renderer's own catalog fetch and runs its merge, selector, repo order and +// buildRows; the phone half groups what main serves from the renderer's published catalog, and +// mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts runs buildSections on it. + +const { rpc, assertCapability } = vi.hoisted(() => ({ rpc: vi.fn(), assertCapability: vi.fn() })) +vi.mock('../../runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: rpc, + assertRuntimeEnvironmentCapability: assertCapability +})) + +function hostFor(target: RuntimeClientTarget) { + const host = PROJECT_SECTION_PARITY_HOSTS.find((entry) => + target.kind === 'local' + ? entry.target.kind === 'local' + : entry.target.kind === 'environment' && entry.target.environmentId === target.environmentId + ) + if (!host) { + throw new Error('unknown parity host') + } + return host +} + +function answerHosts(): void { + const local = hostFor({ kind: 'local' }) + vi.stubGlobal('window', { + api: { + repos: { list: async () => local.repos }, + projects: { + list: async () => local.lists?.projects ?? [], + listHostSetups: async () => local.lists?.setups ?? [] + } + } + }) + rpc.mockImplementation(async (target: RuntimeClientTarget, method: string) => { + const host = hostFor(target) + if (method === 'repo.list') { + return { repos: host.repos } + } + if (!host.lists) { + throw new Error('method_not_found') + } + return method === 'project.list' + ? { projects: host.lists.projects } + : { setups: host.lists.setups } + }) +} + +async function desktopCatalog(): Promise<{ repos: readonly Repo[]; rows: ProjectCatalogRows }> { + answerHosts() + // The all-host load's applyCatalog, host by host: local first, then each server. + let repos: readonly Repo[] = [] + let rows: ProjectCatalogRows = { projects: [], projectHostSetups: [] } + for (const host of PROJECT_SECTION_PARITY_HOSTS) { + const catalog = await fetchRepoCatalogForTarget(host.target) + const result = mergeFetchedRepoCatalog(catalog, repos) + const reconciled = reconcileSupersededSshRepos(result.repos, { pendingSshRepoReadoptions: [] }) + const finalized = applyManualRepoOrder(reconciled.repos, []) + rows = mergeFetchedHostProjectCatalog({ + previous: { + projects: rows.projects, + projectHostSetups: filterSetupsForPrunedRepoRows( + rows.projectHostSetups, + result.repos, + finalized + ) + }, + fetched: catalog.projectHostSetupCompatibility, + repos: finalized, + hostId: result.hostId + }) + repos = finalized + } + return { repos, rows } +} + +async function desktopHeaders(projectOrderBy: ProjectOrderBy) { + const { repos, rows } = await desktopCatalog() + const projection = getProjectHostSetupProjectionFromState({ repos, ...rows }) + const worktrees: Worktree[] = PROJECT_SECTION_PARITY_ROWS.map((row) => ({ + ...worktreeFixture, + id: row.id, + repoId: row.repoId, + lastActivityAt: row.lastActivityAt + })) + const withWorktrees = new Set(PROJECT_SECTION_PARITY_ROWS.map((row) => row.repoId)) + return buildRows( + 'repo', + worktrees, + new Map(repos.map((repo) => [repo.id, repo])), + null, + new Set(), + // As use-section-rows ranks it: the order of state.repos. + getLogicalRepoOrderRankById(repos.map((repo) => repo.id)), + undefined, + projectOrderBy, + {}, + undefined, + false, + undefined, + [], + new Set(repos.filter((repo) => !withWorktrees.has(repo.id)).map((repo) => repo.id)), + new Map(), + new Map(), + [], + { projects: projection.projects, projectHostSetups: projection.setups } + ).flatMap((row) => (row.type === 'header' ? [{ key: row.key, label: row.label }] : [])) +} + +/** + * What main serves the phone: the renderer's published catalog as JSON crosses the wire. Main + * stores and answers it unchanged (src/main/ipc/runtime.test.ts, mobile-project-sections.test.ts). + */ +async function servedCatalog(projectOrderBy: ProjectOrderBy): Promise { + const { repos, rows } = await desktopCatalog() + const published = getProjectSectionCatalogFromState({ repos, ...rows, projectOrderBy }) + if (!published) { + throw new Error('the renderer published no catalog') + } + return JSON.parse(JSON.stringify(published)) +} + +/** The phone's grouping over a served catalog (mobile/src/worktree/project-section-catalog.ts). */ +function phoneHeaders(served: MobileProjectSectionCatalog) { + const withRows = new Set(PROJECT_SECTION_PARITY_ROWS.map((row) => row.repoId)) + return withRepoSectionDisplayLabels( + buildProjectSections({ + items: PROJECT_SECTION_PARITY_ROWS, + repoIdOf: (row) => row.repoId, + repoMap: new Map(served.repos.map((repo) => [repo.id, repo])), + projectIndex: buildProjectGroupingIndex({ + projects: served.projects, + projectHostSetups: served.setups + }), + placeholderRepoIds: served.repos + .filter((repo) => !withRows.has(repo.id)) + .map((repo) => repo.id), + repoOrder: getLogicalRepoOrderRankById(served.repos.map((repo) => repo.id)), + projectOrderBy: served.projectOrderBy + }) + ).map(([key, section]) => ({ key, label: section.label })) +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.resetAllMocks() +}) + +describe('the phone groups by Project exactly as the paired desktop sidebar', () => { + it.each(['recent', 'manual'] as const)( + 'gives checkouts of one project on local and servers the same %s headers', + async (projectOrderBy) => { + expect(await desktopHeaders(projectOrderBy)).toEqual( + PROJECT_SECTION_PARITY_HEADERS[projectOrderBy] + ) + const served = await servedCatalog(projectOrderBy) + expect(served).toEqual({ ...PROJECT_SECTION_PARITY_SERVED, projectOrderBy }) + expect(phoneHeaders(served)).toEqual(PROJECT_SECTION_PARITY_HEADERS[projectOrderBy]) + } + ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts index 8bbf99f9260..54d86f00fdb 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import { buildRows } from './worktree-list/grouping/build-rows' import { getNoticeHostContextLabels } from './worktree-list/grouping/host-labels' -import { buildProjectGroupingIndex } from './worktree-list/grouping/project-grouping' +import { buildProjectGroupingIndex } from '../../../../shared/project-section-grouping' import { repo, worktree, project, projectHostSetups } from './worktree-list-groups-test-fixtures' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { ProjectHostSetup } from '../../../../shared/project-types' diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts index 7a3041ce078..03a3cd4dce5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts @@ -19,8 +19,8 @@ import { getMixedWorktreeHostContextLabels, getNoticeHostContextLabels } from './host-labels' -import { buildProjectGroupingIndex } from './project-grouping' -import type { ProjectGroupingModel } from './project-grouping' +import { buildProjectGroupingIndex } from '../../../../../../shared/project-section-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import { appendProjectGroupSections } from './project-group-sections' import { getPinnedSectionWorktrees } from '../../pinned-section-worktrees' import { emitPinnedGroup } from './pinned-group-rows' @@ -42,7 +42,8 @@ import type { Row, WorktreeGroupBy } from './row-types' -import { getRenderedNaturalAnchorRepoIds, withRepoSectionDisplayLabels } from './section-order' +import { getRenderedNaturalAnchorRepoIds } from './section-order' +import { withRepoSectionDisplayLabels } from '../../../../../../shared/project-section-order' import { buildOrderedGroups } from './worktree-grouping' export function buildRows( diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts index bbba41b609b..607ab0e593a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts @@ -14,7 +14,8 @@ import { getLaneHostWorktreeIds, getMixedHostContextLabels } from './host-labels' -import type { OrderedGroupEntry, ProjectGroupingIndex } from './project-grouping' +import type { OrderedGroupEntry } from './worktree-group-entry' +import type { ProjectGroupingIndex } from '../../../../../../shared/project-section-grouping' import { appendWorktreeRows, buildFolderWorkspaceRow, diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts index 925b04eed41..9479d5243b7 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts @@ -12,9 +12,9 @@ import { import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' import { getProjectGroupingForRepo, - type ProjectGroupingIndex, - type WorktreeGroupEntry -} from './project-grouping' + type ProjectGroupingIndex +} from '../../../../../../shared/project-section-grouping' +import type { WorktreeGroupEntry } from './worktree-group-entry' import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id' import type { RenderableFolderWorkspace } from './folder-workspace-lanes' diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts index 6a3b01a9ff9..2993d40561f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts @@ -8,12 +8,12 @@ import { getEffectiveProjectGroupManualRank } from '../../../../../../shared/pro import { PROJECT_GROUP_META, getProjectGroupHeaderKey } from './group-keys' import { appendOrderedGroups } from './group-sections' import type { SectionAppendContext } from './group-sections' -import type { OrderedGroupEntry } from './project-grouping' +import type { OrderedGroupEntry } from './worktree-group-entry' import { compareRecentRank, createRecentRankLookup, withRepoSectionDisplayLabels -} from './section-order' +} from '../../../../../../shared/project-section-order' import { buildFolderWorkspaceRow } from './row-builders' export function appendProjectGroupSections( diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts deleted file mode 100644 index c0554bef73c..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type { RenderableFolderWorkspace } from './folder-workspace-lanes' -import { toSshExecutionHostId } from '../../../../../../shared/execution-host' -import { parseWslUncPath } from '../../../../../../shared/wsl-paths' -import { - isWindowsAbsolutePathLike, - normalizeRuntimePathForComparison -} from '../../../../../../shared/cross-platform-path' - -export type OrderedGroupEntry = [string, WorktreeGroupEntry] - -export type ProjectGroupingModel = { - projects: readonly Project[] - projectHostSetups: readonly ProjectHostSetup[] -} - -export type WorktreeGroupEntry = { - label: string - items: Worktree[] - repo?: Repo - repoIds: Set - /** Folder workspaces bucketed into this lane under non-repo grouping. Carries - * the owning group because FolderWorkspaceRow requires a non-optional one. */ - folderWorkspaces?: RenderableFolderWorkspace[] -} - -export type ProjectGroupingIndex = { - projectById: Map - setupByRepoId: Map - surfaceKeysRequiringSetupGroups: Set -} - -const projectGroupingIndexCache = new WeakMap() - -// Why: provisioned and folder setups are not independent Git checkouts. -function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { - return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' -} - -// Why: execution target and filesystem namespace independently identify a surface. -function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { - return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` -} - -function getExecutionSurface(setup: ProjectHostSetup): string { - const connectionId = setup.connectionId?.trim() - if (connectionId) { - return toSshExecutionHostId(connectionId) - } - return setup.executionHostId?.trim() || setup.hostId -} - -// Why: projection twins differ by row identity, not checkout directory. -function getCheckoutIdentity(setup: ProjectHostSetup): string { - return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id -} - -function getPathSurface(setup: ProjectHostSetup): string { - const wslPath = parseWslUncPath(setup.path) - if (wslPath) { - return `wsl:${wslPath.distro.toLowerCase()}` - } - if (isWindowsAbsolutePathLike(setup.path)) { - return 'windows-host' - } - return 'default' -} - -export function buildProjectGroupingIndex( - model?: ProjectGroupingModel -): ProjectGroupingIndex | null { - if (!model) { - return null - } - const cached = projectGroupingIndexCache.get(model) - if (cached !== undefined) { - return cached - } - const projects = model.projects ?? [] - const projectHostSetups = model.projectHostSetups ?? [] - if (projects.length === 0 || projectHostSetups.length === 0) { - projectGroupingIndexCache.set(model, null) - return null - } - const checkoutsByProjectSurface = new Map>() - for (const setup of projectHostSetups) { - if (!isDistinctUserCheckout(setup)) { - continue - } - const key = getProjectSetupSurfaceKey(setup) - const existing = checkoutsByProjectSurface.get(key) - if (existing) { - existing.add(getCheckoutIdentity(setup)) - } else { - checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)])) - } - } - const surfaceKeysRequiringSetupGroups = new Set() - for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) { - if (checkouts.size > 1) { - surfaceKeysRequiringSetupGroups.add(surfaceKey) - } - } - const index = { - projectById: new Map(projects.map((project) => [project.id, project])), - setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), - surfaceKeysRequiringSetupGroups - } - projectGroupingIndexCache.set(model, index) - return index -} - -// Why: Settings splits projects by this same rule so it matches the sidebar headers. -export function isCheckoutScopedProjectSetup( - setup: ProjectHostSetup, - projectIndex: ProjectGroupingIndex -): boolean { - return ( - projectIndex.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) && - isDistinctUserCheckout(setup) - ) -} - -export type ProjectHeaderRevealTarget = { - key: string - label: string - repo?: Repo - projectId?: string -} - -export function getProjectGroupingForRepo( - repoId: string, - repoMap: Map, - projectIndex: ProjectGroupingIndex | null -): ProjectHeaderRevealTarget { - const repo = repoMap.get(repoId) - const setup = projectIndex?.setupByRepoId.get(repoId) - const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined - if (!setup || !project) { - return { - key: `repo:${repoId}`, - label: repo?.displayName ?? 'Unknown', - repo - } - } - if (projectIndex && isCheckoutScopedProjectSetup(setup, projectIndex)) { - // Why: only the ambiguous surface needs checkout-specific headers. - return { - key: `project:${project.id}::setup:${repoId}`, - label: repo?.displayName ?? setup.displayName, - repo, - projectId: project.id - } - } - // Why: provisioned runtime copies and non-ambiguous checkouts follow project - // identity rather than path-scoped setup identity, so they stay in one project. - return { - key: `project:${project.id}`, - label: project.displayName, - repo, - projectId: project.id - } -} - -export function getProjectHeaderRevealTarget( - repoId: string, - repoMap: Map, - projectGrouping?: ProjectGroupingModel -): ProjectHeaderRevealTarget { - return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) -} - -export function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { - group.repoIds.add(repoId) -} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts index 397134f5f53..8d9a042b857 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/recent-project-rank-scan-budget.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' -import { sortProjectEntries } from './section-order' +import { sortProjectEntries } from '../../../../../../shared/project-section-order' import { appendProjectGroupSections } from './project-group-sections' import type { SectionAppendContext } from './group-sections' import { PROJECT_GROUP_META } from './group-keys' -import type { OrderedGroupEntry } from './project-grouping' +import type { OrderedGroupEntry } from './worktree-group-entry' import { repo, worktree } from '../../worktree-list-groups-test-fixtures' function entry( diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts index 4e21be7ac04..b087da6f75b 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts @@ -1,14 +1,8 @@ import type { Repo } from '../../../../../../shared/repo-types' -import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types' import type { AppState } from '../../../../store/types' -import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import { ALL_GROUP_KEY } from './group-keys' -import type { - OrderedGroupEntry, - ProjectGroupingModel, - WorktreeGroupEntry -} from './project-grouping' import type { WorktreeGroupBy } from './row-types' import { getGroupKeyForWorktree } from './worktree-group-keys' @@ -73,158 +67,6 @@ export function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { return [...mainWorktrees, ...worktrees.filter((worktree) => !worktree.isMainWorktree)] } -// Why: disambiguate a section's *own* label, not its anchor checkout's repo -// name. Substituting repo.displayName made a project header read the project -// name while it was the only repo-backed section and the checkout name as soon -// as a second one rendered, so filtering workspaces renamed the project -// (#16127). Path suffixes still resolve genuinely identical labels. -export function withRepoSectionDisplayLabels( - entries: readonly OrderedGroupEntry[] -): OrderedGroupEntry[] { - const labelItems = entries.flatMap(([, group]) => - group.repo ? [{ ...group.repo, displayName: group.label }] : [] - ) - if (labelItems.length === 0) { - return [...entries] - } - const labelsByPath = getRepoDisplayLabelsByPath(labelItems) - return entries.map(([key, group]) => [ - key, - group.repo - ? { ...group, label: labelsByPath.get(getRepoDisplayLabelKey(group.repo)) ?? group.label } - : group - ]) -} - -/** - * Recent rank for a project header. `hasActivity` projects (at least one - * visible worktree) always sort before fallback projects, regardless of the - * numeric values — a placeholder's `addedAt` must never outrank real activity. - * Within each tier, higher timestamps come first. - */ -type RecentRank = { hasActivity: boolean; ts: number } - -export function recentRankForEntry(entry: OrderedGroupEntry): RecentRank { - let max = Number.NEGATIVE_INFINITY - for (const worktree of entry[1].items) { - if (worktree.lastActivityAt > max) { - max = worktree.lastActivityAt - } - } - if (max !== Number.NEGATIVE_INFINITY) { - // Why: Recent must be timestamp-based, not encounter order — the incoming - // array is no longer pre-sorted by recency once decoupled from sortBy. - return { hasActivity: true, ts: max } - } - const addedAt = entry[1].repo?.addedAt - return { - hasActivity: false, - ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY - } -} - -export function createRecentRankLookup(): (entry: OrderedGroupEntry) => RecentRank { - const ranks = new Map() - return (entry) => { - const existing = ranks.get(entry) - if (existing) { - return existing - } - const rank = recentRankForEntry(entry) - ranks.set(entry, rank) - return rank - } -} - -export function compareRecentRank(a: RecentRank, b: RecentRank): number { - if (a.hasActivity !== b.hasActivity) { - return a.hasActivity ? -1 : 1 - } - return b.ts - a.ts -} - -function manualRankForEntry( - entry: OrderedGroupEntry, - repoOrder: Map | undefined -): number { - const key = entry[0] - const repoIds = - entry[1].repoIds.size > 0 - ? [...entry[1].repoIds] - : [key.startsWith('repo:') ? key.slice('repo:'.length) : key] - let rank = Number.POSITIVE_INFINITY - for (const repoId of repoIds) { - const repoRank = repoOrder?.get(repoId) - if (repoRank !== undefined && repoRank < rank) { - rank = repoRank - } - } - return rank -} - -export function getManualOrderAnchorRepo( - group: WorktreeGroupEntry, - repoMap: Map, - repoOrder: Map | undefined -): Repo | undefined { - let anchor = group.repo - let anchorRank = anchor ? (repoOrder?.get(anchor.id) ?? Number.POSITIVE_INFINITY) : undefined - for (const repoId of group.repoIds) { - const repo = repoMap.get(repoId) - if (!repo) { - continue - } - const rank = repoOrder?.get(repoId) ?? Number.POSITIVE_INFINITY - if (!anchor || rank < (anchorRank ?? Number.POSITIVE_INFINITY)) { - anchor = repo - anchorRank = rank - } - } - return anchor -} - -/** - * Order project header entries by the user's project-order preference. Manual - * follows the canonical repoOrder; Recent follows each project's most recent - * visible workspace activity (descending), with empty/imported-only projects - * sorting after active ones, then by manual rank, then label. - */ -export function sortProjectEntries( - entries: OrderedGroupEntry[], - projectOrderBy: ProjectOrderBy, - repoOrder: Map | undefined -): OrderedGroupEntry[] { - if (projectOrderBy === 'recent') { - if (entries.length < 2) { - return [...entries] - } - const getRecentRank = createRecentRankLookup() - return [...entries].sort((a, b) => { - const byRecent = compareRecentRank(getRecentRank(a), getRecentRank(b)) - if (byRecent !== 0) { - return byRecent - } - const ma = manualRankForEntry(a, repoOrder) - const mb = manualRankForEntry(b, repoOrder) - if (ma !== mb) { - return ma - mb - } - return a[1].label.localeCompare(b[1].label) - }) - } - if (!repoOrder) { - return entries - } - return [...entries].sort((a, b) => { - const ra = manualRankForEntry(a, repoOrder) - const rb = manualRankForEntry(b, repoOrder) - if (ra !== rb) { - return ra - rb - } - return a[1].label.localeCompare(b[1].label) - }) -} - /** * Build the flat row list consumed by the virtualizer. * Extracted here to keep WorktreeList.tsx under the line-count lint limit. diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-entry.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-entry.ts new file mode 100644 index 00000000000..6795308a40a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-entry.ts @@ -0,0 +1,11 @@ +import type { ProjectSectionEntry } from '../../../../../../shared/project-section-grouping' +import type { Worktree } from '../../../../../../shared/worktree/types' +import type { RenderableFolderWorkspace } from './folder-workspace-lanes' + +export type OrderedGroupEntry = [string, WorktreeGroupEntry] + +export type WorktreeGroupEntry = ProjectSectionEntry & { + /** Folder workspaces bucketed into this lane under non-repo grouping. Carries + * the owning group because FolderWorkspaceRow requires a non-optional one. */ + folderWorkspaces?: RenderableFolderWorkspace[] +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts index 807e41e97f1..5fe9b0633d2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts @@ -5,8 +5,11 @@ import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace- import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses' import type { AppState } from '../../../../store/types' import { ALL_GROUP_KEY, getPRGroupKey, getProjectGroupHeaderKey } from './group-keys' -import { buildProjectGroupingIndex, getProjectGroupingForRepo } from './project-grouping' -import type { ProjectGroupingModel } from './project-grouping' +import { + buildProjectGroupingIndex, + getProjectGroupingForRepo +} from '../../../../../../shared/project-section-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import type { WorktreeGroupBy } from './row-types' export function getGroupKeyForWorktree( diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts index b6449acd2a1..d4259c0f1fe 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts @@ -14,19 +14,18 @@ import { } from './folder-workspace-lanes' import { PR_GROUP_META, PR_GROUP_ORDER, getPRGroupKey, getPRLaneKey } from './group-keys' import type { PRGroupKey } from './group-keys' -import { addRepoIdToGroup, getProjectGroupingForRepo } from './project-grouping' -import type { - OrderedGroupEntry, - ProjectGroupingIndex, - WorktreeGroupEntry -} from './project-grouping' +import type { OrderedGroupEntry, WorktreeGroupEntry } from './worktree-group-entry' +import { + buildProjectSections, + type ProjectGroupingIndex +} from '../../../../../../shared/project-section-grouping' +import { orderProjectSections } from '../../../../../../shared/project-section-order' import type { ImportedWorktreesCardCandidate, NewExternalWorktreesInboxCandidate, PendingCreationRef, WorktreeGroupBy } from './row-types' -import { getManualOrderAnchorRepo, sortProjectEntries } from './section-order' /** Lane label for a lane a folder workspace opened before any worktree did. */ function getLaneLabelForKey( @@ -78,17 +77,33 @@ export function buildOrderedGroups(args: { folderWorkspaces = [] } = args + if (groupBy === 'repo') { + // Why shared: the paired phone builds its Project sections with this same function. + return buildProjectSections({ + items: naturalWorktrees, + repoIdOf: (worktree) => worktree.repoId, + repoMap, + projectIndex, + placeholderRepoIds, + noticeRepos: [ + ...[...importedWorktreesByRepo].map( + ([repoId, candidate]) => [repoId, candidate.repo] as const + ), + ...[...newExternalWorktreesInboxByRepo].map( + ([repoId, candidate]) => [repoId, candidate.repo] as const + ) + ], + pendingRepoIds: pendingByRepo.keys(), + repoOrder, + projectOrderBy + }) + } + const grouped = new Map() for (const w of naturalWorktrees) { let key: string let label: string - let repo: Repo | undefined - if (groupBy === 'repo') { - const grouping = getProjectGroupingForRepo(w.repoId, repoMap, projectIndex) - key = grouping.key - label = grouping.label - repo = grouping.repo - } else if (groupBy === 'workspace-status') { + if (groupBy === 'workspace-status') { const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses) key = getWorkspaceStatusGroupKey(workspaceStatus) label = @@ -99,111 +114,34 @@ export function buildOrderedGroups(args: { label = PR_GROUP_META[prGroup].label } if (!grouped.has(key)) { - grouped.set(key, { label, items: [], repo, repoIds: new Set() }) + grouped.set(key, { label, items: [], repo: undefined, repoIds: new Set() }) } const group = grouped.get(key)! group.items.push(w) - addRepoIdToGroup(group, w.repoId) + group.repoIds.add(w.repoId) } // Why: folder workspaces are not worktrees, so they never appear in the loop // above. Bucketing them here — and creating the lane when no worktree opened // one — is what lets a folder workspace be the sole occupant of a lane (#15362). - if (groupBy !== 'repo') { - for (const pair of folderWorkspaces) { - const key = getFolderWorkspaceLaneKey(pair, groupBy, workspaceStatuses) - if (!grouped.has(key)) { - grouped.set(key, { - label: getLaneLabelForKey(key, groupBy, workspaceStatuses), - items: [], - repo: undefined, - repoIds: new Set() - }) - } - const group = grouped.get(key)! - group.folderWorkspaces ??= [] - group.folderWorkspaces.push(pair) - } - for (const group of grouped.values()) { - group.folderWorkspaces?.sort((left, right) => - compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace) - ) + for (const pair of folderWorkspaces) { + const key = getFolderWorkspaceLaneKey(pair, groupBy, workspaceStatuses) + if (!grouped.has(key)) { + grouped.set(key, { + label: getLaneLabelForKey(key, groupBy, workspaceStatuses), + items: [], + repo: undefined, + repoIds: new Set() + }) } + const group = grouped.get(key)! + group.folderWorkspaces ??= [] + group.folderWorkspaces.push(pair) } - if (groupBy === 'repo') { - for (const repoId of placeholderRepoIds) { - const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) - if (!grouping.repo) { - continue - } - const key = grouping.key - if (!grouped.has(key)) { - // Why: repos can arrive before worktree scans, but stale IDs passed by - // older snapshots must not render an "Unknown" project header. - grouped.set(key, { - label: grouping.label, - items: [], - repo: grouping.repo, - repoIds: new Set([repoId]) - }) - } else { - addRepoIdToGroup(grouped.get(key)!, repoId) - } - } + for (const group of grouped.values()) { + group.folderWorkspaces?.sort((left, right) => + compareFolderWorkspacesForDisplay(left.folderWorkspace, right.folderWorkspace) + ) } - if (groupBy === 'repo') { - for (const [repoId, candidate] of importedWorktreesByRepo) { - const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) - const key = grouping.key - if (!grouped.has(key)) { - grouped.set(key, { - label: grouping.label, - items: [], - repo: grouping.repo ?? candidate.repo, - repoIds: new Set([repoId]) - }) - } else if (grouped.has(key)) { - addRepoIdToGroup(grouped.get(key)!, repoId) - } - } - } - if (groupBy === 'repo') { - for (const [repoId, candidate] of newExternalWorktreesInboxByRepo) { - const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) - const key = grouping.key - if (!grouped.has(key)) { - // Why: the default policy removes pinned worktrees from natural groups, - // but actionable inbox rows still need a project section to render in. - grouped.set(key, { - label: grouping.label, - items: [], - repo: grouping.repo ?? candidate.repo, - repoIds: new Set([repoId]) - }) - } else if (grouped.has(key)) { - addRepoIdToGroup(grouped.get(key)!, repoId) - } - } - } - if (groupBy === 'repo') { - for (const repoId of pendingByRepo.keys()) { - const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) - const key = grouping.key - if (!grouped.has(key)) { - // Why: creating the first worktree in a repo leaves it with no group yet; - // ensure one so the in-progress row nests under its repo instead of being - // dropped. - grouped.set(key, { - label: grouping.label, - items: [], - repo: grouping.repo, - repoIds: new Set([repoId]) - }) - } else { - addRepoIdToGroup(grouped.get(key)!, repoId) - } - } - } - const orderedGroups: OrderedGroupEntry[] = [] if (groupBy === 'pr-status') { for (const prGroup of PR_GROUP_ORDER) { @@ -224,16 +162,7 @@ export function buildOrderedGroups(args: { } } } else { - for (const group of grouped.values()) { - // Why: logical project headers can contain multiple host setup repos. - // Use the repo that anchors manual order so drag and actions target the - // same persisted order source the row sorter reads. - group.repo = getManualOrderAnchorRepo(group, repoMap, repoOrder) - } - // Why: project header order is its own user choice (projectOrderBy), - // decoupled from workspace sortBy. Manual uses the canonical repoOrder so - // header drag has a stable source of truth; Recent follows activity. - const entries = sortProjectEntries(Array.from(grouped.entries()), projectOrderBy, repoOrder) + const entries = orderProjectSections(grouped, repoMap, repoOrder, projectOrderBy) // Why: large imported repo sets can have one group per repo; spreading // those entries into push can exceed V8's argument limit. for (const entry of entries) { diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts index 1993416b091..3f35069dbf0 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts @@ -6,7 +6,7 @@ import type { Repo } from '../../../../../../shared/repo-types' import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' -import type { ProjectGroupingModel } from '../grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import { getFolderWorkspaceRevealGroupKeys } from '../navigation/folder-reveal' import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index 08499686972..99d1a04e012 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -13,9 +13,9 @@ import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import { getFolderWorkspaceHostIdentity } from '../../../../../../shared/folder-workspace-worktree' import { getHostDisplayLabelOverrides } from '../../../../../../shared/host-setting-overrides' import { buildRows } from '../grouping/build-rows' -import type { ProjectGroupingModel } from '../grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import type { PinnedWorktreeDisplayPolicy, Row, WorktreeGroupBy } from '../grouping/row-types' -import { getLogicalRepoOrderRankById } from '../../project-header-drop' +import { getLogicalRepoOrderRankById } from '../../../../../../shared/project-section-order' import { getEmptyProjectPlaceholderRepoIds } from '../../empty-project-placeholder-repos' import { deferHostSectionCollapse, scopeHostSectionCollapse } from '../../host-section-collapse' import { addHostSectionRows } from '../../host-section-rows' diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts index 465e6928d9d..42594527709 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts @@ -9,7 +9,7 @@ import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shar import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { RenderRow } from '../listing/render-row' -import type { ProjectGroupingModel } from '../grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' import { getFolderWorkspaceRevealGroupKeys } from './folder-reveal' import { getWorktreeRevealCollapsedGroupKeys } from './worktree-reveal-group-keys' diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts index 563387d0b93..44774d453ba 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts @@ -1,7 +1,7 @@ import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' import { getProjectGroupHeaderKey } from '../grouping/group-keys' -import type { ProjectGroupingModel } from '../grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' function getProjectIdFromHeaderRowKey(rowKey: string): string | null { if (!rowKey.startsWith('project:')) { diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts index 3fe1a15b004..dc5082a8c39 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/worktree-reveal-group-keys.ts @@ -14,7 +14,7 @@ import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection' import { getWorktreeLineageGroupKey, PINNED_GROUP_KEY } from '../grouping/group-keys' -import type { ProjectGroupingModel } from '../grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys' diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts index 8fbe8aef935..feb68d74262 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts @@ -21,7 +21,7 @@ import type { ImportedWorktreeCardActionState } from '../../imported-worktrees-c import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions' import type { WorktreeDragGroup } from '../../worktree-manual-order' import type { WorktreeStatusDropAtIndexArgs } from '../drag/drop-commit-context' -import type { ProjectGroupingModel } from '../grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' export const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index 606485eb91f..fbac15468a9 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -1,4 +1,4 @@ -import type { ProjectGroupingModel } from '@/components/sidebar/worktree-list/grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' import type { Repo } from '../../../shared/repo-types' export type SettingsNavigationBuildOptions = { diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 647a90e87fa..821f7eab3a3 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -22,7 +22,7 @@ import { defaultScopeHost } from '@/lib/default-creation-host' import { useAppStore } from '@/store' import { useProjectHostSetupProjection } from '@/store/selectors' import { useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' -import type { ProjectGroupingModel } from '@/components/sidebar/worktree-list/grouping/project-grouping' +import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' import type { Repo } from '../../../shared/repo-types' import { buildCapabilitySettingsSections, diff --git a/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts b/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts new file mode 100644 index 00000000000..4c8b812c789 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + scheduleRuntimeGraphSync, + setRuntimeGraphStoreStateGetter, + setRuntimeGraphSyncEnabled +} from './sync-runtime-graph' +import type { AppState } from '../store/types' +import type { Repo } from '../../../shared/repo-types' +import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types' + +vi.mock('./sync-runtime-graph/terminal-group-grid-sizes', () => ({ + measureTerminalGroupGridSizes: () => undefined +})) + +const repo: Repo = { + id: 'repo-1', + path: '/Users/me/orca', + displayName: 'orca', + badgeColor: '#737373', + addedAt: 1, + hookSettings: { mode: 'auto', scripts: { setup: 'secret-setup', archive: '' } }, + gitUsername: 'me', + repoIcon: { type: 'image', src: 'data:image/png;base64,AAAA', source: 'upload' } +} + +const BASE_STATE = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + agentStatusByPaneKey: {}, + browserTabsByWorktree: {}, + repos: [repo], + projects: [], + projectHostSetups: [], + projectOrderBy: 'recent' +} + +async function flushSync(): Promise { + await vi.advanceTimersByTimeAsync(20) + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + setRuntimeGraphSyncEnabled(false) + setRuntimeGraphStoreStateGetter(null) + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe("the sidebar's Project catalog in the window graph", () => { + it('carries it once per change, without repo hooks, credentials or uploaded icons', async () => { + const syncWindowGraph = vi + .fn<(graph: RuntimeSyncWindowGraph) => Promise>() + .mockResolvedValue({}) + vi.useFakeTimers() + vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } }) + vi.stubGlobal('HTMLElement', class HTMLElement {}) + let state = BASE_STATE + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: graph sync reads only these members of the store. + setRuntimeGraphStoreStateGetter(() => state as unknown as AppState) + setRuntimeGraphSyncEnabled(true) + await flushSync() + + expect(syncWindowGraph.mock.calls[0]?.[0].projectSectionCatalog).toEqual({ + repos: [ + { + id: 'repo-1', + displayName: 'orca', + path: '/Users/me/orca', + badgeColor: '#737373', + addedAt: 1 + } + ], + projects: [{ id: 'repo:repo-1', displayName: 'orca' }], + setups: [ + { + id: 'repo-1', + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/me/orca', + displayName: 'orca', + setupMethod: 'legacy-repo' + } + ], + projectOrderBy: 'recent' + }) + + scheduleRuntimeGraphSync() + await flushSync() + expect(syncWindowGraph).toHaveBeenCalledTimes(2) + expect(syncWindowGraph.mock.calls[1]?.[0]).not.toHaveProperty('projectSectionCatalog') + + state = { ...BASE_STATE, projectOrderBy: 'manual' } + scheduleRuntimeGraphSync() + await flushSync() + expect(syncWindowGraph.mock.calls[2]?.[0].projectSectionCatalog?.projectOrderBy).toBe('manual') + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts index cf058f35e37..4b1f9d9bf15 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -18,6 +18,7 @@ import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' import { isWebOnlyMirroredTerminalTab } from './mobile-session-surfaces' import { resolveRuntimeTerminalTitle } from './sync-projections' import { measureTerminalGroupGridSizes } from './terminal-group-grid-sizes' +import { getProjectSectionCatalogFromState } from './project-section-catalog' import { collectAmbiguousTerminalTabIds, graphState, @@ -60,6 +61,7 @@ export async function syncRuntimeGraph(): Promise { ) const publication = partitionMobileSessionPublication(mobileSessionTabs) const gridSizes = measureTerminalGroupGridSizes(state.settings?.terminalFontSize ?? 14) + const projectSectionCatalog = getProjectSectionCatalogFromState(state) const graph: RuntimeRendererSyncWindowGraph = { tabs: [], leaves: [], @@ -69,6 +71,9 @@ export async function syncRuntimeGraph(): Promise { ...(gridSizes ? { terminalGroupGridSizes: gridSizes.groups } : {}), ...(gridSizes?.singleGroupWorkspace ? { singleGroupWorkspaceGridSize: gridSizes.singleGroupWorkspace } + : {}), + ...(projectSectionCatalog && projectSectionCatalog !== graphState.publishedProjectSectionCatalog + ? { projectSectionCatalog } : {}) } @@ -198,6 +203,8 @@ export async function syncRuntimeGraph(): Promise { try { const result = await window.api.runtime.syncWindowGraph(graph) graphState.publishedTerminalGroupGridSizesKey = JSON.stringify(gridSizes ?? null) + graphState.publishedProjectSectionCatalog = + projectSectionCatalog ?? graphState.publishedProjectSectionCatalog commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) const currentState = graphState.getStoreState() currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts index a7cd35bad39..2e370f156cc 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts @@ -1,3 +1,4 @@ +import type { MobileProjectSectionCatalog } from '../../../../shared/mobile-project-sections-contract' import { createBrowserUuid } from '@/lib/browser-uuid' import type { AppState } from '@/store/types' import type { @@ -31,6 +32,10 @@ export const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] export const mobilePublicationEpoch = `renderer:${createBrowserUuid()}` +function noPublishedProjectSectionCatalog(): MobileProjectSectionCatalog | null { + return null +} + export const graphState = { syncScheduled: false, syncInFlight: false, @@ -56,7 +61,9 @@ export const graphState = { cachedEditorDraftHashes: null as EditorDraftHashCache | null, mobileSessionSnapshotVersion: 0, /** Last group grid sizes main accepted, so a refit republishes only when one changed. */ - publishedTerminalGroupGridSizesKey: '' + publishedTerminalGroupGridSizesKey: '', + /** Last sidebar Project catalog main accepted, so the graph carries it only when it changed. */ + publishedProjectSectionCatalog: noPublishedProjectSectionCatalog() } /** IDs occurring more than once cannot address the legacy tab-keyed runtime maps safely. */ diff --git a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts new file mode 100644 index 00000000000..e375e04d9ea --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts @@ -0,0 +1,44 @@ +import { + buildMobileProjectSectionCatalog, + type MobileProjectSectionCatalog +} from '../../../../shared/mobile-project-sections-contract' +import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' +import type { AppState } from '@/store/types' + +type CatalogSource = { + repos: AppState['repos'] + projection: ProjectHostSetupProjection + projectOrderBy: AppState['projectOrderBy'] + catalog: MobileProjectSectionCatalog +} + +let cachedCatalogSource: CatalogSource | null = null + +/** What the sidebar groups Project headers by: state.repos in its order and the selector's projection. */ +export function getProjectSectionCatalogFromState( + state: Pick +): MobileProjectSectionCatalog | null { + // Why: graph publication must not fail on a store slice that holds no repo catalog. + if (!Array.isArray(state.repos)) { + return null + } + const projection = getProjectHostSetupProjectionFromState(state) + const cached = cachedCatalogSource + if ( + cached && + cached.repos === state.repos && + cached.projection === projection && + cached.projectOrderBy === state.projectOrderBy + ) { + return cached.catalog + } + const catalog = buildMobileProjectSectionCatalog(state.repos, projection, state.projectOrderBy) + cachedCatalogSource = { + repos: state.repos, + projection, + projectOrderBy: state.projectOrderBy, + catalog + } + return catalog +} diff --git a/src/renderer/src/store/adopt-from-endpoint.ts b/src/renderer/src/store/adopt-from-endpoint.ts index 07b8ec6978d..2bdb56874c4 100644 --- a/src/renderer/src/store/adopt-from-endpoint.ts +++ b/src/renderer/src/store/adopt-from-endpoint.ts @@ -8,12 +8,14 @@ import type { ProjectGroup } from '../../../shared/project-group-types' import type { ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import { - getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { normalizeProjectHostSetupRow } from '../../../shared/project-catalog-row-normalization' +import { + adoptEndpointProjectHostSetup, + adoptEndpointRepo +} from '../../../shared/endpoint-catalog-adoption' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { getRuntimeTargetHostId } from '../runtime/runtime-client-target' @@ -81,11 +83,11 @@ export function adoptFromEndpoint( ): Repo | ProjectGroup | ProjectHostSetup | FolderWorkspace | OwnedWorktreeEvent | ExecutionHostId { switch (fetched.kind) { case 'repo': - return adoptRepo(target, fetched.row) + return adoptEndpointRepo(target, fetched.row) case 'projectGroup': return adoptProjectGroup(target, fetched.row) case 'projectHostSetup': - return adoptProjectHostSetup(target, fetched.row) + return adoptEndpointProjectHostSetup(target, fetched.row) case 'folderWorkspace': return { ...fetched.row, @@ -110,17 +112,6 @@ export function adoptFromEndpoint( } } -function adoptRepo(target: RuntimeClientTarget, repo: Repo): Repo { - if (target.kind === 'environment') { - // `connectionId` survives: on a server's row it names that server's nested SSH target. - return { ...repo, executionHostId: getRuntimeTargetHostId(target) } - } - if (repo.connectionId) { - return { ...repo, executionHostId: getRepoExecutionHostId(repo) } - } - return repo.executionHostId ? repo : { ...repo, executionHostId: LOCAL_EXECUTION_HOST_ID } -} - function adoptProjectGroup(target: RuntimeClientTarget, projectGroup: ProjectGroup): ProjectGroup { if (target.kind === 'environment') { return { ...projectGroup, executionHostId: getRuntimeTargetHostId(target) } @@ -130,25 +121,3 @@ function adoptProjectGroup(target: RuntimeClientTarget, projectGroup: ProjectGro } return { ...projectGroup, executionHostId: LOCAL_EXECUTION_HOST_ID } } - -function adoptProjectHostSetup( - target: RuntimeClientTarget, - setup: ProjectHostSetup -): ProjectHostSetup { - // Why here: every setup row crossing IPC or RPC into the renderer passes through this - // adoption step, so it is where the declared field types stop being aspirational. - const adopted = normalizeProjectHostSetupRow(setup) - if (target.kind !== 'environment') { - return adopted - } - const hostId = getRuntimeTargetHostId(target) - const executionHostId = adopted.executionHostId ?? adopted.hostId - return { - ...adopted, - hostId, - executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, - runtimeOwnerEnvironmentId: target.environmentId, - // Why: paired clients route through the HUB and must not treat its private SSH target as client-local configuration. - connectionId: null - } -} diff --git a/src/renderer/src/store/github/work-item-fetch-actions.ts b/src/renderer/src/store/github/work-item-fetch-actions.ts index 26904d761ce..70ac75ef212 100644 --- a/src/renderer/src/store/github/work-item-fetch-actions.ts +++ b/src/renderer/src/store/github/work-item-fetch-actions.ts @@ -6,7 +6,7 @@ import type { WorkItemsCacheError } from './cache-model' import { isGitHubWorkItemsSshRemoteRequiredError } from '../../../../shared/work-items' import { getTaskSourceCacheScope } from '../../../../shared/task-source-context' import { isGitHubWorkItemsQueryTooLarge } from '../slices/github-work-items-query-bounds' -import { reconcileCatalogRows } from '../slices/repo-identity-reconcile' +import { reconcileCatalogRows } from '../../../../shared/catalog-row-reconcile' import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { workItemsCacheKey, workItemsInflightRequestKey } from './cache-identity' import { isFresh, withBoundedCacheEntry, WORK_ITEMS_CACHE_TTL } from './cache-policy' diff --git a/src/renderer/src/store/project-host-setup-selector-normalization.ts b/src/renderer/src/store/project-host-setup-selector-normalization.ts deleted file mode 100644 index 154ee4e5ea0..00000000000 --- a/src/renderer/src/store/project-host-setup-selector-normalization.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Project, ProjectHostSetup } from '../../../shared/project-types' -import type { Repo } from '../../../shared/repo-types' -import { - getProjectIdentityKey, - type ProjectHostSetupProjection -} from '../../../shared/project-host-setup-projection' - -export type NormalizedProjectHostSetupProjection = ProjectHostSetupProjection & { - changed: boolean -} - -export function normalizeHydratedProjectHostSetupProjection( - repos: readonly Repo[], - projects: readonly Project[], - setups: readonly ProjectHostSetup[], - derived: ProjectHostSetupProjection -): NormalizedProjectHostSetupProjection { - const repoById = new Map(repos.map((repo) => [repo.id, repo])) - const derivedProjectIds = new Set(derived.projects.map((project) => project.id)) - const projectIdByHydratedProjectId = new Map() - let changed = false - const normalizedSetups = setups.map((setup) => { - const repo = repoById.get(setup.repoId) ?? repoById.get(setup.id) - if (!repo) { - return setup - } - const projectId = getProjectIdentityKey(repo) - if (projectId === setup.projectId || projectId === `repo:${repo.id}`) { - return setup - } - changed = true - projectIdByHydratedProjectId.set(setup.projectId, projectId) - return { ...setup, projectId } - }) - const normalizedProjects = projects.flatMap((project) => { - const projectId = projectIdByHydratedProjectId.get(project.id) - if (!projectId || projectId === project.id) { - return [project] - } - // Why: runtime-hosted copies of the same Git repo may hydrate path-scoped - // project ids. If the repo-derived project already exists, keep that bucket - // authoritative so VM copies group under the user's single project. - if (derivedProjectIds.has(projectId)) { - changed = true - return [] - } - changed = true - return [{ ...project, id: projectId }] - }) - return { projects: normalizedProjects, setups: normalizedSetups, changed } -} diff --git a/src/renderer/src/store/project-host-setup-selector.ts b/src/renderer/src/store/project-host-setup-selector.ts index f56b3a6e0b3..ca51a218695 100644 --- a/src/renderer/src/store/project-host-setup-selector.ts +++ b/src/renderer/src/store/project-host-setup-selector.ts @@ -3,226 +3,55 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../shared/project-host-setup-projection' +import { resolveProjectHostSetupProjection } from '../../../shared/project-host-setup-hydration' import type { AppState } from './types' -import { normalizeHydratedProjectHostSetupProjection } from './project-host-setup-selector-normalization' -const projectHostSetupProjectionCache = new WeakMap() -const providedProjectHostSetupProjectionCache = new WeakMap< - Project[], - WeakMap ->() -const mergedProjectHostSetupProjectionCache = new WeakMap< - AppState['repos'], - WeakMap> ->() -const normalizedProjectHostSetupProjectionCache = new WeakMap< - AppState['repos'], - WeakMap> ->() +const derivedProjectionByRepos = new WeakMap() // Catalog writers replace these readonly arrays; their identities are the invalidation boundary. -type StateProjectHostSetupProjectionCache = { - repos: AppState['repos'] - projects: AppState['projects'] - setups: AppState['projectHostSetups'] - projection: ProjectHostSetupProjection -} +const resolvedProjectionByCatalog = new WeakMap< + AppState['repos'], + WeakMap> +>() -let stateProjectHostSetupProjectionCache: StateProjectHostSetupProjectionCache | null = null - -function getCachedStateProjectHostSetupProjection( - repos: AppState['repos'], - projects: AppState['projects'], - setups: AppState['projectHostSetups'] -): ProjectHostSetupProjection | undefined { - const cached = stateProjectHostSetupProjectionCache - return cached && - cached.repos === repos && - cached.projects === projects && - cached.setups === setups - ? cached.projection - : undefined -} - -function cacheStateProjectHostSetupProjection( - repos: AppState['repos'], - projects: AppState['projects'], - setups: AppState['projectHostSetups'], - projection: ProjectHostSetupProjection -): void { - stateProjectHostSetupProjectionCache = { repos, projects, setups, projection } -} - -function getCachedProjectHostSetupProjection(repos: AppState['repos']): ProjectHostSetupProjection { - const cachedProjection = projectHostSetupProjectionCache.get(repos) - if (cachedProjection) { - return cachedProjection +function getDerivedProjection(repos: AppState['repos']): ProjectHostSetupProjection { + const cached = derivedProjectionByRepos.get(repos) + if (cached) { + return cached } - const projection = projectHostSetupProjectionFromRepos(repos) - projectHostSetupProjectionCache.set(repos, projection) - return projection -} - -function getCachedProvidedProjectHostSetupProjection( - projects: Project[], - setups: ProjectHostSetup[] -): ProjectHostSetupProjection { - const cachedBySetups = providedProjectHostSetupProjectionCache.get(projects) - const cachedProjection = cachedBySetups?.get(setups) - if (cachedProjection) { - return cachedProjection - } - - const projection = { projects, setups } - const nextCachedBySetups = - cachedBySetups ?? new WeakMap() - nextCachedBySetups.set(setups, projection) - if (!cachedBySetups) { - providedProjectHostSetupProjectionCache.set(projects, nextCachedBySetups) - } - return projection -} - -function mergeById(base: readonly T[], overlay: readonly T[]): T[] { - const merged = [...base] - const indexById = new Map(merged.map((entry, index) => [entry.id, index])) - for (const entry of overlay) { - const index = indexById.get(entry.id) - if (index === undefined) { - indexById.set(entry.id, merged.length) - merged.push(entry) - } else { - merged[index] = entry - } - } - return merged -} - -function mergeProjectHostSetupProjection( - repos: AppState['repos'], - projects: Project[], - setups: ProjectHostSetup[] -): ProjectHostSetupProjection { - const cachedByProjects = mergedProjectHostSetupProjectionCache.get(repos) - const cachedBySetups = cachedByProjects?.get(projects) - const cachedProjection = cachedBySetups?.get(setups) - if (cachedProjection) { - return cachedProjection - } - const derived = getCachedProjectHostSetupProjection(repos) - const normalized = normalizeHydratedProjectHostSetupProjection(repos, projects, setups, derived) - // Why: older runtimes/profiles may hydrate empty or partial project/setup arrays - // beside legacy repos. Keep repo-backed compatibility rows visible in that case. - const projection = { - projects: mergeById(derived.projects, normalized.projects), - setups: mergeById(derived.setups, normalized.setups) - } - const nextCachedByProjects = - cachedByProjects ?? - new WeakMap>() - const nextCachedBySetups = - cachedBySetups ?? new WeakMap() - nextCachedBySetups.set(setups, projection) - if (!cachedBySetups) { - nextCachedByProjects.set(projects, nextCachedBySetups) - } - if (!cachedByProjects) { - mergedProjectHostSetupProjectionCache.set(repos, nextCachedByProjects) - } - return projection -} - -function getCachedNormalizedProjectHostSetupProjection( - repos: AppState['repos'], - projects: Project[], - setups: ProjectHostSetup[], - derived: ProjectHostSetupProjection, - normalized: ProjectHostSetupProjection -): ProjectHostSetupProjection { - const cachedByProjects = normalizedProjectHostSetupProjectionCache.get(repos) - const cachedBySetups = cachedByProjects?.get(projects) - const cachedProjection = cachedBySetups?.get(setups) - if (cachedProjection) { - return cachedProjection - } - const projection = { - projects: mergeById(derived.projects, normalized.projects), - setups: mergeById(derived.setups, normalized.setups) - } - const nextCachedByProjects = - cachedByProjects ?? - new WeakMap>() - const nextCachedBySetups = - cachedBySetups ?? new WeakMap() - nextCachedBySetups.set(setups, projection) - if (!cachedBySetups) { - nextCachedByProjects.set(projects, nextCachedBySetups) - } - if (!cachedByProjects) { - normalizedProjectHostSetupProjectionCache.set(repos, nextCachedByProjects) - } + derivedProjectionByRepos.set(repos, projection) return projection } +// Why: this is a zustand selector compared with Object.is, so the result must be +// reference-stable per (repos, projects, setups) input or every render re-renders. export function getProjectHostSetupProjectionFromState( state: Pick & Partial> ): ProjectHostSetupProjection { - const projects = state.projects - const projectHostSetups = state.projectHostSetups - if (projects && projectHostSetups) { - const cachedProjection = getCachedStateProjectHostSetupProjection( - state.repos, - projects, - projectHostSetups - ) - if (cachedProjection) { - return cachedProjection - } - - const repoIds = new Set(state.repos.map((repo) => repo.id)) - const coveredRepoIds = new Set() - for (const setup of projectHostSetups) { - const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' - if (repoIds.has(repoId)) { - coveredRepoIds.add(repoId) - } - if (repoIds.has(setup.id)) { - coveredRepoIds.add(setup.id) - } - } - let projection: ProjectHostSetupProjection - if (state.repos.length > 0 && coveredRepoIds.size < repoIds.size) { - projection = mergeProjectHostSetupProjection( - state.repos, - projects as Project[], - projectHostSetups as ProjectHostSetup[] - ) - } else { - const derived = getCachedProjectHostSetupProjection(state.repos) - const normalized = normalizeHydratedProjectHostSetupProjection( - state.repos, - projects as Project[], - projectHostSetups as ProjectHostSetup[], - derived - ) - // Why: this is a zustand selector compared with Object.is, so the merged - // result must be reference-stable per (repos, projects, setups) input or - // every render returns a fresh object and triggers a re-render storm. - projection = normalized.changed - ? getCachedNormalizedProjectHostSetupProjection( - state.repos, - projects as Project[], - projectHostSetups as ProjectHostSetup[], - derived, - normalized - ) - : getCachedProvidedProjectHostSetupProjection( - projects as Project[], - projectHostSetups as ProjectHostSetup[] - ) - } - cacheStateProjectHostSetupProjection(state.repos, projects, projectHostSetups, projection) - return projection + const { repos, projects, projectHostSetups: setups } = state + if (!projects || !setups) { + return getDerivedProjection(repos) } - return getCachedProjectHostSetupProjection(state.repos) + let byProjects = resolvedProjectionByCatalog.get(repos) + if (!byProjects) { + byProjects = new WeakMap() + resolvedProjectionByCatalog.set(repos, byProjects) + } + let bySetups = byProjects.get(projects) + if (!bySetups) { + bySetups = new WeakMap() + byProjects.set(projects, bySetups) + } + const cached = bySetups.get(setups) + if (cached) { + return cached + } + const projection = resolveProjectHostSetupProjection( + repos, + projects, + setups, + getDerivedProjection(repos) + ) + bySetups.set(setups, projection) + return projection } diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index ef51b1a86c5..2cac33a7503 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -8,10 +8,6 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' -import { - normalizeProjectHostSetupRows, - normalizeProjectRows -} from '../../../../shared/project-catalog-row-normalization' import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY @@ -22,7 +18,7 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { adoptFromEndpoint } from '../adopt-from-endpoint' +import { adoptEndpointProjectCatalog } from '../../../../shared/endpoint-catalog-adoption' export function getProjectSetupRuntimeTarget( hostId: ProjectHostSetupExistingFolderArgs['hostId'] @@ -33,15 +29,6 @@ export function getProjectSetupRuntimeTarget( : { kind: 'local' } } -function normalizeProjectCatalogProjection( - projection: ProjectHostSetupProjection -): ProjectHostSetupProjection { - return { - projects: normalizeProjectRows([...projection.projects]), - setups: normalizeProjectHostSetupRows([...projection.setups]) - } -} - async function assertProjectHostSetupRuntimeCapability(target: RuntimeClientTarget): Promise { if (target.kind !== 'environment') { return @@ -71,10 +58,11 @@ export async function fetchProjectHostSetupCompatibility( if (!projectsApi?.list || !projectsApi.listHostSetups) { throw new Error('projects_api_unavailable') } - return normalizeProjectCatalogProjection({ - projects: await projectsApi.list(), - setups: await projectsApi.listHostSetups() - }) + return adoptEndpointProjectCatalog( + target, + await projectsApi.list(), + await projectsApi.listHostSetups() + ) } await assertProjectHostSetupRuntimeCapability(target) const [projectResponse, setupResponse] = await Promise.all([ @@ -85,14 +73,7 @@ export async function fetchProjectHostSetupCompatibility( timeoutMs: 15_000 }) ]) - return { - // Why projects too: the same wire response carries them, and a remote host on another - // Orca version can publish a row whose declared field types do not hold. - projects: normalizeProjectRows([...projectResponse.projects]), - setups: setupResponse.setups.map((setup) => - adoptFromEndpoint(target, { kind: 'projectHostSetup', row: setup }) - ) - } + return adoptEndpointProjectCatalog(target, projectResponse.projects, setupResponse.setups) } catch { // Why: newer clients must hydrate against older runtimes that only know repo.list; derive the transitional model locally. return projectHostSetupProjectionFromRepos(repos) diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 7bdb858efd7..8855c8cfeb9 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -5,7 +5,7 @@ import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery' import type { RepoSlice } from '../repos/repo-state' import { getProjectSetupRuntimeTarget } from './project-host-routing' -import { mergeUpdatedProjectCompatibilityProject } from './project-compatibility-core' +import { mergeUpdatedProjectCompatibilityProject } from '../../../../shared/project-compatibility-merge' import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' export function createProjectUpdateActions( diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index f9b8989cf3e..90ee84f35ab 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -27,12 +27,11 @@ import { filterSetupsForPrunedRepoRows, filterTrustedOrcaHooksToValidRepos, mergeFetchedRepoCatalog, - projectCompatibilityForReconciledRepos, reconcileReadoptedSshWorktreeState, reconcileSupersededSshRepos } from './repo-catalog-merge' import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' -import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' +import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' export function createAllHostRepoCatalogActions( @@ -67,11 +66,7 @@ export function createAllHostRepoCatalogActions( const result = mergeFetchedRepoCatalog(catalog, s.repos) const reconciliation = reconcileSupersededSshRepos(result.repos, s) const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) - const projectCompatibility = projectCompatibilityForReconciledRepos( - finalizedRepos, - catalog.projectHostSetupCompatibility - ) - const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { projects: s.projects, projectHostSetups: filterSetupsForPrunedRepoRows( @@ -80,7 +75,7 @@ export function createAllHostRepoCatalogActions( finalizedRepos ) }, - fetched: projectCompatibility, + fetched: catalog.projectHostSetupCompatibility, repos: finalizedRepos, hostId: result.hostId }) diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index 75c304b978f..10b65166d71 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -2,7 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' -import { reconcileCatalogRows } from '../slices/repo-identity-reconcile' +import { reconcileCatalogRows } from '../../../../shared/catalog-row-reconcile' import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' import { mergeSshRepoReadoptions, @@ -25,7 +25,6 @@ import { fetchRepoCatalogForTarget, filterSetupsForPrunedRepoRows, mergeFetchedRepoCatalog, - projectCompatibilityForReconciledRepos, reconcileReadoptedSshWorktreeState, reconcileSupersededSshRepos } from './repo-catalog-merge' @@ -33,8 +32,8 @@ import { getProjectHostSetupOwnerKey, mergeProjectHostSetupCompatibility, projectCompatibilityFromRepos -} from '../projects/project-compatibility-core' -import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' +} from '../../../../shared/project-compatibility-merge' +import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' export function createRepoCatalogActions( @@ -65,7 +64,7 @@ export function createRepoCatalogActions( ) // Why: mergeProjectHostSetupCompatibility always allocates; a no-op readoption // must not churn catalog identity. This write is all-repos, not host-scoped, - // so it cannot go through mergeFetchedProjectCompatibilityForHost. Reconcile + // so it cannot go through mergeFetchedHostProjectCatalog. Reconcile // hands the store arrays straight back when nothing moved, so writing them // unconditionally still leaves identity-keyed selectors untouched. const projects = reconcileCatalogRows( @@ -113,11 +112,7 @@ export function createRepoCatalogActions( const prunedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const validRepoIds = new Set(prunedRepos.map((repo) => repo.id)) const validRepoHostIdentities = new Set(prunedRepos.map(getRepoHostIdentity)) - const projectCompatibility = projectCompatibilityForReconciledRepos( - prunedRepos, - catalog.projectHostSetupCompatibility - ) - const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { projects: s.projects, projectHostSetups: filterSetupsForPrunedRepoRows( @@ -126,7 +121,7 @@ export function createRepoCatalogActions( prunedRepos ) }, - fetched: projectCompatibility, + fetched: catalog.projectHostSetupCompatibility, repos: prunedRepos, hostId: result.hostId }) diff --git a/src/renderer/src/store/repos/repo-catalog-identity.ts b/src/renderer/src/store/repos/repo-catalog-identity.ts index a5c838f630a..ee94fc5a537 100644 --- a/src/renderer/src/store/repos/repo-catalog-identity.ts +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -4,8 +4,8 @@ import { reconcileFetchedRepos } from '../slices/repo-identity-reconcile' import { getRepoHostIdentity } from '../slices/repo-host-identity' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { RepoSlice } from './repo-state' -import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' -import { projectCompatibilityFromRepos } from '../projects/project-compatibility-core' +import { mergeFetchedProjectCompatibilityForHost } from '../../../../shared/project-compatibility-host-merge' +import { projectCompatibilityFromRepos } from '../../../../shared/project-compatibility-merge' import { mergeByIdentity } from '../catalog-identity' export function mergeFetchedReposForHost( diff --git a/src/renderer/src/store/repos/repo-catalog-merge.ts b/src/renderer/src/store/repos/repo-catalog-merge.ts index ce6b6134b58..6f1a2fa7431 100644 --- a/src/renderer/src/store/repos/repo-catalog-merge.ts +++ b/src/renderer/src/store/repos/repo-catalog-merge.ts @@ -9,15 +9,10 @@ import { reconcileReadoptedSshWorktreesByRepo } from '../slices/readopted-ssh-wo import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { getRepoExecutionHostId } from '../../../../shared/execution-host' -import type { RepoSlice } from './repo-state' import { adoptFromEndpoint } from '../adopt-from-endpoint' import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' import { fetchProjectHostSetupCompatibility } from '../projects/project-host-routing' import { mergeFetchedReposForHost } from './repo-catalog-identity' -import { - mergeProjectHostSetupCompatibility, - projectCompatibilityFromRepos -} from '../projects/project-compatibility-core' export type FetchedRepoCatalog = { repos: readonly Repo[] @@ -120,13 +115,6 @@ export function reconcileReadoptedSshWorktreeState( } } -export function projectCompatibilityForReconciledRepos( - repos: readonly Repo[], - fetched: ProjectHostSetupProjection -): Pick { - return mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched) -} - export function filterTrustedOrcaHooksToValidRepos( trust: AppState['trustedOrcaHooks'], validRepoIds: Set diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index 5b2ce12be0f..ed9ea109d46 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -15,12 +15,11 @@ import { fetchRepoCatalogForTarget, filterSetupsForPrunedRepoRows, mergeFetchedRepoCatalog, - projectCompatibilityForReconciledRepos, reconcileReadoptedSshWorktreeState, reconcileSupersededSshRepos } from './repo-catalog-merge' import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' -import { mergeFetchedProjectCompatibilityForHost } from '../projects/project-compatibility-host-merge' +import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' export function createRuntimeRepoCatalogActions( @@ -56,11 +55,7 @@ export function createRuntimeRepoCatalogActions( const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const validRepoIds = new Set(finalizedRepos.map((repo) => repo.id)) const validRepoHostIdentities = new Set(finalizedRepos.map(getRepoHostIdentity)) - const projectCompatibility = projectCompatibilityForReconciledRepos( - finalizedRepos, - catalog.projectHostSetupCompatibility - ) - const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ + const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { projects: s.projects, projectHostSetups: filterSetupsForPrunedRepoRows( @@ -69,7 +64,7 @@ export function createRuntimeRepoCatalogActions( finalizedRepos ) }, - fetched: projectCompatibility, + fetched: catalog.projectHostSetupCompatibility, repos: finalizedRepos, hostId: result.hostId }) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 0e2b4aa1147..a5c564482aa 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -1,6 +1,7 @@ import type { Repo } from '../../../../shared/repo-types' import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { getRepoHostIdentity } from './repo-host-identity' +import { reconcileCatalogRows } from '../../../../shared/catalog-row-reconcile' // Why: after a drag-reorder we optimistically set `repos`, persist, and main // broadcasts `repos:changed`. The renderer's own echo handler refetches, which @@ -13,32 +14,6 @@ import { getRepoHostIdentity } from './repo-host-identity' // gitRemoteIdentity, repoIcon, path arrays) that structured-clone and main's hydrateRepo rebuild // every fetch, so a reference compare would report every repo as changed. -/** - * Reuses equal rows from `previous` — and the whole array when nothing moved — so a refetch that - * changed nothing leaves identity-keyed memos and store subscribers untouched. `getIdentity` must - * be the key the producing merge already dedups by, so it is unique within `next`. - */ -export function reconcileCatalogRows( - previous: readonly T[], - next: readonly T[], - getIdentity: (row: T) => string -): readonly T[] { - const previousByIdentity = new Map(previous.map((row) => [getIdentity(row), row])) - let identical = next.length === previous.length - const reconciled = next.map((row, index) => { - const existing = previousByIdentity.get(getIdentity(row)) - if (existing !== undefined && structuralValuesEqual(existing, row)) { - if (existing !== previous[index]) { - identical = false - } - return existing - } - identical = false - return row - }) - return identical ? previous : reconciled -} - export function reconcileFetchedRepos( previous: readonly Repo[], next: readonly Repo[] diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 25804801467..48563a8ab90 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -15,7 +15,7 @@ import { dismissRuntimeDisconnectedToast, showRuntimeDisconnectedToast } from './runtime-environment-disconnect-toast' -import { reconcileCatalogRows } from './repo-identity-reconcile' +import { reconcileCatalogRows } from '../../../../shared/catalog-row-reconcile' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' import * as runtimeStatusConnectionGeneration from './runtime-status-connection-generation' diff --git a/src/shared/catalog-row-reconcile.ts b/src/shared/catalog-row-reconcile.ts new file mode 100644 index 00000000000..262a913e738 --- /dev/null +++ b/src/shared/catalog-row-reconcile.ts @@ -0,0 +1,27 @@ +import { structuralValuesEqual } from './structural-value-equality' + +/** + * Reuses equal rows from `previous` — and the whole array when nothing moved — so a refetch that + * changed nothing leaves identity-keyed memos and store subscribers untouched. `getIdentity` must + * be the key the producing merge already dedups by, so it is unique within `next`. + */ +export function reconcileCatalogRows( + previous: readonly T[], + next: readonly T[], + getIdentity: (row: T) => string +): readonly T[] { + const previousByIdentity = new Map(previous.map((row) => [getIdentity(row), row])) + let identical = next.length === previous.length + const reconciled = next.map((row, index) => { + const existing = previousByIdentity.get(getIdentity(row)) + if (existing !== undefined && structuralValuesEqual(existing, row)) { + if (existing !== previous[index]) { + identical = false + } + return existing + } + identical = false + return row + }) + return identical ? previous : reconciled +} diff --git a/src/shared/endpoint-catalog-adoption.ts b/src/shared/endpoint-catalog-adoption.ts new file mode 100644 index 00000000000..d6c4ed4df55 --- /dev/null +++ b/src/shared/endpoint-catalog-adoption.ts @@ -0,0 +1,68 @@ +// The host stamp rows take entering a client's all-host catalog; shared so the phone matches the desktop. +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId +} from './execution-host' +import { + normalizeProjectHostSetupRow, + normalizeProjectHostSetupRows, + normalizeProjectRows +} from './project-catalog-row-normalization' +import type { ProjectHostSetupProjection } from './project-host-setup-projection' +import type { Project, ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import type { RuntimeClientTarget } from './runtime-client-target' + +export function adoptEndpointRepo(target: RuntimeClientTarget, repo: Repo): Repo { + if (target.kind === 'environment') { + // `connectionId` survives: on a server's row it names that server's nested SSH target. + return { ...repo, executionHostId: toRuntimeExecutionHostId(target.environmentId) } + } + if (repo.connectionId) { + return { ...repo, executionHostId: getRepoExecutionHostId(repo) } + } + return repo.executionHostId ? repo : { ...repo, executionHostId: LOCAL_EXECUTION_HOST_ID } +} + +export function adoptEndpointProjectHostSetup( + target: RuntimeClientTarget, + setup: ProjectHostSetup +): ProjectHostSetup { + // Why here: every setup row crossing IPC or RPC into a client passes through this + // adoption step, so it is where the declared field types stop being aspirational. + const adopted = normalizeProjectHostSetupRow(setup) + if (target.kind !== 'environment') { + return adopted + } + const hostId = toRuntimeExecutionHostId(target.environmentId) + const executionHostId = adopted.executionHostId ?? adopted.hostId + return { + ...adopted, + hostId, + executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, + runtimeOwnerEnvironmentId: target.environmentId, + // Why: paired clients route through the HUB and must not treat its private SSH target as client-local configuration. + connectionId: null + } +} + +/** An endpoint's `project.list` and `projectHostSetup.list` answers as a client's projection. */ +export function adoptEndpointProjectCatalog( + target: RuntimeClientTarget, + projects: readonly Project[], + setups: readonly ProjectHostSetup[] +): ProjectHostSetupProjection { + if (target.kind === 'local') { + return { + projects: normalizeProjectRows([...projects]), + setups: normalizeProjectHostSetupRows([...setups]) + } + } + return { + // Why projects too: the same wire response carries them, and a remote host on another + // Orca version can publish a row whose declared field types do not hold. + projects: normalizeProjectRows([...projects]), + setups: setups.map((setup) => adoptEndpointProjectHostSetup(target, setup)) + } +} diff --git a/src/shared/mobile-project-sections-contract.ts b/src/shared/mobile-project-sections-contract.ts new file mode 100644 index 00000000000..b24bb6a7304 --- /dev/null +++ b/src/shared/mobile-project-sections-contract.ts @@ -0,0 +1,64 @@ +import type { ProjectHostSetupProjection } from './project-host-setup-projection' +import type { ProjectSectionProject, ProjectSectionSetup } from './project-section-grouping' +import type { RepoIcon } from './repo-icon' +import type { Repo } from './repo-types' +import type { ProjectOrderBy } from './ui-chrome-types' + +// Why the desktop answers it: its sidebar's own grouping inputs, so the phone's Project sections match. +export const MOBILE_PROJECT_SECTIONS_METHOD = 'mobileRelay.projectSections' + +/** A sidebar repo as Project sections read it; never its hooks, credentials or AI settings. */ +export type MobileProjectSectionRepo = Pick & + Partial> & { repoIcon?: RepoIcon } + +export type MobileProjectSectionCatalog = { + /** Every repo the sidebar holds, across hosts, in its header order. */ + repos: MobileProjectSectionRepo[] + projects: ProjectSectionProject[] + setups: ProjectSectionSetup[] + projectOrderBy: ProjectOrderBy +} + +/** Null until a desktop window has published its sidebar catalog. */ +export type MobileProjectSectionsResult = { catalog: MobileProjectSectionCatalog | null } + +function sectionRepo(repo: Repo): MobileProjectSectionRepo { + return { + id: repo.id, + displayName: repo.displayName, + path: repo.path, + badgeColor: repo.badgeColor, + addedAt: repo.addedAt, + ...(repo.connectionId !== undefined ? { connectionId: repo.connectionId } : {}), + ...(repo.executionHostId ? { executionHostId: repo.executionHostId } : {}), + // Why not uploaded icons: a data URL can be hundreds of KB per repo on every read. + ...(repo.repoIcon && !(repo.repoIcon.type === 'image' && repo.repoIcon.src.startsWith('data:')) + ? { repoIcon: repo.repoIcon } + : {}) + } +} + +/** The sidebar's repos, in its order, and the projection its project-header selector produced. */ +export function buildMobileProjectSectionCatalog( + repos: readonly Repo[], + projection: ProjectHostSetupProjection, + projectOrderBy: ProjectOrderBy +): MobileProjectSectionCatalog { + return { + repos: repos.map(sectionRepo), + projects: projection.projects.map(({ id, displayName }) => ({ id, displayName })), + setups: projection.setups.map((setup) => ({ + id: setup.id, + projectId: setup.projectId, + hostId: setup.hostId, + repoId: setup.repoId, + path: setup.path, + displayName: setup.displayName, + setupMethod: setup.setupMethod, + ...(setup.kind ? { kind: setup.kind } : {}), + ...(setup.connectionId !== undefined ? { connectionId: setup.connectionId } : {}), + ...(setup.executionHostId ? { executionHostId: setup.executionHostId } : {}) + })), + projectOrderBy + } +} diff --git a/src/renderer/src/store/projects/project-compatibility-host-merge.ts b/src/shared/project-compatibility-host-merge.ts similarity index 87% rename from src/renderer/src/store/projects/project-compatibility-host-merge.ts rename to src/shared/project-compatibility-host-merge.ts index 8c51cbe9a4b..e37b7104eea 100644 --- a/src/renderer/src/store/projects/project-compatibility-host-merge.ts +++ b/src/shared/project-compatibility-host-merge.ts @@ -1,21 +1,24 @@ -import type { Project, ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import { reconcileCatalogRows } from '../slices/repo-identity-reconcile' +import type { Project, ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import { reconcileCatalogRows } from './catalog-row-reconcile' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, parseExecutionHostId -} from '../../../../shared/execution-host' -import type { RepoSlice } from '../repos/repo-state' +} from './execution-host' import { + type ProjectCatalogRows, getProjectHostSetupOwnerKey, getReposById, getSourceRepoIdsOutsideHost, mergePreviousProjectMetadata, mergeProjectCompatibilityProjects, + mergeProjectHostSetupCompatibility, mergeProjectHostSetupsByOwner, + projectCompatibilityFromRepos, projectWithCurrentSourceRepoIds -} from './project-compatibility-core' +} from './project-compatibility-merge' +import type { ProjectHostSetupProjection } from './project-host-setup-projection' export function getProjectHostIds( project: Project, @@ -130,11 +133,11 @@ export function mergeFetchedProjectCompatibilityForHost({ repos, hostId }: { - previous: Pick - fetched: Pick + previous: ProjectCatalogRows + fetched: ProjectCatalogRows repos: readonly Repo[] hostId: string -}): Pick { +}): ProjectCatalogRows { const setupBelongsToFetchedCatalog = (setup: ProjectHostSetup): boolean => { if (hostId !== LOCAL_EXECUTION_HOST_ID) { return setup.hostId === hostId @@ -224,3 +227,23 @@ export function mergeFetchedProjectCompatibilityForHost({ ) } } + +/** One host's fetched catalog folded into the all-host project rows, as each host refresh does. */ +export function mergeFetchedHostProjectCatalog({ + previous, + fetched, + repos, + hostId +}: { + previous: ProjectCatalogRows + fetched: ProjectHostSetupProjection + repos: readonly Repo[] + hostId: string +}): ProjectCatalogRows { + return mergeFetchedProjectCompatibilityForHost({ + previous, + fetched: mergeProjectHostSetupCompatibility(projectCompatibilityFromRepos(repos), fetched), + repos, + hostId + }) +} diff --git a/src/renderer/src/store/projects/project-compatibility-core.ts b/src/shared/project-compatibility-merge.ts similarity index 92% rename from src/renderer/src/store/projects/project-compatibility-core.ts rename to src/shared/project-compatibility-merge.ts index c0882c8e683..7cf7887e0c9 100644 --- a/src/renderer/src/store/projects/project-compatibility-core.ts +++ b/src/shared/project-compatibility-merge.ts @@ -1,17 +1,20 @@ -import type { Project, ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' +import type { Project, ProjectHostSetup, ProjectUpdateArgs } from './project-types' +import type { Repo } from './repo-types' import { mergeCatalogCreatedAt, mergeCatalogUpdatedAt, projectHostSetupProjectionFromRepos -} from '../../../../shared/project-host-setup-projection' -import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' -import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { ProjectUpdate, RepoSlice } from '../repos/repo-state' +} from './project-host-setup-projection' +import type { ProjectHostSetupProjection } from './project-host-setup-projection' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' -export function projectCompatibilityFromRepos( - repos: readonly Repo[] -): Pick { +/** A catalog's project rows and the host setups that place them. */ +export type ProjectCatalogRows = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} + +export function projectCompatibilityFromRepos(repos: readonly Repo[]): ProjectCatalogRows { const projection = projectHostSetupProjectionFromRepos(repos) return { projects: projection.projects, @@ -63,7 +66,7 @@ export function mergeProjectCompatibilityProjects( export function mergeUpdatedProjectCompatibilityProject( base: Project, updated: Project, - updates: ProjectUpdate + updates: ProjectUpdateArgs['updates'] ): Project { const project = mergeProjectCompatibilityProject(base, updated) if ('localWindowsRuntimePreference' in updates) { @@ -189,9 +192,9 @@ export function mergePreviousProjectMetadata( } export function mergeProjectHostSetupCompatibility( - derived: Pick, + derived: ProjectCatalogRows, fetched: ProjectHostSetupProjection -): Pick { +): ProjectCatalogRows { const fetchedRepoSetupKeys = new Set(fetched.setups.map(getRepoDerivedSetupKey)) const derivedSetups = derived.projectHostSetups.filter( (setup) => !fetchedRepoSetupKeys.has(getRepoDerivedSetupKey(setup)) diff --git a/src/shared/project-host-setup-hydration.ts b/src/shared/project-host-setup-hydration.ts new file mode 100644 index 00000000000..f1c8cf1bb11 --- /dev/null +++ b/src/shared/project-host-setup-hydration.ts @@ -0,0 +1,120 @@ +import type { Project, ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import { + getProjectIdentityKey, + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from './project-host-setup-projection' + +type NormalizedProjectHostSetupProjection = ProjectHostSetupProjection & { + changed: boolean +} + +function normalizeHydratedProjectHostSetupProjection( + repos: readonly Repo[], + projects: readonly Project[], + setups: readonly ProjectHostSetup[], + derived: ProjectHostSetupProjection +): NormalizedProjectHostSetupProjection { + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const derivedProjectIds = new Set(derived.projects.map((project) => project.id)) + const projectIdByHydratedProjectId = new Map() + let changed = false + const normalizedSetups = setups.map((setup) => { + const repo = repoById.get(setup.repoId) ?? repoById.get(setup.id) + if (!repo) { + return setup + } + const projectId = getProjectIdentityKey(repo) + if (projectId === setup.projectId || projectId === `repo:${repo.id}`) { + return setup + } + changed = true + projectIdByHydratedProjectId.set(setup.projectId, projectId) + return { ...setup, projectId } + }) + const normalizedProjects = projects.flatMap((project) => { + const projectId = projectIdByHydratedProjectId.get(project.id) + if (!projectId || projectId === project.id) { + return [project] + } + // Why: runtime-hosted copies of the same Git repo may hydrate path-scoped + // project ids. If the repo-derived project already exists, keep that bucket + // authoritative so VM copies group under the user's single project. + if (derivedProjectIds.has(projectId)) { + changed = true + return [] + } + changed = true + return [{ ...project, id: projectId }] + }) + return { projects: normalizedProjects, setups: normalizedSetups, changed } +} + +/** `overlay` rows replace `base` rows of the same id; new ids append in overlay order. */ +function mergeProjectionRowsById( + base: readonly T[], + overlay: readonly T[] +): T[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged +} + +/** Whether some repo has no host setup naming it, by setup repo id or setup id. */ +function hasReposWithoutProjectHostSetup( + repos: readonly Pick[], + setups: readonly ProjectHostSetup[] +): boolean { + const repoIds = new Set(repos.map((repo) => repo.id)) + const coveredRepoIds = new Set() + for (const setup of setups) { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + if (repoIds.has(repoId)) { + coveredRepoIds.add(repoId) + } + if (repoIds.has(setup.id)) { + coveredRepoIds.add(setup.id) + } + } + return repos.length > 0 && coveredRepoIds.size < repoIds.size +} + +/** The repo-derived rows overlaid with the hydrated ones, so a repo without a setup still groups. */ +function mergeDerivedProjectHostSetupProjection( + derived: ProjectHostSetupProjection, + normalized: ProjectHostSetupProjection +): ProjectHostSetupProjection { + return { + projects: mergeProjectionRowsById(derived.projects, normalized.projects), + setups: mergeProjectionRowsById(derived.setups, normalized.setups) + } +} + +/** + * The projects and setups a catalog groups by: the hydrated rows, normalized onto the repos' + * identities, and backed by the repo-derived rows when any repo has no setup. The renderer's + * selector is a memo over this. + */ +export function resolveProjectHostSetupProjection( + repos: readonly Repo[], + projects: readonly Project[], + setups: readonly ProjectHostSetup[], + derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(repos) +): ProjectHostSetupProjection { + const normalized = normalizeHydratedProjectHostSetupProjection(repos, projects, setups, derived) + // Why: older runtimes/profiles may hydrate empty or partial project/setup arrays + // beside legacy repos. Keep repo-backed compatibility rows visible in that case. + return hasReposWithoutProjectHostSetup(repos, setups) || normalized.changed + ? mergeDerivedProjectHostSetupProjection(derived, normalized) + : { projects, setups } +} diff --git a/src/shared/project-section-grouping.ts b/src/shared/project-section-grouping.ts new file mode 100644 index 00000000000..b6132332a4e --- /dev/null +++ b/src/shared/project-section-grouping.ts @@ -0,0 +1,282 @@ +// The desktop sidebar's Project sections; the paired phone calls these same functions. +import type { ExecutionHostId } from './execution-host' +import { orderProjectSections, type ActivityItem } from './project-section-order' +import type { ProjectOrderBy } from './ui-chrome-types' +import { toSshExecutionHostId } from './execution-host' +import type { Project, ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import { parseWslUncPath } from './wsl-paths' +import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from './cross-platform-path' + +/** The project fields grouping reads. */ +export type ProjectSectionProject = Pick + +/** The host-setup fields grouping and host labels read. */ +export type ProjectSectionSetup = Pick< + ProjectHostSetup, + 'id' | 'projectId' | 'hostId' | 'repoId' | 'path' | 'displayName' | 'setupMethod' +> & + Partial> + +export type ProjectGroupingModel = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} + +/** What grouping reads of a model; a paired phone receives only this much. */ +export type ProjectSectionModel = { + projects: readonly ProjectSectionProject[] + projectHostSetups: readonly ProjectSectionSetup[] +} + +export type ProjectGroupingIndex = { + projectById: Map + setupByRepoId: Map + surfaceKeysRequiringSetupGroups: Set +} + +/** The repo fields section grouping, labelling and ordering read. */ +export type ProjectSectionRepo = { + id: string + displayName: string + path: string + addedAt?: number + connectionId?: string | null + executionHostId?: ExecutionHostId | null +} + +/** One project section: its rows, the repo that anchors its header, and every repo it holds. */ +export type ProjectSectionEntry = { + label: string + items: Item[] + repo?: R + repoIds: Set +} + +const projectGroupingIndexCache = new WeakMap() + +// Why: provisioned and folder setups are not independent Git checkouts. +function isDistinctUserCheckout(setup: ProjectSectionSetup): boolean { + return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' +} + +// Why: execution target and filesystem namespace independently identify a surface. +function getProjectSetupSurfaceKey(setup: ProjectSectionSetup): string { + return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` +} + +function getExecutionSurface(setup: ProjectSectionSetup): string { + const connectionId = setup.connectionId?.trim() + if (connectionId) { + return toSshExecutionHostId(connectionId) + } + return setup.executionHostId?.trim() || setup.hostId +} + +// Why: projection twins differ by row identity, not checkout directory. +function getCheckoutIdentity(setup: ProjectSectionSetup): string { + return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id +} + +function getPathSurface(setup: ProjectSectionSetup): string { + const wslPath = parseWslUncPath(setup.path) + if (wslPath) { + return `wsl:${wslPath.distro.toLowerCase()}` + } + if (isWindowsAbsolutePathLike(setup.path)) { + return 'windows-host' + } + return 'default' +} + +export function buildProjectGroupingIndex( + model?: ProjectSectionModel +): ProjectGroupingIndex | null { + if (!model) { + return null + } + const cached = projectGroupingIndexCache.get(model) + if (cached !== undefined) { + return cached + } + const projects = model.projects ?? [] + const projectHostSetups = model.projectHostSetups ?? [] + if (projects.length === 0 || projectHostSetups.length === 0) { + projectGroupingIndexCache.set(model, null) + return null + } + const checkoutsByProjectSurface = new Map>() + for (const setup of projectHostSetups) { + if (!isDistinctUserCheckout(setup)) { + continue + } + const key = getProjectSetupSurfaceKey(setup) + const existing = checkoutsByProjectSurface.get(key) + if (existing) { + existing.add(getCheckoutIdentity(setup)) + } else { + checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)])) + } + } + const surfaceKeysRequiringSetupGroups = new Set() + for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) { + if (checkouts.size > 1) { + surfaceKeysRequiringSetupGroups.add(surfaceKey) + } + } + const index = { + projectById: new Map(projects.map((project) => [project.id, project])), + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), + surfaceKeysRequiringSetupGroups + } + projectGroupingIndexCache.set(model, index) + return index +} + +// Why: Settings splits projects by this same rule so it matches the sidebar headers. +export function isCheckoutScopedProjectSetup( + setup: ProjectSectionSetup, + projectIndex: ProjectGroupingIndex +): boolean { + return ( + projectIndex.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) && + isDistinctUserCheckout(setup) + ) +} + +export type ProjectHeaderRevealTarget = Repo> = { + key: string + label: string + repo?: R + projectId?: string +} + +export function getProjectGroupingForRepo>( + repoId: string, + repoMap: ReadonlyMap, + projectIndex: ProjectGroupingIndex | null +): ProjectHeaderRevealTarget { + const repo = repoMap.get(repoId) + const setup = projectIndex?.setupByRepoId.get(repoId) + const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined + if (!setup || !project) { + return { + key: `repo:${repoId}`, + label: repo?.displayName ?? 'Unknown', + repo + } + } + if (projectIndex && isCheckoutScopedProjectSetup(setup, projectIndex)) { + // Why: only the ambiguous surface needs checkout-specific headers. + return { + key: `project:${project.id}::setup:${repoId}`, + label: repo?.displayName ?? setup.displayName, + repo, + projectId: project.id + } + } + // Why: provisioned runtime copies and non-ambiguous checkouts follow project + // identity rather than path-scoped setup identity, so they stay in one project. + return { + key: `project:${project.id}`, + label: project.displayName, + repo, + projectId: project.id + } +} + +export function getProjectHeaderRevealTarget( + repoId: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): ProjectHeaderRevealTarget { + return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) +} + +/** Files `item` under its repo's project section, opening the section on first sight. */ +function addItemToProjectSection( + sections: Map>, + item: Item, + repoId: string, + repoMap: ReadonlyMap, + projectIndex: ProjectGroupingIndex | null +): void { + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + let section = sections.get(grouping.key) + if (!section) { + section = { label: grouping.label, items: [], repo: grouping.repo, repoIds: new Set() } + sections.set(grouping.key, section) + } + section.items.push(item) + section.repoIds.add(repoId) +} + +/** + * Opens the project section for a repo that has no rows to file, or joins the repo to the open one. + * `requireRepo` skips repo ids missing from the catalog, which would otherwise read "Unknown". + */ +function ensureProjectSectionForRepo( + sections: Map>, + repoId: string, + repoMap: ReadonlyMap, + projectIndex: ProjectGroupingIndex | null, + options: { requireRepo?: boolean; fallbackRepo?: R } = {} +): void { + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + if (options.requireRepo && !grouping.repo) { + return + } + const section = sections.get(grouping.key) + if (section) { + section.repoIds.add(repoId) + return + } + sections.set(grouping.key, { + label: grouping.label, + items: [], + repo: grouping.repo ?? options.fallbackRepo, + repoIds: new Set([repoId]) + }) +} + +/** + * Project sections in header order, unlabelled (see withRepoSectionDisplayLabels): rows first, then + * empty-project placeholders, notice rows' repos (opened with the notice's own repo if the catalog + * lacks it), and repos with a workspace being created. + */ +export function buildProjectSections< + Item extends ActivityItem, + R extends ProjectSectionRepo +>(args: { + items: readonly Item[] + repoIdOf: (item: Item) => string + repoMap: ReadonlyMap + projectIndex: ProjectGroupingIndex | null + placeholderRepoIds: Iterable + noticeRepos?: Iterable + pendingRepoIds?: Iterable + repoOrder: ReadonlyMap | undefined + projectOrderBy: ProjectOrderBy +}): [string, ProjectSectionEntry][] { + const { repoMap, projectIndex } = args + const sections = new Map>() + for (const item of args.items) { + addItemToProjectSection(sections, item, args.repoIdOf(item), repoMap, projectIndex) + } + // Why: repos can arrive before worktree scans, but stale IDs passed by + // older snapshots must not render an "Unknown" project header. + for (const repoId of args.placeholderRepoIds) { + ensureProjectSectionForRepo(sections, repoId, repoMap, projectIndex, { requireRepo: true }) + } + // Why: the default policy removes pinned worktrees from natural groups, + // but actionable notice rows still need a project section to render in. + for (const [repoId, fallbackRepo] of args.noticeRepos ?? []) { + ensureProjectSectionForRepo(sections, repoId, repoMap, projectIndex, { fallbackRepo }) + } + // Why: creating the first worktree in a repo leaves it with no group yet; + // ensure one so the in-progress row nests under its repo instead of being dropped. + for (const repoId of args.pendingRepoIds ?? []) { + ensureProjectSectionForRepo(sections, repoId, repoMap, projectIndex) + } + return orderProjectSections(sections, repoMap, args.repoOrder, args.projectOrderBy) +} diff --git a/src/shared/project-section-order.ts b/src/shared/project-section-order.ts new file mode 100644 index 00000000000..e164bcc73db --- /dev/null +++ b/src/shared/project-section-order.ts @@ -0,0 +1,201 @@ +// Labels and order of the desktop sidebar's project sections, shared so the paired phone matches. +import type { ProjectSectionEntry, ProjectSectionRepo } from './project-section-grouping' +import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from './repo-display-labels' +import type { ProjectOrderBy } from './ui-chrome-types' + +export type ActivityItem = { lastActivityAt?: number } + +// Why: disambiguate a section's *own* label, not its anchor checkout's repo +// name. Substituting repo.displayName made a project header read the project +// name while it was the only repo-backed section and the checkout name as soon +// as a second one rendered, so filtering workspaces renamed the project +// (#16127). Path suffixes still resolve genuinely identical labels. +export function withRepoSectionDisplayLabels< + E extends ProjectSectionEntry +>(entries: readonly [string, E][]): [string, E][] { + const labelItems = entries.flatMap(([, group]) => + group.repo ? [{ ...group.repo, displayName: group.label }] : [] + ) + if (labelItems.length === 0) { + return [...entries] + } + const labelsByPath = getRepoDisplayLabelsByPath(labelItems) + return entries.map(([key, group]) => [ + key, + group.repo + ? { ...group, label: labelsByPath.get(getRepoDisplayLabelKey(group.repo)) ?? group.label } + : group + ]) +} + +/** + * Recent rank for a project header. `hasActivity` projects (at least one + * visible worktree) always sort before fallback projects, regardless of the + * numeric values — a placeholder's `addedAt` must never outrank real activity. + * Within each tier, higher timestamps come first. + */ +type RecentRank = { hasActivity: boolean; ts: number } + +export function recentRankForEntry( + entry: readonly [string, ProjectSectionEntry] +): RecentRank { + let max = Number.NEGATIVE_INFINITY + for (const worktree of entry[1].items) { + // Why: phone rows from older hosts may omit the timestamp; the desktop's always carry one. + if ((worktree.lastActivityAt ?? Number.NEGATIVE_INFINITY) > max) { + max = worktree.lastActivityAt ?? max + } + } + if (max !== Number.NEGATIVE_INFINITY) { + // Why: Recent must be timestamp-based, not encounter order — the incoming + // array is no longer pre-sorted by recency once decoupled from sortBy. + return { hasActivity: true, ts: max } + } + const addedAt = entry[1].repo?.addedAt + return { + hasActivity: false, + ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY + } +} + +export function createRecentRankLookup< + T extends readonly [string, ProjectSectionEntry] +>(): (entry: T) => RecentRank { + const ranks = new Map() + return (entry) => { + const existing = ranks.get(entry) + if (existing) { + return existing + } + const rank = recentRankForEntry(entry) + ranks.set(entry, rank) + return rank + } +} + +export function compareRecentRank(a: RecentRank, b: RecentRank): number { + if (a.hasActivity !== b.hasActivity) { + return a.hasActivity ? -1 : 1 + } + return b.ts - a.ts +} + +function manualRankForEntry( + entry: readonly [string, ProjectSectionEntry], + repoOrder: ReadonlyMap | undefined +): number { + const key = entry[0] + const repoIds = + entry[1].repoIds.size > 0 + ? [...entry[1].repoIds] + : [key.startsWith('repo:') ? key.slice('repo:'.length) : key] + let rank = Number.POSITIVE_INFINITY + for (const repoId of repoIds) { + const repoRank = repoOrder?.get(repoId) + if (repoRank !== undefined && repoRank < rank) { + rank = repoRank + } + } + return rank +} + +export function getManualOrderAnchorRepo( + group: ProjectSectionEntry, + repoMap: ReadonlyMap, + repoOrder: ReadonlyMap | undefined +): R | undefined { + let anchor = group.repo + let anchorRank = anchor ? (repoOrder?.get(anchor.id) ?? Number.POSITIVE_INFINITY) : undefined + for (const repoId of group.repoIds) { + const repo = repoMap.get(repoId) + if (!repo) { + continue + } + const rank = repoOrder?.get(repoId) ?? Number.POSITIVE_INFINITY + if (!anchor || rank < (anchorRank ?? Number.POSITIVE_INFINITY)) { + anchor = repo + anchorRank = rank + } + } + return anchor +} + +/** + * Order project header entries by the user's project-order preference. Manual + * follows the canonical repoOrder; Recent follows each project's most recent + * visible workspace activity (descending), with empty/imported-only projects + * sorting after active ones, then by manual rank, then label. + */ +export function sortProjectEntries< + T extends readonly [string, ProjectSectionEntry] +>( + entries: T[], + projectOrderBy: ProjectOrderBy, + repoOrder: ReadonlyMap | undefined +): T[] { + if (projectOrderBy === 'recent') { + if (entries.length < 2) { + return [...entries] + } + const getRecentRank = createRecentRankLookup() + return [...entries].sort((a, b) => { + const byRecent = compareRecentRank(getRecentRank(a), getRecentRank(b)) + if (byRecent !== 0) { + return byRecent + } + const ma = manualRankForEntry(a, repoOrder) + const mb = manualRankForEntry(b, repoOrder) + if (ma !== mb) { + return ma - mb + } + return a[1].label.localeCompare(b[1].label) + }) + } + if (!repoOrder) { + return entries + } + return [...entries].sort((a, b) => { + const ra = manualRankForEntry(a, repoOrder) + const rb = manualRankForEntry(b, repoOrder) + if (ra !== rb) { + return ra - rb + } + return a[1].label.localeCompare(b[1].label) + }) +} + +/** + * Project sections in header order. Each header anchors on the repo that leads manual order, so + * drag and actions target the same persisted order source the row sorter reads. + */ +export function orderProjectSections< + R extends ProjectSectionRepo, + E extends ProjectSectionEntry +>( + sections: ReadonlyMap, + repoMap: ReadonlyMap, + repoOrder: ReadonlyMap | undefined, + projectOrderBy: ProjectOrderBy +): [string, E][] { + for (const section of sections.values()) { + section.repo = getManualOrderAnchorRepo(section, repoMap, repoOrder) + } + // Why: project header order is its own user choice (projectOrderBy), decoupled from workspace + // sortBy. Manual uses the canonical repoOrder; Recent follows activity. + return sortProjectEntries(Array.from(sections.entries()), projectOrderBy, repoOrder) +} + +/** Each repo id's rank in catalog order; the first occurrence wins across hosts. */ +export function getLogicalRepoOrderRankById( + orderedRepoIds: readonly string[] +): Map { + const rankById = new Map() + orderedRepoIds.forEach((repoId, index) => { + // Why: paired hosts can contribute the same logical project ID; its merged + // header must anchor to the first occurrence instead of whichever host loaded last. + if (!rankById.has(repoId)) { + rankById.set(repoId, index) + } + }) + return rankById +} diff --git a/src/shared/project-section-parity.test-fixture.ts b/src/shared/project-section-parity.test-fixture.ts new file mode 100644 index 00000000000..d7295e1460d --- /dev/null +++ b/src/shared/project-section-parity.test-fixture.ts @@ -0,0 +1,248 @@ +// Host answers both the desktop sidebar's and the phone's Project grouping tests run on. +import type { MobileProjectSectionCatalog } from './mobile-project-sections-contract' +import type { ExecutionHostId } from './execution-host' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from './project-host-setup-projection' +import type { Repo } from './repo-types' +import type { RuntimeClientTarget } from './runtime-client-target' + +const orcaRemote = { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'git@github.com:stablyai/orca.git' +} + +function hostRepo(id: string, path: string, displayName = 'orca'): Repo { + return { + id, + path, + displayName, + badgeColor: '#737373', + addedAt: 1, + ...(displayName === 'orca' ? { gitRemoteIdentity: orcaRemote } : {}) + } +} + +export type ParityHostAnswer = { + target: RuntimeClientTarget + hostId: ExecutionHostId + /** In the host's own repo.list order. */ + repos: Repo[] + /** The host's project.list/projectHostSetup.list rows; null for a host that predates them. */ + lists: ProjectHostSetupProjection | null +} + +const localRepos = [ + hostRepo('local-stably', '/Users/jinwoo/stably/orca'), + hostRepo('local-assets', '/Users/jinwoo/design-assets', 'design-assets'), + hostRepo('local-workspace', '/Users/jinwoo/orca/workspaces/orca/orca') +] +const windowsRepo = hostRepo('win-orca', 'C:/Users/jinwo/orca/orca') + +// Jinwoo's shape: several local checkouts of stablyai/orca, plus the project on two servers. +export const PROJECT_SECTION_PARITY_HOSTS: readonly ParityHostAnswer[] = [ + { + target: { kind: 'local' }, + hostId: 'local', + repos: localRepos, + lists: projectHostSetupProjectionFromRepos(localRepos) + }, + { + target: { kind: 'environment', environmentId: 'windows2' }, + hostId: 'runtime:windows2', + repos: [windowsRepo], + // A server's own catalog rows: its project name and an imported setup on its local host. + lists: { + projects: [ + { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#737373', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + sourceRepoIds: [windowsRepo.id], + createdAt: 5, + updatedAt: 5 + } + ], + setups: [ + { + id: 'win-setup', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: windowsRepo.id, + path: windowsRepo.path, + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 5, + updatedAt: 5 + } + ] + } + }, + { + target: { kind: 'environment', environmentId: 'm4air' }, + hostId: 'runtime:m4air', + repos: [ + hostRepo('m4-orca', '/Users/m4air/orca/orca'), + hostRepo('m4-orca-two', '/Users/m4air/orca-two/orca') + ], + lists: null + } +] + +/** Workspace rows; activity order differs from catalog order, so Recent and Manual disagree. */ +export const PROJECT_SECTION_PARITY_ROWS: readonly { + id: string + repoId: string + lastActivityAt: number +}[] = [ + { id: 'wt-m4-two', repoId: 'm4-orca-two', lastActivityAt: 90 }, + { id: 'wt-win', repoId: 'win-orca', lastActivityAt: 70 }, + { id: 'wt-workspace', repoId: 'local-workspace', lastActivityAt: 50 }, + { id: 'wt-stably', repoId: 'local-stably', lastActivityAt: 30 }, + { id: 'wt-m4', repoId: 'm4-orca', lastActivityAt: 10 } +] + +const STABLY = { + key: 'project:github:stablyai/orca::setup:local-stably', + label: 'jinwoo/stably/orca' +} +const WORKSPACE = { + key: 'project:github:stablyai/orca::setup:local-workspace', + label: 'workspaces/orca/orca' +} +// Why 'orca', not the server's 'Orca': the later host's derived project name wins the merge. +const WINDOWS = { key: 'project:github:stablyai/orca', label: 'jinwo/orca/orca' } +const M4 = { key: 'project:github:stablyai/orca::setup:m4-orca', label: 'm4air/orca/orca' } +const M4_TWO = { + key: 'project:github:stablyai/orca::setup:m4-orca-two', + label: 'm4air/orca-two/orca' +} +const ASSETS = { key: 'project:repo:local-assets', label: 'design-assets' } + +export const PROJECT_SECTION_PARITY_HEADERS = { + recent: [M4_TWO, WINDOWS, WORKSPACE, STABLY, M4, ASSETS], + manual: [STABLY, ASSETS, WORKSPACE, WINDOWS, M4, M4_TWO] +} as const + +/** What main serves the phone for these answers (the renderer's published catalog). */ +export const PROJECT_SECTION_PARITY_SERVED: MobileProjectSectionCatalog = { + repos: [ + { + id: 'local-stably', + displayName: 'orca', + path: '/Users/jinwoo/stably/orca', + badgeColor: '#737373', + addedAt: 1, + executionHostId: 'local' + }, + { + id: 'local-assets', + displayName: 'design-assets', + path: '/Users/jinwoo/design-assets', + badgeColor: '#737373', + addedAt: 1, + executionHostId: 'local' + }, + { + id: 'local-workspace', + displayName: 'orca', + path: '/Users/jinwoo/orca/workspaces/orca/orca', + badgeColor: '#737373', + addedAt: 1, + executionHostId: 'local' + }, + { + id: 'win-orca', + displayName: 'orca', + path: 'C:/Users/jinwo/orca/orca', + badgeColor: '#737373', + addedAt: 1, + executionHostId: 'runtime:windows2' + }, + { + id: 'm4-orca', + displayName: 'orca', + path: '/Users/m4air/orca/orca', + badgeColor: '#737373', + addedAt: 1, + executionHostId: 'runtime:m4air' + }, + { + id: 'm4-orca-two', + displayName: 'orca', + path: '/Users/m4air/orca-two/orca', + badgeColor: '#737373', + addedAt: 1, + executionHostId: 'runtime:m4air' + } + ], + projects: [ + { id: 'repo:local-assets', displayName: 'design-assets' }, + { id: 'github:stablyai/orca', displayName: 'orca' } + ], + setups: [ + { + id: 'local-stably', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'local-stably', + path: '/Users/jinwoo/stably/orca', + displayName: 'orca', + setupMethod: 'legacy-repo' + }, + { + id: 'local-assets', + projectId: 'repo:local-assets', + hostId: 'local', + repoId: 'local-assets', + path: '/Users/jinwoo/design-assets', + displayName: 'design-assets', + setupMethod: 'legacy-repo' + }, + { + id: 'local-workspace', + projectId: 'github:stablyai/orca', + hostId: 'local', + repoId: 'local-workspace', + path: '/Users/jinwoo/orca/workspaces/orca/orca', + displayName: 'orca', + setupMethod: 'legacy-repo' + }, + { + id: 'win-setup', + projectId: 'github:stablyai/orca', + hostId: 'runtime:windows2', + repoId: 'win-orca', + path: 'C:/Users/jinwo/orca/orca', + displayName: 'orca', + setupMethod: 'imported-existing-folder', + connectionId: null, + executionHostId: 'runtime:windows2' + }, + { + id: 'm4-orca', + projectId: 'github:stablyai/orca', + hostId: 'runtime:m4air', + repoId: 'm4-orca', + path: '/Users/m4air/orca/orca', + displayName: 'orca', + setupMethod: 'legacy-repo', + executionHostId: 'runtime:m4air' + }, + { + id: 'm4-orca-two', + projectId: 'github:stablyai/orca', + hostId: 'runtime:m4air', + repoId: 'm4-orca-two', + path: '/Users/m4air/orca-two/orca', + displayName: 'orca', + setupMethod: 'legacy-repo', + executionHostId: 'runtime:m4air' + } + ], + projectOrderBy: 'manual' +} diff --git a/src/renderer/src/lib/repo-display-labels.test.ts b/src/shared/repo-display-labels.test.ts similarity index 100% rename from src/renderer/src/lib/repo-display-labels.test.ts rename to src/shared/repo-display-labels.test.ts diff --git a/src/renderer/src/lib/repo-display-labels.ts b/src/shared/repo-display-labels.ts similarity index 96% rename from src/renderer/src/lib/repo-display-labels.ts rename to src/shared/repo-display-labels.ts index 89b7beeaeb6..b6c16ffb923 100644 --- a/src/renderer/src/lib/repo-display-labels.ts +++ b/src/shared/repo-display-labels.ts @@ -1,4 +1,4 @@ -import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from './execution-host' type RepoDisplayLabelItem = { path: string diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 3c8d830db0e..6126664d89e 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1077,6 +1077,7 @@ export const RPC_PARAMS_BY_METHOD = { 'markdown.saveTab': SaveMarkdownTab, 'mobileRelay.hosts.list': null, 'mobileRelay.hosts.worktrees': MobileRelayHostWorktreesParamsSchema, + 'mobileRelay.projectSections': null, 'mobileWeb.bundle.chunk': MobileWebBundleChunkParamsSchema, 'mobileWeb.bundle.manifest': null, 'mobileWeb.bundle.range': MobileWebBundleChunkParamsSchema, diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 736ad2ecf21..9c3ecf43e7b 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -1,3 +1,4 @@ +import type { MobileProjectSectionCatalog } from './mobile-project-sections-contract' import type { AgentStatusOrchestrationContext } from './agent-status-types' import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' @@ -166,6 +167,8 @@ export type RuntimeSyncWindowGraph = { terminalGroupGridSizes?: Record /** The grid a workspace with a single tab group gets, for one the window is not showing. */ singleGroupWorkspaceGridSize?: { cols: number; rows: number } + /** The sidebar's Project grouping inputs for paired phones; sent only when they changed. */ + projectSectionCatalog?: MobileProjectSectionCatalog } export type RuntimeRendererSyncWindowGraph = RuntimeSyncWindowGraph & { diff --git a/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts b/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts index 946782d2e15..c8ad8b552fe 100644 --- a/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts +++ b/tests/e2e/paired-mobile-desktop-mirror-workspace-visibility.spec.ts @@ -34,6 +34,13 @@ const ReposSchema = z.object({ repos: z.array(z.looseObject({ id: z.string(), path: z.string() })) }) const HostsSchema = z.object({ hosts: z.array(z.looseObject({ hostId: z.string() })) }) +const ProjectSectionsSchema = z.object({ + catalog: z + .object({ + repos: z.array(z.looseObject({ path: z.string(), executionHostId: z.string().optional() })) + }) + .nullable() +}) const TabsSchema = z.looseObject({ tabs: z.array(z.looseObject({ id: z.string() })) }) let requestId = 0 @@ -77,6 +84,18 @@ test("phone lists a server's workspaces exactly as the desktop sidebar filters t const { repos } = ReposSchema.parse(await result(socket, 'repo.list', {}, serverHostId)) const repo = `id:${repos.find((entry) => entry.path === serverRepo)?.id ?? ''}` const create = { setupDecision: 'skip' } + // The desktop window publishes the catalog its sidebar groups Project sections with. + await expect + .poll( + async () => + ProjectSectionsSchema.parse( + await result(socket, 'mobileRelay.projectSections', {}) + ).catalog?.repos.some( + (entry) => entry.path === serverRepo && entry.executionHostId === serverHostId + ) ?? false, + { timeout: 30_000 } + ) + .toBe(true) // One workspace each: created on the server itself, by the desktop, and by the phone through it. await host.client.call('worktree.create', { repo, name: 'from-server', ...create }) From 814848065508dd39941296a65bf58f08c1e1d2c3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:10:27 -0400 Subject: [PATCH 16/17] Drop a stored terminal row the tab bar already shows when loading the layout (#28889) * fix(layout): drop a legacy terminal row the tab bar already holds when loading the layout The shared layout loader turned a stored terminal row with no tab-bar entry into a tab even when every terminal it names is already shown by a tab-bar tab. The window drops such a row at startup. The loader now uses the same rule, moved from the window's row hydration into src/shared/workspace-layout/legacy-terminal-row-claims.ts so both read one implementation. Co-Authored-By: Claude Opus 5.5 * fix(layout): follow a dropped legacy row's selection to its tab-bar twin When the loader drops a row the tab bar already holds, the saved active tab (global and per workspace) can still name that row. The window's startup moves those selections to the tab-bar tab that holds the same terminal; the loader now does the same. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../app-shell/startup-window-parity.test.ts | 18 ++- .../slices/terminal-session-row-hydration.ts | 121 +--------------- .../legacy-terminal-row-claims.ts | 133 ++++++++++++++++++ ...pace-layout-legacy-profile.test-fixture.ts | 41 ++++++ .../workspace-layout-load-types.ts | 2 + .../workspace-layout-load-workspace.ts | 11 +- .../workspace-layout/workspace-layout-load.ts | 24 +++- 7 files changed, 229 insertions(+), 121 deletions(-) create mode 100644 src/shared/workspace-layout/legacy-terminal-row-claims.ts diff --git a/src/renderer/src/app-shell/startup-window-parity.test.ts b/src/renderer/src/app-shell/startup-window-parity.test.ts index 8fd5d1b5774..af470097722 100644 --- a/src/renderer/src/app-shell/startup-window-parity.test.ts +++ b/src/renderer/src/app-shell/startup-window-parity.test.ts @@ -47,11 +47,6 @@ const KNOWN_DIFFERENCES: readonly KnownDifference[] = [ rule: 'ghost tab: the Loader gives every row a tab-bar entry; the window adopts only rows it can reattach', paths: [`${GIT} group 0 tabs`, `${GIT} tab tab-b`, ...terminal('tab-b')] }, - { - fixture: 'legacy row repeating a tab-bar tab’s terminal', - rule: 'duplicate tab: the window drops a legacy row whose terminal a tab-bar tab holds; the Loader keeps it as a tab with an unbound pane', - paths: [`${GIT} group 0 tabs`, `${GIT} tab tab-legacy`, ...terminal('tab-legacy')] - }, { fixture: 'row terminal differs from its focused pane’s', rule: 'row.ptyId rewrite: the Loader sets the row terminal to its focused pane’s, which changes what the tab reattaches', @@ -134,6 +129,19 @@ describe('the window shows the same session before and after the shared Loader r } ) + it('keeps the tab-bar twin selected when a selected legacy copy is dropped', async () => { + const profile = PROFILES.find( + (entry) => entry.name === 'legacy row repeating a tab-bar tab’s terminal, selected' + )! + const view = projectStartupWindowView( + await bootWindowFromPartitions(createTestStore(), [ + { hostId: profile.hostId, session: normalize(profile.hostId, profile.build()) } + ]) + ) + expect(view['active.terminal']).toBe('tab-a') + expect(view[`${GIT} active terminal`]).toBe('tab-a') + }) + it('lists differences only for fixtures that exist', () => { const names = new Set(PROFILES.map((profile) => profile.name)) expect(KNOWN_DIFFERENCES.filter((entry) => !names.has(entry.fixture))).toEqual([]) diff --git a/src/renderer/src/store/slices/terminal-session-row-hydration.ts b/src/renderer/src/store/slices/terminal-session-row-hydration.ts index ea0d6e5e8f9..f1b2dbd6265 100644 --- a/src/renderer/src/store/slices/terminal-session-row-hydration.ts +++ b/src/renderer/src/store/slices/terminal-session-row-hydration.ts @@ -2,7 +2,11 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { + readCanonicalPtyOwners, + resolveCanonicalPtyClaim, + type CanonicalPtyOwners +} from '../../../../shared/workspace-layout/legacy-terminal-row-claims' import { clearTransientTerminalState } from './terminal-helpers' /** @@ -12,11 +16,7 @@ import { clearTransientTerminalState } from './terminal-helpers' * PTYs off rows it only partially subsumes. */ -type CanonicalTerminals = { - /** Terminal ids the unified tab model owns in this workspace. */ - tabIds: Set - /** PTYs already claimed by canonical rows that survive hydration, keyed to their claimant. */ - tabIdByPtyId: Map +type CanonicalTerminals = CanonicalPtyOwners & { quickCommandLabelByTabId: Map aiVaultTitleByTabId: Map } @@ -117,18 +117,8 @@ function readCanonicalTerminals( const canonicalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( (tab) => tab.contentType === 'terminal' ) - const tabIds = new Set(canonicalTabs.map((tab) => tab.entityId)) - // Why: only rows that survive the id check can claim PTY ownership; a dropped invalid-id mirror must not evict the valid row sharing its PTY. - const tabIdByPtyId = new Map( - rows - .filter((row) => tabIds.has(row.id) && isValidTerminalTabId(row.id)) - .flatMap((row) => - readPersistedTerminalPtyIds(session, row).claimable.map((ptyId) => [ptyId, row.id] as const) - ) - ) return { - tabIds, - tabIdByPtyId, + ...readCanonicalPtyOwners(session, worktreeId, rows), quickCommandLabelByTabId: new Map( canonicalTabs.flatMap((tab) => tab.quickCommandLabel?.trim() ? [[tab.entityId, tab.quickCommandLabel.trim()]] : [] @@ -140,50 +130,6 @@ function readCanonicalTerminals( } } -type CanonicalPtyClaim = - | { kind: 'subsumed'; canonicalTabId: string } - | { kind: 'retained'; releasedPtyIds: Set; reconnectPtyId?: string } - -const RETAINED_UNCLAIMED: CanonicalPtyClaim = { kind: 'retained', releasedPtyIds: new Set() } - -/** Canonical mounts win PTY ownership over stale legacy duplicates of the same terminal. */ -function resolveCanonicalPtyClaim( - session: WorkspaceSessionState, - row: TerminalTab, - canonical: CanonicalTerminals -): CanonicalPtyClaim { - if (canonical.tabIds.has(row.id)) { - return RETAINED_UNCLAIMED - } - const { owned, orphaned, mountedByPrimacy } = readPersistedTerminalPtyIds(session, row) - const claimed = owned.filter((ptyId) => canonical.tabIdByPtyId.has(ptyId)) - // Why: an unclaimed row has no canonical twin, which also keeps a PTY-less row — it duplicates - // nothing — out of the fully-claimed branch. - const canonicalTabId = claimed.length > 0 ? canonical.tabIdByPtyId.get(claimed[0]) : undefined - if (canonicalTabId && claimed.length === owned.length) { - return { kind: 'subsumed', canonicalTabId } - } - // Why: a split row with an independent pane owns a PTY nothing else can reattach to, but keeping the - // shared PTY too would leave two recorded owners and make ownership resolution ambiguous (#10486). - // Stale unmounted bindings go too, else reconnect republishes the canonical PTY under this row. - const releasedPtyIds = new Set([ - ...claimed, - ...orphaned.filter((ptyId) => canonical.tabIdByPtyId.has(ptyId)) - ]) - return { - kind: 'retained', - releasedPtyIds, - // Why: the surviving pane becomes this row's primary; reconnect anchors liveness on it so the - // orphan sweep can't delete the row while its tab-level PTY is gone (#10486). Only a row that - // just handed a PTY to a live canonical mount earns this — a leaf binding on its own proves - // nothing and must stay sweepable (e.g. a layout outliving a removed SSH target, #9911). - reconnectPtyId: - releasedPtyIds.size > 0 - ? mountedByPrimacy.find((ptyId) => !releasedPtyIds.has(ptyId)) - : undefined - } -} - function restoreCanonicalMetadata( row: TerminalTab, index: number, @@ -202,56 +148,3 @@ function restoreCanonicalMetadata( restoredFromSession: true } } - -type PersistedTerminalPtyIds = { - /** PTYs a live pane or the tab itself still reattaches to — the only ones that prove ownership. */ - owned: string[] - /** The `owned` PTYs whose mount is unambiguous, so a canonical row may take them off another row. */ - claimable: string[] - /** Live pane PTYs, active leaf first — the order a row picks its primary session from. */ - mountedByPrimacy: string[] - /** PTYs stranded in `ptyIdsByLeafId` by panes that already left the tree; they reattach nothing. */ - orphaned: string[] -} - -function readPersistedTerminalPtyIds( - session: WorkspaceSessionState, - tab: TerminalTab -): PersistedTerminalPtyIds { - const layout = session.terminalLayoutsByTabId[tab.id] - // Why: ptyIdsByLeafId is merged but never pruned, and hydration reads it before - // normalizeTerminalLayoutSnapshot runs, so unmounted leaves still carry dead bindings here. - // Rootless layouts bind their sole pane off-tree, so `owned` treats every entry as mounted. - const mountedLeafIds = layout?.root ? new Set(collectLeafIdsInOrder(layout.root)) : null - const bindings = Object.entries(layout?.ptyIdsByLeafId ?? {}).filter(([, ptyId]) => - Boolean(ptyId) - ) - const mounted: string[] = [] - const unmounted: string[] = [] - for (const [leafId, ptyId] of bindings) { - ;(!mountedLeafIds || mountedLeafIds.has(leafId) ? mounted : unmounted).push(ptyId) - } - const tabLevel = [tab.ptyId, session.remoteSessionIdsByTabId?.[tab.id]].filter( - (ptyId): ptyId is string => Boolean(ptyId) - ) - const owned = new Set([...tabLevel, ...mounted]) - // Why: "sole pane off-tree" is the only rootless shape that proves ownership. A never-pruned map can - // hold more, and claiming those would evict the live row that really owns them (#13098). - const provenLeafId = bindings.length === 1 ? bindings[0]![0] : layout?.activeLeafId - const claimableLeafPtyIds = mountedLeafIds - ? mounted - : bindings.filter(([leafId]) => leafId === provenLeafId).map(([, ptyId]) => ptyId) - const activeLeafPtyId = layout?.activeLeafId - ? layout.ptyIdsByLeafId?.[layout.activeLeafId] - : undefined - const mountedByPrimacy = - activeLeafPtyId && mounted.includes(activeLeafPtyId) - ? [activeLeafPtyId, ...mounted.filter((ptyId) => ptyId !== activeLeafPtyId)] - : mounted - return { - owned: [...owned], - claimable: [...new Set([...tabLevel, ...claimableLeafPtyIds])], - mountedByPrimacy: [...new Set(mountedByPrimacy)], - orphaned: [...new Set(unmounted)].filter((ptyId) => !owned.has(ptyId)) - } -} diff --git a/src/shared/workspace-layout/legacy-terminal-row-claims.ts b/src/shared/workspace-layout/legacy-terminal-row-claims.ts new file mode 100644 index 00000000000..255a5e20099 --- /dev/null +++ b/src/shared/workspace-layout/legacy-terminal-row-claims.ts @@ -0,0 +1,133 @@ +// Which stored terminal rows the tab bar already holds, read from stored data alone. A persisted +// session describes the same terminals twice: as tab-bar entries and as legacy per-workspace rows. +// The window's startup hydration and the shared Loader both resolve ownership with these rules. + +import { isValidTerminalTabId } from '../terminal-tab-id' +import type { TerminalTab } from '../terminal-tab-types' +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { collectLayoutLeafIdsInOrder } from './terminal-pane-tree' + +export type CanonicalPtyOwners = { + /** Terminal ids the tab bar owns in this workspace. */ + tabIds: Set + /** PTYs already claimed by tab-bar rows, keyed to their claimant. */ + tabIdByPtyId: Map +} + +export function readCanonicalPtyOwners( + session: WorkspaceSessionState, + workspaceKey: string, + rows: readonly TerminalTab[] +): CanonicalPtyOwners { + const tabIds = new Set( + (session.unifiedTabs?.[workspaceKey] ?? []) + .filter((tab) => tab.contentType === 'terminal') + .map((tab) => tab.entityId) + ) + // Why: only rows that survive the id check can claim PTY ownership; a dropped invalid-id mirror must not evict the valid row sharing its PTY. + const tabIdByPtyId = new Map( + rows + .filter((row) => tabIds.has(row.id) && isValidTerminalTabId(row.id)) + .flatMap((row) => + readPersistedTerminalPtyIds(session, row).claimable.map((ptyId) => [ptyId, row.id] as const) + ) + ) + return { tabIds, tabIdByPtyId } +} + +export type CanonicalPtyClaim = + | { kind: 'subsumed'; canonicalTabId: string } + | { kind: 'retained'; releasedPtyIds: Set; reconnectPtyId?: string } + +const RETAINED_UNCLAIMED: CanonicalPtyClaim = { kind: 'retained', releasedPtyIds: new Set() } + +/** Canonical mounts win PTY ownership over stale legacy duplicates of the same terminal. */ +export function resolveCanonicalPtyClaim( + session: WorkspaceSessionState, + row: TerminalTab, + canonical: CanonicalPtyOwners +): CanonicalPtyClaim { + if (canonical.tabIds.has(row.id)) { + return RETAINED_UNCLAIMED + } + const { owned, orphaned, mountedByPrimacy } = readPersistedTerminalPtyIds(session, row) + const claimed = owned.filter((ptyId) => canonical.tabIdByPtyId.has(ptyId)) + // Why: an unclaimed row has no canonical twin, which also keeps a PTY-less row — it duplicates + // nothing — out of the fully-claimed branch. + const canonicalTabId = claimed.length > 0 ? canonical.tabIdByPtyId.get(claimed[0]) : undefined + if (canonicalTabId && claimed.length === owned.length) { + return { kind: 'subsumed', canonicalTabId } + } + // Why: a split row with an independent pane owns a PTY nothing else can reattach to, but keeping the + // shared PTY too would leave two recorded owners and make ownership resolution ambiguous (#10486). + // Stale unmounted bindings go too, else reconnect republishes the canonical PTY under this row. + const releasedPtyIds = new Set([ + ...claimed, + ...orphaned.filter((ptyId) => canonical.tabIdByPtyId.has(ptyId)) + ]) + return { + kind: 'retained', + releasedPtyIds, + // Why: the surviving pane becomes this row's primary; reconnect anchors liveness on it so the + // orphan sweep can't delete the row while its tab-level PTY is gone (#10486). Only a row that + // just handed a PTY to a live canonical mount earns this — a leaf binding on its own proves + // nothing and must stay sweepable (e.g. a layout outliving a removed SSH target, #9911). + reconnectPtyId: + releasedPtyIds.size > 0 + ? mountedByPrimacy.find((ptyId) => !releasedPtyIds.has(ptyId)) + : undefined + } +} + +type PersistedTerminalPtyIds = { + /** PTYs a live pane or the tab itself still reattaches to — the only ones that prove ownership. */ + owned: string[] + /** The `owned` PTYs whose mount is unambiguous, so a canonical row may take them off another row. */ + claimable: string[] + /** Live pane PTYs, active leaf first — the order a row picks its primary session from. */ + mountedByPrimacy: string[] + /** PTYs stranded in `ptyIdsByLeafId` by panes that already left the tree; they reattach nothing. */ + orphaned: string[] +} + +function readPersistedTerminalPtyIds( + session: WorkspaceSessionState, + tab: TerminalTab +): PersistedTerminalPtyIds { + const layout = session.terminalLayoutsByTabId?.[tab.id] + // Why: ptyIdsByLeafId is merged but never pruned, and hydration reads it before + // normalizeTerminalLayoutSnapshot runs, so unmounted leaves still carry dead bindings here. + // Rootless layouts bind their sole pane off-tree, so `owned` treats every entry as mounted. + const mountedLeafIds = layout?.root ? new Set(collectLayoutLeafIdsInOrder(layout.root)) : null + const bindings = Object.entries(layout?.ptyIdsByLeafId ?? {}).filter(([, ptyId]) => + Boolean(ptyId) + ) + const mounted: string[] = [] + const unmounted: string[] = [] + for (const [leafId, ptyId] of bindings) { + ;(!mountedLeafIds || mountedLeafIds.has(leafId) ? mounted : unmounted).push(ptyId) + } + const tabLevel = [tab.ptyId, session.remoteSessionIdsByTabId?.[tab.id]].filter( + (ptyId): ptyId is string => Boolean(ptyId) + ) + const owned = new Set([...tabLevel, ...mounted]) + // Why: "sole pane off-tree" is the only rootless shape that proves ownership. A never-pruned map can + // hold more, and claiming those would evict the live row that really owns them (#13098). + const provenLeafId = bindings.length === 1 ? bindings[0]![0] : layout?.activeLeafId + const claimableLeafPtyIds = mountedLeafIds + ? mounted + : bindings.filter(([leafId]) => leafId === provenLeafId).map(([, ptyId]) => ptyId) + const activeLeafPtyId = layout?.activeLeafId + ? layout.ptyIdsByLeafId?.[layout.activeLeafId] + : undefined + const mountedByPrimacy = + activeLeafPtyId && mounted.includes(activeLeafPtyId) + ? [activeLeafPtyId, ...mounted.filter((ptyId) => ptyId !== activeLeafPtyId)] + : mounted + return { + owned: [...owned], + claimable: [...new Set([...tabLevel, ...claimableLeafPtyIds])], + mountedByPrimacy: [...new Set(mountedByPrimacy)], + orphaned: [...new Set(unmounted)].filter((ptyId) => !owned.has(ptyId)) + } +} diff --git a/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts b/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts index dadd428053a..21d66626e00 100644 --- a/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts +++ b/src/shared/workspace-layout/workspace-layout-legacy-profile.test-fixture.ts @@ -60,6 +60,47 @@ export const LEGACY_WORKSPACE_LAYOUT_PROFILES: readonly WorkspaceLayoutProfile[] } return session }), + local('legacy row repeating a tab-bar tab’s terminal, selected', () => { + const session = twoTabs() + session.tabsByWorktree[GIT_KEY]!.push({ + ...session.tabsByWorktree[GIT_KEY]![0]!, + id: 'tab-legacy', + title: 'Terminal 3', + sortOrder: 2, + createdAt: 1_700_000_009_000 + }) + session.terminalLayoutsByTabId['tab-legacy'] = { + root: { type: 'leaf', leafId: leaf(3) }, + activeLeafId: leaf(3), + expandedLeafId: null, + ptyIdsByLeafId: { [leaf(3)]: 'pty-a' } + } + session.activeTabId = 'tab-legacy' + session.activeTabIdByWorktree = { [GIT_KEY]: 'tab-legacy' } + return session + }), + local('legacy split row sharing one pane’s terminal with a tab-bar tab', () => { + const session = twoTabs() + session.tabsByWorktree[GIT_KEY]!.push({ + ...session.tabsByWorktree[GIT_KEY]![0]!, + id: 'tab-legacy', + title: 'Terminal 3', + sortOrder: 2, + createdAt: 1_700_000_009_000 + }) + session.terminalLayoutsByTabId['tab-legacy'] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: leaf(3) }, + second: { type: 'leaf', leafId: leaf(4) } + }, + activeLeafId: leaf(3), + expandedLeafId: null, + ptyIdsByLeafId: { [leaf(3)]: 'pty-a', [leaf(4)]: 'pty-c' } + } + return session + }), local('row terminal differs from its focused pane’s', () => { const session = twoTabs() session.tabsByWorktree[GIT_KEY]![0]!.ptyId = 'pty-stale' diff --git a/src/shared/workspace-layout/workspace-layout-load-types.ts b/src/shared/workspace-layout/workspace-layout-load-types.ts index 6509439e6be..740ad8b47cc 100644 --- a/src/shared/workspace-layout/workspace-layout-load-types.ts +++ b/src/shared/workspace-layout/workspace-layout-load-types.ts @@ -43,4 +43,6 @@ export type WorkspaceLoadArgs = { facts: LayoutContentFacts /** Terminal tab id → its stored pane layout (a legacy row's built one). */ layouts: Map + /** A dropped row's id → the tab-bar terminal that holds its terminals. */ + canonicalTabIdBySubsumedTabId: Map } diff --git a/src/shared/workspace-layout/workspace-layout-load-workspace.ts b/src/shared/workspace-layout/workspace-layout-load-workspace.ts index 471354e857d..0a4e75251f6 100644 --- a/src/shared/workspace-layout/workspace-layout-load-workspace.ts +++ b/src/shared/workspace-layout/workspace-layout-load-workspace.ts @@ -2,6 +2,7 @@ import type { Tab } from '../tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../terminal-tab-types' import type { WorkspaceSessionState } from '../workspace-session-state-types' import { childRecord, pickStoredFields } from './stored-record-fields' +import { readCanonicalPtyOwners, resolveCanonicalPtyClaim } from './legacy-terminal-row-claims' import { resolveGroupOrder, type OrderCandidate } from './workspace-layout-load-order' import { loadBrowserTabs, @@ -126,8 +127,16 @@ function loadTabs(args: WorkspaceLoadArgs): { tabs: LayoutTab[]; candidates: Ord childRecord(args.view.lastFocusedAt, key)[entry.id] = entry.lastFocusedAt } } + // A row outside the tab bar whose every terminal a tab-bar tab holds is a stale copy of it. + const canonical = readCanonicalPtyOwners(session, key, rows) for (const row of rows) { - if (!merged.has(row.id)) { + if (merged.has(row.id)) { + continue + } + const claim = resolveCanonicalPtyClaim(session, row, canonical) + if (claim.kind === 'subsumed') { + args.canonicalTabIdBySubsumedTabId.set(row.id, claim.canonicalTabId) + } else { addTerminal(row, undefined) } } diff --git a/src/shared/workspace-layout/workspace-layout-load.ts b/src/shared/workspace-layout/workspace-layout-load.ts index 2c0931e3937..197faf40919 100644 --- a/src/shared/workspace-layout/workspace-layout-load.ts +++ b/src/shared/workspace-layout/workspace-layout-load.ts @@ -112,6 +112,25 @@ function placeClosedTabs( } } +/** A selection naming a dropped row follows its tab-bar twin, as the window's startup does. */ +function followSubsumedTabSelections( + view: DesktopLayoutView, + canonicalTabIdBySubsumedTabId: ReadonlyMap +): void { + const follow = (tabId: string) => canonicalTabIdBySubsumedTabId.get(tabId) ?? tabId + if (view.activeTabId) { + view.activeTabId = follow(view.activeTabId) + } + if (view.activeTabIdByWorktree) { + view.activeTabIdByWorktree = Object.fromEntries( + Object.entries(view.activeTabIdByWorktree).map(([key, tabId]) => [ + key, + tabId ? follow(tabId) : tabId + ]) + ) + } +} + /** * Loads by fixed precedence only. What the next save would write differently from `stored` is * reported as one field-by-field diff, so every changed value is in it by construction. @@ -158,6 +177,7 @@ export function loadWorkspaceLayoutWithoutReport( } const terminalHomes = resolveTerminalHomes(session) const layouts = new Map() + const canonicalTabIdBySubsumedTabId = new Map() for (const key of workspaceKeys(session)) { layout.workspaces[key] = loadWorkspace({ session, @@ -167,9 +187,11 @@ export function loadWorkspaceLayoutWithoutReport( context, view: desktopView, facts, - layouts + layouts, + canonicalTabIdBySubsumedTabId }) } + followSubsumedTabSelections(desktopView, canonicalTabIdBySubsumedTabId) const carried: CarriedSessionFields = { ...pickStoredFields(session, CARRIED_SESSION_FIELDS), unownedTerminalLayouts: Object.fromEntries( From 1bdbc2364801a30d33b62c49247dc22b93b497e1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:11:03 -0700 Subject: [PATCH 17/17] refactor(runtime): check session graphs and tab lifecycle (#27694) --- config/main-layout-writer-baseline.txt | 6 +- .../pr-e2e-layout-oracle-routing.test.mjs | 5 +- config/scripts/pr-layout-oracle-route.mjs | 4 + config/ts-nocheck-baseline.txt | 17 - src/main/agent-workspace-trust-spawn.ts | 2 +- .../terminal-stale-pane-tree-client.test.ts | 2 +- .../runtime/orca-runtime-attach-window.ts | 98 ------ ...ld-headless-mobile-session-browser-tabs.ts | 2 +- .../runtime/orca-runtime-file-commands.ts | 2 +- .../orca-runtime-list-managed-worktrees.ts | 2 +- ...runtime-persist-headless-terminal-title.ts | 2 +- ...le-headless-mobile-session-browser-tabs.ts | 2 +- ...ser-network-execution-host-for-worktree.ts | 2 +- ...me-schedule-mobile-session-tabs-changed.ts | 2 +- .../runtime/orca-runtime-split-terminal.ts | 2 +- .../worktree-removal-execution-host.spec.ts | 2 +- .../orca-runtime-wait-for-leaf-pty-id.ts | 2 +- ...runtime-write-orchestration-pointer-pty.ts | 2 +- ...rer-browser-session-reconciliation.test.ts | 2 +- ...ime-migration-catalog-capabilities.test.ts | 6 +- src/main/runtime/runtime-store-contract.ts | 2 + .../session-graph-authority.ts} | 38 +- .../session-graph/session-graph-promotion.ts | 192 ++++++++++ .../session-graph-reload-lifecycle.ts} | 60 +++- .../session-window-graph.ts} | 14 +- .../explicit-terminal-close-stops.ts} | 17 +- .../session-tabs/headless-tab-properties.ts | 2 +- .../headless-terminal-tab-close.ts} | 59 +++- .../mobile-session-hydration.ts} | 86 ++++- .../mobile-session-preservation.ts} | 22 +- ...obile-session-publication-preservation.ts} | 25 +- .../mobile-session-reconciliation.ts} | 45 ++- .../renderer-session-terminal-rescue.ts} | 18 +- .../session-tabs/restored-chat-tab-changes.ts | 30 ++ .../session-tab-prototype-dispatch.test.ts | 328 +++++++++++++++++- .../session-tabs-inventory-publication.ts | 2 +- .../session-tabs-inventory.ts} | 85 ++++- .../structured-session-create-support.ts} | 52 +-- .../structured-session-tab-close.ts} | 53 ++- .../structured-session-tab-restoration.ts} | 77 ++-- .../terminal-surface-retirements.ts} | 27 +- .../lifecycle/floating-session-liveness.ts} | 16 +- .../wait/terminal-interactive-wait.ts | 2 +- .../runtime/workspaces/workspace-selectors.ts | 2 +- src/shared/tui-agent-launch-defaults.ts | 8 +- 45 files changed, 1091 insertions(+), 335 deletions(-) delete mode 100644 src/main/runtime/orca-runtime-attach-window.ts rename src/main/runtime/{orca-runtime-transition-graph-reload-to-terminal-state.ts => session-graph/session-graph-authority.ts} (78%) create mode 100644 src/main/runtime/session-graph/session-graph-promotion.ts rename src/main/runtime/{orca-runtime-has-terminals-for-worktree.ts => session-graph/session-graph-reload-lifecycle.ts} (67%) rename src/main/runtime/{orca-runtime-sync-window-graph.ts => session-graph/session-window-graph.ts} (96%) rename src/main/runtime/{orca-runtime-stop-explicitly-closed-tab-ptys.ts => session-tabs/explicit-terminal-close-stops.ts} (93%) rename src/main/runtime/{orca-runtime-close-headless-mobile-terminal-tab.ts => session-tabs/headless-terminal-tab-close.ts} (75%) rename src/main/runtime/{orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts => session-tabs/mobile-session-hydration.ts} (76%) rename src/main/runtime/{orca-runtime-merge-preserved-headless-mobile-session-tabs.ts => session-tabs/mobile-session-preservation.ts} (88%) rename src/main/runtime/{orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts => session-tabs/mobile-session-publication-preservation.ts} (86%) rename src/main/runtime/{orca-runtime-sync-mobile-session-tabs.ts => session-tabs/mobile-session-reconciliation.ts} (83%) rename src/main/runtime/{orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts => session-tabs/renderer-session-terminal-rescue.ts} (87%) create mode 100644 src/main/runtime/session-tabs/restored-chat-tab-changes.ts rename src/main/runtime/{orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts => session-tabs/session-tabs-inventory.ts} (61%) rename src/main/runtime/{orca-runtime-get-structured-agent-session-create-support.ts => session-tabs/structured-session-create-support.ts} (86%) rename src/main/runtime/{orca-runtime-close-structured-agent-session-tab.ts => session-tabs/structured-session-tab-close.ts} (80%) rename src/main/runtime/{orca-runtime-restore-structured-agent-session-tabs-once.ts => session-tabs/structured-session-tab-restoration.ts} (82%) rename src/main/runtime/{orca-runtime-persist-terminal-surface-retirements.ts => session-tabs/terminal-surface-retirements.ts} (84%) rename src/main/runtime/{orca-runtime-refresh-floating-workspace-pty-liveness.ts => terminal/lifecycle/floating-session-liveness.ts} (90%) diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index 7da39e86bb9..2df2e2cc215 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -54,12 +54,12 @@ 3 src/main/runtime/mobile-session-layout-projection.ts 2 src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts 2 src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts -2 src/main/runtime/orca-runtime-attach-window.ts +2 src/main/runtime/session-graph/session-graph-promotion.ts 2 src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts -1 src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +1 src/main/runtime/session-tabs/structured-session-tab-close.ts 1 src/main/runtime/orca-runtime-get-runtime-id.ts 2 src/main/runtime/orca-runtime-persist-headless-terminal-title.ts -1 src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +1 src/main/runtime/session-tabs/terminal-surface-retirements.ts 1 src/main/runtime/orca-runtime-pty-foreground-process-reads.ts 3 src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts 1 src/main/runtime/orca-runtime-terminal-drivers.ts diff --git a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs index 41b27c68e21..a7944d3eec6 100644 --- a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs +++ b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs @@ -37,7 +37,10 @@ const STACK_WRITERS = [ 'src/renderer/src/runtime/sync-runtime-graph.ts', 'src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts', 'src/main/runtime/orca-runtime-split-terminal.ts', - 'src/main/runtime/orca-runtime-sync-window-graph.ts', + 'src/main/runtime/session-graph/session-window-graph.ts', + 'src/main/runtime/session-tabs/mobile-session-hydration.ts', + 'src/main/runtime/session-tabs/mobile-session-reconciliation.ts', + 'src/main/runtime/session-tabs/mobile-session-preservation.ts', 'src/main/runtime/mobile-session-layout-projection.ts', 'src/main/runtime/headless-runtime-graph.ts', 'src/main/persistence/loading-store/session-host-partitions.ts', diff --git a/config/scripts/pr-layout-oracle-route.mjs b/config/scripts/pr-layout-oracle-route.mjs index ccf472417bc..8af3645833f 100644 --- a/config/scripts/pr-layout-oracle-route.mjs +++ b/config/scripts/pr-layout-oracle-route.mjs @@ -38,6 +38,10 @@ const LAYOUT_ORACLE_PREFIXES = [ 'src/main/runtime/terminal-surface-close.ts', 'src/main/runtime/terminal/output/terminal-derived-agent-facts.ts', 'src/main/runtime/terminal/output/terminal-output-generation.ts', + 'src/main/runtime/session-graph/session-window-graph.ts', + 'src/main/runtime/session-tabs/mobile-session-hydration.ts', + 'src/main/runtime/session-tabs/mobile-session-reconciliation.ts', + 'src/main/runtime/session-tabs/mobile-session-preservation.ts', 'src/renderer/src/components/terminal/terminal-tab-actions.ts', 'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts', 'src/shared/rpc-contract/workspace-layout-params.ts', diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index cf4eb2ba233..35b0643c8b3 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -5,13 +5,9 @@ # Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only) src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts -src/main/runtime/orca-runtime-attach-window.ts src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts src/main/runtime/orca-runtime-build-pty-terminal-summary.ts -src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts src/main/runtime/orca-runtime-close-mobile-session-tab.ts -src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts -src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts src/main/runtime/orca-runtime-create-agent-session.ts src/main/runtime/orca-runtime-create-mobile-session-terminal.ts @@ -26,29 +22,23 @@ src/main/runtime/orca-runtime-focus-terminal.ts src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts src/main/runtime/orca-runtime-get-runtime-id.ts -src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts src/main/runtime/orca-runtime-get-worktree-ps.ts src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts src/main/runtime/orca-runtime-has-live-or-persisted-serve-or-ssh-owned-pty-binding.ts -src/main/runtime/orca-runtime-has-terminals-for-worktree.ts -src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts src/main/runtime/orca-runtime-linear-commands.ts src/main/runtime/orca-runtime-list-managed-worktrees.ts -src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts src/main/runtime/orca-runtime-notify-ssh-state-changed.ts src/main/runtime/orca-runtime-on-pty-exit.ts src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts src/main/runtime/orca-runtime-persist-headless-terminal-title.ts -src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts src/main/runtime/orca-runtime-prepare-pty-execution-context.ts src/main/runtime/orca-runtime-preserved-branch-cleanup.ts src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts src/main/runtime/orca-runtime-pty-foreground-process-reads.ts src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts -src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts @@ -57,8 +47,6 @@ src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktre src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts src/main/runtime/orca-runtime-resolve-terminal-pane.ts src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts -src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts -src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts src/main/runtime/orca-runtime-runtime-id.ts src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts @@ -68,16 +56,11 @@ src/main/runtime/orca-runtime-split-pty-backed-terminal.ts src/main/runtime/orca-runtime-split-terminal.ts src/main/runtime/orca-runtime-state-fields.ts src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts -src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts src/main/runtime/orca-runtime-stop-requested-pty-ids.ts src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts -src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts -src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts -src/main/runtime/orca-runtime-sync-window-graph.ts src/main/runtime/orca-runtime-terminal-create-deduplication.ts src/main/runtime/orca-runtime-terminal-drivers.ts src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts -src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts diff --git a/src/main/agent-workspace-trust-spawn.ts b/src/main/agent-workspace-trust-spawn.ts index f893529b53b..8d901951bbb 100644 --- a/src/main/agent-workspace-trust-spawn.ts +++ b/src/main/agent-workspace-trust-spawn.ts @@ -9,7 +9,7 @@ import { import { AGENT_TRUST_KEYED_BY_START_FOLDER } from './execution-host-workspace-trust' type AgentWorkspaceTrustSetting = - | Pick + | Partial> | null | undefined diff --git a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts index 61a06bac146..8393d03a622 100644 --- a/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts +++ b/src/main/persistence/terminal-topology/terminal-stale-pane-tree-client.test.ts @@ -6,7 +6,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' -import type { OrcaRuntimeWithCloseHeadlessMobileTerminalTab } from '../../runtime/orca-runtime-close-headless-mobile-terminal-tab' +import type { OrcaRuntimeWithCloseHeadlessMobileTerminalTab } from '../../runtime/session-tabs/headless-terminal-tab-close' import { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../loading-store/store' import { closeLeafOrTab } from './terminal-topology-commit' diff --git a/src/main/runtime/orca-runtime-attach-window.ts b/src/main/runtime/orca-runtime-attach-window.ts deleted file mode 100644 index fea1683adb0..00000000000 --- a/src/main/runtime/orca-runtime-attach-window.ts +++ /dev/null @@ -1,98 +0,0 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithNotifySshStateChanged } from './orca-runtime-notify-ssh-state-changed' -import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' -import type { ExecutionHostId } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -export class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifySshStateChanged { - attachWindow(windowId: number): void { - if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { - if ( - this.pendingHeadlessPromotionWindowId !== null && - windowId !== this.pendingHeadlessPromotionWindowId - ) { - return - } - // Why: promotion is a renderer reload of the same graph owner, not a new - // runtime; stale handles must transition before the real window publishes. - this.persistWindowlessPtyBindingsForDesktopAttach() - this.pendingHeadlessPromotionWindowId = windowId - this.authoritativeWindowId = windowId - this.beginGraphReload(windowId) - return - } - if (this.authoritativeWindowId === null) { - // Why: a promoted serve can close and later reopen its window while new - // background PTYs keep arriving; every windowless gap needs this handoff. - this.persistWindowlessPtyBindingsForDesktopAttach() - this.authoritativeWindowId = windowId - } - } - - protected persistWindowlessPtyBindingsForDesktopAttach(): void { - if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) { - return - } - const partitions = new Map< - ExecutionHostId, - { session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] } - >() - for (const pty of this.ptysById.values()) { - if (!pty.connected || !pty.tabId) { - continue - } - const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId) - const session = this.store.getWorkspaceSession(hostId) - const tab = session.tabsByWorktree[pty.worktreeId]?.find( - (candidate) => candidate.id === pty.tabId - ) - if (!tab) { - continue - } - const layoutPtyIds = Object.values( - session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} - ) - if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) { - continue - } - const partition = partitions.get(hostId) ?? { session, ptys: [] } - partition.ptys.push(pty) - partitions.set(hostId, partition) - } - - for (const [hostId, { session, ptys }] of partitions) { - // Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session. - const activeWorktreeIdsOnShutdown = [ - ...new Set([ - ...(session.activeWorktreeIdsOnShutdown ?? []), - ...ptys.map((pty) => pty.worktreeId) - ]) - ] - const activeConnectionIdsAtShutdown = [ - ...new Set([ - ...(session.activeConnectionIdsAtShutdown ?? []), - ...ptys - .map((pty) => pty.connectionId) - .filter((connectionId): connectionId is string => connectionId !== null) - ]) - ] - const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } - for (const pty of ptys) { - if (pty.connectionId && pty.tabId) { - remoteSessionIdsByTabId[pty.tabId] = pty.ptyId - } - } - - this.store.setWorkspaceSession( - { - ...session, - activeWorktreeIdsOnShutdown, - ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), - ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) - }, - hostId - ) - } - } -} diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index f74c1154008..e314ccff4b1 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './orca-runtime-persist-terminal-surface-retirements' +import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './session-tabs/terminal-surface-retirements' import type { RuntimeMobileSessionBrowserTab, RuntimeMobileSessionTabsResult, diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index fc9a386b3d1..00d0b04c807 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -33,7 +33,7 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' import type { Repo } from '../../shared/repo-types' export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchCleanup { - protected abstract requireStore(): RuntimeStore + abstract requireStore(): RuntimeStore protected abstract resolveWorktreeSelector(selector: string): Promise diff --git a/src/main/runtime/orca-runtime-list-managed-worktrees.ts b/src/main/runtime/orca-runtime-list-managed-worktrees.ts index aa8fbe17e97..9d7fd618d5d 100644 --- a/src/main/runtime/orca-runtime-list-managed-worktrees.ts +++ b/src/main/runtime/orca-runtime-list-managed-worktrees.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce } from './orca-runtime-restore-structured-agent-session-tabs-once' +import { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce } from './session-tabs/structured-session-tab-restoration' import { DEFAULT_WORKTREE_LIST_LIMIT } from './orca-runtime-postlude' import type { RuntimeWorktreeListResult } from '../../shared/runtime-types' import type { DetectedWorktreeListResult, Worktree } from '../../shared/worktree/types' diff --git a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts index 301b2a35d77..33bb80e240e 100644 --- a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts +++ b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts @@ -194,7 +194,7 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim // Why: same defect as `resolveRuntimeGitTarget` above, in ~30 filesystem dispatches. `getRepo(id)` // is host-blind and never read `worktree.hostId`, and the `connectionId` it returned spelled // "runtime host", "unresolved" and "genuinely local" all as `undefined` (#11163). - protected async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ + async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ worktree: ResolvedWorktree executionHostId: ExecutionHostId }> { diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index 096aebf7bf0..1ed5c9c6fc2 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession } from './orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session' +import { OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession } from './session-tabs/mobile-session-hydration' import type { RuntimeMobileSessionBrowserTab, RuntimeMobileSessionSnapshotTab, diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index b3a33f356f1..1073782474b 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithTransitionGraphReloadToTerminalState } from './orca-runtime-transition-graph-reload-to-terminal-state' +import { OrcaRuntimeWithTransitionGraphReloadToTerminalState } from './session-graph/session-graph-authority' import type { ExecutionHostId } from '../../shared/execution-host' import { floatingWorkspaceToWorktree, diff --git a/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts b/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts index 17e0e2872c3..75acde4d898 100644 --- a/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts +++ b/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab } from './orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab' +import { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab } from './session-tabs/mobile-session-publication-preservation' import type { BrowserTabInfo, RuntimeMobileSessionClientTab, diff --git a/src/main/runtime/orca-runtime-split-terminal.ts b/src/main/runtime/orca-runtime-split-terminal.ts index 7dc006ef24e..a782c515e39 100644 --- a/src/main/runtime/orca-runtime-split-terminal.ts +++ b/src/main/runtime/orca-runtime-split-terminal.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithStopExplicitlyClosedTabPtys } from './orca-runtime-stop-explicitly-closed-tab-ptys' +import { OrcaRuntimeWithStopExplicitlyClosedTabPtys } from './session-tabs/explicit-terminal-close-stops' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { RuntimeTerminalSplit } from '../../shared/runtime-types' import { randomUUID } from 'node:crypto' diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts index e3a7c88f169..8395212c755 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts @@ -149,7 +149,7 @@ class RemovalRuntime extends OrcaRuntimeService { return super.localRemovalJobHost(runtimeStore) } - protected override requireStore(): RuntimeStore { + override requireStore(): RuntimeStore { if (this.stopAtStore) { throw this.helperFailure } diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 18a300c117e..3c6c728c4c5 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals } from './orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals' +import { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals } from './session-tabs/renderer-session-terminal-rescue' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals { diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 93e5927d126..2237d61f61c 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -1,6 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { isRemoteRuntimePtyId } from '../../shared/remote-runtime-pty-id' -import { OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness } from './orca-runtime-refresh-floating-workspace-pty-liveness' +import { OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness } from './terminal/lifecycle/floating-session-liveness' import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' diff --git a/src/main/runtime/renderer-browser-session-reconciliation.test.ts b/src/main/runtime/renderer-browser-session-reconciliation.test.ts index 7dc929c231d..a11874046fa 100644 --- a/src/main/runtime/renderer-browser-session-reconciliation.test.ts +++ b/src/main/runtime/renderer-browser-session-reconciliation.test.ts @@ -3,7 +3,7 @@ import type { RuntimeMobileSessionBrowserTab, RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './orca-runtime-close-structured-agent-session-tab' +import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './session-tabs/structured-session-tab-close' import { OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs } from './orca-runtime-reconcile-headless-mobile-session-browser-tabs' const rendererPage: RuntimeMobileSessionBrowserTab = { diff --git a/src/main/runtime/runtime-migration-catalog-capabilities.test.ts b/src/main/runtime/runtime-migration-catalog-capabilities.test.ts index 76f079eb116..df24611a040 100644 --- a/src/main/runtime/runtime-migration-catalog-capabilities.test.ts +++ b/src/main/runtime/runtime-migration-catalog-capabilities.test.ts @@ -28,7 +28,7 @@ describe('runtime migration catalog capabilities', () => { it('re-enters requireStore after checking the captured store capabilities', () => { const seen: OrcaRuntimeService[] = [] class Probe extends OrcaRuntimeService { - protected override requireStore(): RuntimeStore { + override requireStore(): RuntimeStore { seen.push(this) return super.requireStore() } @@ -41,7 +41,7 @@ describe('runtime migration catalog capabilities', () => { it('retains the native missing-method error on a different returned store', () => { const seen: OrcaRuntimeService[] = [] class Probe extends OrcaRuntimeService { - protected override requireStore(): RuntimeStore { + override requireStore(): RuntimeStore { seen.push(this) return store } @@ -54,7 +54,7 @@ describe('runtime migration catalog capabilities', () => { it('mutates the returned store before its native missing-flush rejection', async () => { const stage = vi.fn(() => staged) class Probe extends OrcaRuntimeService { - protected override requireStore(): RuntimeStore { + override requireStore(): RuntimeStore { return { ...store, stageOrcadMigrationCatalog: stage } } } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 9528b78bf88..035a0499d94 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -88,6 +88,8 @@ export type RuntimeStore = { getMobileClientTabSelections?: Store['getMobileClientTabSelections'] setMobileClientTabSelections?: Store['setMobileClientTabSelections'] getSettings(): { + agentWorkspaceTrustEnabled?: GlobalSettings['agentWorkspaceTrustEnabled'] + codexManagedAccounts?: GlobalSettings['codexManagedAccounts'] nestedWorkerMaxDepth?: GlobalSettings['nestedWorkerMaxDepth'] workspaceDir: string nestWorkspaces: boolean diff --git a/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts b/src/main/runtime/session-graph/session-graph-authority.ts similarity index 78% rename from src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts rename to src/main/runtime/session-graph/session-graph-authority.ts index 05701907f64..fe3faee892a 100644 --- a/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts +++ b/src/main/runtime/session-graph/session-graph-authority.ts @@ -1,23 +1,31 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithHasTerminalsForWorktree } from './orca-runtime-has-terminals-for-worktree' -import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree as ResolveBrowserNetworkExecutionHostForWorktreeMethods } from '../orca-runtime-resolve-browser-network-execution-host-for-worktree' +import { OrcaRuntimeWithHasTerminalsForWorktree } from './session-graph-reload-lifecycle' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' import { assertFolderWorkspacePathUsable, getFolderWorkspacePathStatus, inferFolderWorkspacePathConnection -} from '../project-groups/folder-workspace-path-status' -import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' -import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' -import type { ResolvedWorktree } from './runtime-worktree-path-identity' -import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' -import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route' -import type { ClientHostedBrowserRpcRoute } from './runtime-browser-client-automation' -import { routeRuntimeBrowserClientAutomation } from './runtime-browser-client-automation' -import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' -import { getBrowserHostLeaseRegistry } from './browser-host-lease-registry-instance' +} from '../../project-groups/folder-workspace-path-status' +import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { ResolvedWorktree } from '../runtime-worktree-path-identity' +import type { BrowserExecutionHostKeyResolution } from '../runtime-browser-client-page-adoption' +import { browserNetworkExecutionHostKey } from '../../browser/browser-network-execution-route' +import type { ClientHostedBrowserRpcRoute } from '../runtime-browser-client-automation' +import { routeRuntimeBrowserClientAutomation } from '../runtime-browser-client-automation' +import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' +import { getBrowserHostLeaseRegistry } from '../browser-host-lease-registry-instance' + +export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extends OrcaRuntimeWithHasTerminalsForWorktree { + protected abstract resolveBrowserNetworkExecutionHostForWorktree( + ...args: Parameters< + ResolveBrowserNetworkExecutionHostForWorktreeMethods['resolveBrowserNetworkExecutionHostForWorktree'] + > + ): ReturnType< + ResolveBrowserNetworkExecutionHostForWorktreeMethods['resolveBrowserNetworkExecutionHostForWorktree'] + > -export class OrcaRuntimeWithTransitionGraphReloadToTerminalState extends OrcaRuntimeWithHasTerminalsForWorktree { protected transitionGraphReloadToTerminalState(windowId: number): void { if (this.shouldRestoreHeadlessGraph(windowId)) { this.restoreHeadlessGraphAuthority() diff --git a/src/main/runtime/session-graph/session-graph-promotion.ts b/src/main/runtime/session-graph/session-graph-promotion.ts new file mode 100644 index 00000000000..0c6151edea7 --- /dev/null +++ b/src/main/runtime/session-graph/session-graph-promotion.ts @@ -0,0 +1,192 @@ +import type { OrcaRuntimeWithHasTerminalsForWorktree as HasTerminalsForWorktreeMethods } from './session-graph-reload-lifecycle' +import type { OrcaRuntimeWithBindPtyIncarnationHandle as BindPtyIncarnationHandleMethods } from '../terminal/identity/terminal-handle-bindings' +import type { OrcaRuntimeWithResolveExitWaiters as ResolveExitWaitersMethods } from '../terminal/wait/terminal-wait-settlements' +import type { OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees as CollectMobileVisibleGraphChangedWorktreesMethods } from '../session-tabs/session-tabs-inventory' +import type { OrcaRuntimeWithGetPtyRecordForPaneKey as GetPtyRecordForPaneKeyMethods } from '../orca-runtime-get-pty-record-for-pane-key' +import type { OrcaRuntimeWithResolveWaiter as ResolveWaiterMethods } from '../orca-runtime-resolve-waiter' +import type { OrcaRuntimeWithMarkPtyLivenessUnverifiable as MarkPtyLivenessUnverifiableMethods } from '../terminal/layout/terminal-input-floor' +import type { OrcaRuntimeWithRecordPtyWorktree as RecordPtyWorktreeMethods } from '../terminal/identity/pty-worktree-record' +import type { RuntimeSessionTabsInventoryPublication as SessionTabsInventoryPublicationMethods } from '../session-tabs/session-tabs-inventory-publication' +import type { OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness as RefreshFloatingWorkspacePtyLivenessMethods } from '../terminal/lifecycle/floating-session-liveness' +import type { OrcaRuntimeWithTouchMobileSessionTabsForWorktree as TouchMobileSessionTabsForWorktreeMethods } from '../orca-runtime-touch-mobile-session-tabs-for-worktree' +import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileSessionTabsChangedMethods } from '../orca-runtime-schedule-mobile-session-tabs-changed' +import type { OrcaRuntimeWithSyncMobileSessionTabs as SyncMobileSessionTabsMethods } from '../session-tabs/mobile-session-reconciliation' +import { OrcaRuntimeWithNotifySshStateChanged } from '../orca-runtime-notify-ssh-state-changed' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { RuntimePtyWorktreeRecord } from '../runtime-terminal-state-records' + +export abstract class OrcaRuntimeWithAttachWindow extends OrcaRuntimeWithNotifySshStateChanged { + protected abstract beginGraphReload( + ...args: Parameters + ): ReturnType + + protected abstract adoptFirstPtyForLeafHandle( + ...args: Parameters + ): ReturnType + + protected abstract adoptPreAllocatedHandle( + ...args: Parameters + ): ReturnType + + protected abstract checkDeliverySettledAndArmRecheck( + ...args: Parameters + ): ReturnType + + protected abstract collectMobileVisibleGraphChangedWorktrees( + ...args: Parameters< + CollectMobileVisibleGraphChangedWorktreesMethods['collectMobileVisibleGraphChangedWorktrees'] + > + ): ReturnType< + CollectMobileVisibleGraphChangedWorktreesMethods['collectMobileVisibleGraphChangedWorktrees'] + > + + protected abstract deliverPendingMessagesForLeaf( + ...args: Parameters + ): ReturnType + + protected abstract getLeafKey( + ...args: Parameters + ): ReturnType + + abstract getPtyLivenessVerdict( + ...args: Parameters + ): ReturnType + + protected abstract invalidateLeafHandle( + ...args: Parameters + ): ReturnType + + protected abstract makeRuntimePaneKey( + ...args: Parameters + ): ReturnType + + abstract markGraphReady( + ...args: Parameters + ): ReturnType + + protected abstract markSessionTabsInventoryPublished( + ...args: Parameters + ): ReturnType + + protected abstract nextTitleObservationSequence( + ...args: Parameters + ): ReturnType + + protected abstract rebuildLeafPtyIndex( + ...args: Parameters + ): ReturnType + + protected abstract reconcileMobileSessionRetirementFences( + ...args: Parameters< + TouchMobileSessionTabsForWorktreeMethods['reconcileMobileSessionRetirementFences'] + > + ): ReturnType + + protected abstract reconcilePtyIncarnationHandles( + ...args: Parameters + ): ReturnType + + protected abstract recordPtyWorktree( + ...args: Parameters + ): ReturnType + + protected abstract scheduleMobileSessionTabsChanged( + ...args: Parameters + ): ReturnType + + protected abstract syncMobileSessionTabs( + ...args: Parameters + ): ReturnType + + attachWindow(windowId: number): void { + if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + if ( + this.pendingHeadlessPromotionWindowId !== null && + windowId !== this.pendingHeadlessPromotionWindowId + ) { + return + } + // Why: promotion is a renderer reload of the same graph owner, not a new + // runtime; stale handles must transition before the real window publishes. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.pendingHeadlessPromotionWindowId = windowId + this.authoritativeWindowId = windowId + this.beginGraphReload(windowId) + return + } + if (this.authoritativeWindowId === null) { + // Why: a promoted serve can close and later reopen its window while new + // background PTYs keep arriving; every windowless gap needs this handoff. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.authoritativeWindowId = windowId + } + } + + protected persistWindowlessPtyBindingsForDesktopAttach(): void { + if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) { + return + } + const partitions = new Map< + ExecutionHostId, + { session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] } + >() + for (const pty of this.ptysById.values()) { + if (!pty.connected || !pty.tabId) { + continue + } + const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId) + const session = this.store.getWorkspaceSession(hostId) + const tab = session.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + if (!tab) { + continue + } + const layoutPtyIds = Object.values( + session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} + ) + if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) { + continue + } + const partition = partitions.get(hostId) ?? { session, ptys: [] } + partition.ptys.push(pty) + partitions.set(hostId, partition) + } + + for (const [hostId, { session, ptys }] of partitions) { + // Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...ptys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...ptys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of ptys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession( + { + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }, + hostId + ) + } + } +} diff --git a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts b/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts similarity index 67% rename from src/main/runtime/orca-runtime-has-terminals-for-worktree.ts rename to src/main/runtime/session-graph/session-graph-reload-lifecycle.ts index ebedfb362fc..6114be9f2cd 100644 --- a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts +++ b/src/main/runtime/session-graph/session-graph-reload-lifecycle.ts @@ -1,9 +1,59 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithStopExactTerminalsForWorktree } from './orca-runtime-stop-exact-terminals-for-worktree' -import type { RuntimeRendererReloadFence } from './orca-runtime-core' -import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' +import type { OrcaRuntimeWithTransitionGraphReloadToTerminalState as TransitionGraphReloadToTerminalStateMethods } from './session-graph-authority' +import type { OrcaRuntimeWithBindPtyIncarnationHandle as BindPtyIncarnationHandleMethods } from '../terminal/identity/terminal-handle-bindings' +import type { OrcaRuntimeWithResolveWaiter as ResolveWaiterMethods } from '../orca-runtime-resolve-waiter' +import { OrcaRuntimeWithStopExactTerminalsForWorktree } from '../orca-runtime-stop-exact-terminals-for-worktree' +import type { RuntimeRendererReloadFence } from '../orca-runtime-core' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' + +export abstract class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopExactTerminalsForWorktree { + protected abstract assertStableReadyGraph( + ...args: Parameters + ): ReturnType + + protected abstract captureReadyGraphEpoch( + ...args: Parameters + ): ReturnType + + protected abstract clearPtyIncarnationHandles( + ...args: Parameters + ): ReturnType + + protected abstract invalidatePtyIncarnationHandle( + ...args: Parameters + ): ReturnType + + protected abstract refreshWritableFlags( + ...args: Parameters + ): ReturnType + + protected abstract rejectAllWaiters( + ...args: Parameters + ): ReturnType + + protected abstract rejectWaitersForHandle( + ...args: Parameters + ): ReturnType + + protected abstract rememberDetachedPreAllocatedLeaves( + ...args: Parameters + ): ReturnType + + protected abstract restoreHeadlessGraphAuthority( + ...args: Parameters< + TransitionGraphReloadToTerminalStateMethods['restoreHeadlessGraphAuthority'] + > + ): ReturnType + + protected abstract shouldRestoreHeadlessGraph( + ...args: Parameters + ): ReturnType + + protected abstract transitionGraphReloadToTerminalState( + ...args: Parameters< + TransitionGraphReloadToTerminalStateMethods['transitionGraphReloadToTerminalState'] + > + ): ReturnType -export class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopExactTerminalsForWorktree { async hasTerminalsForWorktree(worktreeSelector: string): Promise { const graphEpoch = this.captureReadyGraphEpoch() const worktree = await this.resolveWorktreeSelector(worktreeSelector) diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/session-graph/session-window-graph.ts similarity index 96% rename from src/main/runtime/orca-runtime-sync-window-graph.ts rename to src/main/runtime/session-graph/session-window-graph.ts index d244e0ff253..b02d8eee566 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/session-graph/session-window-graph.ts @@ -1,14 +1,13 @@ /* eslint-disable unicorn/no-useless-spread */ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithAttachWindow } from './orca-runtime-attach-window' +import { OrcaRuntimeWithAttachWindow } from './session-graph-promotion' import type { RuntimeRendererSyncWindowGraph, RuntimeSyncedTab, RuntimeSyncWindowGraph, RuntimeSyncWindowGraphResult -} from '../../shared/runtime-types' -import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' -import type { RuntimeLeafRecord } from './runtime-terminal-state-records' +} from '../../../shared/runtime-session-contracts' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types' +import type { RuntimeLeafRecord } from '../runtime-terminal-state-records' /** The runtime indexes graph tabs by bare id, so duplicate ids cannot be routed safely. */ function assertUniqueRuntimeGraphTabIds(tabs: readonly RuntimeSyncedTab[]): void { @@ -21,7 +20,10 @@ function assertUniqueRuntimeGraphTabIds(tabs: readonly RuntimeSyncedTab[]): void } } -export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow { +export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow { + declare protected terminalGroupGridSizes?: RuntimeSyncWindowGraph['terminalGroupGridSizes'] + declare protected singleGroupWorkspaceGridSize?: RuntimeSyncWindowGraph['singleGroupWorkspaceGridSize'] + shouldRelayTerminalBrowserOpens(): boolean { return this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID } diff --git a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts similarity index 93% rename from src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts rename to src/main/runtime/session-tabs/explicit-terminal-close-stops.ts index 9cdc7b96441..5a1be77ce3d 100644 --- a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts @@ -1,18 +1,17 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithFocusTerminal } from './orca-runtime-focus-terminal' -import { EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS } from './orca-runtime-core' -import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' -import type { RuntimeTerminalClose } from '../../shared/runtime-types' -import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records' -import { parsePaneKey } from '../../shared/stable-pane-id' -import { errorMessage } from '../../shared/error-message' +import { OrcaRuntimeWithFocusTerminal } from '../orca-runtime-focus-terminal' +import { EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS } from '../orca-runtime-core' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../shared/pty-liveness-verdict' +import type { RuntimeTerminalClose } from '../../../shared/runtime-types' +import type { RuntimePtyTabCloseAuthority } from '../runtime-terminal-state-records' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { errorMessage } from '../../../shared/error-message' /** How an explicit close's stop of its addressed PTY ended. */ type ExplicitCloseStop = { stopped: boolean } const NO_STOP: ExplicitCloseStop = { stopped: false } -export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithFocusTerminal { +export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithFocusTerminal { protected async stopExplicitlyClosedTabPtys( ptyIds: readonly string[], addressedPtyId: string diff --git a/src/main/runtime/session-tabs/headless-tab-properties.ts b/src/main/runtime/session-tabs/headless-tab-properties.ts index d4ae383e615..0fc43393ecf 100644 --- a/src/main/runtime/session-tabs/headless-tab-properties.ts +++ b/src/main/runtime/session-tabs/headless-tab-properties.ts @@ -1,4 +1,4 @@ -import { OrcaRuntimeWithCloseHeadlessMobileTerminalTab } from '../orca-runtime-close-headless-mobile-terminal-tab' +import { OrcaRuntimeWithCloseHeadlessMobileTerminalTab } from './headless-terminal-tab-close' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts' import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts' diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/session-tabs/headless-terminal-tab-close.ts similarity index 75% rename from src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts rename to src/main/runtime/session-tabs/headless-terminal-tab-close.ts index 5ef34314e42..04482de7438 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/session-tabs/headless-terminal-tab-close.ts @@ -1,19 +1,58 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './orca-runtime-close-structured-agent-session-tab' +import type { RuntimeHeadlessTabProperties as HeadlessTabPropertiesMethods } from './headless-tab-properties' +import type { RuntimeHeadlessTabGroups as HeadlessTabGroupsMethods } from './headless-tab-group-moves' +import type { OrcaRuntimeWithPersistHeadlessTerminalTitle as PersistHeadlessTerminalTitleMethods } from '../orca-runtime-persist-headless-terminal-title' +import type { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals as RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods } from './renderer-session-terminal-rescue' +import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './structured-session-tab-close' import type { RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab -} from '../../shared/runtime-types' -import { parseAppSshPtyId } from '../../shared/ssh-pty-id' -import { buildHeadlessMobileSessionTabGroups } from './mobile-session-layout-projection' -import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' -import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-session-contracts' +} from '../../../shared/runtime-types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { buildHeadlessMobileSessionTabGroups } from '../mobile-session-layout-projection' +import { appendRetiredTerminalSurfaceProofs } from '../mobile-session-terminal-retirement-proof' +import type { RuntimePtyWorktreeRecord } from '../runtime-terminal-state-records' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { RuntimeSessionTabCloseReason } from '../../../shared/runtime-session-contracts' + +export abstract class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab { + protected abstract applyHeadlessSessionTabPropsToSnapshot( + ...args: Parameters + ): ReturnType + + protected abstract applyHeadlessTerminalPaneLayoutToSnapshot( + ...args: Parameters + ): ReturnType + + protected abstract moveHeadlessMobileSessionTab( + ...args: Parameters + ): ReturnType + + protected abstract normalizeMobileSessionTabOrder( + ...args: Parameters + ): ReturnType + + protected abstract persistHeadlessSessionTabProps( + ...args: Parameters + ): ReturnType + + protected abstract persistHeadlessTerminalPaneLayout( + ...args: Parameters + ): ReturnType + + protected abstract resolveMobileSessionHostTabId( + ...args: Parameters + ): ReturnType + + protected abstract setPairedRendererSessionOwnership( + ...args: Parameters< + RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods['setPairedRendererSessionOwnership'] + > + ): ReturnType< + RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods['setPairedRendererSessionOwnership'] + > -export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab { protected async closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, diff --git a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts b/src/main/runtime/session-tabs/mobile-session-hydration.ts similarity index 76% rename from src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts rename to src/main/runtime/session-tabs/mobile-session-hydration.ts index 5b3c6dceb8f..b584f7fecbd 100644 --- a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts +++ b/src/main/runtime/session-tabs/mobile-session-hydration.ts @@ -1,35 +1,97 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import type { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs as BuildHeadlessMobileSessionBrowserTabsMethods } from '../orca-runtime-build-headless-mobile-session-browser-tabs' +import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as StoredMobileSnapshotHasStalePreservedTabMethods } from './mobile-session-publication-preservation' +import type { OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs as ReconcileHeadlessMobileSessionBrowserTabsMethods } from '../orca-runtime-reconcile-headless-mobile-session-browser-tabs' import type { BrowserWindow } from 'electron' -import { RuntimeSessionTabsInventoryPublication } from './session-tabs/session-tabs-inventory-publication' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' -import { buildHeadlessMobileSessionEditorTabs } from './mobile-session-editor-projection' +import { RuntimeSessionTabsInventoryPublication } from './session-tabs-inventory-publication' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' +import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { buildHeadlessMobileSessionTerminalTabs } from '../mobile-session-terminal-projection' +import { buildHeadlessMobileSessionEditorTabs } from '../mobile-session-editor-projection' import type { RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTabGroup, RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab -} from '../../shared/runtime-types' +} from '../../../shared/runtime-types' import { collectHeadlessParentTabOrder, distributeHeadlessTabsAcrossGroups, getHeadlessMobileSessionGroupId, pickHeadlessActiveTerminalTab, pickRestoredActiveGroupId -} from './mobile-session-layout-projection' +} from '../mobile-session-layout-projection' import { mergeMobileSessionSnapshotTabs, mergeMobileSessionTabGroups -} from './mobile-session-tab-merge' +} from '../mobile-session-tab-merge' import { appendBrowserTabOrder, collectBrowserGroupAssignment -} from './mobile-session-browser-group-projection' -import { headlessMobileSnapshotContentUnchanged } from './mobile-session-snapshot-equality' +} from '../mobile-session-browser-group-projection' +import { headlessMobileSnapshotContentUnchanged } from '../mobile-session-snapshot-equality' export abstract class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession extends RuntimeSessionTabsInventoryPublication { + protected abstract buildHeadlessMobileSessionBrowserTabs( + ...args: Parameters< + BuildHeadlessMobileSessionBrowserTabsMethods['buildHeadlessMobileSessionBrowserTabs'] + > + ): ReturnType< + BuildHeadlessMobileSessionBrowserTabsMethods['buildHeadlessMobileSessionBrowserTabs'] + > + + protected abstract getMergedMobileSessionPublicationEpoch( + ...args: Parameters< + StoredMobileSnapshotHasStalePreservedTabMethods['getMergedMobileSessionPublicationEpoch'] + > + ): ReturnType< + StoredMobileSnapshotHasStalePreservedTabMethods['getMergedMobileSessionPublicationEpoch'] + > + + protected abstract hasRecentExpiredSshLeasePane( + ...args: Parameters< + ReconcileHeadlessMobileSessionBrowserTabsMethods['hasRecentExpiredSshLeasePane'] + > + ): ReturnType + + protected abstract hasServeOrSshOwnedBinding( + ...args: Parameters< + ReconcileHeadlessMobileSessionBrowserTabsMethods['hasServeOrSshOwnedBinding'] + > + ): ReturnType + + protected abstract isHeadlessBuiltMobileSessionPublicationBase( + ...args: Parameters< + StoredMobileSnapshotHasStalePreservedTabMethods['isHeadlessBuiltMobileSessionPublicationBase'] + > + ): ReturnType< + StoredMobileSnapshotHasStalePreservedTabMethods['isHeadlessBuiltMobileSessionPublicationBase'] + > + + protected abstract reconcileHeadlessMobileSessionBrowserTabs( + ...args: Parameters< + ReconcileHeadlessMobileSessionBrowserTabsMethods['reconcileHeadlessMobileSessionBrowserTabs'] + > + ): ReturnType< + ReconcileHeadlessMobileSessionBrowserTabsMethods['reconcileHeadlessMobileSessionBrowserTabs'] + > + + protected abstract workspaceSessionHasRuntimeOwnedPtyCandidate( + ...args: Parameters< + ReconcileHeadlessMobileSessionBrowserTabsMethods['workspaceSessionHasRuntimeOwnedPtyCandidate'] + > + ): ReturnType< + ReconcileHeadlessMobileSessionBrowserTabsMethods['workspaceSessionHasRuntimeOwnedPtyCandidate'] + > + + protected abstract workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( + ...args: Parameters< + ReconcileHeadlessMobileSessionBrowserTabsMethods['workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate'] + > + ): ReturnType< + ReconcileHeadlessMobileSessionBrowserTabsMethods['workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate'] + > + protected abstract getAvailableAuthoritativeWindow(): BrowserWindow | null protected hydrateHeadlessMobileSessionTabsFromWorkspaceSession( diff --git a/src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts b/src/main/runtime/session-tabs/mobile-session-preservation.ts similarity index 88% rename from src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts rename to src/main/runtime/session-tabs/mobile-session-preservation.ts index 2ea80067093..bb349791dec 100644 --- a/src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts +++ b/src/main/runtime/session-tabs/mobile-session-preservation.ts @@ -1,20 +1,28 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithSyncMobileSessionTabs } from './orca-runtime-sync-mobile-session-tabs' +import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as StoredMobileSnapshotHasStalePreservedTabMethods } from './mobile-session-publication-preservation' +import { OrcaRuntimeWithSyncMobileSessionTabs } from './mobile-session-reconciliation' import type { RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTabGroup, RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab -} from '../../shared/runtime-types' +} from '../../../shared/runtime-types' import { getMobileSessionSnapshotTabIdentityKeys, mergeMobileSessionSnapshotTabs, mergeMobileSessionTabGroups -} from './mobile-session-tab-merge' -import { terminalLayoutContainsLeaf } from '../../shared/workspace-session-pane-ownership' -import { getHeadlessMobileSessionGroupId } from './mobile-session-layout-projection' +} from '../mobile-session-tab-merge' +import { terminalLayoutContainsLeaf } from '../../../shared/workspace-session-pane-ownership' +import { getHeadlessMobileSessionGroupId } from '../mobile-session-layout-projection' + +export abstract class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs extends OrcaRuntimeWithSyncMobileSessionTabs { + protected abstract collectPreservedHeadlessMobileSessionTabs( + ...args: Parameters< + StoredMobileSnapshotHasStalePreservedTabMethods['collectPreservedHeadlessMobileSessionTabs'] + > + ): ReturnType< + StoredMobileSnapshotHasStalePreservedTabMethods['collectPreservedHeadlessMobileSessionTabs'] + > -export class OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs extends OrcaRuntimeWithSyncMobileSessionTabs { protected mergePreservedHeadlessMobileSessionTabs( snapshot: RuntimeMobileSessionTabsSnapshot, existing: RuntimeMobileSessionTabsSnapshot | undefined diff --git a/src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts b/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts similarity index 86% rename from src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts rename to src/main/runtime/session-tabs/mobile-session-publication-preservation.ts index 726d3973aef..03e6c289e51 100644 --- a/src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts +++ b/src/main/runtime/session-tabs/mobile-session-publication-preservation.ts @@ -1,16 +1,25 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs } from './orca-runtime-merge-preserved-headless-mobile-session-tabs' +import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileSessionTabsChangedMethods } from '../orca-runtime-schedule-mobile-session-tabs-changed' +import { OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs } from './mobile-session-preservation' import type { RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsSnapshot -} from '../../shared/runtime-types' -import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge' -import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' -import { sameRuntimeBrowserPlacement } from '../../shared/runtime-browser-placement' -import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' +} from '../../../shared/runtime-types' +import { getMobileSessionSnapshotTabIdentityKeys } from '../mobile-session-tab-merge' +import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' +import { sameRuntimeBrowserPlacement } from '../../../shared/runtime-browser-placement' +import type { ClientHostedBrowserRowsEvent } from '../../../shared/client-hosted-browser-rows' + +export abstract class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab extends OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs { + protected abstract getListedBrowserTabsByPageId( + ...args: Parameters + ): ReturnType + protected abstract notifyMobileSessionTabsChangedNow( + ...args: Parameters< + ScheduleMobileSessionTabsChangedMethods['notifyMobileSessionTabsChangedNow'] + > + ): ReturnType -export class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab extends OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs { // Why: the accepted-revision no-op gate must not fossilize preserved runtime // tabs. A stored merged snapshot's tabs absent from the accepted renderer // publication exist only via preservation; if any such tab no longer diff --git a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts b/src/main/runtime/session-tabs/mobile-session-reconciliation.ts similarity index 83% rename from src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts rename to src/main/runtime/session-tabs/mobile-session-reconciliation.ts index 51997577179..24dcd5d627d 100644 --- a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts +++ b/src/main/runtime/session-tabs/mobile-session-reconciliation.ts @@ -1,11 +1,44 @@ +import type { OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs as MergePreservedHeadlessMobileSessionTabsMethods } from './mobile-session-preservation' +import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as StoredMobileSnapshotHasStalePreservedTabMethods } from './mobile-session-publication-preservation' /* eslint-disable unicorn/no-useless-spread */ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithWriteOrchestrationPointerPty } from './orca-runtime-write-orchestration-pointer-pty' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge' +import { OrcaRuntimeWithWriteOrchestrationPointerPty } from '../orca-runtime-write-orchestration-pointer-pty' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getMobileSessionSnapshotTabIdentityKeys } from '../mobile-session-tab-merge' + +export abstract class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOrchestrationPointerPty { + protected abstract buildPreservedHeadlessMobileSessionSnapshot( + ...args: Parameters< + MergePreservedHeadlessMobileSessionTabsMethods['buildPreservedHeadlessMobileSessionSnapshot'] + > + ): ReturnType< + MergePreservedHeadlessMobileSessionTabsMethods['buildPreservedHeadlessMobileSessionSnapshot'] + > + + protected abstract mergePreservedHeadlessMobileSessionTabs( + ...args: Parameters< + MergePreservedHeadlessMobileSessionTabsMethods['mergePreservedHeadlessMobileSessionTabs'] + > + ): ReturnType< + MergePreservedHeadlessMobileSessionTabsMethods['mergePreservedHeadlessMobileSessionTabs'] + > + + protected abstract releaseRuntimeSessionOwnershipForRendererRetiredTabs( + ...args: Parameters< + MergePreservedHeadlessMobileSessionTabsMethods['releaseRuntimeSessionOwnershipForRendererRetiredTabs'] + > + ): ReturnType< + MergePreservedHeadlessMobileSessionTabsMethods['releaseRuntimeSessionOwnershipForRendererRetiredTabs'] + > + + protected abstract storedMobileSnapshotHasStalePreservedTab( + ...args: Parameters< + StoredMobileSnapshotHasStalePreservedTabMethods['storedMobileSnapshotHasStalePreservedTab'] + > + ): ReturnType< + StoredMobileSnapshotHasStalePreservedTabMethods['storedMobileSnapshotHasStalePreservedTab'] + > -export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOrchestrationPointerPty { // Returns the worktrees whose stored snapshot object changed during this // sync, so the caller can fan out only actually-changed worktrees. protected syncMobileSessionTabs( diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/session-tabs/renderer-session-terminal-rescue.ts similarity index 87% rename from src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts rename to src/main/runtime/session-tabs/renderer-session-terminal-rescue.ts index 8e9ad65dc32..684e109d62a 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/session-tabs/renderer-session-terminal-rescue.ts @@ -1,11 +1,15 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithWaitForMobileTerminalSurface } from './orca-runtime-wait-for-mobile-terminal-surface' -import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { parsePaneKey } from '../../shared/stable-pane-id' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types' +import type { OrcaRuntimeWithWaitForLeafPtyId as WaitForLeafPtyIdMethods } from '../orca-runtime-wait-for-leaf-pty-id' +import { OrcaRuntimeWithWaitForMobileTerminalSurface } from '../orca-runtime-wait-for-mobile-terminal-surface' +import { runtimeWorktreeIdsEqual } from '../runtime-worktree-path-identity' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { RuntimePtyWorktreeRecord } from '../runtime-terminal-state-records' +import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types' + +export abstract class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals extends OrcaRuntimeWithWaitForMobileTerminalSurface { + protected abstract resolveHandleForTab( + ...args: Parameters + ): ReturnType -export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals extends OrcaRuntimeWithWaitForMobileTerminalSurface { protected restoreLivePairedRendererSessionOwnedMobileTerminals( worktreeId: string | null, options: { missingSnapshotOnly?: boolean; notify?: boolean } = {} diff --git a/src/main/runtime/session-tabs/restored-chat-tab-changes.ts b/src/main/runtime/session-tabs/restored-chat-tab-changes.ts new file mode 100644 index 00000000000..24a7d9a54d0 --- /dev/null +++ b/src/main/runtime/session-tabs/restored-chat-tab-changes.ts @@ -0,0 +1,30 @@ +import { chatTabChangesMatchingIndex } from '../structured-agent-session-tab-index-seed' +import { chatTabId, type ChatTabChange } from '../agent-session-tab-layout' +import { structuredSessionTabRetired } from '../structured-worker-custody' +import type { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' + +type ChatTabOpen = Extract['open'] + +/** Chat-tab changes that make each worktree's saved tabs match the restored sessions. */ +export function restoredChatTabChanges( + savedTabsByWorktree: Parameters[0], + host: ReturnType, + restored: readonly { workspaceId: string; sessionId: string; agent: ChatTabOpen['agent'] }[] +): Map { + return chatTabChangesMatchingIndex( + savedTabsByWorktree, + restored.map((session) => ({ + worktreeId: session.workspaceId, + open: { + sessionId: session.sessionId, + agent: session.agent, + tabId: chatTabId(session.sessionId) + } + })), + // A chat a newer Orca wrote is only unreadable here, so it keeps its tab. + (sessionId) => + !!host && + structuredSessionTabRetired(host, sessionId) && + !host.deps?.store?.isSessionUnreadable?.(sessionId) + ) +} diff --git a/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts b/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts index 96732fb61a9..570a4677f7c 100644 --- a/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts +++ b/src/main/runtime/session-tabs/session-tab-prototype-dispatch.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { + RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabGroup, RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, @@ -8,6 +9,8 @@ import type { import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts' import type { TabGroupLayoutNode } from '../../../shared/tab-types' import { OrcaRuntimeService } from '../orca-runtime' +import { setStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { ResolvedWorktree } from '../runtime-worktree-path-identity' function snapshot(): RuntimeMobileSessionTabsSnapshot { return { @@ -283,3 +286,326 @@ describe('original session tab runtime prototypes', () => { expect(topLevelId).toBe('tab') }) }) + +class SessionRuntime extends OrcaRuntimeService { + readonly refreshArgumentCounts: number[] = [] + readonly reloadRequests: number[] = [] + restoreRequests = 0 + restoreResult = Promise.resolve() + + override async resolveWorktreeSelector(): Promise { + const git = { + path: 'workspace', + head: '', + branch: 'selected', + isBare: false, + isMainWorktree: false + } + return { + ...git, + git, + id: 'workspace', + repoId: 'repo', + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + } + } + + protected override refreshMobileSessionPtyRecords( + ...args: Parameters + ): Promise | null> { + this.refreshArgumentCounts.push(args.length) + return Promise.resolve(null) + } + + protected override hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set { + return new Set() + } + + protected override restoreLivePairedRendererSessionOwnedMobileTerminals(): void {} + + protected override beginGraphReload(windowId: number): number { + this.reloadRequests.push(windowId) + return 77 + } + + protected override hasPersistedStructuredAgentSessionStore(): boolean { + return true + } + + override prepareStructuredAgentSessionStartupRestoration(): Promise { + return Promise.resolve() + } + + protected override restoreStructuredAgentSessionTabsOnce(): Promise { + this.restoreRequests += 1 + return this.restoreResult + } + + seedHeadlessSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { + this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot) + } + + setHostTabResolver( + resolver: (snapshot: RuntimeMobileSessionTabsSnapshot, tabId: string) => string | null + ): void { + this.resolveMobileSessionHostTabId = resolver + } + + restoreTabs(): Promise { + return this.restoreStructuredAgentSessionTabs() + } + + closeTab(tab: RuntimeMobileSessionAgentTab): Promise { + return this.closeStructuredAgentSessionTab(tab, 'user-close') + } +} + +describe('session runtime reentry', () => { + afterEach(() => setStructuredAgentSessionHost(null)) + + it('keeps omitted refresh arguments omitted after asynchronous workspace resolution', async () => { + const runtime = new SessionRuntime() + + await runtime.listMobileSessionTabs('selected') + + expect(runtime.refreshArgumentCounts).toEqual([0]) + }) + + it('uses public methods replaced after construction', async () => { + const runtime = new SessionRuntime() + const list = vi.spyOn(runtime, 'listAllMobileSessionTabsWithChangeSequence').mockResolvedValue({ + snapshots: [], + changeSequence: 73 + }) + const project = vi + .spyOn(runtime, 'projectStructuredAgentSessionTab') + .mockImplementation(() => {}) + const input = { + workspaceId: 'workspace', + sessionId: 'session', + agent: 'claude', + activate: false + } satisfies Parameters[0] + + await expect(runtime.listAllMobileSessionTabs('client')).resolves.toEqual([]) + await runtime.publishStructuredAgentSessionTab(input) + + expect(list).toHaveBeenCalledExactlyOnceWith('client') + expect(project).toHaveBeenCalledExactlyOnceWith(input) + }) + + it('re-enters the protected graph reload override', () => { + const runtime = new SessionRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + expect(runtime.markRendererReloading(1)).toEqual({ revision: 77, recovery: 'renderer' }) + expect(runtime.reloadRequests).toEqual([1]) + }) + + it('shares the protected asynchronous restoration operation', async () => { + const runtime = new SessionRuntime() + const gate = Promise.withResolvers() + runtime.restoreResult = gate.promise + + const first = runtime.restoreTabs() + const second = runtime.restoreTabs() + + expect(first).toBe(second) + expect(runtime.restoreRequests).toBe(1) + gate.resolve() + await first + }) + + it('captures the headless callback before evaluating move arguments', async () => { + const runtime = new SessionRuntime() + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: 'workspace', + publicationEpoch: 'epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + runtime.seedHeadlessSnapshot(snapshot) + const first = vi.fn(function ( + this: OrcaRuntimeService, + received: RuntimeMobileSessionTabsSnapshot, + tabId: string + ) { + expect(this).toBe(runtime) + expect(received).toBe(snapshot) + expect(tabId).toBe('tab') + expect(arguments.length).toBe(2) + return null + }) + const replacement = vi.fn(function ( + this: OrcaRuntimeService, + received: RuntimeMobileSessionTabsSnapshot, + tabId: string + ) { + expect(this).toBe(runtime) + expect(received).toBe(snapshot) + expect(tabId).toBe('tab') + return null + }) + runtime.setHostTabResolver(first) + + await expect( + runtime.moveMobileSessionTab('selected', { + kind: 'reorder', + get tabId() { + runtime.setHostTabResolver(replacement) + return 'tab' + }, + targetGroupId: 'group', + tabOrder: ['tab'] + }) + ).rejects.toThrow('tab_not_found') + + expect(first).toHaveBeenCalledExactlyOnceWith(snapshot, 'tab') + expect(replacement).not.toHaveBeenCalled() + await expect( + runtime.moveMobileSessionTab('selected', { + kind: 'reorder', + tabId: 'tab', + targetGroupId: 'group', + tabOrder: ['tab'] + }) + ).rejects.toThrow('tab_not_found') + expect(replacement).toHaveBeenCalledExactlyOnceWith(snapshot, 'tab') + }) + + it('captures the runtime callee before a tab getter replaces it', async () => { + const runtime = new SessionRuntime() + const first = vi.fn(function (this: OrcaRuntimeService, sessionId: string) { + expect(this).toBe(runtime) + expect(sessionId).toBe('session') + return false + }) + const replacement = vi.fn(() => false) + runtime.retireStructuredAgentSessionTabFromSnapshot = first + const tab: RuntimeMobileSessionAgentTab = { + type: 'agent-session', + id: 'agent-session:session', + title: '', + agent: 'claude', + isActive: false, + get sessionId() { + runtime.retireStructuredAgentSessionTabFromSnapshot = replacement + return 'session' + } + } + + await runtime.closeTab(tab) + + expect(first).toHaveBeenCalledOnce() + expect(replacement).not.toHaveBeenCalled() + await runtime.closeTab(tab) + expect(replacement).toHaveBeenCalledExactlyOnceWith('session') + }) +}) + +class GraphRuntime extends OrcaRuntimeService { + get graphTabs() { + return this.tabs + } + get graphLeaves() { + return this.leaves + } + get graphLeavesByPtyId() { + return this.leavesByPtyId + } + get graphAvailability() { + return this.graphStatus + } + get graphEpoch() { + return this.rendererGraphEpoch + } + get detachedLeaves() { + return this.detachedPreAllocatedLeaves + } + get syncCallbacks() { + return this.graphSyncCallbacks + } +} + +const graph = { + tabs: [{ tabId: 'tab', worktreeId: 'workspace', activeLeafId: 'leaf', layout: null, title: '' }], + leaves: [ + { + tabId: 'tab', + worktreeId: 'workspace', + leafId: 'leaf', + paneRuntimeId: 1, + ptyId: 'pty', + paneTitle: null + } + ] +} + +describe('runtime graph ownership', () => { + it('shares detached preallocated leaves with terminal identity during renderer reload', async () => { + const runtime = new GraphRuntime() + const handle = runtime.preAllocateHandleForPty('pty') + runtime.attachWindow(1) + runtime.syncWindowGraph(1, graph) + const leaf = runtime.graphLeaves.get('tab::leaf') + + runtime.markRendererReloading(1) + + expect(runtime.detachedLeaves.get('pty')).toBe(leaf) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtyData('pty', 'after omitted leaf\n', 100) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ + tail: ['after omitted leaf'] + }) + }) + + it('keeps the installed graph and its PTY index in one per-runtime authority', () => { + const first = new GraphRuntime() + const second = new GraphRuntime() + first.attachWindow(1) + first.syncWindowGraph(1, graph) + const leaf = first.graphLeavesByPtyId.get('pty')?.[0] + expect(leaf).toBe(first.graphLeaves.get('tab::leaf')) + expect(first.graphLeavesByPtyId.has('pty')).toBe(true) + expect(second.graphLeavesByPtyId.has('pty')).toBe(false) + expect(second.graphAvailability).toBe('unavailable') + + first.markGraphUnavailable(1) + expect(first.graphTabs.size).toBe(0) + expect(first.graphLeaves.size).toBe(0) + expect(first.graphLeavesByPtyId.has('pty')).toBe(false) + expect(second.graphEpoch).toBe(0) + }) + + it('drains the captured callback list when a callback changes registration', () => { + const runtime = new GraphRuntime() + runtime.attachWindow(1) + const later = vi.fn() + const original = vi.fn() + runtime.syncCallbacks.push(() => { + runtime.syncCallbacks.splice(1, 1) + runtime.syncCallbacks.push(later) + }, original) + + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + expect(original).toHaveBeenCalledOnce() + expect(later).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/session-tabs/session-tabs-inventory-publication.ts b/src/main/runtime/session-tabs/session-tabs-inventory-publication.ts index 9ee5c62918a..3ecf7b8df73 100644 --- a/src/main/runtime/session-tabs/session-tabs-inventory-publication.ts +++ b/src/main/runtime/session-tabs/session-tabs-inventory-publication.ts @@ -1,4 +1,4 @@ -import { OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees } from '../orca-runtime-collect-mobile-visible-graph-changed-worktrees' +import { OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees } from './session-tabs-inventory' export abstract class RuntimeSessionTabsInventoryPublication extends OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees { protected waitForSessionTabsInventoryPublication(signal?: AbortSignal): Promise { diff --git a/src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts b/src/main/runtime/session-tabs/session-tabs-inventory.ts similarity index 61% rename from src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts rename to src/main/runtime/session-tabs/session-tabs-inventory.ts index 391a33f0e24..81b580f1f39 100644 --- a/src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts +++ b/src/main/runtime/session-tabs/session-tabs-inventory.ts @@ -1,11 +1,82 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithSyncWindowGraph } from './orca-runtime-sync-window-graph' -import type { RuntimeMobileSessionTabsResult, RuntimeSyncedTab } from '../../shared/runtime-types' -import type { RuntimeLeafRecord } from './runtime-terminal-state-records' -import type { PtyControllerInventory } from './runtime-pty-controller-contract' -import { parseExecutionHostId } from '../../shared/execution-host' +import type { RuntimeSessionTabsInventoryPublication as SessionTabsInventoryPublicationMethods } from './session-tabs-inventory-publication' +import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileSessionTabsChangedMethods } from '../orca-runtime-schedule-mobile-session-tabs-changed' +import type { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree as ResolveBrowserNetworkExecutionHostForWorktreeMethods } from '../orca-runtime-resolve-browser-network-execution-host-for-worktree' +import type { OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession as HydrateHeadlessMobileSessionTabsFromWorkspaceSessionMethods } from './mobile-session-hydration' +import type { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs as BuildHeadlessMobileSessionBrowserTabsMethods } from '../orca-runtime-build-headless-mobile-session-browser-tabs' +import type { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals as RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods } from './renderer-session-terminal-rescue' +import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout as PruneMobileSessionTabGroupLayoutMethods } from '../orca-runtime-prune-mobile-session-tab-group-layout' +import { OrcaRuntimeWithSyncWindowGraph } from '../session-graph/session-window-graph' +import type { + RuntimeMobileSessionTabsResult, + RuntimeSyncedTab +} from '../../../shared/runtime-types' +import type { RuntimeLeafRecord } from '../runtime-terminal-state-records' +import type { PtyControllerInventory } from '../runtime-pty-controller-contract' +import { parseExecutionHostId } from '../../../shared/execution-host' + +export abstract class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends OrcaRuntimeWithSyncWindowGraph { + protected abstract getAuthoritativeSessionTabsInventoryEpoch( + ...args: Parameters< + SessionTabsInventoryPublicationMethods['getAuthoritativeSessionTabsInventoryEpoch'] + > + ): ReturnType + + protected abstract getMobileSessionTabsForWorktree( + ...args: Parameters + ): ReturnType + + protected abstract getValidatedExplicitWorktreeIdSelector( + ...args: Parameters< + ResolveBrowserNetworkExecutionHostForWorktreeMethods['getValidatedExplicitWorktreeIdSelector'] + > + ): ReturnType< + ResolveBrowserNetworkExecutionHostForWorktreeMethods['getValidatedExplicitWorktreeIdSelector'] + > + + protected abstract hydrateHeadlessMobileSessionTabsFromWorkspaceSession( + ...args: Parameters< + HydrateHeadlessMobileSessionTabsFromWorkspaceSessionMethods['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'] + > + ): ReturnType< + HydrateHeadlessMobileSessionTabsFromWorkspaceSessionMethods['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'] + > + + protected abstract projectMobileSessionTabsForClient( + ...args: Parameters< + BuildHeadlessMobileSessionBrowserTabsMethods['projectMobileSessionTabsForClient'] + > + ): ReturnType + + protected abstract refreshMobileSessionPtyInventory( + ...args: Parameters< + BuildHeadlessMobileSessionBrowserTabsMethods['refreshMobileSessionPtyInventory'] + > + ): ReturnType + + protected abstract refreshMobileSessionPtyRecords( + ...args: Parameters< + BuildHeadlessMobileSessionBrowserTabsMethods['refreshMobileSessionPtyRecords'] + > + ): ReturnType + + protected abstract restoreLivePairedRendererSessionOwnedMobileTerminals( + ...args: Parameters< + RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods['restoreLivePairedRendererSessionOwnedMobileTerminals'] + > + ): ReturnType< + RestoreLivePairedRendererSessionOwnedMobileTerminalsMethods['restoreLivePairedRendererSessionOwnedMobileTerminals'] + > + + protected abstract toMobileSessionTabsResult( + ...args: Parameters + ): ReturnType + + protected abstract waitForSessionTabsInventoryPublication( + ...args: Parameters< + SessionTabsInventoryPublicationMethods['waitForSessionTabsInventoryPublication'] + > + ): ReturnType -export class OrcaRuntimeWithCollectMobileVisibleGraphChangedWorktrees extends OrcaRuntimeWithSyncWindowGraph { // Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and // this.leaves, so any tab/leaf delta a graph sync installs can flip the // client payload (pending-handle → ready, tab title) with zero change to the diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts similarity index 86% rename from src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts rename to src/main/runtime/session-tabs/structured-session-create-support.ts index c4ad2e6c116..7a947b738d5 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -1,34 +1,42 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce as RestoreStructuredAgentSessionTabsOnceMethods } from './structured-session-tab-restoration' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { OrcaRuntimeWithGetWorktreePs } from '../orca-runtime-get-worktree-ps' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { resolveCommittedStructuredAgentSessionAdoptionIntent, resolveStructuredAgentSessionAdoptionForCreate -} from './structured-agent-session-create-adoption' -import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' +} from '../structured-agent-session-create-adoption' +import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { AgentSessionAttachParams } from '../../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveHostStructuredAgentCreateSupport, resolveHostStructuredAgentLaunchEnvironment -} from './structured-agent-launch-support' -import { structuredAgentRuntimeRegistration } from './structured-agent-runtime-registrations' -import { structuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' -import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' -import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' -import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' -import { parseWslUncPath } from '../../shared/wsl-paths' +} from '../structured-agent-launch-support' +import { structuredAgentRuntimeRegistration } from '../structured-agent-runtime-registrations' +import { structuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' +import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from '../structured-agent-session-runtime' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../structured-agent-session-host-refusal' +import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' +import { parseWslUncPath } from '../../../shared/wsl-paths' import { isFloatingWorkspaceId, isFloatingWorkspaceSelector -} from '../../shared/floating-workspace-worktree' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' -import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' -import { readPiSessionHeader, samePiDirectory } from '../pi/rpc-launch-resolution' +} from '../../../shared/floating-workspace-worktree' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { readPiSessionHeader, samePiDirectory } from '../../pi/rpc-launch-resolution' + +export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { + protected abstract restoreStructuredAgentSessionTabsOnce( + ...args: Parameters< + RestoreStructuredAgentSessionTabsOnceMethods['restoreStructuredAgentSessionTabsOnce'] + > + ): ReturnType< + RestoreStructuredAgentSessionTabsOnceMethods['restoreStructuredAgentSessionTabsOnce'] + > -export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, agent: StructuredAgentId @@ -270,7 +278,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR providerHandle: { transport: definition.handleTransport, agent: definition.agent, - nativeId: transcriptImport.handleNamesTranscript + nativeId: transcriptImport!.handleNamesTranscript ? adoption.transcriptPath : input.resumeFrom.providerSessionId }, diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/session-tabs/structured-session-tab-close.ts similarity index 80% rename from src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts rename to src/main/runtime/session-tabs/structured-session-tab-close.ts index eafb9f55883..aa95e6cc18f 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-close.ts @@ -1,27 +1,46 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithCloseMobileSessionTab } from './orca-runtime-close-mobile-session-tab' +import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout as PruneMobileSessionTabGroupLayoutMethods } from '../orca-runtime-prune-mobile-session-tab-group-layout' +import type { RuntimeHeadlessTabGroups as HeadlessTabGroupsMethods } from './headless-tab-group-moves' +import { OrcaRuntimeWithCloseMobileSessionTab } from '../orca-runtime-close-mobile-session-tab' import type { + BrowserTabInfo, RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionRetiredTerminalSurface, RuntimeMobileSessionTabsSnapshot, RuntimeMobileSessionTerminalTab -} from '../../shared/runtime-types' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge' -import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication' -import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' -import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' -import { retireStructuredAgentSessionTabFrom } from './structured-agent-session-tab-retirement' -import { chatTabId, withChatTabChanges, type ChatTabChange } from './agent-session-tab-layout' -import { savedSessionId } from './saved-structured-agent-session-restoration' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { structuredSessionTabRetired } from './structured-worker-custody' -import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +} from '../../../shared/runtime-types' +import type { RuntimePtyWorktreeRecord } from '../runtime-terminal-state-records' +import { getMobileSessionSnapshotTabIdentityKeys } from '../mobile-session-tab-merge' +import type { BrowserSessionTabSelectionOptions } from '../browser-tab-create-publication' +import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry' +import { applyBrowserSessionTabSelection } from '../browser-session-tab-selection-snapshot' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionCloseCause } from '../../native-chat/agent-session-wire/structured-agent-session-host-lifetime' +import { retireStructuredAgentSessionTabFrom } from '../structured-agent-session-tab-retirement' +import { chatTabId, withChatTabChanges, type ChatTabChange } from '../agent-session-tab-layout' +import { savedSessionId } from '../saved-structured-agent-session-restoration' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { structuredSessionTabRetired } from '../structured-worker-custody' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' + +export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { + protected abstract getListedBrowserTabsByPageId(worktreeId: string): Map + protected abstract findPtyForMobileTerminalTab( + ...args: Parameters + ): ReturnType + + protected abstract getMobileTerminalLeafPtyIds( + ...args: Parameters + ): ReturnType + + protected abstract getMobileTerminalPaneKey( + ...args: Parameters + ): ReturnType + + protected abstract persistHeadlessTabGroups( + ...args: Parameters + ): ReturnType -export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { protected async closeStructuredAgentSessionTab( tab: RuntimeMobileSessionAgentTab, cause: StructuredAgentSessionCloseCause diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts similarity index 82% rename from src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts rename to src/main/runtime/session-tabs/structured-session-tab-restoration.ts index 46e7c3350f7..91a46c8c617 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts @@ -1,46 +1,46 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' -import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-runtime-get-structured-agent-session-create-support' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' -import { - chatTabChangesMatchingIndex, - seedStructuredAgentSessionTabIndex -} from './structured-agent-session-tab-index-seed' -import { chatTabId } from './agent-session-tab-layout' -import { structuredSessionTabRetired } from './structured-worker-custody' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileSessionTabsChangedMethods } from '../orca-runtime-schedule-mobile-session-tabs-changed' +import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label' +import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './structured-session-create-support' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent-session-restoration' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { seedStructuredAgentSessionTabIndex } from '../structured-agent-session-tab-index-seed' +import { restoredChatTabChanges } from './restored-chat-tab-changes' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsSnapshot, RuntimeRepoSearchRefs -} from '../../shared/runtime-types' -import { getHeadlessMobileSessionGroupId } from './mobile-session-layout-projection' -import { DEFAULT_REPO_SEARCH_REFS_LIMIT } from './orca-runtime-postlude' -import type { Repo } from '../../shared/repo-types' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' +} from '../../../shared/runtime-types' +import { getHeadlessMobileSessionGroupId } from '../mobile-session-layout-projection' +import { DEFAULT_REPO_SEARCH_REFS_LIMIT } from '../orca-runtime-postlude' +import type { Repo } from '../../../shared/repo-types' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' import { getLocalProjectGhExecOptions, type LocalProjectGhExecOptions -} from '../project-runtime-git-options' +} from '../../project-runtime-git-options' import { agentLaunchPlatformForScope, newWorktreeLaunchScope, resolveAgentLaunchRuntime, resolveStartupAgentLaunchRuntime -} from './agent-launch-scope-runtime' -import type { TuiAgent } from '../../shared/tui-agent' -import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' -import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' -import { parseAppSshPtyId } from '../../shared/ssh-pty-id' -import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +} from '../agent-launch-scope-runtime' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { AgentLaunchRuntime } from '../../../shared/agent-launch-runtime' +import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { PtyProcessInspection } from '../../providers/pty-process-inspection' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' export type AgentLaunchScope = Parameters< OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce['getAgentLaunchPlatformForWorkspace'] >[0] -export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { +export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { + protected abstract notifyMobileSessionTabSnapshots( + ...args: Parameters + ): ReturnType + protected async restoreStructuredAgentSessionTabsOnce(): Promise { await this.prepareStructuredAgentSessionStartupRestoration() const host = getStructuredAgentSessionHost() @@ -73,21 +73,10 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu targets, restored.map((session) => session.sessionId) ) - const changes = chatTabChangesMatchingIndex( + const changes = restoredChatTabChanges( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {}, - restored.map((session) => ({ - worktreeId: session.workspaceId, - open: { - sessionId: session.sessionId, - agent: session.agent, - tabId: chatTabId(session.sessionId) - } - })), - // A chat a newer Orca wrote is only unreadable here, so it keeps its tab. - (sessionId) => - !!host && - structuredSessionTabRetired(host, sessionId) && - !host.deps?.store?.isSessionUnreadable?.(sessionId) + host, + restored ) for (const [worktreeId, worktreeChanges] of changes) { this.changeWindowlessChatTabs(worktreeId, worktreeChanges) @@ -301,7 +290,13 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu connectionId?: string | null } ): NodeJS.Platform { - return agentLaunchPlatformForScope(this.requireStore(), scope) + // Orchestration passes a repo-only scope; the platform never reads its id or folder. + return agentLaunchPlatformForScope(this.requireStore(), { + id: '', + folderWorkspace: null, + ...scope, + connectionId: scope.connectionId ?? null + }) } protected resolveAgentLaunchRuntime( diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/session-tabs/terminal-surface-retirements.ts similarity index 84% rename from src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts rename to src/main/runtime/session-tabs/terminal-surface-retirements.ts index 5458543815a..5b264340788 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/session-tabs/terminal-surface-retirements.ts @@ -1,14 +1,18 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithTouchMobileSessionTabsForWorktree } from './orca-runtime-touch-mobile-session-tabs-for-worktree' -import type { RetiredTerminalSurface } from './mobile-session-terminal-retirement' -import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../shared/runtime-types' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' -import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' -import { attachRetirementProofsToSnapshot } from './mobile-session-terminal-retirement-proof' -import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import type { OrcaRuntimeWithBindPtyIncarnationHandle as BindPtyIncarnationHandleMethods } from '../terminal/identity/terminal-handle-bindings' +import { OrcaRuntimeWithTouchMobileSessionTabsForWorktree } from '../orca-runtime-touch-mobile-session-tabs-for-worktree' +import type { RetiredTerminalSurface } from '../mobile-session-terminal-retirement' +import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../../shared/runtime-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { retireTerminalSurfaceFromPersistence } from '../mobile-session-terminal-persistence-retirement' +import { retireTerminalSurfacesFromSnapshot } from '../mobile-session-terminal-retirement' +import { attachRetirementProofsToSnapshot } from '../mobile-session-terminal-retirement-proof' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +export abstract class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntimeWithTouchMobileSessionTabsForWorktree { + protected abstract findHandleForPtyRecord( + ...args: Parameters + ): ReturnType -export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntimeWithTouchMobileSessionTabsForWorktree { /** * Retires each surface in the in-memory session partition of the host that owns its worktree. * Why: an SSH pane's durable surface lives in that connection's partition; retiring it @@ -34,7 +38,8 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim continue } try { - this.store.setWorkspaceSession(next, hostId) + // The existing catch handles a missing store setter. + this.store!.setWorkspaceSession!(next, hostId) accepted.push(surface) } catch (error) { // Why: the process is gone whether or not the profile admits the write (quit, maintenance). diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts similarity index 90% rename from src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts rename to src/main/runtime/terminal/lifecycle/floating-session-liveness.ts index 2ff33658b92..6152e4363bc 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts @@ -1,11 +1,13 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory } from './orca-runtime-refresh-pty-worktree-records-with-controller-inventory' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' -import { indexFloatingSnapshotPtyBindings } from './floating-snapshot-pty-bindings' -import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { DISCONNECTED_PTY_RECORD_MAX } from './orca-runtime-postlude' +import { OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory } from '../../orca-runtime-refresh-pty-worktree-records-with-controller-inventory' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { indexFloatingSnapshotPtyBindings } from '../../floating-snapshot-pty-bindings' +import type { + RuntimeLeafRecord, + RuntimePtyWorktreeRecord +} from '../../runtime-terminal-state-records' +import { DISCONNECTED_PTY_RECORD_MAX } from '../../orca-runtime-postlude' -export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory { +export abstract class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInventory { protected refreshFloatingWorkspacePtyLiveness(): Set | null { const controller = this.ptyController if (!controller?.hasPty) { diff --git a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts index 94621cbd134..9d0d52fceb9 100644 --- a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts +++ b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts @@ -11,7 +11,7 @@ import type { TerminalWaitStatusEvidence } from './terminal-wait-permission' -import type { AgentLaunchScope } from '../../orca-runtime-restore-structured-agent-session-tabs-once' +import type { AgentLaunchScope } from '../../session-tabs/structured-session-tab-restoration' import { withTimeout } from '../../runtime-async-boundaries' import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from '../../orca-runtime-core' import { parsePaneKey } from '../../../../shared/stable-pane-id' diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts index 5837bac2127..ddd3e7c00b4 100644 --- a/src/main/runtime/workspaces/workspace-selectors.ts +++ b/src/main/runtime/workspaces/workspace-selectors.ts @@ -186,7 +186,7 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr throw new Error('repo_not_found') } - protected requireStore(): RuntimeStore { + requireStore(): RuntimeStore { if (!this.store) { throw new Error('runtime_unavailable') } diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index 9db70ce4c83..8230e30ff9f 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -16,7 +16,7 @@ const UNSUPPORTED_TUI_AGENT_ARGS: Partial> = export const DEFAULT_TUI_AGENT_ARGS: Partial> = YOLO_TUI_AGENT_ARGS -export const DEFAULT_TUI_AGENT_ENV: Partial>> = +export const DEFAULT_TUI_AGENT_ENV: Partial>> = YOLO_TUI_AGENT_ENV function argPattern(arg: string): RegExp { @@ -111,7 +111,7 @@ export function getTuiAgentDefaultArgs(agent: TuiAgent): string { return DEFAULT_TUI_AGENT_ARGS[agent] ?? '' } -export function getTuiAgentDefaultEnv(agent: TuiAgent): Record { +export function getTuiAgentDefaultEnv(agent: string): Record { return { ...DEFAULT_TUI_AGENT_ENV[agent] } } @@ -160,8 +160,8 @@ export function resolvedTuiAgentArgsBypassPermissions( } export function resolveTuiAgentLaunchEnv( - agent: TuiAgent, - configuredEnv: Partial>> | null | undefined + agent: string, + configuredEnv: Partial>> | null | undefined ): Record { if (configuredEnv && Object.hasOwn(configuredEnv, agent)) { return { ...configuredEnv[agent] }