From fdaafc93dde2494ecd47170e9a2d14fe83cd3808 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:37:40 -0700 Subject: [PATCH 01/39] refactor(renderer): split SettingsFormControls below max-lines limit (#13747) --- config/max-lines-baseline.txt | 1 - .../components/settings/FontAutocomplete.tsx | 333 +++++++++++ .../settings/SettingsFormControls.tsx | 537 +----------------- .../settings/TerminalThemePicker.tsx | 207 +++++++ 4 files changed, 545 insertions(+), 533 deletions(-) create mode 100644 src/renderer/src/components/settings/FontAutocomplete.tsx create mode 100644 src/renderer/src/components/settings/TerminalThemePicker.tsx diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 8710b4e82e3..0b88c5f537c 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -232,7 +232,6 @@ inline src/renderer/src/components/settings/AgentsPane.tsx inline src/renderer/src/components/settings/RepositoryHooksSection.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx -inline src/renderer/src/components/settings/SettingsFormControls.tsx inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx diff --git a/src/renderer/src/components/settings/FontAutocomplete.tsx b/src/renderer/src/components/settings/FontAutocomplete.tsx new file mode 100644 index 00000000000..aae2c65eaaf --- /dev/null +++ b/src/renderer/src/components/settings/FontAutocomplete.tsx @@ -0,0 +1,333 @@ +import type React from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { Check, ChevronsUpDown, CircleX } from 'lucide-react' +import { Input } from '../ui/input' +import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover' +import { ScrollArea } from '../ui/scroll-area' +import { filterFontSuggestions, getRenderedFontSuggestions } from './settings-form-option-filter' +import { translate } from '@/i18n/i18n' + +type FontAutocompleteProps = { + value: string + suggestions: string[] + onChange: (value: string) => void + placeholder?: string + onRequestSuggestions?: () => void + /** Fires with whichever option the user is currently highlighting in the + * dropdown (via mouse hover or keyboard arrow), or null when nothing is + * highlighted / the dropdown is closed. Lets a consumer show a live + * preview of the font without committing the selection. */ + onPreviewFontFamily?: (font: string | null) => void +} + +export function FontAutocomplete({ + value, + suggestions, + onChange, + placeholder = 'SF Mono', + onRequestSuggestions, + onPreviewFontFamily +}: FontAutocompleteProps): React.JSX.Element { + const [query, setQuery] = useState(value) + const [prevValue, setPrevValue] = useState(value) + const [open, setOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const [isFilteringQuery, setIsFilteringQuery] = useState(false) + const inputRef = useRef(null) + const rootRef = useRef(null) + const previewFontFamilyRef = useRef(onPreviewFontFamily) + const listboxId = useId() + + useEffect(() => { + previewFontFamilyRef.current = onPreviewFontFamily + }, [onPreviewFontFamily]) + + const setRootNode = useCallback((element: HTMLDivElement | null): void => { + rootRef.current = element + if (!element) { + // Why: settings search can unmount this control while a hover preview is + // active; the consumer must not keep rendering that transient font. + previewFontFamilyRef.current?.(null) + } + }, []) + + if (value !== prevValue) { + setPrevValue(value) + setQuery(value) + if (value !== query) { + setIsFilteringQuery(false) + } + } + + const requestSuggestions = useCallback((): void => { + onRequestSuggestions?.() + }, [onRequestSuggestions]) + + const handleOpenChange = (nextOpen: boolean): void => { + setOpen(nextOpen) + if (nextOpen) { + requestSuggestions() + } + if (!nextOpen) { + setIsFilteringQuery(false) + } + } + + const normalizedQuery = query.trim().toLowerCase() + const normalizedValue = value.trim().toLowerCase() + const filteredSuggestions = useMemo( + () => filterFontSuggestions(suggestions, query), + [suggestions, query] + ) + // Why: the committed font fills the input, but opening the chooser should + // still reveal every installed font instead of only fonts sharing that name. + const visibleSuggestions = + !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions + const renderedSuggestions = useMemo( + () => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex), + [visibleSuggestions, highlightedIndex] + ) + + // Why: sync the highlighted index during render rather than via useEffect so + // the correct item is highlighted on the very first paint after open/filter + // changes — useEffect would leave one render with the stale index visible. + const [prevVisibleSuggestions, setPrevVisibleSuggestions] = useState(visibleSuggestions) + const [prevOpen, setPrevOpen] = useState(open) + const [prevHighlightedValue, setPrevHighlightedValue] = useState(value) + if ( + visibleSuggestions !== prevVisibleSuggestions || + open !== prevOpen || + value !== prevHighlightedValue + ) { + setPrevVisibleSuggestions(visibleSuggestions) + setPrevOpen(open) + setPrevHighlightedValue(value) + if (!open || visibleSuggestions.length === 0) { + setHighlightedIndex(-1) + } else { + const selectedIndex = visibleSuggestions.indexOf(value) + setHighlightedIndex(Math.max(selectedIndex, 0)) + } + } + + // Why: notify the consumer of the currently-highlighted font so it can + // render a live preview. Closing the dropdown or moving past all options + // clears the preview back to the committed value. + useEffect(() => { + if (!onPreviewFontFamily) { + return + } + if (!open || highlightedIndex < 0) { + onPreviewFontFamily(null) + return + } + onPreviewFontFamily(visibleSuggestions[highlightedIndex] ?? null) + }, [visibleSuggestions, highlightedIndex, onPreviewFontFamily, open]) + + const commitValue = (nextValue: string): void => { + setQuery(nextValue) + setIsFilteringQuery(false) + onChange(nextValue) + setOpen(false) + } + + const focusInput = (): void => { + inputRef.current?.focus() + } + const popoverAvailableHeightStyle = { + // Why: tailwind-merge rewrites this arbitrary max-height class on the + // ScrollArea root, so keep the Radix available-height clamp as inline style. + maxHeight: 'var(--radix-popover-content-available-height)' + } as React.CSSProperties + + return ( +
+ + +
+ { + const next = e.target.value + requestSuggestions() + setQuery(next) + setIsFilteringQuery(true) + onChange(next) + setOpen(true) + }} + onFocus={() => { + requestSuggestions() + setIsFilteringQuery(false) + setOpen(true) + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + if (open) { + e.preventDefault() + setOpen(false) + setIsFilteringQuery(false) + } + return + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + if (visibleSuggestions.length > 0) { + setHighlightedIndex((current) => + current < 0 ? 0 : Math.min(current + 1, visibleSuggestions.length - 1) + ) + } + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + setOpen(true) + if (visibleSuggestions.length > 0) { + setHighlightedIndex((current) => + current < 0 ? visibleSuggestions.length - 1 : Math.max(current - 1, 0) + ) + } + return + } + + if (e.key === 'Enter' && open && highlightedIndex >= 0) { + const highlightedFont = visibleSuggestions[highlightedIndex] + if (highlightedFont) { + e.preventDefault() + commitValue(highlightedFont) + } + } + }} + placeholder={placeholder} + className="pr-18" + role="combobox" + aria-autocomplete="list" + aria-expanded={open} + aria-controls={listboxId} + aria-activedescendant={ + open && highlightedIndex >= 0 + ? `${listboxId}-option-${highlightedIndex}` + : undefined + } + /> +
+ {query ? ( + + ) : null} + +
+
+
+ + {/* Why: portal the dropdown outside the settings section — an in-flow + absolute panel makes the highlighted option's scrollIntoView scroll + the whole settings pane, pushing the section content out of view. */} + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + onInteractOutside={(e) => { + // Why: the input and its clear/toggle buttons are the anchor, not + // the content, so Radix would otherwise dismiss on every click there. + if (rootRef.current?.contains(e.target as Node)) { + e.preventDefault() + } + }} + > + 8 ? 'h-64' : undefined} + style={popoverAvailableHeightStyle} + viewportProps={{ style: popoverAvailableHeightStyle }} + > +
+ {visibleSuggestions.length > 0 ? ( + renderedSuggestions.map(({ font, sourceIndex }) => ( + + )) + ) : ( +
+ {translate( + 'auto.components.settings.SettingsFormControls.42a4d15a30', + 'No matching fonts.' + )} +
+ )} +
+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 703dbbfbb21..b82e90a0984 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -1,26 +1,16 @@ -/* eslint-disable max-lines -- Why: these small settings form primitives and controls -co-locate shared layout and keyboard interaction logic, which keeps the settings -panel wiring simple even though the file exceeds the default line limit. */ import type React from 'react' -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' -import { ScrollArea } from '../ui/scroll-area' +import { useState } from 'react' import { Input } from '../ui/input' import { Label } from '../ui/label' -import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { Switch } from '../ui/switch' -import { Check, ChevronsUpDown, CircleX } from 'lucide-react' -import { normalizeColor, type TerminalThemeOption } from '@/lib/terminal-theme' -import { MAX_THEME_RESULTS } from './SettingsConstants' -import { - filterFontSuggestions, - filterTerminalThemeOptions, - getRenderedFontSuggestions, - isSettingsFormOptionQueryTooLarge -} from './settings-form-option-filter' +import { normalizeColor } from '@/lib/terminal-theme' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +export { FontAutocomplete } from './FontAutocomplete' +export { ThemePicker } from './TerminalThemePicker' + type SettingsSwitchProps = { checked: boolean onChange: () => void @@ -261,19 +251,6 @@ export function SettingsSubsectionHeader({ ) } -type ThemePickerProps = { - label: string - description: string - selectedTheme: string - themeOptions: TerminalThemeOption[] - query: string - onQueryChange: (value: string) => void - onSelectTheme: (theme: string) => void - /** Bumps when themes are imported; scrolls the Imported group into view and - * briefly highlights it so freshly-imported themes are easy to find. */ - importedHighlightSignal?: number -} - type ColorFieldProps = { label: string description: string @@ -294,200 +271,6 @@ type NumberFieldProps = { suffix?: string } -type FontAutocompleteProps = { - value: string - suggestions: string[] - onChange: (value: string) => void - placeholder?: string - onRequestSuggestions?: () => void - /** Fires with whichever option the user is currently highlighting in the - * dropdown (via mouse hover or keyboard arrow), or null when nothing is - * highlighted / the dropdown is closed. Lets a consumer show a live - * preview of the font without committing the selection. */ - onPreviewFontFamily?: (font: string | null) => void -} - -export function ThemePicker({ - label, - description, - selectedTheme, - themeOptions, - query, - onQueryChange, - onSelectTheme, - importedHighlightSignal -}: ThemePickerProps): React.JSX.Element { - const importedGroupRef = useRef(null) - const [highlightImported, setHighlightImported] = useState(false) - - // Why: imported themes render below the built-in list inside a fixed-height - // scroll area, so after an import they sit off-screen. On each import signal, - // scroll the Imported group into view and flash a highlight so it's easy to spot. - useEffect(() => { - if (!importedHighlightSignal) { - return - } - importedGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - setHighlightImported(true) - const timer = setTimeout(() => setHighlightImported(false), 2000) - return () => clearTimeout(timer) - }, [importedHighlightSignal]) - - const themeQuery = query.trim() - const shouldShowThemeQueryLabel = - themeQuery.length > 0 && !isSettingsFormOptionQueryTooLarge(themeQuery) - const matchingThemes = filterTerminalThemeOptions(themeOptions, query) - const selectedThemeLabel = - themeOptions.find((option) => option.value === selectedTheme)?.label ?? selectedTheme - const groupedThemes = [ - { - label: translate('auto.components.settings.SettingsFormControls.builtin_themes', 'Built-in'), - themes: matchingThemes - .filter((theme) => theme.group === 'built-in') - .slice(0, MAX_THEME_RESULTS) - }, - { - label: translate('auto.components.settings.SettingsFormControls.imported_themes', 'Imported'), - themes: matchingThemes - .filter((theme) => theme.group === 'imported') - .slice(0, MAX_THEME_RESULTS) - } - ].filter((group) => group.themes.length > 0) - const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0) - - return ( -
-
- -

{description}

-
- onQueryChange(e.target.value)} - placeholder={translate( - 'auto.components.settings.SettingsFormControls.search_terminal_themes', - 'Search terminal themes' - )} - /> -
-
- - {translate('auto.components.settings.SettingsFormControls.fbb428db98', 'Selected:')}{' '} - {selectedThemeLabel} - - - {translate('auto.components.settings.SettingsFormControls.4e11f87ca6', 'Showing')}{' '} - {visibleThemeCount} - {shouldShowThemeQueryLabel - ? translate( - 'auto.components.settings.SettingsFormControls.c822571b2e', - ' matching "{{value0}}"', - { value0: themeQuery } - ) - : translate( - 'auto.components.settings.SettingsFormControls.cb330ef7f8', - ' of {{value0}}', - { value0: themeOptions.length } - )} - -
- -
- {groupedThemes.map((group) => { - const isImported = - group.label === - translate( - 'auto.components.settings.SettingsFormControls.imported_themes', - 'Imported' - ) - return ( -
-

- {group.label} -

- {group.themes.map((theme) => ( - - ))} -
- ) - })} - {visibleThemeCount === 0 ? ( -
- {translate( - 'auto.components.settings.SettingsFormControls.ceefb9d7f1', - 'No themes found.' - )} -
- ) : null} -
-
-
-
- ) -} - export function ColorField({ label, description, @@ -596,313 +379,3 @@ export function NumberField({ /> ) } - -export function FontAutocomplete({ - value, - suggestions, - onChange, - placeholder = 'SF Mono', - onRequestSuggestions, - onPreviewFontFamily -}: FontAutocompleteProps): React.JSX.Element { - const [query, setQuery] = useState(value) - const [prevValue, setPrevValue] = useState(value) - const [open, setOpen] = useState(false) - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const [isFilteringQuery, setIsFilteringQuery] = useState(false) - const inputRef = useRef(null) - const rootRef = useRef(null) - const previewFontFamilyRef = useRef(onPreviewFontFamily) - const listboxId = useId() - - previewFontFamilyRef.current = onPreviewFontFamily - - const setRootNode = useCallback((element: HTMLDivElement | null): void => { - rootRef.current = element - if (!element) { - // Why: settings search can unmount this control while a hover preview is - // active; the consumer must not keep rendering that transient font. - previewFontFamilyRef.current?.(null) - } - }, []) - - if (value !== prevValue) { - setPrevValue(value) - setQuery(value) - if (value !== query) { - setIsFilteringQuery(false) - } - } - - const requestSuggestions = useCallback((): void => { - onRequestSuggestions?.() - }, [onRequestSuggestions]) - - const handleOpenChange = (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - requestSuggestions() - } - if (!nextOpen) { - setIsFilteringQuery(false) - } - } - - const normalizedQuery = query.trim().toLowerCase() - const normalizedValue = value.trim().toLowerCase() - const filteredSuggestions = useMemo( - () => filterFontSuggestions(suggestions, query), - [suggestions, query] - ) - // Why: the committed font fills the input, but opening the chooser should - // still reveal every installed font instead of only fonts sharing that name. - const visibleSuggestions = - !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions - const renderedSuggestions = useMemo( - () => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex), - [visibleSuggestions, highlightedIndex] - ) - - // Why: sync the highlighted index during render rather than via useEffect so - // the correct item is highlighted on the very first paint after open/filter - // changes — useEffect would leave one render with the stale index visible. - const [prevVisibleSuggestions, setPrevVisibleSuggestions] = useState(visibleSuggestions) - const [prevOpen, setPrevOpen] = useState(open) - const [prevHighlightedValue, setPrevHighlightedValue] = useState(value) - if ( - visibleSuggestions !== prevVisibleSuggestions || - open !== prevOpen || - value !== prevHighlightedValue - ) { - setPrevVisibleSuggestions(visibleSuggestions) - setPrevOpen(open) - setPrevHighlightedValue(value) - if (!open || visibleSuggestions.length === 0) { - setHighlightedIndex(-1) - } else { - const selectedIndex = visibleSuggestions.indexOf(value) - setHighlightedIndex(Math.max(selectedIndex, 0)) - } - } - - // Why: notify the consumer of the currently-highlighted font so it can - // render a live preview. Closing the dropdown or moving past all options - // clears the preview back to the committed value. - useEffect(() => { - if (!onPreviewFontFamily) { - return - } - if (!open || highlightedIndex < 0) { - onPreviewFontFamily(null) - return - } - onPreviewFontFamily(visibleSuggestions[highlightedIndex] ?? null) - }, [visibleSuggestions, highlightedIndex, onPreviewFontFamily, open]) - - const commitValue = (nextValue: string): void => { - setQuery(nextValue) - setIsFilteringQuery(false) - onChange(nextValue) - setOpen(false) - } - - const focusInput = (): void => { - inputRef.current?.focus() - } - const popoverAvailableHeightStyle = { - // Why: tailwind-merge rewrites this arbitrary max-height class on the - // ScrollArea root, so keep the Radix available-height clamp as inline style. - maxHeight: 'var(--radix-popover-content-available-height)' - } as React.CSSProperties - - return ( -
- - -
- { - const next = e.target.value - requestSuggestions() - setQuery(next) - setIsFilteringQuery(true) - onChange(next) - setOpen(true) - }} - onFocus={() => { - requestSuggestions() - setIsFilteringQuery(false) - setOpen(true) - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - if (open) { - e.preventDefault() - setOpen(false) - setIsFilteringQuery(false) - } - return - } - - if (e.key === 'ArrowDown') { - e.preventDefault() - setOpen(true) - if (visibleSuggestions.length > 0) { - setHighlightedIndex((current) => - current < 0 ? 0 : Math.min(current + 1, visibleSuggestions.length - 1) - ) - } - return - } - - if (e.key === 'ArrowUp') { - e.preventDefault() - setOpen(true) - if (visibleSuggestions.length > 0) { - setHighlightedIndex((current) => - current < 0 ? visibleSuggestions.length - 1 : Math.max(current - 1, 0) - ) - } - return - } - - if (e.key === 'Enter' && open && highlightedIndex >= 0) { - const highlightedFont = visibleSuggestions[highlightedIndex] - if (highlightedFont) { - e.preventDefault() - commitValue(highlightedFont) - } - } - }} - placeholder={placeholder} - className="pr-18" - role="combobox" - aria-autocomplete="list" - aria-expanded={open} - aria-controls={listboxId} - aria-activedescendant={ - open && highlightedIndex >= 0 - ? `${listboxId}-option-${highlightedIndex}` - : undefined - } - /> -
- {query ? ( - - ) : null} - -
-
-
- - {/* Why: portal the dropdown outside the settings section — an in-flow - absolute panel makes the highlighted option's scrollIntoView scroll - the whole settings pane, pushing the section content out of view. */} - e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - onInteractOutside={(e) => { - // Why: the input and its clear/toggle buttons are the anchor, not - // the content, so Radix would otherwise dismiss on every click there. - if (rootRef.current?.contains(e.target as Node)) { - e.preventDefault() - } - }} - > - 8 ? 'h-64' : undefined} - style={popoverAvailableHeightStyle} - viewportProps={{ style: popoverAvailableHeightStyle }} - > -
- {visibleSuggestions.length > 0 ? ( - renderedSuggestions.map(({ font, sourceIndex }) => ( - - )) - ) : ( -
- {translate( - 'auto.components.settings.SettingsFormControls.42a4d15a30', - 'No matching fonts.' - )} -
- )} -
-
-
-
-
- ) -} diff --git a/src/renderer/src/components/settings/TerminalThemePicker.tsx b/src/renderer/src/components/settings/TerminalThemePicker.tsx new file mode 100644 index 00000000000..f37d3a5492e --- /dev/null +++ b/src/renderer/src/components/settings/TerminalThemePicker.tsx @@ -0,0 +1,207 @@ +import type React from 'react' +import { useEffect, useRef, useState } from 'react' +import { ScrollArea } from '../ui/scroll-area' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { MAX_THEME_RESULTS } from './SettingsConstants' +import { + filterTerminalThemeOptions, + isSettingsFormOptionQueryTooLarge +} from './settings-form-option-filter' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { TerminalThemeOption } from '@/lib/terminal-theme' + +type ThemePickerProps = { + label: string + description: string + selectedTheme: string + themeOptions: TerminalThemeOption[] + query: string + onQueryChange: (value: string) => void + onSelectTheme: (theme: string) => void + /** Bumps when themes are imported; scrolls the Imported group into view and + * briefly highlights it so freshly-imported themes are easy to find. */ + importedHighlightSignal?: number +} + +export function ThemePicker({ + label, + description, + selectedTheme, + themeOptions, + query, + onQueryChange, + onSelectTheme, + importedHighlightSignal +}: ThemePickerProps): React.JSX.Element { + const importedGroupRef = useRef(null) + const [highlightImported, setHighlightImported] = useState(false) + + // Why: imported themes render below the built-in list inside a fixed-height + // scroll area, so after an import they sit off-screen. On each import signal, + // scroll the Imported group into view and flash a highlight so it's easy to spot. + useEffect(() => { + if (!importedHighlightSignal) { + return + } + importedGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + setHighlightImported(true) + const timer = setTimeout(() => setHighlightImported(false), 2000) + return () => clearTimeout(timer) + }, [importedHighlightSignal]) + + const themeQuery = query.trim() + const shouldShowThemeQueryLabel = + themeQuery.length > 0 && !isSettingsFormOptionQueryTooLarge(themeQuery) + const matchingThemes = filterTerminalThemeOptions(themeOptions, query) + const selectedThemeLabel = + themeOptions.find((option) => option.value === selectedTheme)?.label ?? selectedTheme + const groupedThemes = [ + { + label: translate('auto.components.settings.SettingsFormControls.builtin_themes', 'Built-in'), + themes: matchingThemes + .filter((theme) => theme.group === 'built-in') + .slice(0, MAX_THEME_RESULTS) + }, + { + label: translate('auto.components.settings.SettingsFormControls.imported_themes', 'Imported'), + themes: matchingThemes + .filter((theme) => theme.group === 'imported') + .slice(0, MAX_THEME_RESULTS) + } + ].filter((group) => group.themes.length > 0) + const visibleThemeCount = groupedThemes.reduce((sum, group) => sum + group.themes.length, 0) + + return ( +
+
+ +

{description}

+
+ onQueryChange(e.target.value)} + placeholder={translate( + 'auto.components.settings.SettingsFormControls.search_terminal_themes', + 'Search terminal themes' + )} + /> +
+
+ + {translate('auto.components.settings.SettingsFormControls.fbb428db98', 'Selected:')}{' '} + {selectedThemeLabel} + + + {translate('auto.components.settings.SettingsFormControls.4e11f87ca6', 'Showing')}{' '} + {visibleThemeCount} + {shouldShowThemeQueryLabel + ? translate( + 'auto.components.settings.SettingsFormControls.c822571b2e', + ' matching "{{value0}}"', + { value0: themeQuery } + ) + : translate( + 'auto.components.settings.SettingsFormControls.cb330ef7f8', + ' of {{value0}}', + { value0: themeOptions.length } + )} + +
+ +
+ {groupedThemes.map((group) => { + const isImported = + group.label === + translate( + 'auto.components.settings.SettingsFormControls.imported_themes', + 'Imported' + ) + return ( +
+

+ {group.label} +

+ {group.themes.map((theme) => ( + + ))} +
+ ) + })} + {visibleThemeCount === 0 ? ( +
+ {translate( + 'auto.components.settings.SettingsFormControls.ceefb9d7f1', + 'No themes found.' + )} +
+ ) : null} +
+
+
+
+ ) +} From 063022f62d5bb7a1637327bfd84691b3d1046650 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:42:46 -0700 Subject: [PATCH 02/39] fix(native-chat): open composer pickers upward (#13754) --- .../NativeChatSessionOptionPickers.test.tsx | 35 ++++++++++++++++++- .../NativeChatSessionOptionPickers.tsx | 4 +-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index e7d3eda0985..cc9963b94b2 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -40,7 +40,23 @@ vi.mock('@/components/ui/dropdown-menu', () => { children: React.ReactNode disabled?: boolean }) =>
{children}
, - DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ + children, + side, + collisionPadding + }: { + children: React.ReactNode + side?: string + collisionPadding?: number + }) => ( +
+ {children} +
+ ), DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuSeparator: () =>
, DropdownMenuItem: ({ @@ -172,6 +188,23 @@ const fast: SessionOptionDescriptor = { afterEach(() => cleanup()) describe('NativeChatSessionOptionPickers', () => { + it('prefers collision-aware upward placement for model and option menus', () => { + render( + + ) + + const menus = screen.getAllByTestId('session-option-menu') + expect(menus).toHaveLength(2) + for (const menu of menus) { + expect(menu.getAttribute('data-side')).toBe('top') + expect(menu.getAttribute('data-collision-padding')).toBe('8') + } + }) + it('renders model and joined option labels, and hides an empty options pill', () => { const { rerender } = render( descriptor.valueSource === 'dispatched')} /> - + {options.map((descriptor, index) => { const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason) return ( @@ -272,7 +272,7 @@ function NativeChatSessionOptionPickersInner({ disabledReason={modelReason} dispatched={model.valueSource === 'dispatched'} /> - + {modelReason && !model.settable ? ( {modelReason} ) : null} From 0345d71214bb9cc6a7cb5858b6b419e72d1e633b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:43:10 -0700 Subject: [PATCH 03/39] refactor(renderer): split oversized TabBar module (#13739) --- config/max-lines-baseline.txt | 1 - .../src/components/tab-bar/TabBar.tsx | 1330 +---------------- .../components/tab-bar/tab-bar-item-model.ts | 242 +++ .../tab-bar/tab-bar-item-surface.tsx | 223 +++ .../src/components/tab-bar/tab-bar-props.ts | 53 + .../tab-bar/tab-bar-static-create-menu.tsx | 194 +++ .../components/tab-bar/tab-bar-surface.tsx | 256 ++++ .../tab-bar/tab-bar-windows-shell-options.ts | 64 + .../use-tab-bar-create-menu-controller.ts | 289 ++++ .../tab-bar/use-tab-bar-item-projection.ts | 139 ++ .../tab-bar/use-tab-bar-runtime-model.ts | 261 ++++ 11 files changed, 1786 insertions(+), 1266 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/tab-bar-item-model.ts create mode 100644 src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-bar-props.ts create mode 100644 src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-bar-surface.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts create mode 100644 src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts create mode 100644 src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts create mode 100644 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 0b88c5f537c..59368273624 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -244,7 +244,6 @@ inline src/renderer/src/components/stats/usage-overview-model.ts inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx inline src/renderer/src/components/status-bar/StatusBar.tsx inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx -inline src/renderer/src/components/tab-bar/TabBar.tsx inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts inline src/renderer/src/components/tab-group/useTabDragSplit.ts inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 9e9a5bf580b..43f3255676a 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -1,1286 +1,86 @@ -/* oxlint-disable max-lines -- Why: per-type tab render branches (terminal/browser/editor) share little beyond drag data; consolidating costs more clarity than the ~5 lines it saves. */ -import React, { useEffect, useMemo, useRef, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' -import { SortableContext } from '@dnd-kit/sortable' -import { - ChevronLeft, - ChevronRight, - FilePlus, - FileText, - Globe, - Plus, - Smartphone, - TerminalSquare -} from 'lucide-react' -import { toast } from 'sonner' -import type { - BrowserTab as BrowserTabState, - Tab, - TerminalTab, - TuiAgent, - WorkspaceVisibleTabType -} from '../../../../shared/types' -import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' -import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' -import { useAppStore } from '../../store' -import { buildStatusMap } from '../right-sidebar/status-display' -import type { OpenFile } from '../../store/slices/editor' -import SortableTab from './SortableTab' -import EditorFileTab from './EditorFileTab' -import BrowserTab, { getBrowserTabLabel } from './BrowserTab' -import { QuickLaunchAgentMenuItems } from './QuickLaunchButton' -import type { DropIndicator } from './drop-indicator' -import { reconcileTabOrder } from './reconcile-order' -import type { HoveredTabInsertion, TabDragItemData } from '../tab-group/useTabDragSplit' -import { resolveTabIndicatorEdges } from '../tab-group/tab-insertion' -import { getEditorDisplayLabel } from '@/components/editor/editor-labels' -import TabBarCreateEntry from './TabBarCreateEntry' -import { ShellIcon } from './shell-icons' -import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { useDetectedAgents } from '@/hooks/useDetectedAgents' -import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget' -import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import { normalizeRelativePath } from '@/lib/path' -import { - getWindowsTerminalCapabilityOwnerKey, - useWindowsTerminalCapabilities -} from '@/lib/windows-terminal-capabilities' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { getConnectionIdFromState } from '@/lib/connection-context' -import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' -import { - type BuiltInWindowsTerminalShell, - WINDOWS_GIT_BASH_SHELL -} from '../../../../shared/windows-terminal-shell' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Button } from '@/components/ui/button' -import type { TabCreateEntryArgs } from './tab-create-entry-action' -import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options' -import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' -import { MobileEmulatorTabIntroCallout } from '../emulator-pane/MobileEmulatorTabIntroCallout' -import { shouldShowMobileEmulatorTabIntro } from '../emulator-pane/mobile-emulator-tab-intro-visibility' -import { translate } from '@/i18n/i18n' -import { TabStripScrollIndicator } from './TabStripScrollIndicator' -import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics' +import React from 'react' import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' -import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' -import { canToggleNativeChat } from '../native-chat/native-chat-availability' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' -import { selectTabBarAgentProjections } from './tab-agent-types-by-tab-id' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import type { TabBarProps } from './tab-bar-props' +import type { TabBarItem } from './tab-bar-item-model' +import { useTabBarRuntimeModel } from './use-tab-bar-runtime-model' +import { useTabBarCreateMenuController } from './use-tab-bar-create-menu-controller' +import { useTabBarItemProjection } from './use-tab-bar-item-projection' +import { renderTabBarSurface } from './tab-bar-surface' -const isWindows = navigator.userAgent.includes('Windows') -const isMacOs = navigator.userAgent.includes('Mac') -const NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS = 50 -const NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS = 5000 -type GitStatusEntries = ReturnType['gitStatusByWorktree'][string] -const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] -const EMPTY_AGENT_CMD_OVERRIDES: Partial> = {} -const EMPTY_UNIFIED_TABS: readonly Tab[] = [] - -function getProjectRuntimeShellMenuMode( - projectRuntime: ProjectExecutionRuntimeResolution | undefined -): 'host' | 'wsl' | null { - if (!projectRuntime) { - return null - } - if (projectRuntime.status === 'repair-required') { - return 'wsl' - } - return projectRuntime.runtime.kind === 'wsl' ? 'wsl' : 'host' -} - -type TabBarProps = { - tabs: (TerminalTab & { unifiedTabId?: string })[] - activeTabId: string | null - groupId?: string - worktreeId: string - expandedPaneByTabId: Record - onActivate: (tabId: string) => void - onClose: (tabId: string) => void - onCloseOthers: (tabId: string) => void - onCloseToRight: (tabId: string) => void - onCloseToLeft: (tabId: string) => void - onNewTerminalTab: () => void - /** On Windows, opens a new terminal with a specific shell instead of the default. */ - onNewTerminalWithShell?: (shell: string) => void - onNewBrowserTab: () => void - onNewSimulatorTab?: () => void - onOpenEntry?: (args: TabCreateEntryArgs) => Promise - terminalOnly?: boolean - showAgentLaunchItems?: boolean - onNewFileTab?: () => void - onOpenFileTab?: () => void - newTabMenuOrder?: 'default' | 'markdown-first' - onSetCustomTitle: (tabId: string, title: string | null) => void - onSetTabColor: (tabId: string, color: string | null) => void - onTogglePaneExpand: (tabId: string) => void - editorFiles?: (OpenFile & { tabId?: string })[] - browserTabs?: (BrowserTabState & { tabId?: string })[] - activeFileId?: string | null - activeBrowserTabId?: string | null - activeSimulatorTabId?: string | null - activeTabType?: WorkspaceVisibleTabType - onActivateFile?: (fileId: string) => void - onCloseFile?: (fileId: string) => void - onActivateBrowserTab?: (tabId: string) => void - onCloseBrowserTab?: (tabId: string) => void - onDuplicateBrowserTab?: (tabId: string) => void - onCloseAllFiles?: () => void - onMakePreviewFilePermanent?: (fileId: string, tabId?: string) => void - onPinFile?: (fileId: string, tabId?: string) => void - tabBarOrder?: string[] - hoveredTabInsertion?: HoveredTabInsertion | null - /** Floating workspace panels are rounded; skip tab top borders that clash with the curve. */ - tabStripChrome?: 'default' | 'floating-panel' -} - -type TabItem = - | { - type: 'terminal' - id: string - unifiedTabId: string - isPinned: boolean - data: TerminalTab & { unifiedTabId?: string } - } - | { - type: 'editor' - id: string - unifiedTabId: string - isPinned: boolean - data: OpenFile & { tabId?: string } - } - | { - type: 'browser' - id: string - unifiedTabId: string - isPinned: boolean - data: BrowserTabState & { tabId?: string } - } - | { - type: 'simulator' - id: string - unifiedTabId: string - isPinned: boolean - data: Tab - } - -function getTabDragLabel(item: TabItem, generatedTitlesEnabled: boolean): string { - if (item.type === 'terminal') { - return resolveTerminalTabTitle(item.data, generatedTitlesEnabled, item.data.title) - } - if (item.type === 'browser') { - return getBrowserTabLabel(item.data) - } - if (item.type === 'simulator') { - return item.data.label || 'Mobile Emulator' - } - return getEditorDisplayLabel(item.data) -} - -function getTabLayoutSignature( - item: TabItem, - { - generatedTitlesEnabled, - isExpanded, - status - }: { - generatedTitlesEnabled: boolean - isExpanded: boolean - status?: string | null - } -): string { - const label = getTabDragLabel(item, generatedTitlesEnabled) - if (item.type === 'terminal') { - return `${item.type}:${item.id}:${item.isPinned}:${isExpanded}:${Boolean(item.data.color)}:${label}` - } - if (item.type === 'browser') { - return `${item.type}:${item.id}:${item.isPinned}:${item.data.loading}:${Boolean(item.data.loadError)}:${label}` - } - if (item.type === 'editor') { - return `${item.type}:${item.id}:${item.isPinned}:${item.data.isDirty}:${item.data.isPreview}:${item.data.externalMutation ?? ''}:${status ?? ''}:${label}` - } - return `${item.type}:${item.id}:${item.isPinned}:${label}` -} - -function createUnifiedTabLookup(tabs: readonly Tab[], groupId: string): Map { - const lookup = new Map() - for (const tab of tabs) { - if (tab.groupId !== groupId) { - continue - } - lookup.set(tab.id, tab) - if (tab.contentType === 'terminal' || tab.contentType === 'browser') { - lookup.set(tab.entityId, tab) - } - } - return lookup -} - -function TabBarInner({ - tabs, - activeTabId, - groupId, - worktreeId, - expandedPaneByTabId, - onActivate, - onClose, - onCloseOthers, - onCloseToRight, - onCloseToLeft, - onNewTerminalTab, - onNewTerminalWithShell, - onNewBrowserTab, - onNewSimulatorTab, - onOpenEntry, - terminalOnly = false, - showAgentLaunchItems = true, - onNewFileTab, - onOpenFileTab, - newTabMenuOrder = 'default', - onSetCustomTitle, - onSetTabColor, - onTogglePaneExpand, - editorFiles, - browserTabs, - activeFileId, - activeBrowserTabId, - activeSimulatorTabId, - activeTabType, - onActivateFile, - onCloseFile, - onActivateBrowserTab, - onCloseBrowserTab, - onDuplicateBrowserTab, - onCloseAllFiles, - onMakePreviewFilePermanent, - onPinFile, - tabBarOrder, - hoveredTabInsertion, - tabStripChrome = 'default' -}: TabBarProps): React.JSX.Element { - const includeTopTabBorder = tabStripChrome !== 'floating-panel' - const newTerminalShortcut = useShortcutLabel('tab.newTerminal') - const newBrowserShortcut = useShortcutLabel('tab.newBrowser') - const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') - const newFileShortcut = useShortcutLabel('tab.newMarkdown') - const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') - const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) - const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) - const persistedUIReady = useAppStore((s) => s.persistedUIReady) - const mobileEmulatorTabIntroDismissed = useAppStore((s) => s.mobileEmulatorTabIntroDismissed) - const showMobileEmulatorIntroCallout = shouldShowMobileEmulatorTabIntro({ - persistedUIReady, - mobileEmulatorTabIntroDismissed, - mobileEmulatorEnabled, - isMacOs - }) - const gitStatusEntries = useAppStore( - (s) => s.gitStatusByWorktree[worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES - ) - const unifiedTabs = useAppStore((s) => s.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS) - const pinTab = useAppStore((s) => s.pinTab) - const unpinTab = useAppStore((s) => s.unpinTab) - const activeGroupIdForWorktree = useAppStore((s) => s.activeGroupIdByWorktree[worktreeId]) - const defaultWindowsShell = useAppStore( - (s) => s.settings?.terminalWindowsShell ?? 'powershell.exe' - ) - const defaultWindowsPowerShellImplementation = useAppStore( - (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' - ) - const activeRepoId = useAppStore((s) => s.activeRepoId) - const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const projects = useAppStore((s) => s.projects) - const repos = useAppStore((s) => s.repos) - const settings = useAppStore((s) => s.settings) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) - // Why: use the worktree's owning host so offered Windows shells match the host that actually runs the terminal. - const activeRuntimeEnvironmentId = useAppStore( - (s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null - ) - // Why: retained tab strips rerun selectors on every store write; reuse canonical indexes, don't flatten both slices here. - const worktreeConnectionId = useAppStore( - (s) => getConnectionIdFromState(s, worktreeId)?.trim() || null - ) - const worktreeRemotePlatform = useAppStore((s) => { - if (!worktreeConnectionId) { - return null - } - return s.sshConnectionStates.get(worktreeConnectionId)?.remotePlatform ?? null - }) - const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) - const agentCmdOverrides = useAppStore( - (s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES - ) - const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) - const { detectedIds } = useDetectedAgents(agentDetectionTarget) - const agentLaunchOptions = useMemo( - () => - buildTabAgentLaunchOptions( - orderTabLaunchAgents(defaultAgent, detectedIds ?? []), - agentCmdOverrides - ), - [agentCmdOverrides, defaultAgent, detectedIds] - ) - const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true - const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey( - activeRuntimeEnvironmentId, - worktreeConnectionId - ) - const runtimeTarget = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), - [activeRuntimeEnvironmentId] - ) - const shouldProbeWindowsShellCapabilities = - isWindows || - Boolean(activeRuntimeEnvironmentId?.trim()) || - isWebClient || - Boolean(worktreeConnectionId) - const windowsTerminalCapabilities = useWindowsTerminalCapabilities( - shouldProbeWindowsShellCapabilities, - false, - windowsTerminalCapabilityOwnerKey, - runtimeTarget, - worktreeConnectionId - ) - const shellMenuHostPlatform = worktreeConnectionId - ? (worktreeRemotePlatform ?? windowsTerminalCapabilities.hostPlatform) - : windowsTerminalCapabilities.hostPlatform - const showWindowsShellMenu = shouldShowWindowsShellMenu({ - activeRuntimeEnvironmentId, - hostPlatform: shellMenuHostPlatform, - isWindowsClient: isWindows, - worktreeHasRemoteConnection: Boolean(worktreeConnectionId) - }) - const localProjectRuntime = useMemo(() => { - if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim() || worktreeConnectionId) { - return undefined - } - return getLocalProjectExecutionRuntimeContext( - { - activeRepoId, - activeWorktreeId, - projects, - repos, - settings, - worktreesByRepo - }, - worktreeId, - 'win32', - { - wslAvailable: windowsTerminalCapabilities.isLoading - ? undefined - : windowsTerminalCapabilities.wslAvailable, - availableWslDistros: windowsTerminalCapabilities.isLoading - ? null - : windowsTerminalCapabilities.wslDistros - } - ) - }, [ - activeRepoId, - activeRuntimeEnvironmentId, - activeWorktreeId, - projects, - repos, - settings, - showWindowsShellMenu, - worktreeConnectionId, - windowsTerminalCapabilities.isLoading, - windowsTerminalCapabilities.wslAvailable, - windowsTerminalCapabilities.wslDistros, +function TabBarInner(props: TabBarProps): React.JSX.Element { + const { worktreeId, - worktreesByRepo - ]) - const projectRuntimeShellMenuMode = getProjectRuntimeShellMenuMode(localProjectRuntime) - const resolvedGroupId = groupId ?? activeGroupIdForWorktree ?? worktreeId - - const statusByRelativePath = useMemo(() => buildStatusMap(gitStatusEntries), [gitStatusEntries]) - const unifiedTabByVisibleId = useMemo( - () => createUnifiedTabLookup(unifiedTabs, resolvedGroupId), - [resolvedGroupId, unifiedTabs] - ) - const workspaceHasSimulatorTab = useMemo( - () => unifiedTabs.some((tab) => tab.contentType === 'simulator'), - [unifiedTabs] - ) - - // Why: tab-wide launch/title hints are safe only before split; gate the view-mode toggle to the active leaf's agent. - const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) - // Why: every retained TabBar observes the same hot maps; one feature-gated selector shares their projections. - const { nativeChatEnabled, tabAgentTypesByTabId, nativeChatTabWideFallbackUnsafeTabsById } = - useAppStore(useShallow(selectTabBarAgentProjections)) - const nativeChatTranscriptIsLocalReadable = useAppStore((s) => - isNativeChatTranscriptLocalReadable(getConnectionIdFromState(s, worktreeId)) - ) - - // Why: clicks are out-of-process, so Radix's document-pointerdown outside-click check misses them; use window blur. - const [newTabMenuOpen, setNewTabMenuOpen] = useState(false) - const [createMenuQuery, setCreateMenuQuery] = useState('') - const pendingNewTabMenuFocusRef = useRef<(() => void) | null>(null) - const pendingNewTabMenuFocusAnimationRef = useRef(null) - const pendingNewTabMenuFocusRetryRef = useRef(null) - const clearPendingNewTabMenuFocusAnimation = (): void => { - if (pendingNewTabMenuFocusAnimationRef.current === null) { - return - } - cancelAnimationFrame(pendingNewTabMenuFocusAnimationRef.current) - pendingNewTabMenuFocusAnimationRef.current = null - } - const clearPendingNewTabMenuFocusRetry = (): void => { - if (pendingNewTabMenuFocusRetryRef.current === null) { - return - } - window.clearTimeout(pendingNewTabMenuFocusRetryRef.current) - pendingNewTabMenuFocusRetryRef.current = null - } - const focusNewActiveTerminalWhenReady = ( - previousActiveTabId: string | null, - expiresAt: number - ): void => { - const state = useAppStore.getState() - if ( - (state.activeTabType === 'terminal' || state.activeTabType === 'simulator') && - state.activeTabId && - state.activeTabId !== previousActiveTabId - ) { - focusTerminalTabSurface(state.activeTabId) - return - } - if (Date.now() >= expiresAt) { - return - } - pendingNewTabMenuFocusRetryRef.current = window.setTimeout(() => { - pendingNewTabMenuFocusRetryRef.current = null - focusNewActiveTerminalWhenReady(previousActiveTabId, expiresAt) - }, NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS) - } - const queueNewActiveTerminalFocusAfterNewTabMenuClose = (): void => { - const previousActiveTabId = useAppStore.getState().activeTabId - pendingNewTabMenuFocusRef.current = () => { - // Why: paired web/SSH tab creation is async; await the host snapshot's new terminal instead of the pre-existing active tab. - focusNewActiveTerminalWhenReady( - previousActiveTabId, - Date.now() + NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS - ) - } - } - const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => { - pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId) - } - const queueFocusAfterNewTabMenuClose = (focus: () => void): void => { - pendingNewTabMenuFocusRef.current = focus - } - const windowsShellEntries = useMemo(() => { - if (!showWindowsShellMenu || !onNewTerminalWithShell) { - return undefined - } - const includeHostShells = projectRuntimeShellMenuMode !== 'wsl' - const includeWslShell = projectRuntimeShellMenuMode !== 'host' - const allShells: { - label: string - shell: BuiltInWindowsTerminalShell - }[] = [] - if (includeHostShells) { - allShells.push( - { - label: translate('auto.components.tab.bar.TabBar.2148f65e04', 'PowerShell'), - shell: 'powershell.exe' - }, - { - label: translate('auto.components.tab.bar.TabBar.1a8af49530', 'CMD Prompt'), - shell: 'cmd.exe' - } - ) - if (windowsTerminalCapabilities.gitBashAvailable) { - allShells.push({ - label: translate('auto.components.tab.bar.TabBar.efb33546ff', 'Git Bash'), - shell: WINDOWS_GIT_BASH_SHELL - }) - } - } - if (includeWslShell && windowsTerminalCapabilities.wslAvailable) { - allShells.push({ - label: translate('auto.components.tab.bar.TabBar.d1afac112b', 'WSL'), - shell: 'wsl.exe' - }) - } - if (allShells.length === 0) { - return undefined - } - const defaultEntry = - allShells.find((shell) => shell.shell === defaultWindowsShell) ?? allShells[0] - const orderedShells = [ - defaultEntry, - ...allShells.filter((shell) => shell.shell !== defaultEntry.shell) - ] - return orderedShells.map((entry) => ({ label: entry.label, shell: entry.shell })) - }, [ - defaultWindowsShell, + groupId, + terminalOnly = false, + onNewTerminalTab, onNewTerminalWithShell, - projectRuntimeShellMenuMode, - showWindowsShellMenu, - windowsTerminalCapabilities.gitBashAvailable, - windowsTerminalCapabilities.wslAvailable - ]) - const createMenuOptions = useMemo( - () => - buildTabCreateMenuOptions({ - terminalOnly, - windowsShellEntries, - hasNewBrowser: !terminalOnly, - hasNewMarkdown: !terminalOnly && Boolean(onNewFileTab), - hasOpenMarkdown: !terminalOnly && Boolean(onOpenFileTab), - hasSimulator: !terminalOnly && mobileEmulatorEnabled && Boolean(onNewSimulatorTab), - simulatorIsGoTo: workspaceHasSimulatorTab - }), - [ - mobileEmulatorEnabled, - onNewFileTab, - onNewSimulatorTab, - onOpenFileTab, - terminalOnly, - windowsShellEntries, - workspaceHasSimulatorTab - ] - ) - const handleSelectCreateMenuOption = (option: TabCreateMenuOption): void => { - switch (option.kind) { - case 'new-terminal': - queueNewActiveTerminalFocusAfterNewTabMenuClose() - onNewTerminalTab() - break - case 'new-terminal-shell': - if (!onNewTerminalWithShell || !option.shell) { - break - } - queueNewActiveTerminalFocusAfterNewTabMenuClose() - onNewTerminalWithShell( - resolveWindowsShellLaunchTarget( - option.shell, - defaultWindowsPowerShellImplementation, - windowsTerminalCapabilities.pwshAvailable - ) - ) - break - case 'new-browser': - onNewBrowserTab() - break - case 'new-markdown': - onNewFileTab?.() - break - case 'open-markdown': - onOpenFileTab?.() - break - case 'new-simulator': - case 'go-to-simulator': - onNewSimulatorTab?.() - break - } - } - const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { - const option = agentLaunchOptions.find((candidate) => candidate.agent === agent) - const result = launchAgentInNewTab({ - agent, - worktreeId, - groupId: resolvedGroupId, - launchSource: 'tab_bar_quick_launch' - }) - if (!result) { - toast.error( - translate( - 'auto.components.tab.bar.TabBar.ab589350e5', - 'Could not build launch command for {{value0}}.', - { value0: option?.label ?? agent } - ) - ) - return - } - if (result.tabId) { - queueTerminalTabFocusAfterNewTabMenuClose(result.tabId) - return - } - queueNewActiveTerminalFocusAfterNewTabMenuClose() - } - const runPendingNewTabMenuFocusAfterClose = (): void => { - const pendingFocus = pendingNewTabMenuFocusRef.current - pendingNewTabMenuFocusRef.current = null - clearPendingNewTabMenuFocusAnimation() - clearPendingNewTabMenuFocusRetry() - if (pendingFocus) { - pendingNewTabMenuFocusAnimationRef.current = requestAnimationFrame(() => { - pendingNewTabMenuFocusAnimationRef.current = null - pendingFocus() - }) - } - } - - const clearPendingNewTabMenuFocusOnUnmountRef = useRef< - ((node: HTMLDivElement | null) => void) | null - >(null) - if (clearPendingNewTabMenuFocusOnUnmountRef.current === null) { - clearPendingNewTabMenuFocusOnUnmountRef.current = (node: HTMLDivElement | null): void => { - if (node !== null) { - return - } - // Why: cancel the delayed focus handoff via this root ref cleanup, avoiding an otherwise cleanup-only React Effect. - clearPendingNewTabMenuFocusAnimation() - clearPendingNewTabMenuFocusRetry() - } - } - const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current - - const defaultTerminalMenuItems = - windowsShellEntries && onNewTerminalWithShell ? ( - windowsShellEntries.map((entry, idx) => { - const isDefault = idx === 0 - return ( - { - // Why: menu models shell categories not executables; preserve the user's chosen PowerShell 7+ over inbox powershell.exe. - queueNewActiveTerminalFocusAfterNewTabMenuClose() - onNewTerminalWithShell( - resolveWindowsShellLaunchTarget( - entry.shell, - defaultWindowsPowerShellImplementation, - windowsTerminalCapabilities.pwshAvailable - ) - ) - }} - className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - > - - - {translate('auto.components.tab.bar.TabBar.7c1313d237', 'New Terminal:')}{' '} - {entry.label} - - {isDefault ? {newTerminalShortcut} : null} - - ) - }) - ) : ( - { - queueNewActiveTerminalFocusAfterNewTabMenuClose() - onNewTerminalTab() - }} - className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - > - - {translate('auto.components.tab.bar.TabBar.d364f3c8d4', 'New Terminal')} - {newTerminalShortcut} - - ) - const newBrowserMenuItem = !terminalOnly ? ( - - - {translate('auto.components.tab.bar.TabBar.4833fb2cbe', 'New Browser Tab')} - {newBrowserShortcut} - - ) : null - const newSimulatorMenuItem = - !terminalOnly && mobileEmulatorEnabled && onNewSimulatorTab ? ( - workspaceHasSimulatorTab ? ( - - - - - {translate('auto.components.tab.bar.TabBar.b426bb2615', 'Go to Mobile Emulator')} - {newSimulatorShortcut} - - - - {translate( - 'auto.components.tab.bar.TabBar.aea43b5748', - 'Open the existing emulator tab.' - )} - - - ) : ( - - - {translate('auto.components.tab.bar.TabBar.fd2b42aaa3', 'New Mobile Emulator')} - {newSimulatorShortcut} - - ) - ) : null - const newMarkdownMenuItem = - !terminalOnly && onNewFileTab ? ( - - - {translate('auto.components.tab.bar.TabBar.3d5d6c960d', 'New Markdown')} - {newFileShortcut} - - ) : null - const openMarkdownMenuItem = - !terminalOnly && onOpenFileTab ? ( - - - {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} - {openMarkdownShortcut ? ( - {openMarkdownShortcut} - ) : null} - - ) : null - const mobileEmulatorIntroMenuBlock = - showMobileEmulatorIntroCallout && - !terminalOnly && - isMacOs && - mobileEmulatorEnabled && - onNewSimulatorTab ? ( - - ) : null - const standardCreateMenuItems = - newTabMenuOrder === 'markdown-first' ? ( - <> - {newMarkdownMenuItem} - {openMarkdownMenuItem} - {defaultTerminalMenuItems} - {newBrowserMenuItem} - {newSimulatorMenuItem} - {mobileEmulatorIntroMenuBlock} - - ) : ( - <> - {defaultTerminalMenuItems} - {newBrowserMenuItem} - {newMarkdownMenuItem} - {openMarkdownMenuItem} - {newSimulatorMenuItem} - {mobileEmulatorIntroMenuBlock} - - ) - - useEffect(() => { - if (!newTabMenuOpen) { - return - } - const dismiss = (): void => setNewTabMenuOpen(false) - window.addEventListener('blur', dismiss) - return () => window.removeEventListener('blur', dismiss) - }, [newTabMenuOpen]) - - useEffect(() => { - if (!newTabMenuOpen) { - setCreateMenuQuery('') - } - }, [newTabMenuOpen]) - - const showStaticCreateMenuItems = createMenuQuery.trim().length === 0 - - const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs]) - const editorMap = useMemo( - () => new Map((editorFiles ?? []).map((f) => [f.tabId ?? f.id, f])), - [editorFiles] - ) - const browserMap = useMemo( - () => new Map((browserTabs ?? []).map((t) => [t.id, t])), - [browserTabs] - ) - - const terminalIds = useMemo(() => tabs.map((t) => t.id), [tabs]) - const editorFileIds = useMemo(() => editorFiles?.map((f) => f.tabId ?? f.id) ?? [], [editorFiles]) - const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs]) - const simulatorTabIds = useMemo( - () => - (unifiedTabs ?? []) - .filter((t) => t.groupId === resolvedGroupId && t.contentType === 'simulator') - .map((t) => t.id), - [unifiedTabs, resolvedGroupId] - ) - - // Build the unified ordered list, reconciling stored order with current items - const orderedItems = useMemo(() => { - const ids = reconcileTabOrder( - tabBarOrder, - terminalIds, - editorFileIds, - browserTabIds, - simulatorTabIds - ) - const items: TabItem[] = [] - for (const id of ids) { - const terminal = terminalMap.get(id) - if (terminal) { - const unifiedTab = unifiedTabByVisibleId.get(id) - items.push({ - type: 'terminal', - id, - unifiedTabId: terminal.unifiedTabId ?? unifiedTab?.id ?? terminal.id, - isPinned: unifiedTab?.isPinned === true, - data: terminal - }) - continue - } - const file = editorMap.get(id) - if (file) { - const unifiedTab = unifiedTabByVisibleId.get(id) ?? unifiedTabByVisibleId.get(file.id) - items.push({ - type: 'editor', - id, - unifiedTabId: file.tabId ?? unifiedTab?.id ?? file.id, - isPinned: unifiedTab?.isPinned === true, - data: file - }) - continue - } - const browserTab = browserMap.get(id) - if (browserTab) { - const unifiedTab = unifiedTabByVisibleId.get(id) - items.push({ - type: 'browser', - id, - unifiedTabId: browserTab.tabId ?? unifiedTab?.id ?? browserTab.id, - isPinned: unifiedTab?.isPinned === true, - data: browserTab - }) - continue - } - const simUnified = unifiedTabByVisibleId.get(id) - if (simUnified && simUnified.contentType === 'simulator') { - items.push({ - type: 'simulator', - id, - unifiedTabId: simUnified.id, - isPinned: simUnified.isPinned === true, - data: simUnified - }) - continue - } - } - return items - }, [ - tabBarOrder, - terminalIds, - editorFileIds, - browserTabIds, - simulatorTabIds, - terminalMap, - editorMap, - browserMap, - unifiedTabByVisibleId - ]) - - const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems]) - - const activeIndicator = - hoveredTabInsertion?.groupId === resolvedGroupId ? hoveredTabInsertion : null - const dropIndicatorByVisibleId = useMemo(() => { - const indicators = new Map() - for (const edge of resolveTabIndicatorEdges( - orderedItems.map((item) => item.id), - activeIndicator - )) { - indicators.set(edge.visibleTabId, edge.side) - } - return indicators - }, [activeIndicator, orderedItems]) - - const activeVisibleTabId = useMemo(() => { - const activeItem = orderedItems.find((item) => { - if (item.type === 'terminal') { - return ( - (activeTabType === 'terminal' || activeTabType === 'simulator') && item.id === activeTabId - ) - } - if (item.type === 'browser') { - return activeTabType === 'browser' && item.id === activeBrowserTabId - } - if (item.type === 'simulator') { - return activeTabType === 'simulator' && item.id === activeSimulatorTabId - } - return ( - (activeTabType === 'editor' || activeTabType === 'simulator') && activeFileId === item.id - ) - }) - return activeItem?.id ?? null - }, [ - activeBrowserTabId, - activeFileId, - activeSimulatorTabId, - activeTabId, - activeTabType, - orderedItems - ]) - const tabStripLayoutKey = useMemo( - () => - orderedItems - .map((item) => - getTabLayoutSignature(item, { - generatedTitlesEnabled: generatedTabTitlesEnabled, - isExpanded: expandedPaneByTabId[item.id] === true, - status: - item.type === 'editor' - ? (statusByRelativePath.get(normalizeRelativePath(item.data.relativePath)) ?? null) - : null - }) - ) - .join('\u001f'), - [expandedPaneByTabId, generatedTabTitlesEnabled, orderedItems, statusByRelativePath] - ) - - const togglePinned = (item: TabItem): void => { + onNewBrowserTab, + onNewSimulatorTab, + onNewFileTab, + onOpenFileTab, + onPinFile + } = props + const runtime = useTabBarRuntimeModel({ worktreeId, groupId }) + const createMenu = useTabBarCreateMenuController({ + worktreeId, + resolvedGroupId: runtime.resolvedGroupId, + terminalOnly, + mobileEmulatorEnabled: runtime.mobileEmulatorEnabled, + workspaceHasSimulatorTab: runtime.workspaceHasSimulatorTab, + showWindowsShellMenu: runtime.showWindowsShellMenu, + projectRuntimeShellMenuMode: runtime.projectRuntimeShellMenuMode, + defaultWindowsShell: runtime.defaultWindowsShell, + defaultWindowsPowerShellImplementation: runtime.defaultWindowsPowerShellImplementation, + windowsTerminalCapabilities: runtime.windowsTerminalCapabilities, + agentLaunchOptions: runtime.agentLaunchOptions, + onNewTerminalTab, + onNewTerminalWithShell, + onNewBrowserTab, + onNewSimulatorTab, + onNewFileTab, + onOpenFileTab + }) + const itemProjection = useTabBarItemProjection({ + props, + resolvedGroupId: runtime.resolvedGroupId, + unifiedTabs: runtime.unifiedTabs, + unifiedTabByVisibleId: runtime.unifiedTabByVisibleId, + generatedTabTitlesEnabled: runtime.generatedTabTitlesEnabled, + statusByRelativePath: runtime.statusByRelativePath + }) + const togglePinned = (item: TabBarItem): void => { // pinTab/unpinTab mirror the change to the host for remote-server tabs. if (item.isPinned) { - unpinTab(item.unifiedTabId) + runtime.unpinTab(item.unifiedTabId) return } if (item.type === 'editor' && onPinFile) { onPinFile(item.data.id, item.unifiedTabId) return } - pinTab(item.unifiedTabId) + runtime.pinTab(item.unifiedTabId) } - - const { tabStripRef, tabStripOverflowState, scrollTabStrip } = useTabStripOverflowNavigation({ - activeVisibleTabId, - layoutKey: tabStripLayoutKey, - tabCount: orderedItems.length, + const tabStripNavigation = useTabStripOverflowNavigation({ + activeVisibleTabId: itemProjection.activeVisibleTabId, + layoutKey: itemProjection.tabStripLayoutKey, + tabCount: itemProjection.orderedItems.length, worktreeId }) - const tabStripDragScroll = useTabStripDragScrollHandlers(scrollTabStrip, { - start: tabStripOverflowState.canScrollStart, - end: tabStripOverflowState.canScrollEnd + const tabStripDragScroll = useTabStripDragScrollHandlers(tabStripNavigation.scrollTabStrip, { + start: tabStripNavigation.tabStripOverflowState.canScrollStart, + end: tabStripNavigation.tabStripOverflowState.canScrollEnd }) - return ( -
- {tabStripOverflowState.hasOverflow ? ( - - - - - - {translate('auto.components.tab.bar.TabBar.7a9b4af2af', 'Scroll tabs left')} - - - ) : null} - {/* Why: no strategy stops dnd-kit animating siblings, so tabs stay anchored during drag; only the insertion bar moves. */} - - {/* Why: no-drag lets tab interactions work inside the titlebar's drag region (outer container stays window-draggable). */} -
-
- {orderedItems.map((item, index) => { - const dragData: TabDragItemData = { - kind: 'tab', - worktreeId, - groupId: resolvedGroupId, - unifiedTabId: item.unifiedTabId, - visibleTabId: item.id, - tabType: item.type, - label: getTabDragLabel(item, generatedTabTitlesEnabled), - iconPath: item.type === 'editor' ? item.data.filePath : undefined, - color: item.type === 'terminal' ? (item.data.color ?? null) : null - } - if (item.type === 'terminal') { - const terminalTab = { - ...item.data, - title: resolveTerminalTabTitle( - item.data, - generatedTabTitlesEnabled, - item.data.title - ) - } - const unifiedTabForItem = unifiedTabByVisibleId.get(item.id) - // Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok. - const resolvedAgent = - resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? - resolveCommittedTitleAgentType(terminalTab.title) - // Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id. - const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null - const tabWideFallbackSafe = - nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true - const canToggleViewMode = - unifiedTabForItem !== undefined && - canToggleNativeChat({ - experimentalNativeChatEnabled: nativeChatEnabled, - contentType: 'terminal', - launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, - detectedAgent, - resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, - nativeChatTranscriptIsLocalReadable, - isChatViewMode: unifiedTabForItem.viewMode === 'chat' - }) - return ( - toggleTabViewMode(unifiedTabForItem.id) : undefined - } - hasTabsToRight={index < orderedItems.length - 1} - hasTabsToLeft={index > 0} - isActive={ - (activeTabType === 'terminal' || activeTabType === 'simulator') && - item.id === activeTabId - } - isPinned={item.isPinned} - isExpanded={expandedPaneByTabId[item.id] === true} - onActivate={onActivate} - onClose={onClose} - onCloseOthers={onCloseOthers} - onCloseToRight={onCloseToRight} - onCloseToLeft={onCloseToLeft} - onSetCustomTitle={onSetCustomTitle} - onSetTabColor={onSetTabColor} - onTogglePin={() => togglePinned(item)} - onToggleExpand={onTogglePaneExpand} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) - } - if (item.type === 'browser') { - return ( - 0} - tabCount={orderedItems.length} - onActivate={() => onActivateBrowserTab?.(item.id)} - onClose={() => onCloseBrowserTab?.(item.id)} - onCloseOthers={() => onCloseOthers(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onCloseToLeft={() => onCloseToLeft(item.id)} - onDuplicate={() => onDuplicateBrowserTab?.(item.id)} - onTogglePin={() => togglePinned(item)} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) - } - if (item.type === 'simulator') { - const simLabel = item.data.label || 'Mobile Emulator' - const simFile: OpenFile & { tabId: string } = { - id: item.id, - tabId: item.id, - filePath: simLabel, - relativePath: simLabel, - worktreeId, - language: 'simulator', - isPreview: false, - isDirty: false, - mode: 'edit' - } - return ( - 0} - tabCount={orderedItems.length} - statusByRelativePath={statusByRelativePath} - onActivate={() => onActivateFile?.(item.id)} - onClose={() => onCloseFile?.(item.id)} - onCloseOthers={() => onCloseOthers(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onCloseToLeft={() => onCloseToLeft(item.id)} - onCloseAll={() => onCloseAllFiles?.()} - onMakePermanent={() => {}} - onTogglePin={() => togglePinned(item)} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) - } - return ( - 0} - tabCount={orderedItems.length} - statusByRelativePath={statusByRelativePath} - onActivate={() => onActivateFile?.(item.id)} - onClose={() => onCloseFile?.(item.id)} - onCloseOthers={() => onCloseOthers(item.id)} - onCloseToRight={() => onCloseToRight(item.id)} - onCloseToLeft={() => onCloseToLeft(item.id)} - onCloseAll={() => onCloseAllFiles?.()} - onMakePermanent={() => - onMakePreviewFilePermanent?.(item.data.id, item.data.tabId) - } - onTogglePin={() => togglePinned(item)} - dragData={dragData} - dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} - includeTopTabBorder={includeTopTabBorder} - /> - ) - })} -
- -
-
- {tabStripOverflowState.hasOverflow ? ( - - - - - - {translate('auto.components.tab.bar.TabBar.232e075b07', 'Scroll tabs right')} - - - ) : null} - - - - - { - // Why: Radix restores focus to the "+" trigger on close, stealing it from the freshly-mounted terminal. - e.preventDefault() - runPendingNewTabMenuFocusAfterClose() - }} - > - {!terminalOnly && onOpenEntry ? ( - <> - { - queueNewActiveTerminalFocusAfterNewTabMenuClose() - onNewTerminalTab() - }} - onOpenEntry={onOpenEntry} - onQueryChange={setCreateMenuQuery} - onQueueSwitchFocus={queueFocusAfterNewTabMenuClose} - onSelectMenuOption={handleSelectCreateMenuOption} - onDidOpenEntry={() => setNewTabMenuOpen(false)} - /> - {showStaticCreateMenuItems ? : null} - - ) : null} - {showStaticCreateMenuItems ? standardCreateMenuItems : null} - {showStaticCreateMenuItems && showAgentLaunchItems ? ( - <> - - - - ) : null} - - -
- ) + return renderTabBarSurface({ + props, + runtime, + createMenu, + itemProjection, + tabStripNavigation, + tabStripDragScroll, + togglePinned + }) } export default React.memo(TabBarInner) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts new file mode 100644 index 00000000000..89e2891908d --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts @@ -0,0 +1,242 @@ +import type { + BrowserTab as BrowserTabState, + Tab, + TerminalTab, + WorkspaceVisibleTabType +} from '../../../../shared/types' +import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import type { OpenFile } from '../../store/slices/editor' +import { getEditorDisplayLabel } from '@/components/editor/editor-labels' +import { normalizeRelativePath } from '@/lib/path' +import { getBrowserTabLabel } from './BrowserTab' +import type { DropIndicator } from './drop-indicator' +import { reconcileTabOrder } from './reconcile-order' +import { resolveTabIndicatorEdges } from '../tab-group/tab-insertion' +import type { HoveredTabInsertion } from '../tab-group/useTabDragSplit' + +export type TabBarItem = + | { + type: 'terminal' + id: string + unifiedTabId: string + isPinned: boolean + data: TerminalTab & { unifiedTabId?: string } + } + | { + type: 'editor' + id: string + unifiedTabId: string + isPinned: boolean + data: OpenFile & { tabId?: string } + } + | { + type: 'browser' + id: string + unifiedTabId: string + isPinned: boolean + data: BrowserTabState & { tabId?: string } + } + | { + type: 'simulator' + id: string + unifiedTabId: string + isPinned: boolean + data: Tab + } + +export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolean): string { + if (item.type === 'terminal') { + return resolveTerminalTabTitle(item.data, generatedTitlesEnabled, item.data.title) + } + if (item.type === 'browser') { + return getBrowserTabLabel(item.data) + } + if (item.type === 'simulator') { + return item.data.label || 'Mobile Emulator' + } + return getEditorDisplayLabel(item.data) +} + +export function getTabLayoutSignature( + item: TabBarItem, + { + generatedTitlesEnabled, + isExpanded, + status + }: { + generatedTitlesEnabled: boolean + isExpanded: boolean + status?: string | null + } +): string { + const label = getTabDragLabel(item, generatedTitlesEnabled) + if (item.type === 'terminal') { + return `${item.type}:${item.id}:${item.isPinned}:${isExpanded}:${Boolean(item.data.color)}:${label}` + } + if (item.type === 'browser') { + return `${item.type}:${item.id}:${item.isPinned}:${item.data.loading}:${Boolean(item.data.loadError)}:${label}` + } + if (item.type === 'editor') { + return `${item.type}:${item.id}:${item.isPinned}:${item.data.isDirty}:${item.data.isPreview}:${item.data.externalMutation ?? ''}:${status ?? ''}:${label}` + } + return `${item.type}:${item.id}:${item.isPinned}:${label}` +} + +export function createUnifiedTabLookup(tabs: readonly Tab[], groupId: string): Map { + const lookup = new Map() + for (const tab of tabs) { + if (tab.groupId !== groupId) { + continue + } + lookup.set(tab.id, tab) + if (tab.contentType === 'terminal' || tab.contentType === 'browser') { + lookup.set(tab.entityId, tab) + } + } + return lookup +} + +export function buildOrderedTabItems({ + tabBarOrder, + terminalIds, + editorFileIds, + browserTabIds, + simulatorTabIds, + terminalMap, + editorMap, + browserMap, + unifiedTabByVisibleId +}: { + tabBarOrder?: string[] + terminalIds: string[] + editorFileIds: string[] + browserTabIds: string[] + simulatorTabIds: string[] + terminalMap: Map + editorMap: Map + browserMap: Map + unifiedTabByVisibleId: Map +}): TabBarItem[] { + const ids = reconcileTabOrder( + tabBarOrder, + terminalIds, + editorFileIds, + browserTabIds, + simulatorTabIds + ) + const items: TabBarItem[] = [] + for (const id of ids) { + const terminal = terminalMap.get(id) + if (terminal) { + const unifiedTab = unifiedTabByVisibleId.get(id) + items.push({ + type: 'terminal', + id, + unifiedTabId: terminal.unifiedTabId ?? unifiedTab?.id ?? terminal.id, + isPinned: unifiedTab?.isPinned === true, + data: terminal + }) + continue + } + const file = editorMap.get(id) + if (file) { + const unifiedTab = unifiedTabByVisibleId.get(id) ?? unifiedTabByVisibleId.get(file.id) + items.push({ + type: 'editor', + id, + unifiedTabId: file.tabId ?? unifiedTab?.id ?? file.id, + isPinned: unifiedTab?.isPinned === true, + data: file + }) + continue + } + const browserTab = browserMap.get(id) + if (browserTab) { + const unifiedTab = unifiedTabByVisibleId.get(id) + items.push({ + type: 'browser', + id, + unifiedTabId: browserTab.tabId ?? unifiedTab?.id ?? browserTab.id, + isPinned: unifiedTab?.isPinned === true, + data: browserTab + }) + continue + } + const simulatorTab = unifiedTabByVisibleId.get(id) + if (simulatorTab?.contentType === 'simulator') { + items.push({ + type: 'simulator', + id, + unifiedTabId: simulatorTab.id, + isPinned: simulatorTab.isPinned === true, + data: simulatorTab + }) + } + } + return items +} + +export function buildTabDropIndicators( + items: readonly TabBarItem[], + insertion: HoveredTabInsertion | null +): Map { + const indicators = new Map() + for (const edge of resolveTabIndicatorEdges( + items.map((item) => item.id), + insertion + )) { + indicators.set(edge.visibleTabId, edge.side) + } + return indicators +} + +export function findActiveVisibleTabId( + items: readonly TabBarItem[], + active: { + activeTabId: string | null + activeFileId?: string | null + activeBrowserTabId?: string | null + activeSimulatorTabId?: string | null + activeTabType?: WorkspaceVisibleTabType + } +): string | null { + const activeItem = items.find((item) => { + if (item.type === 'terminal') { + return ( + (active.activeTabType === 'terminal' || active.activeTabType === 'simulator') && + item.id === active.activeTabId + ) + } + if (item.type === 'browser') { + return active.activeTabType === 'browser' && item.id === active.activeBrowserTabId + } + if (item.type === 'simulator') { + return active.activeTabType === 'simulator' && item.id === active.activeSimulatorTabId + } + return ( + (active.activeTabType === 'editor' || active.activeTabType === 'simulator') && + active.activeFileId === item.id + ) + }) + return activeItem?.id ?? null +} + +export function buildTabStripLayoutKey( + items: readonly TabBarItem[], + generatedTitlesEnabled: boolean, + expandedPaneByTabId: Record, + statusByRelativePath: Map +): string { + return items + .map((item) => + getTabLayoutSignature(item, { + generatedTitlesEnabled, + isExpanded: expandedPaneByTabId[item.id] === true, + status: + item.type === 'editor' + ? (statusByRelativePath.get(normalizeRelativePath(item.data.relativePath)) ?? null) + : null + }) + ) + .join('\u001f') +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx new file mode 100644 index 00000000000..b67ce3b000a --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import type { OpenFile } from '../../store/slices/editor' +import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import SortableTab from './SortableTab' +import EditorFileTab from './EditorFileTab' +import BrowserTab from './BrowserTab' +import type { DropIndicator } from './drop-indicator' +import type { TabDragItemData } from '../tab-group/useTabDragSplit' +import { getTabDragLabel, type TabBarItem } from './tab-bar-item-model' +import type { TabBarProps } from './tab-bar-props' +import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model' + +export function renderTabBarItems({ + items, + props, + runtime, + dropIndicatorByVisibleId, + includeTopTabBorder, + togglePinned +}: { + items: TabBarItem[] + props: TabBarProps + runtime: TabBarRuntimeModel + dropIndicatorByVisibleId: Map + includeTopTabBorder: boolean + togglePinned: (item: TabBarItem) => void +}): React.ReactNode[] { + const { + worktreeId, + activeTabId, + activeFileId, + activeBrowserTabId, + activeSimulatorTabId, + activeTabType, + expandedPaneByTabId, + onActivate, + onClose, + onCloseOthers, + onCloseToRight, + onCloseToLeft, + onSetCustomTitle, + onSetTabColor, + onTogglePaneExpand, + onActivateFile, + onCloseFile, + onActivateBrowserTab, + onCloseBrowserTab, + onDuplicateBrowserTab, + onCloseAllFiles, + onMakePreviewFilePermanent + } = props + const { + resolvedGroupId, + generatedTabTitlesEnabled, + unifiedTabByVisibleId, + nativeChatEnabled, + tabAgentTypesByTabId, + nativeChatTabWideFallbackUnsafeTabsById, + nativeChatTranscriptIsLocalReadable, + toggleTabViewMode, + statusByRelativePath + } = runtime + + return items.map((item, index) => { + const dragData: TabDragItemData = { + kind: 'tab', + worktreeId, + groupId: resolvedGroupId, + unifiedTabId: item.unifiedTabId, + visibleTabId: item.id, + tabType: item.type, + label: getTabDragLabel(item, generatedTabTitlesEnabled), + iconPath: item.type === 'editor' ? item.data.filePath : undefined, + color: item.type === 'terminal' ? (item.data.color ?? null) : null + } + if (item.type === 'terminal') { + const terminalTab = { + ...item.data, + title: resolveTerminalTabTitle(item.data, generatedTabTitlesEnabled, item.data.title) + } + const unifiedTabForItem = unifiedTabByVisibleId.get(item.id) + // Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok. + const resolvedAgent = + resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? + resolveCommittedTitleAgentType(terminalTab.title) + // Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id. + const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null + const tabWideFallbackSafe = nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true + const canToggleViewMode = + unifiedTabForItem !== undefined && + canToggleNativeChat({ + experimentalNativeChatEnabled: nativeChatEnabled, + contentType: 'terminal', + launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, + detectedAgent, + resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, + nativeChatTranscriptIsLocalReadable, + isChatViewMode: unifiedTabForItem.viewMode === 'chat' + }) + return ( + toggleTabViewMode(unifiedTabForItem.id) : undefined + } + hasTabsToRight={index < items.length - 1} + hasTabsToLeft={index > 0} + isActive={ + (activeTabType === 'terminal' || activeTabType === 'simulator') && + item.id === activeTabId + } + isPinned={item.isPinned} + isExpanded={expandedPaneByTabId[item.id] === true} + onActivate={onActivate} + onClose={onClose} + onCloseOthers={onCloseOthers} + onCloseToRight={onCloseToRight} + onCloseToLeft={onCloseToLeft} + onSetCustomTitle={onSetCustomTitle} + onSetTabColor={onSetTabColor} + onTogglePin={() => togglePinned(item)} + onToggleExpand={onTogglePaneExpand} + dragData={dragData} + dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} + /> + ) + } + if (item.type === 'browser') { + return ( + 0} + tabCount={items.length} + onActivate={() => onActivateBrowserTab?.(item.id)} + onClose={() => onCloseBrowserTab?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} + onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} + onDuplicate={() => onDuplicateBrowserTab?.(item.id)} + onTogglePin={() => togglePinned(item)} + dragData={dragData} + dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} + /> + ) + } + if (item.type === 'simulator') { + const simulatorLabel = item.data.label || 'Mobile Emulator' + const simulatorFile: OpenFile & { tabId: string } = { + id: item.id, + tabId: item.id, + filePath: simulatorLabel, + relativePath: simulatorLabel, + worktreeId, + language: 'simulator', + isPreview: false, + isDirty: false, + mode: 'edit' + } + return ( + 0} + tabCount={items.length} + statusByRelativePath={statusByRelativePath} + onActivate={() => onActivateFile?.(item.id)} + onClose={() => onCloseFile?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} + onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} + onCloseAll={() => onCloseAllFiles?.()} + onMakePermanent={() => {}} + onTogglePin={() => togglePinned(item)} + dragData={dragData} + dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} + /> + ) + } + return ( + 0} + tabCount={items.length} + statusByRelativePath={statusByRelativePath} + onActivate={() => onActivateFile?.(item.id)} + onClose={() => onCloseFile?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} + onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} + onCloseAll={() => onCloseAllFiles?.()} + onMakePermanent={() => onMakePreviewFilePermanent?.(item.data.id, item.data.tabId)} + onTogglePin={() => togglePinned(item)} + dragData={dragData} + dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} + /> + ) + }) +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-props.ts b/src/renderer/src/components/tab-bar/tab-bar-props.ts new file mode 100644 index 00000000000..6f8d93ec0a7 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-props.ts @@ -0,0 +1,53 @@ +import type { + BrowserTab as BrowserTabState, + TerminalTab, + WorkspaceVisibleTabType +} from '../../../../shared/types' +import type { OpenFile } from '../../store/slices/editor' +import type { HoveredTabInsertion } from '../tab-group/useTabDragSplit' +import type { TabCreateEntryArgs } from './tab-create-entry-action' + +export type TabBarProps = { + tabs: (TerminalTab & { unifiedTabId?: string })[] + activeTabId: string | null + groupId?: string + worktreeId: string + expandedPaneByTabId: Record + onActivate: (tabId: string) => void + onClose: (tabId: string) => void + onCloseOthers: (tabId: string) => void + onCloseToRight: (tabId: string) => void + onCloseToLeft: (tabId: string) => void + onNewTerminalTab: () => void + /** On Windows, opens a new terminal with a specific shell instead of the default. */ + onNewTerminalWithShell?: (shell: string) => void + onNewBrowserTab: () => void + onNewSimulatorTab?: () => void + onOpenEntry?: (args: TabCreateEntryArgs) => Promise + terminalOnly?: boolean + showAgentLaunchItems?: boolean + onNewFileTab?: () => void + onOpenFileTab?: () => void + newTabMenuOrder?: 'default' | 'markdown-first' + onSetCustomTitle: (tabId: string, title: string | null) => void + onSetTabColor: (tabId: string, color: string | null) => void + onTogglePaneExpand: (tabId: string) => void + editorFiles?: (OpenFile & { tabId?: string })[] + browserTabs?: (BrowserTabState & { tabId?: string })[] + activeFileId?: string | null + activeBrowserTabId?: string | null + activeSimulatorTabId?: string | null + activeTabType?: WorkspaceVisibleTabType + onActivateFile?: (fileId: string) => void + onCloseFile?: (fileId: string) => void + onActivateBrowserTab?: (tabId: string) => void + onCloseBrowserTab?: (tabId: string) => void + onDuplicateBrowserTab?: (tabId: string) => void + onCloseAllFiles?: () => void + onMakePreviewFilePermanent?: (fileId: string, tabId?: string) => void + onPinFile?: (fileId: string, tabId?: string) => void + tabBarOrder?: string[] + hoveredTabInsertion?: HoveredTabInsertion | null + /** Floating workspace panels are rounded; skip tab top borders that clash with the curve. */ + tabStripChrome?: 'default' | 'floating-panel' +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx new file mode 100644 index 00000000000..9d68808ad94 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-static-create-menu.tsx @@ -0,0 +1,194 @@ +import React from 'react' +import { FilePlus, FileText, Globe, Smartphone, TerminalSquare } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { MobileEmulatorTabIntroCallout } from '../emulator-pane/MobileEmulatorTabIntroCallout' +import { ShellIcon } from './shell-icons' +import type { WindowsShellMenuEntry } from './tab-bar-windows-shell-options' +import { + isMacOs, + type resolveWindowsPowerShellImplementationSetting +} from './use-tab-bar-runtime-model' +import type { TabBarProps } from './tab-bar-props' +import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' + +export function renderTabBarStaticCreateMenu({ + terminalOnly, + mobileEmulatorEnabled, + workspaceHasSimulatorTab, + showMobileEmulatorIntroCallout, + props, + windowsShellEntries, + defaultWindowsPowerShellImplementation, + pwshAvailable, + newTerminalShortcut, + newBrowserShortcut, + newSimulatorShortcut, + newFileShortcut, + openMarkdownShortcut, + queueNewActiveTerminalFocusAfterNewTabMenuClose +}: { + props: TabBarProps + terminalOnly: boolean + mobileEmulatorEnabled: boolean + workspaceHasSimulatorTab: boolean + showMobileEmulatorIntroCallout: boolean + windowsShellEntries: WindowsShellMenuEntry[] | undefined + defaultWindowsPowerShellImplementation: ReturnType< + typeof resolveWindowsPowerShellImplementationSetting + > + pwshAvailable: boolean + newTerminalShortcut: string + newBrowserShortcut: string + newSimulatorShortcut: string + newFileShortcut: string + openMarkdownShortcut: string | null + queueNewActiveTerminalFocusAfterNewTabMenuClose: () => void +}): React.ReactNode { + const { + newTabMenuOrder = 'default', + onNewTerminalTab, + onNewTerminalWithShell, + onNewBrowserTab, + onNewSimulatorTab, + onNewFileTab, + onOpenFileTab + } = props + const defaultTerminalMenuItems = + windowsShellEntries && onNewTerminalWithShell ? ( + windowsShellEntries.map((entry, index) => { + const isDefault = index === 0 + return ( + { + // Why: menu models shell categories not executables; preserve the user's chosen PowerShell 7+ over inbox powershell.exe. + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalWithShell( + resolveWindowsShellLaunchTarget( + entry.shell, + defaultWindowsPowerShellImplementation, + pwshAvailable + ) + ) + }} + className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" + > + + + {translate('auto.components.tab.bar.TabBar.7c1313d237', 'New Terminal:')}{' '} + {entry.label} + + {isDefault ? {newTerminalShortcut} : null} + + ) + }) + ) : ( + { + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalTab() + }} + className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" + > + + {translate('auto.components.tab.bar.TabBar.d364f3c8d4', 'New Terminal')} + {newTerminalShortcut} + + ) + const newBrowserMenuItem = !terminalOnly ? ( + + + {translate('auto.components.tab.bar.TabBar.4833fb2cbe', 'New Browser Tab')} + {newBrowserShortcut} + + ) : null + const newSimulatorMenuItem = + !terminalOnly && mobileEmulatorEnabled && onNewSimulatorTab ? ( + workspaceHasSimulatorTab ? ( + + + + + {translate('auto.components.tab.bar.TabBar.b426bb2615', 'Go to Mobile Emulator')} + {newSimulatorShortcut} + + + + {translate( + 'auto.components.tab.bar.TabBar.aea43b5748', + 'Open the existing emulator tab.' + )} + + + ) : ( + + + {translate('auto.components.tab.bar.TabBar.fd2b42aaa3', 'New Mobile Emulator')} + {newSimulatorShortcut} + + ) + ) : null + const newMarkdownMenuItem = + !terminalOnly && onNewFileTab ? ( + + + {translate('auto.components.tab.bar.TabBar.3d5d6c960d', 'New Markdown')} + {newFileShortcut} + + ) : null + const openMarkdownMenuItem = + !terminalOnly && onOpenFileTab ? ( + + + {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + {openMarkdownShortcut ? ( + {openMarkdownShortcut} + ) : null} + + ) : null + const mobileEmulatorIntroMenuBlock = + showMobileEmulatorIntroCallout && + !terminalOnly && + isMacOs && + mobileEmulatorEnabled && + onNewSimulatorTab ? ( + + ) : null + + return newTabMenuOrder === 'markdown-first' ? ( + <> + {newMarkdownMenuItem} + {openMarkdownMenuItem} + {defaultTerminalMenuItems} + {newBrowserMenuItem} + {newSimulatorMenuItem} + {mobileEmulatorIntroMenuBlock} + + ) : ( + <> + {defaultTerminalMenuItems} + {newBrowserMenuItem} + {newMarkdownMenuItem} + {openMarkdownMenuItem} + {newSimulatorMenuItem} + {mobileEmulatorIntroMenuBlock} + + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx new file mode 100644 index 00000000000..d3b18ee5820 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -0,0 +1,256 @@ +import React from 'react' +import { SortableContext } from '@dnd-kit/sortable' +import { ChevronLeft, ChevronRight, Plus } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { QuickLaunchAgentMenuItems } from './QuickLaunchButton' +import TabBarCreateEntry from './TabBarCreateEntry' +import { TabStripScrollIndicator } from './TabStripScrollIndicator' +import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics' +import type { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' +import type { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' +import type { TabBarProps } from './tab-bar-props' +import type { TabBarRuntimeModel } from './use-tab-bar-runtime-model' +import type { TabBarCreateMenuController } from './use-tab-bar-create-menu-controller' +import type { TabBarItemProjection } from './use-tab-bar-item-projection' +import type { TabBarItem } from './tab-bar-item-model' +import { renderTabBarItems } from './tab-bar-item-surface' +import { renderTabBarStaticCreateMenu } from './tab-bar-static-create-menu' + +export function renderTabBarSurface({ + props, + runtime, + createMenu, + itemProjection, + tabStripNavigation, + tabStripDragScroll, + togglePinned +}: { + props: TabBarProps + runtime: TabBarRuntimeModel + createMenu: TabBarCreateMenuController + itemProjection: TabBarItemProjection + tabStripNavigation: ReturnType + tabStripDragScroll: ReturnType + togglePinned: (item: TabBarItem) => void +}): React.JSX.Element { + const { + worktreeId, + terminalOnly = false, + showAgentLaunchItems = true, + onNewTerminalTab, + onOpenEntry, + tabStripChrome = 'default' + } = props + const { + resolvedGroupId, + mobileEmulatorEnabled, + workspaceHasSimulatorTab, + showMobileEmulatorIntroCallout, + windowsTerminalCapabilities, + defaultWindowsPowerShellImplementation, + agentLaunchOptions, + newTerminalShortcut, + newBrowserShortcut, + newSimulatorShortcut, + newFileShortcut, + openMarkdownShortcut + } = runtime + const { + newTabMenuOpen, + setNewTabMenuOpen, + setCreateMenuQuery, + createMenuOptions, + windowsShellEntries, + handleSelectCreateMenuOption, + launchAgentFromNewTabEntry, + runPendingNewTabMenuFocusAfterClose, + clearPendingNewTabMenuFocusOnUnmount, + queueNewActiveTerminalFocusAfterNewTabMenuClose, + queueTerminalTabFocusAfterNewTabMenuClose, + queueFocusAfterNewTabMenuClose, + showStaticCreateMenuItems + } = createMenu + const { orderedItems, sortableIds, dropIndicatorByVisibleId } = itemProjection + const { tabStripRef, tabStripOverflowState, scrollTabStrip } = tabStripNavigation + const includeTopTabBorder = tabStripChrome !== 'floating-panel' + const renderedItems = renderTabBarItems({ + items: orderedItems, + props, + runtime, + dropIndicatorByVisibleId, + includeTopTabBorder, + togglePinned + }) + const standardCreateMenuItems = renderTabBarStaticCreateMenu({ + props, + terminalOnly, + mobileEmulatorEnabled, + workspaceHasSimulatorTab, + showMobileEmulatorIntroCallout, + windowsShellEntries, + defaultWindowsPowerShellImplementation, + pwshAvailable: windowsTerminalCapabilities.pwshAvailable, + newTerminalShortcut, + newBrowserShortcut, + newSimulatorShortcut, + newFileShortcut, + openMarkdownShortcut, + queueNewActiveTerminalFocusAfterNewTabMenuClose + }) + + return ( +
+ {tabStripOverflowState.hasOverflow ? ( + + + + + + {translate('auto.components.tab.bar.TabBar.7a9b4af2af', 'Scroll tabs left')} + + + ) : null} + {/* Why: no strategy stops dnd-kit animating siblings, so tabs stay anchored during drag; only the insertion bar moves. */} + + {/* Why: no-drag lets tab interactions work inside the titlebar's drag region (outer container stays window-draggable). */} +
+
+ {renderedItems} +
+ +
+
+ {tabStripOverflowState.hasOverflow ? ( + + + + + + {translate('auto.components.tab.bar.TabBar.232e075b07', 'Scroll tabs right')} + + + ) : null} + + + + + { + // Why: Radix restores focus to the "+" trigger on close, stealing it from the freshly-mounted terminal. + event.preventDefault() + runPendingNewTabMenuFocusAfterClose() + }} + > + {!terminalOnly && onOpenEntry ? ( + <> + { + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalTab() + }} + onOpenEntry={onOpenEntry} + onQueryChange={setCreateMenuQuery} + onQueueSwitchFocus={queueFocusAfterNewTabMenuClose} + onSelectMenuOption={handleSelectCreateMenuOption} + onDidOpenEntry={() => setNewTabMenuOpen(false)} + /> + {showStaticCreateMenuItems ? : null} + + ) : null} + {showStaticCreateMenuItems ? standardCreateMenuItems : null} + {showStaticCreateMenuItems && showAgentLaunchItems ? ( + <> + + + + ) : null} + + +
+ ) +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts new file mode 100644 index 00000000000..b691359d1b4 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts @@ -0,0 +1,64 @@ +import type { BuiltInWindowsTerminalShell } from '../../../../shared/windows-terminal-shell' +import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { translate } from '@/i18n/i18n' +import type { getProjectRuntimeShellMenuMode } from './use-tab-bar-runtime-model' + +export type WindowsShellMenuEntry = { + label: string + shell: BuiltInWindowsTerminalShell +} + +export function buildWindowsShellMenuEntries({ + showWindowsShellMenu, + hasShellLauncher, + projectRuntimeShellMenuMode, + defaultWindowsShell, + gitBashAvailable, + wslAvailable +}: { + showWindowsShellMenu: boolean + hasShellLauncher: boolean + projectRuntimeShellMenuMode: ReturnType + defaultWindowsShell: string + gitBashAvailable: boolean + wslAvailable: boolean +}): WindowsShellMenuEntry[] | undefined { + if (!showWindowsShellMenu || !hasShellLauncher) { + return undefined + } + const includeHostShells = projectRuntimeShellMenuMode !== 'wsl' + const includeWslShell = projectRuntimeShellMenuMode !== 'host' + const allShells: WindowsShellMenuEntry[] = [] + if (includeHostShells) { + allShells.push( + { + label: translate('auto.components.tab.bar.TabBar.2148f65e04', 'PowerShell'), + shell: 'powershell.exe' + }, + { + label: translate('auto.components.tab.bar.TabBar.1a8af49530', 'CMD Prompt'), + shell: 'cmd.exe' + } + ) + if (gitBashAvailable) { + allShells.push({ + label: translate('auto.components.tab.bar.TabBar.efb33546ff', 'Git Bash'), + shell: WINDOWS_GIT_BASH_SHELL + }) + } + } + if (includeWslShell && wslAvailable) { + allShells.push({ + label: translate('auto.components.tab.bar.TabBar.d1afac112b', 'WSL'), + shell: 'wsl.exe' + }) + } + if (allShells.length === 0) { + return undefined + } + const defaultEntry = + allShells.find((shell) => shell.shell === defaultWindowsShell) ?? allShells[0] + return [defaultEntry, ...allShells.filter((shell) => shell.shell !== defaultEntry.shell)].map( + (entry) => ({ label: entry.label, shell: entry.shell }) + ) +} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts new file mode 100644 index 00000000000..be4a56788db --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -0,0 +1,289 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import type { TuiAgent } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' +import { useAppStore } from '../../store' +import type { TabAgentLaunchOption } from './tab-agent-launch-options' +import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' +import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' +import { + buildWindowsShellMenuEntries, + type WindowsShellMenuEntry +} from './tab-bar-windows-shell-options' +import type { + getProjectRuntimeShellMenuMode, + resolveWindowsPowerShellImplementationSetting +} from './use-tab-bar-runtime-model' + +const NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS = 50 +const NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS = 5000 + +export type TabBarCreateMenuController = { + newTabMenuOpen: boolean + setNewTabMenuOpen: (open: boolean) => void + setCreateMenuQuery: (query: string) => void + createMenuOptions: TabCreateMenuOption[] + windowsShellEntries: WindowsShellMenuEntry[] | undefined + handleSelectCreateMenuOption: (option: TabCreateMenuOption) => void + launchAgentFromNewTabEntry: (agent: TuiAgent) => void + runPendingNewTabMenuFocusAfterClose: () => void + clearPendingNewTabMenuFocusOnUnmount: (node: HTMLDivElement | null) => void + queueNewActiveTerminalFocusAfterNewTabMenuClose: () => void + queueTerminalTabFocusAfterNewTabMenuClose: (tabId: string) => void + queueFocusAfterNewTabMenuClose: (focus: () => void) => void + showStaticCreateMenuItems: boolean +} + +export function useTabBarCreateMenuController({ + worktreeId, + resolvedGroupId, + terminalOnly, + mobileEmulatorEnabled, + workspaceHasSimulatorTab, + showWindowsShellMenu, + projectRuntimeShellMenuMode, + defaultWindowsShell, + defaultWindowsPowerShellImplementation, + windowsTerminalCapabilities, + agentLaunchOptions, + onNewTerminalTab, + onNewTerminalWithShell, + onNewBrowserTab, + onNewSimulatorTab, + onNewFileTab, + onOpenFileTab +}: { + worktreeId: string + resolvedGroupId: string + terminalOnly: boolean + mobileEmulatorEnabled: boolean + workspaceHasSimulatorTab: boolean + showWindowsShellMenu: boolean + projectRuntimeShellMenuMode: ReturnType + defaultWindowsShell: string + defaultWindowsPowerShellImplementation: ReturnType< + typeof resolveWindowsPowerShellImplementationSetting + > + windowsTerminalCapabilities: WindowsTerminalCapabilities + agentLaunchOptions: TabAgentLaunchOption[] + onNewTerminalTab: () => void + onNewTerminalWithShell?: (shell: string) => void + onNewBrowserTab: () => void + onNewSimulatorTab?: () => void + onNewFileTab?: () => void + onOpenFileTab?: () => void +}): TabBarCreateMenuController { + // Why: clicks are out-of-process, so Radix's document-pointerdown outside-click check misses them; use window blur. + const [newTabMenuOpen, setNewTabMenuOpen] = useState(false) + const [createMenuQuery, setCreateMenuQuery] = useState('') + const pendingNewTabMenuFocusRef = useRef<(() => void) | null>(null) + const pendingNewTabMenuFocusAnimationRef = useRef(null) + const pendingNewTabMenuFocusRetryRef = useRef(null) + const clearPendingNewTabMenuFocusAnimation = (): void => { + if (pendingNewTabMenuFocusAnimationRef.current === null) { + return + } + cancelAnimationFrame(pendingNewTabMenuFocusAnimationRef.current) + pendingNewTabMenuFocusAnimationRef.current = null + } + const clearPendingNewTabMenuFocusRetry = (): void => { + if (pendingNewTabMenuFocusRetryRef.current === null) { + return + } + window.clearTimeout(pendingNewTabMenuFocusRetryRef.current) + pendingNewTabMenuFocusRetryRef.current = null + } + const focusNewActiveTerminalWhenReady = ( + previousActiveTabId: string | null, + expiresAt: number + ): void => { + const state = useAppStore.getState() + if ( + (state.activeTabType === 'terminal' || state.activeTabType === 'simulator') && + state.activeTabId && + state.activeTabId !== previousActiveTabId + ) { + focusTerminalTabSurface(state.activeTabId) + return + } + if (Date.now() >= expiresAt) { + return + } + pendingNewTabMenuFocusRetryRef.current = window.setTimeout(() => { + pendingNewTabMenuFocusRetryRef.current = null + focusNewActiveTerminalWhenReady(previousActiveTabId, expiresAt) + }, NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS) + } + const queueNewActiveTerminalFocusAfterNewTabMenuClose = (): void => { + const previousActiveTabId = useAppStore.getState().activeTabId + pendingNewTabMenuFocusRef.current = () => { + // Why: paired web/SSH tab creation is async; await the host snapshot's new terminal instead of the pre-existing active tab. + focusNewActiveTerminalWhenReady( + previousActiveTabId, + Date.now() + NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS + ) + } + } + const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => { + pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId) + } + const queueFocusAfterNewTabMenuClose = (focus: () => void): void => { + pendingNewTabMenuFocusRef.current = focus + } + const windowsShellEntries = useMemo(() => { + return buildWindowsShellMenuEntries({ + showWindowsShellMenu, + hasShellLauncher: Boolean(onNewTerminalWithShell), + projectRuntimeShellMenuMode, + defaultWindowsShell, + gitBashAvailable: windowsTerminalCapabilities.gitBashAvailable, + wslAvailable: windowsTerminalCapabilities.wslAvailable + }) + }, [ + defaultWindowsShell, + onNewTerminalWithShell, + projectRuntimeShellMenuMode, + showWindowsShellMenu, + windowsTerminalCapabilities.gitBashAvailable, + windowsTerminalCapabilities.wslAvailable + ]) + const createMenuOptions = useMemo( + () => + buildTabCreateMenuOptions({ + terminalOnly, + windowsShellEntries, + hasNewBrowser: !terminalOnly, + hasNewMarkdown: !terminalOnly && Boolean(onNewFileTab), + hasOpenMarkdown: !terminalOnly && Boolean(onOpenFileTab), + hasSimulator: !terminalOnly && mobileEmulatorEnabled && Boolean(onNewSimulatorTab), + simulatorIsGoTo: workspaceHasSimulatorTab + }), + [ + mobileEmulatorEnabled, + onNewFileTab, + onNewSimulatorTab, + onOpenFileTab, + terminalOnly, + windowsShellEntries, + workspaceHasSimulatorTab + ] + ) + const handleSelectCreateMenuOption = (option: TabCreateMenuOption): void => { + switch (option.kind) { + case 'new-terminal': + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalTab() + break + case 'new-terminal-shell': + if (!onNewTerminalWithShell || !option.shell) { + break + } + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalWithShell( + resolveWindowsShellLaunchTarget( + option.shell, + defaultWindowsPowerShellImplementation, + windowsTerminalCapabilities.pwshAvailable + ) + ) + break + case 'new-browser': + onNewBrowserTab() + break + case 'new-markdown': + onNewFileTab?.() + break + case 'open-markdown': + onOpenFileTab?.() + break + case 'new-simulator': + case 'go-to-simulator': + onNewSimulatorTab?.() + break + } + } + const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { + const option = agentLaunchOptions.find((candidate) => candidate.agent === agent) + const result = launchAgentInNewTab({ + agent, + worktreeId, + groupId: resolvedGroupId, + launchSource: 'tab_bar_quick_launch' + }) + if (!result) { + toast.error( + translate( + 'auto.components.tab.bar.TabBar.ab589350e5', + 'Could not build launch command for {{value0}}.', + { value0: option?.label ?? agent } + ) + ) + return + } + if (result.tabId) { + queueTerminalTabFocusAfterNewTabMenuClose(result.tabId) + return + } + queueNewActiveTerminalFocusAfterNewTabMenuClose() + } + const runPendingNewTabMenuFocusAfterClose = (): void => { + const pendingFocus = pendingNewTabMenuFocusRef.current + pendingNewTabMenuFocusRef.current = null + clearPendingNewTabMenuFocusAnimation() + clearPendingNewTabMenuFocusRetry() + if (pendingFocus) { + pendingNewTabMenuFocusAnimationRef.current = requestAnimationFrame(() => { + pendingNewTabMenuFocusAnimationRef.current = null + pendingFocus() + }) + } + } + const clearPendingNewTabMenuFocusOnUnmountRef = useRef< + ((node: HTMLDivElement | null) => void) | null + >(null) + if (clearPendingNewTabMenuFocusOnUnmountRef.current === null) { + clearPendingNewTabMenuFocusOnUnmountRef.current = (node: HTMLDivElement | null): void => { + if (node !== null) { + return + } + // Why: cancel the delayed focus handoff via this root ref cleanup, avoiding an otherwise cleanup-only React Effect. + clearPendingNewTabMenuFocusAnimation() + clearPendingNewTabMenuFocusRetry() + } + } + const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current + + useEffect(() => { + if (!newTabMenuOpen) { + return + } + const dismiss = (): void => setNewTabMenuOpen(false) + window.addEventListener('blur', dismiss) + return () => window.removeEventListener('blur', dismiss) + }, [newTabMenuOpen]) + + useEffect(() => { + if (!newTabMenuOpen) { + setCreateMenuQuery('') + } + }, [newTabMenuOpen]) + + return { + newTabMenuOpen, + setNewTabMenuOpen, + setCreateMenuQuery, + createMenuOptions, + windowsShellEntries, + handleSelectCreateMenuOption, + launchAgentFromNewTabEntry, + runPendingNewTabMenuFocusAfterClose, + clearPendingNewTabMenuFocusOnUnmount, + queueNewActiveTerminalFocusAfterNewTabMenuClose, + queueTerminalTabFocusAfterNewTabMenuClose, + queueFocusAfterNewTabMenuClose, + showStaticCreateMenuItems: createMenuQuery.trim().length === 0 + } +} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts new file mode 100644 index 00000000000..0137c1f7cd7 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts @@ -0,0 +1,139 @@ +import { useMemo } from 'react' +import type { GitFileStatus, Tab } from '../../../../shared/types' +import type { TabBarProps } from './tab-bar-props' +import { + buildOrderedTabItems, + buildTabDropIndicators, + buildTabStripLayoutKey, + findActiveVisibleTabId, + type TabBarItem +} from './tab-bar-item-model' +import type { DropIndicator } from './drop-indicator' + +export type TabBarItemProjection = { + orderedItems: TabBarItem[] + sortableIds: string[] + dropIndicatorByVisibleId: Map + activeVisibleTabId: string | null + tabStripLayoutKey: string +} + +export function useTabBarItemProjection({ + props, + resolvedGroupId, + unifiedTabs, + unifiedTabByVisibleId, + generatedTabTitlesEnabled, + statusByRelativePath +}: { + props: TabBarProps + resolvedGroupId: string + unifiedTabs: readonly Tab[] + unifiedTabByVisibleId: Map + generatedTabTitlesEnabled: boolean + statusByRelativePath: Map +}): TabBarItemProjection { + const { + tabs, + editorFiles, + browserTabs, + tabBarOrder, + hoveredTabInsertion, + activeTabId, + activeFileId, + activeBrowserTabId, + activeSimulatorTabId, + activeTabType, + expandedPaneByTabId + } = props + const terminalMap = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs]) + const editorMap = useMemo( + () => new Map((editorFiles ?? []).map((file) => [file.tabId ?? file.id, file])), + [editorFiles] + ) + const browserMap = useMemo( + () => new Map((browserTabs ?? []).map((tab) => [tab.id, tab])), + [browserTabs] + ) + const terminalIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]) + const editorFileIds = useMemo( + () => editorFiles?.map((file) => file.tabId ?? file.id) ?? [], + [editorFiles] + ) + const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs]) + const simulatorTabIds = useMemo( + () => + unifiedTabs + .filter((tab) => tab.groupId === resolvedGroupId && tab.contentType === 'simulator') + .map((tab) => tab.id), + [unifiedTabs, resolvedGroupId] + ) + const orderedItems = useMemo( + () => + buildOrderedTabItems({ + tabBarOrder, + terminalIds, + editorFileIds, + browserTabIds, + simulatorTabIds, + terminalMap, + editorMap, + browserMap, + unifiedTabByVisibleId + }), + [ + tabBarOrder, + terminalIds, + editorFileIds, + browserTabIds, + simulatorTabIds, + terminalMap, + editorMap, + browserMap, + unifiedTabByVisibleId + ] + ) + const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems]) + const activeIndicator = + hoveredTabInsertion?.groupId === resolvedGroupId ? hoveredTabInsertion : null + const dropIndicatorByVisibleId = useMemo( + () => buildTabDropIndicators(orderedItems, activeIndicator), + [activeIndicator, orderedItems] + ) + const activeVisibleTabId = useMemo( + () => + findActiveVisibleTabId(orderedItems, { + activeTabId, + activeFileId, + activeBrowserTabId, + activeSimulatorTabId, + activeTabType + }), + [ + activeBrowserTabId, + activeFileId, + activeSimulatorTabId, + activeTabId, + activeTabType, + orderedItems + ] + ) + const tabStripLayoutKey = useMemo( + () => + buildTabStripLayoutKey( + orderedItems, + generatedTabTitlesEnabled, + expandedPaneByTabId, + statusByRelativePath + ), + [expandedPaneByTabId, generatedTabTitlesEnabled, orderedItems, statusByRelativePath] + ) + + return { + orderedItems, + sortableIds, + dropIndicatorByVisibleId, + activeVisibleTabId, + tabStripLayoutKey + } +} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts new file mode 100644 index 00000000000..e56534cc3a1 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -0,0 +1,261 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { GitFileStatus, GlobalSettings, Tab, TuiAgent } from '../../../../shared/types' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import { useAppStore } from '../../store' +import { buildStatusMap } from '../right-sidebar/status-display' +import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' +import { + getWindowsTerminalCapabilityOwnerKey, + useWindowsTerminalCapabilities, + type WindowsTerminalCapabilities +} from '@/lib/windows-terminal-capabilities' +import { shouldShowMobileEmulatorTabIntro } from '../emulator-pane/mobile-emulator-tab-intro-visibility' +import { + selectTabBarAgentProjections, + type TabBarAgentProjections +} from './tab-agent-types-by-tab-id' +import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options' +import type { TabAgentLaunchOption } from './tab-agent-launch-options' +import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' +import { createUnifiedTabLookup } from './tab-bar-item-model' + +const isWindows = navigator.userAgent.includes('Windows') +export const isMacOs = navigator.userAgent.includes('Mac') +type AppStoreState = ReturnType +type GitStatusEntries = AppStoreState['gitStatusByWorktree'][string] +const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] +const EMPTY_AGENT_CMD_OVERRIDES: Partial> = {} +const EMPTY_UNIFIED_TABS: readonly Tab[] = [] + +export function getProjectRuntimeShellMenuMode( + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): 'host' | 'wsl' | null { + if (!projectRuntime) { + return null + } + if (projectRuntime.status === 'repair-required') { + return 'wsl' + } + return projectRuntime.runtime.kind === 'wsl' ? 'wsl' : 'host' +} + +export function resolveWindowsPowerShellImplementationSetting(settings: GlobalSettings | null) { + return settings?.terminalWindowsPowerShellImplementation ?? 'auto' +} + +export type TabBarRuntimeModel = { + newTerminalShortcut: string + newBrowserShortcut: string + newSimulatorShortcut: string + newFileShortcut: string + openMarkdownShortcut: string | null + generatedTabTitlesEnabled: boolean + mobileEmulatorEnabled: boolean + showMobileEmulatorIntroCallout: boolean + unifiedTabs: readonly Tab[] + pinTab: (tabId: string) => void + unpinTab: (tabId: string) => void + defaultWindowsShell: string + defaultWindowsPowerShellImplementation: ReturnType< + typeof resolveWindowsPowerShellImplementationSetting + > + agentLaunchOptions: TabAgentLaunchOption[] + windowsTerminalCapabilities: WindowsTerminalCapabilities + showWindowsShellMenu: boolean + projectRuntimeShellMenuMode: ReturnType + resolvedGroupId: string + statusByRelativePath: Map + unifiedTabByVisibleId: Map + workspaceHasSimulatorTab: boolean + toggleTabViewMode: (tabId: string) => void + nativeChatTranscriptIsLocalReadable: boolean +} & TabBarAgentProjections + +export function useTabBarRuntimeModel({ + worktreeId, + groupId +}: { + worktreeId: string + groupId?: string +}): TabBarRuntimeModel { + const newTerminalShortcut = useShortcutLabel('tab.newTerminal') + const newBrowserShortcut = useShortcutLabel('tab.newBrowser') + const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') + const newFileShortcut = useShortcutLabel('tab.newMarkdown') + const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') + const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) + const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const mobileEmulatorTabIntroDismissed = useAppStore((s) => s.mobileEmulatorTabIntroDismissed) + const showMobileEmulatorIntroCallout = shouldShowMobileEmulatorTabIntro({ + persistedUIReady, + mobileEmulatorTabIntroDismissed, + mobileEmulatorEnabled, + isMacOs + }) + const gitStatusEntries = useAppStore( + (s) => s.gitStatusByWorktree[worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES + ) + const unifiedTabs = useAppStore((s) => s.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS) + const pinTab = useAppStore((s) => s.pinTab) + const unpinTab = useAppStore((s) => s.unpinTab) + const activeGroupIdForWorktree = useAppStore((s) => s.activeGroupIdByWorktree[worktreeId]) + const defaultWindowsShell = useAppStore( + (s) => s.settings?.terminalWindowsShell ?? 'powershell.exe' + ) + const defaultWindowsPowerShellImplementation = useAppStore((s) => + resolveWindowsPowerShellImplementationSetting(s.settings) + ) + const activeRepoId = useAppStore((s) => s.activeRepoId) + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const projects = useAppStore((s) => s.projects) + const repos = useAppStore((s) => s.repos) + const settings = useAppStore((s) => s.settings) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + // Why: use the worktree's owning host so offered Windows shells match the host that actually runs the terminal. + const activeRuntimeEnvironmentId = useAppStore( + (s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null + ) + // Why: retained tab strips rerun selectors on every store write; reuse canonical indexes, don't flatten both slices here. + const worktreeConnectionId = useAppStore( + (s) => getConnectionIdFromState(s, worktreeId)?.trim() || null + ) + const worktreeRemotePlatform = useAppStore((s) => { + if (!worktreeConnectionId) { + return null + } + return s.sshConnectionStates.get(worktreeConnectionId)?.remotePlatform ?? null + }) + const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) + const agentCmdOverrides = useAppStore( + (s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES + ) + const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) + const { detectedIds } = useDetectedAgents(agentDetectionTarget) + const agentLaunchOptions = useMemo( + () => + buildTabAgentLaunchOptions( + orderTabLaunchAgents(defaultAgent, detectedIds ?? []), + agentCmdOverrides + ), + [agentCmdOverrides, defaultAgent, detectedIds] + ) + const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true + const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey( + activeRuntimeEnvironmentId, + worktreeConnectionId + ) + const runtimeTarget = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), + [activeRuntimeEnvironmentId] + ) + const shouldProbeWindowsShellCapabilities = + isWindows || + Boolean(activeRuntimeEnvironmentId?.trim()) || + isWebClient || + Boolean(worktreeConnectionId) + const windowsTerminalCapabilities = useWindowsTerminalCapabilities( + shouldProbeWindowsShellCapabilities, + false, + windowsTerminalCapabilityOwnerKey, + runtimeTarget, + worktreeConnectionId + ) + const shellMenuHostPlatform = worktreeConnectionId + ? (worktreeRemotePlatform ?? windowsTerminalCapabilities.hostPlatform) + : windowsTerminalCapabilities.hostPlatform + const showWindowsShellMenu = shouldShowWindowsShellMenu({ + activeRuntimeEnvironmentId, + hostPlatform: shellMenuHostPlatform, + isWindowsClient: isWindows, + worktreeHasRemoteConnection: Boolean(worktreeConnectionId) + }) + const localProjectRuntime = useMemo(() => { + if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim() || worktreeConnectionId) { + return undefined + } + return getLocalProjectExecutionRuntimeContext( + { activeRepoId, activeWorktreeId, projects, repos, settings, worktreesByRepo }, + worktreeId, + 'win32', + { + wslAvailable: windowsTerminalCapabilities.isLoading + ? undefined + : windowsTerminalCapabilities.wslAvailable, + availableWslDistros: windowsTerminalCapabilities.isLoading + ? null + : windowsTerminalCapabilities.wslDistros + } + ) + }, [ + activeRepoId, + activeRuntimeEnvironmentId, + activeWorktreeId, + projects, + repos, + settings, + showWindowsShellMenu, + worktreeConnectionId, + windowsTerminalCapabilities.isLoading, + windowsTerminalCapabilities.wslAvailable, + windowsTerminalCapabilities.wslDistros, + worktreeId, + worktreesByRepo + ]) + const projectRuntimeShellMenuMode = getProjectRuntimeShellMenuMode(localProjectRuntime) + const resolvedGroupId = groupId ?? activeGroupIdForWorktree ?? worktreeId + const statusByRelativePath = useMemo(() => buildStatusMap(gitStatusEntries), [gitStatusEntries]) + const unifiedTabByVisibleId = useMemo( + () => createUnifiedTabLookup(unifiedTabs, resolvedGroupId), + [resolvedGroupId, unifiedTabs] + ) + const workspaceHasSimulatorTab = useMemo( + () => unifiedTabs.some((tab) => tab.contentType === 'simulator'), + [unifiedTabs] + ) + // Why: tab-wide launch/title hints are safe only before split; gate the view-mode toggle to the active leaf's agent. + const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode) + // Why: every retained TabBar observes the same hot maps; one feature-gated selector shares their projections. + const { nativeChatEnabled, tabAgentTypesByTabId, nativeChatTabWideFallbackUnsafeTabsById } = + useAppStore(useShallow(selectTabBarAgentProjections)) + const nativeChatTranscriptIsLocalReadable = useAppStore((s) => + isNativeChatTranscriptLocalReadable(getConnectionIdFromState(s, worktreeId)) + ) + + return { + newTerminalShortcut, + newBrowserShortcut, + newSimulatorShortcut, + newFileShortcut, + openMarkdownShortcut, + generatedTabTitlesEnabled, + mobileEmulatorEnabled, + showMobileEmulatorIntroCallout, + unifiedTabs, + pinTab, + unpinTab, + defaultWindowsShell, + defaultWindowsPowerShellImplementation, + agentLaunchOptions, + windowsTerminalCapabilities, + showWindowsShellMenu, + projectRuntimeShellMenuMode, + resolvedGroupId, + statusByRelativePath, + unifiedTabByVisibleId, + workspaceHasSimulatorTab, + toggleTabViewMode, + nativeChatEnabled, + tabAgentTypesByTabId, + nativeChatTabWideFallbackUnsafeTabsById, + nativeChatTranscriptIsLocalReadable + } +} From 7250fde148d4e00294d23f83928c73dda8138763 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:47:32 -0700 Subject: [PATCH 04/39] fix(codex): repair hook trust before restored shells reconnect (#13745) * fix(codex): reconcile hooks for restored shells * docs: move restored shell validation report * perf(codex): gate retained pane inventory --- ...tored-shell-codex-hook-trust-validation.md | 83 +++++++++++++++++++ .../runtime-home-service.test.ts | 38 +++++++++ .../codex-accounts/runtime-home-service.ts | 35 ++++++++ src/main/codex/codex-pane-account-registry.ts | 12 +++ .../codex/codex-stale-pane-accounts.test.ts | 35 ++++++++ .../codex/retained-codex-hook-state.test.ts | 45 ++++++++++ src/main/codex/retained-codex-hook-state.ts | 26 ++++++ src/main/index.ts | 14 +++- .../startup/desktop-startup-ordering.test.ts | 15 +++- 9 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 docs/reference/restored-shell-codex-hook-trust-validation.md create mode 100644 src/main/codex/retained-codex-hook-state.test.ts create mode 100644 src/main/codex/retained-codex-hook-state.ts diff --git a/docs/reference/restored-shell-codex-hook-trust-validation.md b/docs/reference/restored-shell-codex-hook-trust-validation.md new file mode 100644 index 00000000000..b4a1621e78b --- /dev/null +++ b/docs/reference/restored-shell-codex-hook-trust-validation.md @@ -0,0 +1,83 @@ +# Restored-shell Codex hook-trust validation + +## Root cause + +Restored daemon shells became interactive after the daemon and hook server started, while managed Codex hook reconciliation remained an unawaited background task. A retained shell could therefore launch manually typed `codex` against its launch-time managed home before Orca refreshed that home's hook definitions and trusted hashes. The background repair then made persisted state and later `hooks/list` checks look healthy. + +The retained shell's launch-time home is independent of the currently selected Codex routing lane. Startup now inventories authoritative live daemon PTYs on every retained-shell startup, resolves only registered Orca-managed host homes, and reconciles those homes before the startup gate completes. + +## Security boundary + +- Shared and legacy managed homes resolve only to Orca's private runtime home. +- Per-account homes pass existing Orca ownership validation. +- Real-home, WSL, SSH/non-host, unknown, missing-account, and unowned paths are skipped. +- Enabled settings reinstall/repair only Orca-managed hooks. +- Disabled settings remove only Orca-managed entries while preserving user hooks. + +## Electron/CDP reproduction + +Build: + +```bash +pnpm run build:electron-vite +``` + +Isolated launch (the shortened profile path avoids the Unix-socket path limit): + +```bash +/usr/bin/env -u ELECTRON_RUN_AS_NODE -u CODEX_HOME -u ORCA_CODEX_HOME \ + PATH='/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' \ + HOME='/tmp/orca-rce-060679/home' \ + USERPROFILE='/tmp/orca-rce-060679/home' \ + ORCA_E2E_USER_DATA_DIR='/tmp/orca-rce-060679' \ + ORCA_E2E_HOME_DIR='/tmp/orca-rce-060679/home' \ + ORCA_E2E_HEADFUL=1 NODE_ENV=development \ + '/node_modules/.pnpm/electron@43.1.0/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron' \ + out/main/index.js --remote-debugging-port=9460 +``` + +Validation used `agent-browser --cdp 9460` only. A real floating-shell PTY was created, the Electron main process was terminated without stopping the daemon, its pane registry retained the managed shared-home route, and an Orca lifecycle-hook `trusted_hash` was made stale. A deterministic Codex fixture was added only after startup so it reported the same review/ready state from the managed config without allowing generic startup CLI detection to hide the race. + +Local capture filenames (uploaded to the PR as GitHub attachments; intentionally not committed): + +- Before: `restored-codex-hook-review-before.png` +- After: `restored-codex-hook-trusted-after.png` +- Fresh pane: `fresh-codex-hook-trusted.png` +- Disabled hooks after retained restart: `restored-codex-hooks-disabled.png` + +The pre-fix restart retained the stale hash and showed `Hooks need review`. The fixed restart repaired it before `awaitFirstWindowStartupServices()` resolved and the same retained PTY showed trusted/ready. A fresh pane reached normal Codex onboarding without a hook-review prompt. With `agentStatusHooksEnabled: false`, a retained restart left `hooks.json` as `{ "hooks": {} }` and `config.toml` with an empty `[hooks.state]`. + +## Automated validation + +```bash +pnpm exec vitest run \ + src/main/codex/retained-codex-hook-state.test.ts \ + src/main/startup/desktop-startup-ordering.test.ts \ + src/main/codex-accounts/runtime-home-service.test.ts + +pnpm run typecheck:node + +pnpm exec oxfmt --check \ + src/main/index.ts \ + src/main/codex-accounts/runtime-home-service.ts \ + src/main/codex-accounts/runtime-home-service.test.ts \ + src/main/codex/retained-codex-hook-state.ts \ + src/main/codex/retained-codex-hook-state.test.ts \ + src/main/startup/desktop-startup-ordering.test.ts + +pnpm exec oxlint \ + src/main/index.ts \ + src/main/codex-accounts/runtime-home-service.ts \ + src/main/codex-accounts/runtime-home-service.test.ts \ + src/main/codex/retained-codex-hook-state.ts \ + src/main/codex/retained-codex-hook-state.test.ts \ + src/main/startup/desktop-startup-ordering.test.ts + +git diff --check +``` + +Results: 113 focused tests passed; node typecheck, formatting, direct oxlint, diff check, and Electron build passed. The aggregate changed-code script was not used because Node 26's unsupported-engine warning breaks its JSON parser; direct oxlint covered all changed source files. + +## Remaining risk + +The repair deliberately covers only host-side Orca-managed runtime homes recorded for live PTYs. Folder workspaces use the same verified host-home path; real-home, WSL, SSH, and arbitrary user or repository hook paths are not auto-trusted, and their existing launch-specific flows remain unchanged. diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 15a48821501..966d901f994 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1206,6 +1206,44 @@ describe('CodexRuntimeHomeService', () => { } }) + it('resolves only Orca-owned homes used by live retained host shells', async () => { + const accountHome = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + ) + const unownedHome = join(testState.fakeHomeDir, 'unowned-codex-home') + mkdirSync(unownedHome, { recursive: true }) + writeFileSync(join(unownedHome, '.orca-managed-home'), 'account-2\n', 'utf-8') + writePaneRegistry({ + 'shared-pane': { selectionKey: 'host', accountId: null, homeRoute: 'shared-home' }, + 'account-pane': { selectionKey: 'host', accountId: 'account-1', homeRoute: 'account-home' }, + 'unowned-pane': { selectionKey: 'host', accountId: 'account-2', homeRoute: 'account-home' }, + 'real-pane': { selectionKey: 'host', accountId: null, homeRoute: 'real-home' }, + 'wsl-pane': { selectionKey: 'wsl:Ubuntu', accountId: null, homeRoute: 'wsl-home' } + }) + const settings = createSettings({ + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'managed@example.com', 'acct-managed', accountHome), + createCodexAccountRecord('account-2', 'other@example.com', 'acct-other', unownedHome) + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect( + service.getRetainedHostCodexHookHomePaths([ + 'shared-pane', + 'account-pane', + 'unowned-pane', + 'real-pane', + 'wsl-pane', + 'unknown-pane' + ]) + ).toEqual([getRuntimeCodexHomePath(), accountHome]) + }) + it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => { const oldSystemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-system') const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 68d054f3571..2d4b8239aad 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -83,6 +83,7 @@ import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-mi import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' import { + getCodexPaneAccount, hasRecordedLegacySharedCodexPane, type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' @@ -545,6 +546,40 @@ export class CodexRuntimeHomeService { return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' } + getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { + const settings = this.store.getSettings() + const homes = new Map() + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (!record || record.selectionKey !== 'host') { + continue + } + if ( + record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' + ) { + const homePath = this.getRuntimeHomePath() + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + continue + } + if (record.homeRoute !== 'account-home' || !record.accountId) { + continue + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === record.accountId + ) + if (!account || this.getWslManagedHomePath(account)) { + continue + } + const homePath = this.getTrustedSelfContainedManagedHomePath(account) + if (homePath) { + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + } + } + return [...homes.values()] + } + // Why: the real-home hook installer flips this gate off when the trust-grant // client reports the host incapable, keeping that host byte-identical to the // managed lane instead of shipping status-blind panes. diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 640dcd17506..359bc3e9e8f 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -256,6 +256,18 @@ export function hasRecordedLegacySharedCodexPane(): boolean { ) } +/** True when startup may need to repair hooks for a retained managed host pane. */ +export function hasRecordedManagedHostCodexPane(): boolean { + return Object.values(readRegistry().panes).some( + (record) => + record.selectionKey === 'host' && + (record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' || + (record.homeRoute === 'account-home' && record.accountId !== null)) + ) +} + /** Drops records whose daemon PTYs are authoritatively absent. */ export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void { const registry = readRegistry() diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index c5ee4802e5b..720f2101f81 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -8,6 +8,7 @@ import { forgetCodexPaneAccount, getCodexPaneAccount, hasRecordedLegacySharedCodexPane, + hasRecordedManagedHostCodexPane, reconcileCodexPaneAccountsWithLivePtys, recordCodexPaneAccount } from './codex-pane-account-registry' @@ -125,6 +126,40 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('requests startup inventory only for managed host panes', () => { + recordCodexPaneAccount('pty-real', { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + }) + recordCodexPaneAccount('pty-wsl', { + selectionKey: 'wsl:Ubuntu', + accountId: 'account-wsl', + homeRoute: 'account-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-shared', { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(true) + + forgetCodexPaneAccount('pty-shared') + expect(hasRecordedManagedHostCodexPane()).toBe(false) + + recordCodexPaneAccount('pty-account', { + selectionKey: 'host', + accountId: 'account-host', + homeRoute: 'account-home' + }) + + expect(hasRecordedManagedHostCodexPane()).toBe(true) + }) + it('drops leaked records that are absent from the authoritative daemon inventory', () => { recordCodexPaneAccount('pty-live', { selectionKey: 'host', diff --git a/src/main/codex/retained-codex-hook-state.test.ts b/src/main/codex/retained-codex-hook-state.test.ts new file mode 100644 index 00000000000..642b8bc7476 --- /dev/null +++ b/src/main/codex/retained-codex-hook-state.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { reconcileRetainedCodexHookHomes } from './retained-codex-hook-state' + +function status(state: 'installed' | 'not_installed' | 'error'): AgentHookInstallStatus { + return { + agent: 'codex', + state, + configPath: '/runtime/hooks.json', + managedHooksPresent: state === 'installed', + detail: state === 'error' ? 'failed' : null + } +} + +describe('retained Codex hook state', () => { + it('repairs Orca hooks before a retained shell can launch Codex', () => { + const install = vi.fn(() => status('installed')) + const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) + + reconcileRetainedCodexHookHomes({ + hookService: { install, refreshRuntimeUserHooks }, + hooksEnabled: true, + runtimeHomePaths: ['/orca/shared-home', '/orca/account-home'] + }) + + expect(install).toHaveBeenCalledTimes(2) + expect(install).toHaveBeenNthCalledWith(1, '/orca/shared-home') + expect(install).toHaveBeenNthCalledWith(2, '/orca/account-home') + expect(refreshRuntimeUserHooks).not.toHaveBeenCalled() + }) + + it('removes only Orca hooks from retained homes when hooks are disabled', () => { + const install = vi.fn(() => status('installed')) + const refreshRuntimeUserHooks = vi.fn(() => status('not_installed')) + + reconcileRetainedCodexHookHomes({ + hookService: { install, refreshRuntimeUserHooks }, + hooksEnabled: false, + runtimeHomePaths: ['/orca/shared-home'] + }) + + expect(refreshRuntimeUserHooks).toHaveBeenCalledWith('/orca/shared-home') + expect(install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/retained-codex-hook-state.ts b/src/main/codex/retained-codex-hook-state.ts new file mode 100644 index 00000000000..ccdf8c86af3 --- /dev/null +++ b/src/main/codex/retained-codex-hook-state.ts @@ -0,0 +1,26 @@ +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' + +type RetainedCodexHookService = { + install: (runtimeHomePath: string) => AgentHookInstallStatus + refreshRuntimeUserHooks: (runtimeHomePath: string) => AgentHookInstallStatus +} + +export function reconcileRetainedCodexHookHomes(args: { + hookService: RetainedCodexHookService + hooksEnabled: boolean + runtimeHomePaths: readonly string[] +}): void { + for (const runtimeHomePath of args.runtimeHomePaths) { + try { + const status = args.hooksEnabled + ? args.hookService.install(runtimeHomePath) + : args.hookService.refreshRuntimeUserHooks(runtimeHomePath) + if (status.state === 'error') { + console.warn('[codex-hook-service] failed to reconcile retained Codex home', status.detail) + } + } catch (error) { + // Why: a retained home repair is best-effort; daemon availability must not depend on a writable Codex config. + console.warn('[codex-hook-service] failed to reconcile retained Codex home', error) + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 0a4be2562c8..55180cc9ff8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,7 +39,7 @@ import { shutdownDaemon } from './daemon/daemon-init' import { - hasRecordedLegacySharedCodexPane, + hasRecordedManagedHostCodexPane, reconcileCodexPaneAccountsWithLivePtys } from './codex/codex-pane-account-registry' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -216,6 +216,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' +import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state' import { ensureRealHomeCodexHookState, isRealHomeCodexHookLaneUsable @@ -917,10 +918,19 @@ function startTerminalRuntimeStartupServices(): Promise { await initDaemonPtyProvider(signal, { macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode }) - if (codexRuntimeHome?.isHostSystemDefaultRealHome() && hasRecordedLegacySharedCodexPane()) { + // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. + if (codexRuntimeHome && hasRecordedManagedHostCodexPane()) { const livePtyIds = await listLiveDaemonPtyIds() if (livePtyIds) { reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + const settings = store?.getSettings() + reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }) } } // Why: retained shells can invoke Codex immediately after the startup gate. diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 001bc5252d1..376e6a48814 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -98,19 +98,26 @@ describe('startup ordering', () => { it('reconciles retained Codex homes after authoritative daemon inventory', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') - const routeGateIndex = source.indexOf( - 'codexRuntimeHome?.isHostSystemDefaultRealHome()', + const retainedPaneGateIndex = source.indexOf( + 'hasRecordedManagedHostCodexPane()', daemonInitIndex ) const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) + const hookReconciliationIndex = source.indexOf( + 'reconcileRetainedCodexHookHomes({', + inventoryIndex + ) const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex) expect(daemonInitIndex).toBeGreaterThanOrEqual(0) - expect(routeGateIndex).toBeGreaterThan(daemonInitIndex) - expect(inventoryIndex).toBeGreaterThan(routeGateIndex) + expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(retainedPaneGateIndex) + expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) + expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) expect(serveIndex).toBeGreaterThan(reconciliationIndex) expect(desktopIndex).toBeGreaterThan(serveIndex) From 4dcb4109828d6c11c98c5642b38f80cad9651358 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:49:38 -0700 Subject: [PATCH 05/39] docs: remove restored shell validation report (#13759) --- ...tored-shell-codex-hook-trust-validation.md | 83 ------------------- 1 file changed, 83 deletions(-) delete mode 100644 docs/reference/restored-shell-codex-hook-trust-validation.md diff --git a/docs/reference/restored-shell-codex-hook-trust-validation.md b/docs/reference/restored-shell-codex-hook-trust-validation.md deleted file mode 100644 index b4a1621e78b..00000000000 --- a/docs/reference/restored-shell-codex-hook-trust-validation.md +++ /dev/null @@ -1,83 +0,0 @@ -# Restored-shell Codex hook-trust validation - -## Root cause - -Restored daemon shells became interactive after the daemon and hook server started, while managed Codex hook reconciliation remained an unawaited background task. A retained shell could therefore launch manually typed `codex` against its launch-time managed home before Orca refreshed that home's hook definitions and trusted hashes. The background repair then made persisted state and later `hooks/list` checks look healthy. - -The retained shell's launch-time home is independent of the currently selected Codex routing lane. Startup now inventories authoritative live daemon PTYs on every retained-shell startup, resolves only registered Orca-managed host homes, and reconciles those homes before the startup gate completes. - -## Security boundary - -- Shared and legacy managed homes resolve only to Orca's private runtime home. -- Per-account homes pass existing Orca ownership validation. -- Real-home, WSL, SSH/non-host, unknown, missing-account, and unowned paths are skipped. -- Enabled settings reinstall/repair only Orca-managed hooks. -- Disabled settings remove only Orca-managed entries while preserving user hooks. - -## Electron/CDP reproduction - -Build: - -```bash -pnpm run build:electron-vite -``` - -Isolated launch (the shortened profile path avoids the Unix-socket path limit): - -```bash -/usr/bin/env -u ELECTRON_RUN_AS_NODE -u CODEX_HOME -u ORCA_CODEX_HOME \ - PATH='/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' \ - HOME='/tmp/orca-rce-060679/home' \ - USERPROFILE='/tmp/orca-rce-060679/home' \ - ORCA_E2E_USER_DATA_DIR='/tmp/orca-rce-060679' \ - ORCA_E2E_HOME_DIR='/tmp/orca-rce-060679/home' \ - ORCA_E2E_HEADFUL=1 NODE_ENV=development \ - '/node_modules/.pnpm/electron@43.1.0/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron' \ - out/main/index.js --remote-debugging-port=9460 -``` - -Validation used `agent-browser --cdp 9460` only. A real floating-shell PTY was created, the Electron main process was terminated without stopping the daemon, its pane registry retained the managed shared-home route, and an Orca lifecycle-hook `trusted_hash` was made stale. A deterministic Codex fixture was added only after startup so it reported the same review/ready state from the managed config without allowing generic startup CLI detection to hide the race. - -Local capture filenames (uploaded to the PR as GitHub attachments; intentionally not committed): - -- Before: `restored-codex-hook-review-before.png` -- After: `restored-codex-hook-trusted-after.png` -- Fresh pane: `fresh-codex-hook-trusted.png` -- Disabled hooks after retained restart: `restored-codex-hooks-disabled.png` - -The pre-fix restart retained the stale hash and showed `Hooks need review`. The fixed restart repaired it before `awaitFirstWindowStartupServices()` resolved and the same retained PTY showed trusted/ready. A fresh pane reached normal Codex onboarding without a hook-review prompt. With `agentStatusHooksEnabled: false`, a retained restart left `hooks.json` as `{ "hooks": {} }` and `config.toml` with an empty `[hooks.state]`. - -## Automated validation - -```bash -pnpm exec vitest run \ - src/main/codex/retained-codex-hook-state.test.ts \ - src/main/startup/desktop-startup-ordering.test.ts \ - src/main/codex-accounts/runtime-home-service.test.ts - -pnpm run typecheck:node - -pnpm exec oxfmt --check \ - src/main/index.ts \ - src/main/codex-accounts/runtime-home-service.ts \ - src/main/codex-accounts/runtime-home-service.test.ts \ - src/main/codex/retained-codex-hook-state.ts \ - src/main/codex/retained-codex-hook-state.test.ts \ - src/main/startup/desktop-startup-ordering.test.ts - -pnpm exec oxlint \ - src/main/index.ts \ - src/main/codex-accounts/runtime-home-service.ts \ - src/main/codex-accounts/runtime-home-service.test.ts \ - src/main/codex/retained-codex-hook-state.ts \ - src/main/codex/retained-codex-hook-state.test.ts \ - src/main/startup/desktop-startup-ordering.test.ts - -git diff --check -``` - -Results: 113 focused tests passed; node typecheck, formatting, direct oxlint, diff check, and Electron build passed. The aggregate changed-code script was not used because Node 26's unsupported-engine warning breaks its JSON parser; direct oxlint covered all changed source files. - -## Remaining risk - -The repair deliberately covers only host-side Orca-managed runtime homes recorded for live PTYs. Folder workspaces use the same verified host-home path; real-home, WSL, SSH, and arbitrary user or repository hook paths are not auto-trusted, and their existing launch-specific flows remain unchanged. From 915ee50cc221ee682ad03ebdca824c58992dabce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:50:41 -0700 Subject: [PATCH 06/39] fix(ui): rename Caffeinate Auto mode to Agent (#13751) * fix(ui): rename caffeinate auto mode to agent * fix(ui): center caffeinate radio indicator * fix(ui): center caffeinate indicator within option --- docs/assets/caffeinate-agent-menu-after.png | Bin 0 -> 12202 bytes docs/assets/caffeinate-agent-menu-before.png | Bin 0 -> 14078 bytes .../components/settings/AgentAwakeSetting.tsx | 2 +- .../src/components/settings/AgentsPane.test.tsx | 6 +++--- .../src/components/settings/agent-awake-copy.ts | 4 ++-- .../status-bar/CaffeinateStatusSegment.tsx | 8 ++++---- src/renderer/src/i18n/locales/en.json | 8 ++++---- 7 files changed, 14 insertions(+), 14 deletions(-) create mode 100644 docs/assets/caffeinate-agent-menu-after.png create mode 100644 docs/assets/caffeinate-agent-menu-before.png diff --git a/docs/assets/caffeinate-agent-menu-after.png b/docs/assets/caffeinate-agent-menu-after.png new file mode 100644 index 0000000000000000000000000000000000000000..393fbd5c0b154773ab5b2e73ac9f2d765175acad GIT binary patch literal 12202 zcma)?bx@UE*zSQ%$ELfxyQLeHmy(u}PHAb_bT>#U9SV|4cc-M%ok}B}XL-(?Kfjr8 zW)DMb;Ca?_ulxR8*R`TG)ReH$$d(a>$?Tkhj_3g9aSn(uFJH1Kh8gKeIlkzb(Lrtw+~3=Iw4U!O?L z&=43wqF1=a)7FsN1dG-4sQ8xK{q)r1+se};`K?C6F4tPU63uXpcZ-u~G$>q9YwX3v z(4adWzo#VvAM|%csArSej63-aAU_e9=ym*a?84U{j$YA$cJPaSJ1? zhC%A+(veu3$hV(J$l6*Ai|c5Rt`R?+uoi^CLi`X$gVHggy_ZLHHI!$)1A@Bd<}#*x zBe9P}Be6V`m6fCM7@La5eljkpXg4@6oNo{RRSxa!3<^d-n)ji2HI@WCoy%d;jWCB>5`7mJ9=+9YdK?mxq5j;cDAWzqNkYG1eHWM?x?BV% zc)U<#<5JZuGC_L^ac`a$jF&s38P9W_PlN;n9veMKvoGE)i-^FHs(lhC{qEZMZspxt zN5IpA&s*w|51dPNR-<4w(63<@N9W_hYmO@|f@*tx(fDT+6}D4_&2Bp_{ti;?V6tV4YJ25VZ=HtOcoQ6C$l|KD3gari!KP&Vi*Tmi* zN;@dT5Cjuu@maM5KHmM^o8W!i9nWz;aQgX0XLh#AI0trmP~Ax-=}%&lC*-s^UGi#g zEDQf>hnTX4X#7E?#MrcurN2DJioAx@+8{+aFmPu@tVuu1bYHDij*EMD` zYSD3V1S-&kovzlCj{Cn8QboQ0

RvRmInYG`sIvqt*O=)A&wv*tjT#@l~mM-X|FL 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 LntYY4S;+qZ364>U literal 0 HcmV?d00001 diff --git a/docs/assets/caffeinate-agent-menu-before.png b/docs/assets/caffeinate-agent-menu-before.png new file mode 100644 index 0000000000000000000000000000000000000000..7c12d9ca5811e5d0b274ce40767d0ac1316f7bf2 GIT binary patch literal 14078 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 yF0!(+`OPNp*>HSSSu&BNr-k4c-49Z { expect(markup).not.toContain('aria-label="Agent runtime"') expect(markup).toContain('Keep computer awake') expect(markup).toContain( - 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Agent, or Off. Agent mode stays awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) expect(markup).toContain('role="radiogroup"') - expect(markup).toContain('>Auto<') + expect(markup).toContain('>Agent<') }) it('hides desktop-only awake modes in paired web clients', () => { @@ -316,7 +316,7 @@ describe('AgentsPane', () => { it('describes Windows lid behavior according to the device', () => { expect(getAgentAwakeDescription('Windows')).toBe( - "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." + "Choose On, Agent, or Off. Agent mode stays awake while agents are working; lid-close behavior follows this device's power settings." ) }) diff --git a/src/renderer/src/components/settings/agent-awake-copy.ts b/src/renderer/src/components/settings/agent-awake-copy.ts index 1076312ed18..307b8ef125d 100644 --- a/src/renderer/src/components/settings/agent-awake-copy.ts +++ b/src/renderer/src/components/settings/agent-awake-copy.ts @@ -17,13 +17,13 @@ export function getAgentAwakeDescription( if (userAgent.includes('Windows')) { return translate( AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY, - "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." + "Choose On, Agent, or Off. Agent mode stays awake while agents are working; lid-close behavior follows this device's power settings." ) } return translate( AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY, - 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Agent, or Off. Agent mode stays awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) } diff --git a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx index b991e63a096..6bf6daabcad 100644 --- a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx @@ -31,7 +31,7 @@ function modeLabel(mode: ComputerAwakeMode): string { return translate('auto.components.status.bar.CaffeinateStatusSegment.on', 'On') } if (mode === 'auto') { - return translate('auto.components.status.bar.CaffeinateStatusSegment.auto', 'Auto') + return translate('auto.components.status.bar.CaffeinateStatusSegment.auto', 'Agent') } return translate('auto.components.status.bar.CaffeinateStatusSegment.off', 'Off') } @@ -137,7 +137,7 @@ export function CaffeinateStatusSegment({ - + {modeLabel('on')} @@ -148,7 +148,7 @@ export function CaffeinateStatusSegment({ - + {modeLabel('auto')} @@ -159,7 +159,7 @@ export function CaffeinateStatusSegment({ - + {modeLabel('off')} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1888c99acc4..e44b76eabd1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3639,7 +3639,7 @@ }, "CaffeinateStatusSegment": { "on": "On", - "auto": "Auto", + "auto": "Agent", "off": "Off", "active": "Active", "inactive": "Inactive", @@ -9510,8 +9510,8 @@ "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.", "modeTitle": "Keep computer awake", - "modeDescriptionWindows": "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings.", - "modeDescriptionDefault": "Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." + "modeDescriptionWindows": "Choose On, Agent, or Off. Agent mode stays awake while agents are working; lid-close behavior follows this device's power settings.", + "modeDescriptionDefault": "Choose On, Agent, or Off. Agent mode stays awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." }, "agent-status-hooks-copy": { "7707c15abb": "Agent status hooks", @@ -10573,7 +10573,7 @@ }, "AgentAwakeSetting": { "on": "On", - "auto": "Auto", + "auto": "Agent", "off": "Off" } }, From 3ed4dc484f515f0d3998cdb2370a6552802e096f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:51:18 -0700 Subject: [PATCH 07/39] fix(native-chat): scope persisted options to chat launches (#13675) * fix(native-chat): defer to Codex model picker defaults * fix(native-chat): settle Codex picker ownership * fix(native-chat): record typed options after submit * fix(native-chat): verify typed option submission * perf(native-chat): skip option probe for chat sends * perf(native-chat): scope verified sends to Codex * fix(native-chat): require standalone picker command * fix(native-chat): scope launch options to chat view * fix(native-chat): align smart folder launch mode --- .../FloatingTerminalWindowControls.test.tsx | 9 ++ .../FloatingTerminalWindowControls.tsx | 7 -- ...native-chat-launch-session-options.test.ts | 54 ++++++++++++ .../native-chat-launch-session-options.ts | 33 ++++++++ .../onboarding-folder-agent-startup.test.ts | 39 +++++++++ .../buildSourceControlAgentDeliveryPlan.ts | 19 +++-- .../components/sidebar/NonGitFolderDialog.tsx | 4 +- ...poserState-host-context-boundaries.test.ts | 13 +++ src/renderer/src/hooks/useComposerState.ts | 82 +++++++++++++++---- .../src/lib/launch-agent-in-new-tab.test.ts | 71 ++++++++++++++++ .../src/lib/launch-agent-in-new-tab.ts | 37 ++++----- .../lib/launch-work-item-direct-agent.test.ts | 51 +++++++++++- .../src/lib/launch-work-item-direct-agent.ts | 16 ++-- .../src/lib/launch-work-item-direct.ts | 3 + .../lib/onboarding-folder-agent-startup.ts | 15 ++-- src/renderer/src/store/slices/repos.ts | 4 +- 16 files changed, 394 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-launch-session-options.ts diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx index a4be125a2f9..189a77c9b58 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -185,6 +185,13 @@ afterEach(() => { describe('FloatingTerminalWindowControls default-agent launch', () => { it('activates the new agent tab so the floating panel selects and focuses it', () => { + ;( + storeBox.state as { + settings: Record + } + ).settings.nativeChatSessionOptions = { + claude: { model: 'opus', valuesByModel: { opus: { effort: 'high' } } } + } const element = FloatingTerminalWindowControls({ maximized: false, onToggleMaximized: vi.fn(), @@ -194,6 +201,8 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { const launch = findOnClickByAriaLabel(element, 'Open Claude in floating workspace') launch() + expect(mocks.buildAgentStartupPlan.mock.calls[0]?.[0]).not.toHaveProperty('sessionOptions') + expect(mocks.createTab).toHaveBeenCalledWith( FLOATING_TERMINAL_WORKTREE_ID, undefined, diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index b97290541bb..b7150aa7cef 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -20,8 +20,6 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' -import { resolveNativeChatLaunchSessionOptions } from '@/components/native-chat/native-chat-session-option-enrichment' -import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -80,10 +78,6 @@ export function FloatingTerminalWindowControls({ cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(defaultAgent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(defaultAgent, state.settings?.agentDefaultEnv), - sessionOptions: resolveNativeChatLaunchSessionOptions( - state.settings?.nativeChatSessionOptions, - defaultAgent - ), platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true }) @@ -98,7 +92,6 @@ export function FloatingTerminalWindowControls({ return } const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) - seedNativeChatAppliedSessionOptions(tab.id, defaultAgent, startupPlan.sessionOptions) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts new file mode 100644 index 00000000000..551be10ab0c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-launch-session-options.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { resolveInitialNativeChatSessionOptions } from './native-chat-launch-session-options' + +const settings = { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } + } +} + +describe('resolveInitialNativeChatSessionOptions', () => { + it('omits native-chat preferences from terminal-default launches', () => { + expect( + resolveInitialNativeChatSessionOptions( + { ...settings, openAgentTabsInChatByDefault: false }, + { agent: 'codex' } + ) + ).toBeUndefined() + }) + + it('applies native-chat preferences when the launch resolves to chat', () => { + expect(resolveInitialNativeChatSessionOptions(settings, { agent: 'codex' })).toEqual({ + model: 'gpt-5.2-codex', + effort: 'medium' + }) + }) + + it('omits preferences when a draft forces the initial view back to terminal', () => { + expect( + resolveInitialNativeChatSessionOptions(settings, { + agent: 'codex', + promptDelivery: 'draft', + launchDraftText: 'one\u2028two' + }) + ).toBeUndefined() + }) + + it('omits preferences when a remote transcript forces the initial view to terminal', () => { + const grokSettings = { + ...settings, + nativeChatSessionOptions: { grok: { model: 'grok-4.5' } } + } + expect( + resolveInitialNativeChatSessionOptions(grokSettings, { + agent: 'grok', + nativeChatTranscriptIsLocalReadable: false + }) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts b/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts new file mode 100644 index 00000000000..2257bb77120 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-launch-session-options.ts @@ -0,0 +1,33 @@ +import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import { + decideInitialAgentTabViewMode, + type NativeChatLaunchPromptDelivery +} from '@/lib/native-chat-initial-view-mode' +import { resolveNativeChatLaunchSessionOptions } from './native-chat-session-option-enrichment' + +type NativeChatLaunchSettings = Pick< + GlobalSettings, + 'experimentalNativeChat' | 'openAgentTabsInChatByDefault' | 'nativeChatSessionOptions' +> + +export type InitialNativeChatSessionOptionsArgs = { + agent: TuiAgent + promptDelivery?: NativeChatLaunchPromptDelivery + launchDraftText?: string + nativeChatTranscriptIsLocalReadable?: boolean +} + +export function resolveInitialNativeChatSessionOptions( + settings: NativeChatLaunchSettings | null | undefined, + args: InitialNativeChatSessionOptionsArgs +): Record | undefined { + const viewMode = decideInitialAgentTabViewMode({ + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + ...args + }) + return viewMode === 'chat' + ? resolveNativeChatLaunchSessionOptions(settings?.nativeChatSessionOptions, args.agent) + : undefined +} diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 18068817358..fa8a791ace1 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -40,6 +40,45 @@ describe('buildOnboardingFolderAgentStartup', () => { expect(startup).toBeUndefined() }) + it('omits native-chat preferences from terminal-default folder launches', () => { + const startup = buildOnboardingFolderAgentStartup({ + ...getDefaultSettings('/tmp/orca-workspaces'), + defaultTuiAgent: 'codex', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false, + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } + } + }) + + expect(startup?.command).not.toContain("'-m'") + expect(startup?.sessionOptions).toBeUndefined() + }) + + it('applies native-chat preferences to chat-default folder launches', () => { + const startup = buildOnboardingFolderAgentStartup({ + ...getDefaultSettings('/tmp/orca-workspaces'), + defaultTuiAgent: 'codex', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } + } + }) + + expect(startup?.command).toContain("'-m' 'gpt-5.2-codex'") + expect(startup?.sessionOptions).toEqual({ + model: 'gpt-5.2-codex', + effort: 'medium' + }) + }) + it('does not infer an agent from auto mode', () => { const startup = buildOnboardingFolderAgentStartup({ ...getDefaultSettings('/tmp/orca-workspaces'), diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d4edcd5a62f..7093101eeee 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -3,7 +3,7 @@ import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/types' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' -import { resolveNativeChatLaunchSessionOptions } from '@/components/native-chat/native-chat-session-option-enrichment' +import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null @@ -31,21 +31,24 @@ export function buildSourceControlAgentDeliveryPlan({ if (connectionUnavailable) { return buildSourceControlAgentConnectionErrorPlan() } + const settings = useAppStore.getState().settings const result = planSourceControlAgentActionLaunch({ agent: selectedAgent, commandInput, agentArgs, sessionOptions: selectedAgent - ? resolveNativeChatLaunchSessionOptions( - useAppStore.getState().settings?.nativeChatSessionOptions, - selectedAgent - ) + ? resolveInitialNativeChatSessionOptions(settings, { + agent: selectedAgent, + promptDelivery, + launchDraftText: commandInput.trim(), + nativeChatTranscriptIsLocalReadable: !isRemote + }) : undefined, promptDelivery, detectedAgents, - disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, - cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides, - terminalWindowsShell: useAppStore.getState().settings?.terminalWindowsShell, + disabledAgents: settings?.disabledTuiAgents, + cmdOverrides: settings?.agentCmdOverrides, + terminalWindowsShell: settings?.terminalWindowsShell, platform: launchPlatform, isRemote }) diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx index 8cc5ed91d69..20e76e8d2e8 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' @@ -86,7 +87,8 @@ const NonGitFolderDialog = React.memo(function NonGitFolderDialog() { const startup = buildDismissedOnboardingFolderAgentStartup( useAppStore.getState().settings, onboarding, - hadProjectBeforeAdd + hadProjectBeforeAdd, + isNativeChatTranscriptLocalReadable(connectionId) ) activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto', diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 635399b63d8..a7f5aa4d0d1 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -636,6 +636,19 @@ describe('useComposerState host-context boundaries', () => { expect(section).toContain('setCreateError({') }) + it('uses submit-time smart metadata for both folder launch mode and startup content', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const submitFolderTarget', + 'const submit = useCallback' + ) + expect(section).toContain( + 'const submitLinkedWorkItem = smartGitHubMetadata?.linkedWorkItem ?? linkedWorkItem' + ) + expect(section).toContain('resolveFolderWorkspaceLaunchDraft(submitLinkedWorkItem, note)') + expect(section).toContain('linkedWorkItem: submitLinkedWorkItem') + }) + it('gates every submit path on the derived source intent', () => { // Why: derived from name+mode, so the submitted name and the gate can never disagree. expect(HOOK_SOURCE).toContain( diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 35f403c8e09..b5cdcd01503 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -23,7 +23,7 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agen import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' -import { resolveNativeChatLaunchSessionOptions } from '@/components/native-chat/native-chat-session-option-enrichment' +import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { resolveTuiAgentLaunchArgs, @@ -146,7 +146,11 @@ import { toLinearLinkedWorkItem } from '@/components/sidebar/folder-workspace-composer-helpers' import { useFolderWorkspaceComposerPathStatus } from '@/components/sidebar/folder-workspace-composer-path-status' -import { submitFolderWorkspaceCreate } from '@/components/sidebar/folder-workspace-composer-submit' +import { + resolveFolderWorkspaceLaunchDraft, + submitFolderWorkspaceCreate +} from '@/components/sidebar/folder-workspace-composer-submit' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' import { getRepoExecutionHostId, @@ -3440,6 +3444,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const smartGitHubResolution = smartGitHubSettlement.value const smartGitHubMetadata = smartGitHubResolution.kind === 'none' ? null : smartGitHubResolution + const submitLinkedWorkItem = smartGitHubMetadata?.linkedWorkItem ?? linkedWorkItem const agent = requestedAgent && isTuiAgentEnabled(requestedAgent, disabledTuiAgents) ? requestedAgent @@ -3447,11 +3452,15 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (isSubmissionCancelled()) { return } + const folderLaunchDraftText = + agent && submitLinkedWorkItem + ? resolveFolderWorkspaceLaunchDraft(submitLinkedWorkItem, note) + : null const folderWorkspaceCreated = await submitFolderWorkspaceCreate({ projectGroup: selectedProjectGroup, name: smartGitHubMetadata?.workspaceName ?? name, lastAutoName: lastAutoNameRef.current, - linkedWorkItem: smartGitHubMetadata?.linkedWorkItem ?? linkedWorkItem, + linkedWorkItem: submitLinkedWorkItem, linkedTaskSourceContext: taskSourceContext, note, quickAgent: agent, @@ -3462,7 +3471,21 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS : undefined, agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined, sessionOptions: agent - ? resolveNativeChatLaunchSessionOptions(settings?.nativeChatSessionOptions, agent) + ? resolveInitialNativeChatSessionOptions( + { + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + nativeChatSessionOptions: settings?.nativeChatSessionOptions + }, + { + agent, + ...(folderLaunchDraftText + ? { promptDelivery: 'draft' as const, launchDraftText: folderLaunchDraftText } + : {}), + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable(folderTargetConnectionId) + } + ) : undefined, terminalWindowsShell: settings?.terminalWindowsShell, isRemote: folderTargetIsRemote, @@ -3509,6 +3532,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS createFolderWorkspace, disabledTuiAgents, folderCreateDisabled, + folderTargetConnectionId, folderTargetIsRemote, folderTargetRuntimeEnvironmentId, folderSourceRepos.length, @@ -3524,7 +3548,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.experimentalNativeChat, settings?.nativeChatSessionOptions, + settings?.openAgentTabsInChatByDefault, settings?.terminalWindowsShell, taskSourceContext, telemetrySource @@ -3768,9 +3794,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), - sessionOptions: resolveNativeChatLaunchSessionOptions( - settings?.nativeChatSessionOptions, - tuiAgent + sessionOptions: resolveInitialNativeChatSessionOptions( + { + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + nativeChatSessionOptions: settings?.nativeChatSessionOptions + }, + { + agent: tuiAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + selectedRepo.connectionId + ) + } ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, @@ -3980,7 +4015,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.experimentalNativeChat, settings?.nativeChatSessionOptions, + settings?.openAgentTabsInChatByDefault, smartNameMode, setSidebarOpen, setupDecision, @@ -4314,6 +4351,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const promptLinkedWorkItem = agent === null ? null : submitLinkedWorkItem const { prompt: quickPrompt, draftPrompt: quickDraftPrompt } = resolveQuickCreateLinkedWorkItemPrompt(promptLinkedWorkItem, trimmedNote) + const quickSessionOptions = + agent === null + ? undefined + : resolveInitialNativeChatSessionOptions( + { + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + nativeChatSessionOptions: settings?.nativeChatSessionOptions + }, + { + agent, + ...(quickDraftPrompt + ? { promptDelivery: 'draft' as const, launchDraftText: quickDraftPrompt } + : {}), + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + selectedRepo.connectionId + ) + } + ) const draftLaunchPlan = agent === null || !quickDraftPrompt ? null @@ -4323,10 +4379,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - sessionOptions: resolveNativeChatLaunchSessionOptions( - settings?.nativeChatSessionOptions, - agent - ), + sessionOptions: quickSessionOptions, platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote @@ -4355,10 +4408,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - sessionOptions: resolveNativeChatLaunchSessionOptions( - settings?.nativeChatSessionOptions, - agent - ), + sessionOptions: quickSessionOptions, platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, @@ -4562,7 +4612,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + settings?.experimentalNativeChat, settings?.nativeChatSessionOptions, + settings?.openAgentTabsInChatByDefault, smartNameMode, sourceIntentBlocksCreate, disabledTuiAgents, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 97621ec1c87..38fb4f1d566 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -353,6 +353,75 @@ describe('launchAgentInNewTab', () => { }) }) + it('does not inject native-chat model preferences into terminal Quick Commands', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--profile team' }, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false, + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } + } + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'Review this diff', + launchSource: 'quick_command', + quickCommandLabel: 'Review' + }) + + const launch = mockQueueTabStartupCommand.mock.calls[0]?.[1] + expect(launch.command).toContain("'--profile' 'team'") + expect(launch.command).not.toContain("'-m'") + expect(launch.command).not.toContain('model_reasoning_effort=') + expect(launch.sessionOptions).toBeUndefined() + }) + + it('applies native-chat model preferences to Quick Commands opened in chat', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } + } + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'Review this diff', + launchSource: 'quick_command', + quickCommandLabel: 'Review' + }) + + const launch = mockQueueTabStartupCommand.mock.calls[0]?.[1] + expect(launch.command).toContain("'-m' 'gpt-5.2-codex'") + expect(launch.command).toContain("'-c' 'model_reasoning_effort=medium'") + expect(launch.sessionOptions).toEqual({ model: 'gpt-5.2-codex', effort: 'medium' }) + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ viewMode: 'chat' }) + ) + }) + it('preserves paired-host draft delivery and supported launch preferences', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) store.settings = { @@ -360,6 +429,8 @@ describe('launchAgentInNewTab', () => { agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { claude: { model: 'opus', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index b9db088e8d1..58e36fd4a33 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -27,7 +27,7 @@ import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt- import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' import { getConnectionIdFromState } from '@/lib/connection-context' -import { resolveNativeChatLaunchSessionOptions } from '@/components/native-chat/native-chat-session-option-enrichment' +import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' export type LaunchAgentInNewTabArgs = { @@ -107,6 +107,21 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI ? agentArgs : resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) + const trimmedPrompt = prompt?.trim() ?? '' + const hasPrompt = trimmedPrompt.length > 0 + const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' + // Why: the remote host can't infer this client's draft/default view choice, so decide it here for paired tabs too. + const viewModePromptDelivery = + hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery + const initialViewModeOptions = { + agent, + promptDelivery: viewModePromptDelivery, + launchDraftText: trimmedPrompt, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + } + const initialViewModeProps = initialAgentTabViewModeProps(store.settings, initialViewModeOptions) const startupPlanBase = { agent, cmdOverrides, @@ -115,14 +130,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI isRemote, agentArgs: effectiveAgentArgs, agentEnv, - sessionOptions: resolveNativeChatLaunchSessionOptions( - store.settings?.nativeChatSessionOptions, - agent - ) + sessionOptions: resolveInitialNativeChatSessionOptions(store.settings, initialViewModeOptions) } - const trimmedPrompt = prompt?.trim() ?? '' - const hasPrompt = trimmedPrompt.length > 0 - const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' const { startupPlan, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ base: startupPlanBase, prompt: trimmedPrompt, @@ -135,18 +144,6 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI return null } - // Why: the remote host can't infer this client's draft/default view choice, so decide it here for paired tabs too. - const viewModePromptDelivery = - hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery - const initialViewModeProps = initialAgentTabViewModeProps(store.settings, { - agent, - promptDelivery: viewModePromptDelivery, - launchDraftText: trimmedPrompt, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }) - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { const webHostDelivery = launchAgentInWebHostTab({ diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index bf7f33aa9e9..3a834a67454 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -8,7 +8,10 @@ vi.mock('@/lib/telemetry', () => ({ })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, value: string) => value })) -import { buildDirectWorkItemStartupOpts } from './launch-work-item-direct-agent' +import { + buildDirectWorkItemAgentStartupPlan, + buildDirectWorkItemStartupOpts +} from './launch-work-item-direct-agent' import type { AgentStartupPlan } from './tui-agent-startup' describe('buildDirectWorkItemStartupOpts', () => { @@ -59,3 +62,49 @@ describe('buildDirectWorkItemStartupOpts', () => { expect(opts.startup?.launchDraftText).toBe('https://github.com/o/r/issues/12') }) }) + +const settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + experimentalNativeChat: true, + nativeChatSessionOptions: { + codex: { + model: 'gpt-5.2-codex', + valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } + } + } +} + +describe('buildDirectWorkItemAgentStartupPlan', () => { + it('omits native-chat preferences when the new workspace opens in terminal mode', () => { + const result = buildDirectWorkItemAgentStartupPlan({ + agent: 'codex', + draftContent: 'Review issue 42', + promptDelivery: 'draft', + settings: { ...settings, openAgentTabsInChatByDefault: false }, + launchPlatform: 'darwin', + nativeChatTranscriptIsLocalReadable: true + }) + + expect(result.startupPlan?.launchCommand).not.toContain("'-m'") + expect(result.startupPlan?.sessionOptions).toBeUndefined() + }) + + it('applies native-chat preferences when the new workspace opens in chat', () => { + const result = buildDirectWorkItemAgentStartupPlan({ + agent: 'codex', + draftContent: 'Review issue 42', + promptDelivery: 'draft', + settings: { ...settings, openAgentTabsInChatByDefault: true }, + launchPlatform: 'darwin', + nativeChatTranscriptIsLocalReadable: true + }) + + expect(result.startupPlan?.launchCommand).toContain("'-m' 'gpt-5.2-codex'") + expect(result.startupPlan?.sessionOptions).toEqual({ + model: 'gpt-5.2-codex', + effort: 'medium' + }) + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index c6efece61c9..467ef5065b6 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -16,7 +16,7 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' -import { resolveNativeChatLaunchSessionOptions } from '@/components/native-chat/native-chat-session-option-enrichment' +import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' export function buildDirectWorkItemAgentStartupPlan(args: { @@ -29,11 +29,14 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agentCmdOverrides?: Partial> agentDefaultArgs?: Partial> agentDefaultEnv?: Partial>> + experimentalNativeChat?: boolean + openAgentTabsInChatByDefault?: boolean nativeChatSessionOptions?: PersistedNativeChatSessionOptions } | null | undefined launchPlatform: NodeJS.Platform + nativeChatTranscriptIsLocalReadable?: boolean /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean @@ -51,10 +54,13 @@ export function buildDirectWorkItemAgentStartupPlan(args: { ? resolveTuiAgentLaunchArgs(args.agent, args.settings?.agentDefaultArgs) : args.agentArgs const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings?.agentDefaultEnv) - const sessionOptions = resolveNativeChatLaunchSessionOptions( - args.settings?.nativeChatSessionOptions, - args.agent - ) + const sessionOptions = resolveInitialNativeChatSessionOptions(args.settings, { + agent: args.agent, + ...(args.promptDelivery === 'draft' + ? { promptDelivery: 'draft' as const, launchDraftText: args.draftContent } + : {}), + nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable + }) const draftLaunchPlan = args.promptDelivery === 'submit-after-ready' ? null diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index f9cc02e4a0e..6a0fa63a214 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -15,6 +15,7 @@ import { import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { getConnectionId } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { GitPushTarget, SetupDecision, TuiAgent } from '../../../shared/types' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' @@ -278,6 +279,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom promptDelivery, settings, launchPlatform, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable(launchConnectionId), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` // rename must not be applied for remote launches. isRemote: typeof launchConnectionId === 'string' diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index e8023746a42..55668937430 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -9,7 +9,7 @@ import type { AgentStartedTelemetry } from '@/lib/worktree-activation' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../shared/types' -import { resolveNativeChatLaunchSessionOptions } from '@/components/native-chat/native-chat-session-option-enrichment' +import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { SessionOptionValue } from '../../../shared/native-chat-session-options' export type OnboardingFolderAgentStartup = { @@ -30,7 +30,8 @@ function getClientPlatform(): NodeJS.Platform { } export function buildOnboardingFolderAgentStartup( - settings: GlobalSettings | null + settings: GlobalSettings | null, + nativeChatTranscriptIsLocalReadable = true ): OnboardingFolderAgentStartup | undefined { const agent = settings?.defaultTuiAgent if ( @@ -48,7 +49,10 @@ export function buildOnboardingFolderAgentStartup( cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), - sessionOptions: resolveNativeChatLaunchSessionOptions(settings.nativeChatSessionOptions, agent), + sessionOptions: resolveInitialNativeChatSessionOptions(settings, { + agent, + nativeChatTranscriptIsLocalReadable + }), platform: getClientPlatform(), allowEmptyPromptLaunch: true }) @@ -88,10 +92,11 @@ export function shouldSeedFolderAgentAfterDismissedOnboarding( export function buildDismissedOnboardingFolderAgentStartup( settings: GlobalSettings | null, onboarding: OnboardingState | null, - hasExistingProject: boolean + hasExistingProject: boolean, + nativeChatTranscriptIsLocalReadable = true ): OnboardingFolderAgentStartup | undefined { if (!shouldSeedFolderAgentAfterDismissedOnboarding(onboarding, hasExistingProject)) { return undefined } - return buildOnboardingFolderAgentStartup(settings) + return buildOnboardingFolderAgentStartup(settings, nativeChatTranscriptIsLocalReadable) } diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index f8fa9ebc61e..23bd1e48652 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -72,6 +72,7 @@ import { import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery' @@ -3268,7 +3269,8 @@ export const createRepoSlice: StateCreator = (set, const startup = buildDismissedOnboardingFolderAgentStartup( get().settings, onboarding, - hadProjectBeforeAdd + hadProjectBeforeAdd, + isNativeChatTranscriptLocalReadable(repo.connectionId) ) activateAndRevealWorktree(folderWorktree.id, { sidebarRevealBehavior: 'auto', From bdebe53568291114fa9fdfd7a7c9129d4b2b4f1f Mon Sep 17 00:00:00 2001 From: BingZ Date: Tue, 11 Aug 2026 15:07:49 +0800 Subject: [PATCH 08/39] fix(settings): paste bash-native skill setup under WSL PTY (#13710) * fix(settings): paste bash-native skill setup under WSL PTY WSL worktree setup terminals force wsl.exe even when shellOverride is powershell.exe. Auto-pasting the PowerShell `& { wsl.exe ... }` wrapper into bash fails with a leading-& syntax error (#13305). Rewrite that wrapper to a bash login-shell script for setup-terminal paste only; clipboard copy still keeps the PS host wrapper for manual use outside Orca. * fix(settings): align skill paste with resolved PTY shell * fix(onboarding): keep shell preparation out of render --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../feature-tips/CliSkillSetupTerminal.tsx | 10 +-- .../FeatureSetupInlineTerminal.test.tsx | 19 ++++- .../onboarding/FeatureSetupInlineTerminal.tsx | 7 +- ...eCommandTerminal.command-finished.test.tsx | 85 ++++++++++++++++++- .../OnboardingInlineCommandTerminal.tsx | 41 ++++++--- .../settings/AgentSkillSetupPanel.tsx | 4 +- .../settings/CliSkillRuntimeSetup.test.tsx | 23 ++++- .../settings/CliSkillRuntimeSetup.tsx | 42 +++++++-- ...nt-skill-installed-command-callers.test.ts | 8 +- .../terminal-pane/pty-connection.test.ts | 22 +++++ .../terminal-pane/pty-connection.ts | 2 +- .../src/store/slices/store-cascades.test.ts | 8 ++ src/renderer/src/store/slices/terminals.ts | 6 +- src/shared/types.ts | 2 + 14 files changed, 240 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx b/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx index d1e025cbb0d..f03cb767f39 100644 --- a/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx +++ b/src/renderer/src/components/feature-tips/CliSkillSetupTerminal.tsx @@ -21,12 +21,6 @@ export function CliSkillSetupTerminal(): React.JSX.Element { ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND, activeSkillRuntime.installDisabledReason ? undefined : activeSkillRuntime.agentRuntime ) - // The copied string stays as built; only what we execute is adapted. - const setupTerminalCommand = buildSkillSetupTerminalCommand( - skillCommand, - activeSkillRuntime.terminalShellOverride - ) - const handleCopySkillCommand = async (): Promise => { try { await window.api.ui.writeClipboardText(skillCommand) @@ -78,7 +72,8 @@ export function CliSkillSetupTerminal(): React.JSX.Element { ) diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx index 024e617490e..779ae837b82 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.test.tsx @@ -13,7 +13,15 @@ const mocks = vi.hoisted(() => ({ (command: string, runtime?: { runtime: 'host' | 'wsl' }) => `${runtime?.runtime ?? 'host'}:${command}` ), - terminalProps: null as { command: string; shellOverride?: string } | null + buildSetupCommand: vi.fn( + (command: string, shellOverride?: string) => `${shellOverride ?? 'default'}:${command}` + ), + terminalProps: null as { + command: string + forceHostRuntime?: boolean + prepareCommandForShell?: (command: string, shellOverride?: string) => string + shellOverride?: string + } | null })) vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ @@ -21,7 +29,8 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ })) vi.mock('../settings/CliSkillRuntimeSetup', () => ({ - buildSkillCommandForRuntime: mocks.buildCommand + buildSkillCommandForRuntime: mocks.buildCommand, + buildSkillSetupTerminalCommand: mocks.buildSetupCommand })) vi.mock('./OnboardingInlineCommandTerminal', () => ({ @@ -57,8 +66,13 @@ describe('FeatureSetupInlineTerminal', () => { }) expect(mocks.terminalProps).toMatchObject({ command: 'wsl:npx skills add orchestration', + forceHostRuntime: false, + prepareCommandForShell: mocks.buildSetupCommand, shellOverride: 'powershell.exe' }) + expect( + mocks.terminalProps?.prepareCommandForShell?.('wsl:npx skills add orchestration', 'wsl.exe') + ).toBe('wsl.exe:wsl:npx skills add orchestration') }) it('uses the host command builder when the WSL runtime needs repair', () => { @@ -71,6 +85,7 @@ describe('FeatureSetupInlineTerminal', () => { expect(mocks.buildCommand).toHaveBeenCalledWith('npx skills add orchestration', undefined) expect(mocks.terminalProps).toMatchObject({ command: 'host:npx skills add orchestration', + forceHostRuntime: true, shellOverride: 'powershell.exe' }) }) diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx index 3d7f2fcb3da..c6502476c20 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx @@ -2,7 +2,10 @@ import { useCallback, useMemo, useRef, type KeyboardEvent } from 'react' import { track } from '@/lib/telemetry' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' -import { buildSkillCommandForRuntime } from '../settings/CliSkillRuntimeSetup' +import { + buildSkillCommandForRuntime, + buildSkillSetupTerminalCommand +} from '../settings/CliSkillRuntimeSetup' import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' import { getOnboardingFeatureSetupAgentRuntime, @@ -71,7 +74,9 @@ export function FeatureSetupInlineTerminal({ return ( ({ - createTab: vi.fn(() => ({ id: 'tab-1' })), + createTab: vi.fn(() => ({ id: 'tab-1', shellOverride: 'wsl.exe' })), closeTab: vi.fn(), setActiveTabForWorktree: vi.fn(), setTabCustomTitle: vi.fn() @@ -27,7 +28,12 @@ vi.mock('@/store', () => ({ })) vi.mock('@/components/terminal-pane/TerminalPane', () => ({ - default: () =>

+ default: (props: { tabId: string }) => ( +
+
+
$
+
+ ) })) vi.mock('@/lib/focus-terminal-tab-surface', () => ({ @@ -69,6 +75,81 @@ describe('OnboardingInlineCommandTerminal command-finished forwarding', () => { container?.remove() container = null Reflect.deleteProperty(window, 'api') + vi.useRealTimers() + }) + + it('prepares auto-paste again when the resolved tab shell changes', async () => { + vi.useFakeTimers() + mocks.createTab + .mockReturnValueOnce({ id: 'tab-1', shellOverride: 'wsl.exe' }) + .mockReturnValueOnce({ id: 'tab-2', shellOverride: 'powershell.exe' }) + const prepareCommandForShell = vi.fn( + (command: string, shellOverride: string | undefined) => `${shellOverride}:${command}` + ) + const pasted: PasteTerminalTextDetail[] = [] + const handlePaste = (event: Event): void => { + pasted.push((event as CustomEvent).detail) + } + window.addEventListener(PASTE_TERMINAL_TEXT_EVENT, handlePaste) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + try { + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) + await act(async () => { + vi.advanceTimersByTime(250) + }) + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) + await act(async () => { + vi.advanceTimersByTime(250) + }) + + expect(prepareCommandForShell).toHaveBeenCalledWith('npx skills add orchestration', 'wsl.exe') + expect(prepareCommandForShell).toHaveBeenCalledWith( + 'npx skills add orchestration', + 'powershell.exe' + ) + expect(mocks.createTab).toHaveBeenCalledWith( + 'ephemeral-setup-terminal:onboarding-inline-terminal', + undefined, + 'powershell.exe', + expect.objectContaining({ forceHostRuntime: true }) + ) + expect(pasted).toContainEqual({ + tabId: 'tab-1', + text: 'wsl.exe:npx skills add orchestration' + }) + expect(pasted).toContainEqual({ + tabId: 'tab-2', + text: 'powershell.exe:npx skills add orchestration' + }) + } finally { + window.removeEventListener(PASTE_TERMINAL_TEXT_EVENT, handlePaste) + } }) it('forwards exit codes only for its own branded worktree id', async () => { diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 0a7f0ae7a8f..e2e6141ef0e 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -21,6 +21,7 @@ const PTY_TEXT_FALLBACK_MS = 750 type OnboardingInlineCommandTerminalProps = { command: string + prepareCommandForShell?: (command: string, shellOverride: string | undefined) => string title: string description?: string ariaLabel: string @@ -30,6 +31,7 @@ type OnboardingInlineCommandTerminalProps = { autoScrollIntoView?: boolean worktreeId?: string shellOverride?: string + forceHostRuntime?: boolean onOpened?: () => void onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => void onTerminalExit?: () => void @@ -43,6 +45,7 @@ type OnboardingInlineCommandTerminalProps = { */ export function OnboardingInlineCommandTerminal({ command, + prepareCommandForShell, title, description, ariaLabel, @@ -52,6 +55,7 @@ export function OnboardingInlineCommandTerminal({ autoScrollIntoView = true, worktreeId: worktreeIdProp = ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, shellOverride, + forceHostRuntime = false, onOpened, onInteracted, onTerminalExit, @@ -75,13 +79,17 @@ export function OnboardingInlineCommandTerminal({ [] ) const [cwd, setCwd] = useState(null) - const [tabId, setTabId] = useState(null) + const [createdTab, setCreatedTab] = useState<{ + id: string + shellOverride: string | undefined + } | null>(null) + const tabId = createdTab?.id ?? null // Why: starts at `prefersReducedMotion` so users opted out of motion never // see the slide-in frame; otherwise we flip to true after first paint so the // CSS transition has a starting state to interpolate from. const [entered, setEntered] = useState(prefersReducedMotion) const terminalSectionRef = useRef(null) - const autoInsertedRef = useRef(null) + const autoInsertedRef = useRef<{ tabId: string; command: string } | null>(null) useEffect(() => { onOpened?.() @@ -120,11 +128,12 @@ export function OnboardingInlineCommandTerminal({ useEffect(() => { const tab = createTab(worktreeId, undefined, shellOverride, { activate: false, - recordInteraction: false + recordInteraction: false, + forceHostRuntime }) setActiveTabForWorktree(worktreeId, tab.id) setTabCustomTitle(tab.id, title, { recordInteraction: false }) - setTabId(tab.id) + setCreatedTab({ id: tab.id, shellOverride: tab.shellOverride }) return () => { // Why: inline setup panels can disappear after detection succeeds; close // the backing tab so installer shells do not keep running invisibly. @@ -133,6 +142,7 @@ export function OnboardingInlineCommandTerminal({ }, [ closeTab, createTab, + forceHostRuntime, setActiveTabForWorktree, setTabCustomTitle, shellOverride, @@ -201,9 +211,17 @@ export function OnboardingInlineCommandTerminal({ }, [autoScrollIntoView, entered, prefersReducedMotion]) const insertCommand = useCallback(() => { - if (!tabId) { + if (!createdTab) { return } + const terminalCommand = prepareCommandForShell?.(command, createdTab.shellOverride) ?? command + if ( + autoInsertedRef.current?.tabId === createdTab.id && + autoInsertedRef.current.command === terminalCommand + ) { + return + } + autoInsertedRef.current = { tabId: createdTab.id, command: terminalCommand } if (autoScrollIntoView) { terminalSectionRef.current?.scrollIntoView({ behavior: 'auto', @@ -213,16 +231,16 @@ export function OnboardingInlineCommandTerminal({ window.dispatchEvent( new CustomEvent(PASTE_TERMINAL_TEXT_EVENT, { detail: { - tabId, - text: command.trim() + tabId: createdTab.id, + text: terminalCommand.trim() } }) ) - focusTerminalTabSurface(tabId) - }, [autoScrollIntoView, command, tabId]) + focusTerminalTabSurface(createdTab.id) + }, [autoScrollIntoView, command, createdTab, prepareCommandForShell]) useEffect(() => { - if (!tabId || !cwd || autoInsertedRef.current === command) { + if (!tabId || !cwd) { return } let canceled = false @@ -236,7 +254,6 @@ export function OnboardingInlineCommandTerminal({ } insertionTimer = window.setTimeout(() => { if (!canceled) { - autoInsertedRef.current = command insertCommand() } }, AUTO_INSERT_DELAY_MS) @@ -280,7 +297,7 @@ export function OnboardingInlineCommandTerminal({ window.clearTimeout(insertionTimer) } } - }, [command, cwd, insertCommand, tabId]) + }, [cwd, insertCommand, tabId]) // Why: grid 0fr → 1fr animates to the child's natural height without a // hardcoded max-height, so we don't leave dead space if the terminal diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 8db579cfa2f..d1a5d307b04 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -387,11 +387,11 @@ export function AgentSkillSetupPanel({
- {/* The copied string above stays as built; only what we run is adapted. */} { } }) - it('leaves WSL and non-Windows setup terminal commands untouched', () => { + it('rewrites WSL PowerShell wrappers to bash for setup-terminal auto-paste', () => { + const skillCommand = 'npx skills add orchestration --global' const wslCommand = buildSkillCommandForRuntime( - 'npx skills add orchestration --global', + skillCommand, { runtime: 'wsl', wslDistro: 'Ubuntu', label: 'WSL Ubuntu' }, 'win32' ) + expect(wslCommand.startsWith('& {')).toBe(true) expect(buildSkillSetupTerminalCommand(wslCommand, 'powershell.exe', 'win32')).toBe(wslCommand) + const setupCommand = buildSkillSetupTerminalCommand(wslCommand, 'wsl.exe', 'win32') + expect(setupCommand.startsWith('&')).toBe(false) + expect(setupCommand).toBe(buildWslLoginShellCommand(skillCommand)) + expect(setupCommand).toContain('npx skills add orchestration --global') expect( buildSkillSetupTerminalCommand('npx skills add orchestration --global', undefined, 'linux') ).toBe('npx skills add orchestration --global') }) + it('preserves the exact WSL script when adapting setup-terminal auto-paste', () => { + const skillCommand = "printf 'héllo\n# Runs: unchanged'" + const copiedCommand = buildSkillCommandForRuntime(skillCommand, { + runtime: 'wsl', + wslDistro: 'Ubuntu', + label: 'WSL Ubuntu' + }) + + expect(buildSkillSetupTerminalCommand(copiedCommand, 'wsl.exe', 'win32')).toBe( + buildWslLoginShellCommand(skillCommand) + ) + }) + it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => { const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) const previous = useAppStore.getState() diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 1e8ac06c414..9fc8d4dc0b0 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -8,6 +8,7 @@ import { quotePowerShellNativeArgument } from '../../../../shared/powershell-native-argument' import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command' +import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell' import { getProjectAgentSkillTerminalShellOverride } from '@/lib/project-skill-runtime' import { useAppStore } from '@/store' @@ -138,16 +139,23 @@ function normalizeWindowsSkillUpdateCommand( type SkillCommandTarget = 'copied-command' | 'orca-setup-terminal' /** - * Re-adds the npx preflight for Orca's own setup terminal, which - * `getAgentSkillTerminalShellOverride` forces onto powershell.exe. The copied - * string stays bare for POSIX-family shells; only the executed one is wrapped. + * Adapts a copied skill command for Orca's inline setup terminal auto-paste. + * Host Windows installs may gain an npx preflight; WSL-targeted PowerShell wrappers + * must become bash-native because the daemon forces wsl.exe for WSL worktrees. */ export function buildSkillSetupTerminalCommand( copiedCommand: string, - terminalShellOverride: string | undefined, + effectiveShell: string | undefined, currentPlatform = getSkillCommandPlatform() ): string { - if (!isSetupTerminalForcedToPowerShell(terminalShellOverride)) { + // Why: the created tab is authoritative when project runtime replaces the requested shell. + const wslNative = isWslShellName(effectiveShell) + ? decodeWslSetupTerminalCommand(copiedCommand) + : null + if (wslNative) { + return wslNative + } + if (!isSetupTerminalForcedToPowerShell(effectiveShell)) { return copiedCommand } return wrapWindowsSkillCommandWithNpxPrerequisite( @@ -157,6 +165,30 @@ export function buildSkillSetupTerminalCommand( ) } +function decodeWslSetupTerminalCommand(command: string): string | null { + if ( + !command.startsWith("& { $PSNativeCommandArgumentPassing = 'Legacy'; wsl.exe") || + !command.includes(' } # Runs: ') + ) { + return null + } + + const encoded = /-- sh -c 'eval \\"`printf %s ([A-Za-z0-9+/=]+) \| base64 -d`\\"'/.exec( + command + )?.[1] + if (!encoded) { + return null + } + + try { + const binary = atob(encoded) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return new TextDecoder().decode(bytes) + } catch { + return null + } +} + function isSetupTerminalForcedToPowerShell(terminalShellOverride: string | undefined): boolean { const trimmedOverride = terminalShellOverride?.trim() return ( diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index 26287618412..b91f640a9c3 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -170,11 +170,11 @@ describe('AgentSkillSetupPanel installed-command call sites', () => { ) expect(source).toContain('buildSkillCommandForRuntime(') - // The copied string stays bare for POSIX-family shells; the forced-PowerShell - // setup terminal keeps the npx preflight. + // Clipboard and auto-paste share the source command until the created tab + // resolves the shell that prepares the executable form. expect(source).toContain('writeClipboardText(skillCommand)') - expect(source).toContain('buildSkillSetupTerminalCommand(') - expect(source).toContain('command={setupTerminalCommand}') + expect(source).toContain('command={skillCommand}') + expect(source).toContain('prepareCommandForShell={buildSkillSetupTerminalCommand}') expect(source).toContain('shellOverride={activeSkillRuntime.terminalShellOverride}') expect(source).not.toContain('command={ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND}') // This terminal auto-pastes with no install gate, so a repair-required runtime diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 2ff73043cc3..c78ac18274b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -153,6 +153,7 @@ type StoreState = { title?: string launchAgent?: string shellOverride?: string + forceHostRuntime?: boolean generation?: number }[] > @@ -2316,6 +2317,27 @@ describe('connectPanePty', () => { }) }) + it('keeps an explicit host fallback out of the project runtime', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: null, forceHostRuntime: true }] + }, + settings: { + ...mockStoreState.settings, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]?.projectRuntime).toBeUndefined() + }) + it('observes live terminal GitHub PR URLs before agent completion', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5ee42f9c784..41cc4cb1383 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3646,7 +3646,7 @@ export function connectPanePty( ? getCachedWindowsTerminalCapabilities() : null const projectRuntime = - !connectionId && runtimeEnvironmentId === null + !tab?.forceHostRuntime && !connectionId && runtimeEnvironmentId === null ? getLocalProjectExecutionRuntimeContext(state, deps.worktreeId, undefined, { wslAvailable: localWindowsTerminalCapabilities?.wslAvailable, availableWslDistros: localWindowsTerminalCapabilities?.wslDistros ?? null diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 06de2c2b9ae..4eaee9fa68b 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1658,6 +1658,14 @@ describe('setActiveWorktree', () => { const terminal = store.getState().createTab(wt, undefined, 'cmd.exe') expect(terminal.shellOverride).toBe('wsl.exe') + + const hostTerminal = store + .getState() + .createTab(wt, undefined, 'powershell.exe', { forceHostRuntime: true }) + expect(hostTerminal).toMatchObject({ + shellOverride: 'powershell.exe', + forceHostRuntime: true + }) } finally { Object.defineProperty(globalThis, 'navigator', { value: originalNavigator, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 3eb70095083..89fc0657479 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -670,6 +670,7 @@ export type TerminalSlice = { /** Initial native-chat view mode; agent launches pass 'chat' when openAgentTabsInChatByDefault is on, else omitted for the 'terminal' default. */ viewMode?: Tab['viewMode'] startupCwd?: string + forceHostRuntime?: boolean } ) => TerminalTab openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise @@ -1379,7 +1380,9 @@ export const createTerminalSlice: StateCreator : null, // Why: new terminals enter the worktree's repo-scoped WSL distro even when the global Windows shell is PowerShell/cmd.exe. isWslWorktree, - isRemoteWorktree ? undefined : getLocalProjectExecutionRuntimeContext(s, worktreeId) + isRemoteWorktree || options?.forceHostRuntime + ? undefined + : getLocalProjectExecutionRuntimeContext(s, worktreeId) ) tab = { id, @@ -1396,6 +1399,7 @@ export const createTerminalSlice: StateCreator createdAt: Date.now(), ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), + ...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}), ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) diff --git a/src/shared/types.ts b/src/shared/types.ts index 96c6d14dabf..7cdc273df75 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -903,6 +903,8 @@ export type TerminalTab = { * PTY and tab icon stay stable even if the default shell setting changes * later. Older persisted tabs may omit this field. */ shellOverride?: string + /** Keeps an ephemeral host fallback out of the active project's runtime. */ + forceHostRuntime?: boolean /** Why: explorer-created terminals can start below the workspace root while * still belonging to that workspace for tab/session ownership. */ startupCwd?: string From 200b3e53ae5e577c5b997447611badba08efa469 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:08:19 -0700 Subject: [PATCH 09/39] perf(renderer): keep the repos array identity across no-op refetches (#13744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(renderer): keep the repos array identity across no-op refetches reconcileFetchedRepos deliberately returns the previous array when a refetch changes nothing, so identity-keyed memos can skip work. Two later steps in the same chain copied unconditionally and threw that identity away: - reconcileReadoptedSshRepoRows spread the input on its no-prune path, which is the common case. - applyManualRepoOrder allocated a fresh array even when the saved order moved nothing. Both now return the input when they change nothing, so state.repos stays referentially stable through fetchRepos, fetchRuntimeEnvironmentRepos, fetchReposForAllHosts, and hydratePersistedUI. Return type stays Repo[] with the same cast reconcileFetchedRepos already uses; all four call sites only read the result. Co-authored-by: Orca * refactor(renderer): make the store repos array readonly at the type level Preserving the repos array identity means handing callers the same array that is live store state, which previously relied on `as Repo[]` casts to launder readonly inputs back into a mutable field. A cast is a footgun: the next person to add a .push or .sort corrupts store state with no type error. Widen RepoSlice['repos'] to readonly Repo[] and propagate honestly. Consumers that only read take readonly Repo[]; genuine local accumulators are annotated Repo[] and built from copies. Removes all four pre-existing `as Repo[]` casts in the reconcile chain (repo-identity-reconcile, superseded-ssh-repo-rows, manual-repo-order x2) — this lands with fewer casts than main has today. Type-only change; no runtime behavior differs. Co-authored-by: Orca * fix(renderer): compare nested repo fields so reconciliation actually fires Preserving the repos array identity was inert. reconcileFetchedRepos compares repo fields with !==, but every repo the renderer receives carries nested records that are new objects on every fetch: - main's hydrateRepo unconditionally rebuilds hookSettings for every repo, even a pristine one (persistence.ts:5109) - IPC structured-clone (and the JSON hop for SSH/runtime hosts) reclones gitRemoteIdentity, upstream, repoIcon, and the path arrays So every repo compared unequal, `identical` went false on every refresh, and the array was rebuilt regardless of the copies removed in the parent commit. Compare nested plain records structurally instead. They are small sanitized values; anything non-plain falls back to reference equality. The end-to-end test previously passed for a fixture-only reason: its repo had five scalar fields and the mock returned the same object both calls, the one shape production never produces. It now uses a production-shaped repo and fails without this change. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/renderer/src/components/Landing.tsx | 2 +- .../components/NewWorkspaceComposerCard.tsx | 4 +- .../automations/AutomationEditorDialog.tsx | 2 +- .../AutomationEditorDialogFooter.tsx | 2 +- .../automations/AutomationProjectCombobox.tsx | 2 +- .../dashboard-worktree-launch-options.test.ts | 18 ++++++- .../components/dashboard/useRetainedAgents.ts | 4 +- .../components/landing-preflight-dismissal.ts | 6 +-- .../new-workspace/SmartWorkspaceNameField.tsx | 2 +- .../src/components/repo/RepoCombobox.tsx | 2 +- .../useGitStatusPolling.rerender.test.ts | 2 +- .../settings/QuickCommandsScopeFilter.tsx | 4 +- .../resource-usage-open-slices.test.ts | 11 ++++- .../TerminalQuickCommandAdvancedSection.tsx | 2 +- .../TerminalQuickCommandDialog.tsx | 2 +- .../TerminalQuickCommandScopeField.tsx | 4 +- .../src/components/ui/repo-multi-combobox.tsx | 7 ++- .../src/hooks/useAgentDetectionTarget.test.ts | 10 ++-- .../src/lib/ensure-hooks-confirmed.test.ts | 6 +++ .../src/lib/folder-workspace-connection.ts | 2 +- src/renderer/src/lib/http-link-routing.ts | 2 +- .../lib/local-preflight-context-cache.test.ts | 8 +++- .../src/lib/local-preflight-context.test.ts | 26 +++++----- src/renderer/src/lib/repo-search.ts | 2 +- src/renderer/src/lib/repo-slug-index.ts | 2 +- ...workspace-port-localhost-label-selector.ts | 4 +- .../src/store/slices/detected-agents.test.ts | 4 ++ src/renderer/src/store/slices/github.test.ts | 38 ++++++++------- .../src/store/slices/preflight.test.ts | 3 +- .../slices/repo-identity-reconcile.test.ts | 44 +++++++++++++++++ .../store/slices/repo-identity-reconcile.ts | 48 +++++++++++++++++-- src/renderer/src/store/slices/repos.test.ts | 42 +++++++++++++++- src/renderer/src/store/slices/repos.ts | 10 ++-- .../slices/superseded-ssh-repo-rows.test.ts | 6 +++ .../store/slices/superseded-ssh-repo-rows.ts | 6 ++- src/renderer/src/store/slices/worktrees.ts | 3 +- src/shared/manual-repo-order.test.ts | 20 ++++++++ src/shared/manual-repo-order.ts | 9 ++-- 38 files changed, 290 insertions(+), 81 deletions(-) diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 2d4b392b71b..bcf5c66ec1f 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -149,7 +149,7 @@ function PreflightBanner({ repos }: { issues: PreflightIssue[] - repos: Repo[] + repos: readonly Repo[] }): React.JSX.Element | null { // Why: keying the seed on the current GitHub project set means adding a new // GitHub project (which changes the key) re-evaluates dismissals, so a lapsed diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index bc1691f293a..b9683150d17 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -84,7 +84,7 @@ type NewWorkspaceComposerCardProps = { nameInputRef?: React.RefObject quickAgent: TuiAgent | null onQuickAgentChange: (agent: TuiAgent | null) => void - eligibleRepos: RepoOption[] + eligibleRepos: readonly RepoOption[] repoId: string projectOptions?: NewWorkspaceProjectOption[] selectedProjectId?: string | null @@ -98,7 +98,7 @@ type NewWorkspaceComposerCardProps = { selectedEphemeralVmRecipeId?: string | null onEphemeralVmRecipeChange?: (recipeId: string | null) => void ephemeralVmRecipeError?: string | null - repoBackedSearchRepos?: RepoOption[] + repoBackedSearchRepos?: readonly RepoOption[] repoBackedSourcesDisabled?: boolean allowSmartNameAddProject?: boolean smartNameRepoSwitchTarget?: 'project' | 'task-source' diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx index f595290d2a2..90eb5a9df47 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx @@ -61,7 +61,7 @@ type AutomationEditorDialogProps = { isSaving: boolean canSave: boolean createTarget: AutomationCreateTarget - repos: Repo[] + repos: readonly Repo[] projectHostSetups: ProjectHostSetup[] automationYamlHooksByRepoKey: Record getAutomationHooksCacheKey: (repoId: string) => string diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx index 62a7be7aab7..ff5a67cc41b 100644 --- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx +++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx @@ -31,7 +31,7 @@ type AutomationEditorDialogFooterProps = { isHermesCreate: boolean isSaving: boolean canSave: boolean - repos: Repo[] + repos: readonly Repo[] projectHostSetups: ProjectHostSetup[] automationYamlHooksByRepoKey: Record getAutomationHooksCacheKey: (repoId: string) => string diff --git a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx index b63bc5821f8..65d0176bf29 100644 --- a/src/renderer/src/components/automations/AutomationProjectCombobox.tsx +++ b/src/renderer/src/components/automations/AutomationProjectCombobox.tsx @@ -19,7 +19,7 @@ import { } from './automation-project-groups' type AutomationProjectComboboxProps = { - repos: Repo[] + repos: readonly Repo[] value: string onValueChange: (repoId: string) => void placeholder?: string diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts index e7c0c04d85c..42b63e3e993 100644 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -94,8 +94,22 @@ describe('buildDashboardWorktreeLaunchOptions', () => { const options = buildDashboardWorktreeLaunchOptions( state({ repos: [ - { id: 'repo-ssh', connectionId: 'ssh-1' }, - { id: 'repo-runtime', executionHostId: 'runtime:hub-1' } + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'repo-ssh', + badgeColor: 'blue', + addedAt: 1, + connectionId: 'ssh-1' + }, + { + id: 'repo-runtime', + path: '/repo-runtime', + displayName: 'repo-runtime', + badgeColor: 'blue', + addedAt: 1, + executionHostId: 'runtime:hub-1' + } ] as LaunchState['repos'], worktreesByRepo: { 'repo-ssh': [{ id: 'ssh-worktree', repoId: 'repo-ssh' }], diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 61cd0f30ce4..1b88c32bb57 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -22,7 +22,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' type RetainedAgentSnapshot = Map type RetainedAgentsSyncInputs = { - repos: Repo[] + repos: readonly Repo[] worktreesByRepo: Record folderWorkspaces: FolderWorkspace[] tabsByWorktree: Record @@ -38,7 +38,7 @@ function paneKeyTabId(paneKey: string): string | null { } function buildLiveTabIndex(args: { - repos: Repo[] + repos: readonly Repo[] worktreesByRepo: Record folderWorkspaces: FolderWorkspace[] tabsByWorktree: Record diff --git a/src/renderer/src/components/landing-preflight-dismissal.ts b/src/renderer/src/components/landing-preflight-dismissal.ts index 76ef2a47bf5..1fae17b0a3d 100644 --- a/src/renderer/src/components/landing-preflight-dismissal.ts +++ b/src/renderer/src/components/landing-preflight-dismissal.ts @@ -23,7 +23,7 @@ function storageKey(issueId: string): string { /** GitHub-backed project identity keys for the current repo set, de-duped so * the same GitHub project added twice doesn't read as two distinct projects. */ -export function githubProjectKeys(repos: Repo[]): string[] { +export function githubProjectKeys(repos: readonly Repo[]): string[] { const keys = repos .filter((repo) => isGitHubBackedRepo(repo)) .map((repo) => getProjectIdentityKey(repo)) @@ -45,7 +45,7 @@ function readRecord(issueId: string): DismissalRecord | null { /** True when the issue was dismissed and no new GitHub project has appeared * since. A GitHub key present now but absent from the snapshot re-surfaces it. */ -export function isPreflightIssueDismissed(issueId: string, repos: Repo[]): boolean { +export function isPreflightIssueDismissed(issueId: string, repos: readonly Repo[]): boolean { const record = readRecord(issueId) if (!record) { return false @@ -55,7 +55,7 @@ export function isPreflightIssueDismissed(issueId: string, repos: Repo[]): boole return !hasNewGithubProject } -export function dismissPreflightIssue(issueId: string, repos: Repo[]): void { +export function dismissPreflightIssue(issueId: string, repos: readonly Repo[]): void { try { const record: DismissalRecord = { githubKeys: githubProjectKeys(repos) } localStorage.setItem(storageKey(issueId), JSON.stringify(record)) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 539f40deb7c..6ddc4e3ae93 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -114,7 +114,7 @@ type RepoOption = ReturnType['repos'][number] const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = [] type SmartWorkspaceNameFieldProps = { - repos: RepoOption[] + repos: readonly RepoOption[] repoId: string onRepoChange: (repoId: string) => void value: string diff --git a/src/renderer/src/components/repo/RepoCombobox.tsx b/src/renderer/src/components/repo/RepoCombobox.tsx index f9fef8ad902..868c7be2d22 100644 --- a/src/renderer/src/components/repo/RepoCombobox.tsx +++ b/src/renderer/src/components/repo/RepoCombobox.tsx @@ -19,7 +19,7 @@ import RepoBadgeLabel from './RepoBadgeLabel' import { translate } from '@/i18n/i18n' type RepoComboboxProps = { - repos: Repo[] + repos: readonly Repo[] value: string onValueChange: (repoId: string) => void onValueSelected?: (repoId: string) => void diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts index 2f9c3ddabdb..348bf8a1f36 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts @@ -217,7 +217,7 @@ describe('useGitStatusPolling rerender stability', () => { useAppStore.setState({ repos: [{ ...repo, connectionId: 'ssh-1' }], sshConnectionStates: new Map([ - ['ssh-1', { status: 'disconnected', error: null, reconnectAttempt: 0 }] + ['ssh-1', { targetId: 'ssh-1', status: 'disconnected', error: null, reconnectAttempt: 0 }] ]) } as Partial) await renderHook() diff --git a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx index e845b9dd0b4..749d554b783 100644 --- a/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx +++ b/src/renderer/src/components/settings/QuickCommandsScopeFilter.tsx @@ -21,7 +21,7 @@ function ScopeTriggerLabel({ }: { showAll: boolean effectiveSelection: ReadonlySet - repos: Repo[] + repos: readonly Repo[] }): React.JSX.Element { if (showAll) { return ( @@ -57,7 +57,7 @@ export function QuickCommandsScopeFilter({ handleSelectAll, toggleScope }: { - repos: Repo[] + repos: readonly Repo[] effectiveSelection: ReadonlySet showAll: boolean scopePopoverOpen: boolean diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts index 58a51eed488..85663bc2090 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts @@ -117,7 +117,16 @@ describe('resource usage open slices', () => { }) it('gates repo and worktree slices only while closed', () => { - const repos = [{ id: 'repo-1', path: '/repo', kind: 'git' }] as AppState['repos'] + const repos = [ + { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'git' + } + ] as AppState['repos'] const row = worktree() const worktreesByRepo = { 'repo-1': [row] diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx index 646c81dd100..d49be9c9616 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandAdvancedSection.tsx @@ -11,7 +11,7 @@ import { TerminalQuickCommandScopeField } from './TerminalQuickCommandScopeField type TerminalQuickCommandAdvancedSectionProps = { draft: TerminalQuickCommand - repos: Pick[] + repos: readonly Pick[] advancedOpen: boolean selectedScope: ReturnType selectedRepoId: string diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 59bce894d19..493eb755bd6 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -39,7 +39,7 @@ type TerminalQuickCommandDialogProps = { open: boolean mode: TerminalQuickCommandDialogMode command: TerminalQuickCommand - repos?: Pick[] + repos?: readonly Pick[] onOpenChange: (open: boolean) => void onSave: (command: TerminalQuickCommand) => void } diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx index fe09ee6acb4..58cd0ea8fd2 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandScopeField.tsx @@ -18,7 +18,7 @@ import { QUICK_COMMAND_TOGGLE_ITEM_CLASS } from './terminal-quick-command-toggle import { translate } from '@/i18n/i18n' type TerminalQuickCommandScopeFieldProps = { - repos: Pick[] + repos: readonly Pick[] selectedScope: TerminalQuickCommandScope selectedRepoId: string selectedRepoMissing: boolean @@ -32,7 +32,7 @@ function getRepoLabel(repo: Pick): string { } export function getQuickCommandProjectScopeRepoId( - repos: Pick[], + repos: readonly Pick[], lastRepoScopeId: string | null ): string | null { return lastRepoScopeId ?? repos[0]?.id ?? null diff --git a/src/renderer/src/components/ui/repo-multi-combobox.tsx b/src/renderer/src/components/ui/repo-multi-combobox.tsx index 6334f74179e..f3dfef437d0 100644 --- a/src/renderer/src/components/ui/repo-multi-combobox.tsx +++ b/src/renderer/src/components/ui/repo-multi-combobox.tsx @@ -16,7 +16,7 @@ import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { translate } from '@/i18n/i18n' type RepoMultiComboboxProps = { - repos: Repo[] + repos: readonly Repo[] /** Currently selected repo ids. The component enforces `selected.size >= 1` * by disabling the last-selected checkbox. */ selected: ReadonlySet @@ -32,7 +32,10 @@ type RepoMultiComboboxProps = { triggerClassName?: string } -function renderTriggerLabel(repos: Repo[], selected: ReadonlySet): React.JSX.Element { +function renderTriggerLabel( + repos: readonly Repo[], + selected: ReadonlySet +): React.JSX.Element { if (repos.length === 0) { return ( diff --git a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts index 8d0cc61f716..1d1c4fa1417 100644 --- a/src/renderer/src/hooks/useAgentDetectionTarget.test.ts +++ b/src/renderer/src/hooks/useAgentDetectionTarget.test.ts @@ -1,16 +1,20 @@ import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/types' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { getAgentDetectionTargetKeyForWorktree } from './useAgentDetectionTarget' describe('getAgentDetectionTargetKeyForWorktree', () => { it('uses an explicit runtime owner without scanning ambiguous child SSH repos', () => { let projectGroupReads = 0 - const repos = Array.from({ length: 100 }, (_, index) => { - const repo = { + const repos: readonly Repo[] = Array.from({ length: 100 }, (_, index) => { + const repo: Repo = { id: `repo-${index}`, connectionId: `ssh-${index}`, executionHostId: `ssh:ssh-${index}`, - path: `/workspace/repo-${index}` + path: `/workspace/repo-${index}`, + displayName: `repo-${index}`, + badgeColor: 'blue', + addedAt: 1 } Object.defineProperty(repo, 'projectGroupId', { enumerable: true, diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 00b8621a35b..bbfbe637cb7 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -291,7 +291,10 @@ describe('ensureHooksConfirmed', () => { repos: [ { id: 'repo-1', + path: '/repo-1', displayName: 'Repo One', + badgeColor: 'blue', + addedAt: 1, hookSettings: { mode: 'auto', commandSourcePolicy: 'local-only', @@ -318,7 +321,10 @@ describe('ensureHooksConfirmed', () => { repos: [ { id: 'repo-1', + path: '/repo-1', displayName: 'Repo One', + badgeColor: 'blue', + addedAt: 1, hookSettings: { mode: 'auto', scripts: { setup: 'echo local', archive: '' } diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts index 4afb696d700..ee15c61e761 100644 --- a/src/renderer/src/lib/folder-workspace-connection.ts +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -6,7 +6,7 @@ import { parseExecutionHostId } from '../../../shared/execution-host' export type FolderWorkspaceConnectionState = { folderWorkspaces: FolderWorkspace[] projectGroups: ProjectGroup[] - repos: Repo[] + repos: readonly Repo[] } function getFolderScopeCandidateRepos(args: { diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 60f53457fc7..0f010f05348 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -35,7 +35,7 @@ type StoreAccessor = () => { > | null setActiveWorktree: (worktreeId: string) => void createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown - repos?: LocalhostLinkRepo[] + repos?: readonly LocalhostLinkRepo[] projects?: LocalhostLinkProject[] worktreesByRepo?: Record allWorktrees?: () => LocalhostLinkWorktree[] diff --git a/src/renderer/src/lib/local-preflight-context-cache.test.ts b/src/renderer/src/lib/local-preflight-context-cache.test.ts index 602aa0f302e..4a9cec27812 100644 --- a/src/renderer/src/lib/local-preflight-context-cache.test.ts +++ b/src/renderer/src/lib/local-preflight-context-cache.test.ts @@ -16,11 +16,15 @@ afterEach(() => { resetLocalPreflightContextCachesForTests() }) +function makeRepos(id: string, path: string): AppState['repos'] { + return [{ id, path, displayName: id, badgeColor: 'blue', addedAt: 1 }] +} + function makeWslState(distro: string): AppState { return { activeRepoId: 'repo-1', activeWorktreeId: null, - repos: [{ id: 'repo-1', path: `\\\\wsl.localhost\\${distro}\\home\\alice\\repo` }], + repos: makeRepos('repo-1', `\\\\wsl.localhost\\${distro}\\home\\alice\\repo`), worktreesByRepo: {} } as AppState } @@ -29,7 +33,7 @@ function makeWindowsProjectState(projectId: string): AppState { return { activeRepoId: projectId, activeWorktreeId: null, - repos: [{ id: projectId, path: `C:\\Users\\alice\\${projectId}` }], + repos: makeRepos(projectId, `C:\\Users\\alice\\${projectId}`), settings: {}, worktreesByRepo: {} } as AppState diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index b4f5417f8ce..287376bb548 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -11,26 +11,30 @@ import { } from './local-preflight-context' function makeState(args: { - repoPath?: string | null + repoPath?: string worktreePath?: string | null repo?: Partial worktree?: Partial }): AppState { const repoId = 'repo-1' const worktreeId = `${repoId}::worktree-1` + const repos: AppState['repos'] = + args.repoPath === undefined + ? [] + : [ + { + id: repoId, + path: args.repoPath, + displayName: repoId, + badgeColor: 'blue', + addedAt: 1, + ...args.repo + } + ] return { activeRepoId: repoId, activeWorktreeId: args.worktreePath === undefined ? null : worktreeId, - repos: - args.repoPath === undefined - ? [] - : [ - { - id: repoId, - path: args.repoPath, - ...args.repo - } - ], + repos, worktreesByRepo: args.worktreePath === undefined ? {} diff --git a/src/renderer/src/lib/repo-search.ts b/src/renderer/src/lib/repo-search.ts index 7c4faf192ea..5a223af6392 100644 --- a/src/renderer/src/lib/repo-search.ts +++ b/src/renderer/src/lib/repo-search.ts @@ -38,7 +38,7 @@ function matchScore(repo: Repo, query: string): number | null { return null } -export function searchRepos(repos: Repo[], rawQuery: string): Repo[] { +export function searchRepos(repos: readonly Repo[], rawQuery: string): readonly Repo[] { if (isRepoSearchQueryTooLarge(rawQuery)) { return [] } diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index d48dbb869f3..442a29d6d62 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -126,7 +126,7 @@ async function resolveRepoSlug( } async function buildIndex( - repos: Repo[], + repos: readonly Repo[], settings: Pick | null | undefined ): Promise<{ index: SlugIndex; upstreamIndex: SlugIndex; retryDelayMs: number | null }> { // Why: evict cached entries for repos that no longer exist in state so diff --git a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts index ea21dcfed7f..8e996cf6690 100644 --- a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts +++ b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts @@ -9,8 +9,8 @@ import { localhostWorktreeLabelRouteForPort } from './workspace-port-localhost-l // this is the single source for both reactive and imperative call sites. type LocalhostLabelLookupState = { settings?: Pick | null - repos?: Repo[] - projects?: Project[] + repos?: readonly Repo[] + projects?: readonly Project[] getKnownWorktreeById?: (worktreeId: string) => { projectId?: string | null } | null | undefined } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 1755f355072..c4776039e13 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -327,6 +327,10 @@ describe('createDetectedAgentsSlice WSL context', () => { projects: [ { id: 'repo-1', + displayName: 'repo-1', + badgeColor: 'blue', + createdAt: 1, + updatedAt: 1, sourceRepoIds: ['repo-1'], localWindowsRuntimePreference: { kind: 'windows-host' } } diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index f77b8577a94..5e5b0d61ccb 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -6534,17 +6534,18 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'remote-runtime' } }) const store = createTestStore() + const repos: AppState['repos'] = [ + { + id: 'runtime-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' }, - repos: [ - { - id: 'runtime-repo-id', - path: '/server/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1 - } - ] + repos } as Partial) await store.getState().fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open', { @@ -6634,17 +6635,18 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { _meta: { runtimeId: 'source-runtime' } }) const store = createTestStore() + const repos: AppState['repos'] = [ + { + id: 'local-repo-id', + path: '/server/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + } + ] store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, - repos: [ - { - id: 'local-repo-id', - path: '/server/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1 - } - ] + repos } as Partial) const sourceContext = { diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index bd93507ff41..18ebaf839cd 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -265,11 +265,12 @@ describe('createPreflightSlice', () => { new Error('Project runtime requires repair before preflight: wsl-distro-required') ) const store = createTestStore() + const repos: AppState['repos'] = [makeRepo({ id: 'repo-1', path: 'C:\\repo' })] store.setState({ settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: null } }, - repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + repos, worktreesByRepo: {}, activeRepoId: 'repo-1', activeWorktreeId: null diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index f8c334ff4a7..dda221e18b0 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -14,6 +14,50 @@ describe('reconcileFetchedRepos', () => { expect(reconcileFetchedRepos(previous, next)).toBe(previous) }) + it('reconciles repos whose nested records were rebuilt by hydration and IPC', () => { + // Why: main's hydrateRepo reconstructs hookSettings on every list and structured-clone + // rebuilds the rest, so a reference compare would report every real repo as changed. + const nested = (): Partial => ({ + hookSettings: { mode: 'auto', scripts: { setup: 'echo hi', archive: '' } }, + gitRemoteIdentity: { + canonicalKey: 'github.com/o/n', + remoteName: 'origin', + remoteUrl: 'git@github.com:o/n.git' + }, + importedExternalWorktreePaths: ['/a', '/b'] + }) + const previous = [makeRepo('a', nested())] + const next = structuredClone([makeRepo('a', nested())]) as Repo[] + + expect(reconcileFetchedRepos(previous, next)).toBe(previous) + }) + + it('treats a changed nested field as a real change', () => { + const previous = [makeRepo('a', { importedExternalWorktreePaths: ['/a'] })] + const next = [makeRepo('a', { importedExternalWorktreePaths: ['/b'] })] + const result = reconcileFetchedRepos(previous, next) + + expect(result).not.toBe(previous) + expect(result[0].importedExternalWorktreePaths).toEqual(['/b']) + }) + + it('treats a nested field gaining a key as a real change', () => { + const previous = [ + makeRepo('a', { hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } }) + ] + const next = [ + makeRepo('a', { + hookSettings: { + mode: 'auto', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + }) + ] + + expect(reconcileFetchedRepos(previous, next)).not.toBe(previous) + }) + it('reuses unchanged repo objects while reflecting a reorder', () => { const previous = [makeRepo('a'), makeRepo('b')] const next = [makeRepo('b'), makeRepo('a')] diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 119c4ed3e2a..9212313f0c7 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -8,6 +8,45 @@ import { getRepoHostIdentity } from './repo-host-identity' // virtualizer to rebuild + re-measure a tick after the drop — the visible jump. // Reusing equal objects (and the whole array when nothing moved) makes the echo // a no-op render. +// Why: `Repo` carries nested records (hookSettings, upstream, gitRemoteIdentity, repoIcon, path +// arrays). IPC structured-clone rebuilds those every fetch, and main's hydrateRepo always +// reconstructs hookSettings — so a reference compare reports every repo as changed and no repo +// ever reconciles. Compare nested plain values structurally; they are small sanitized records. +function areValuesEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((item, index) => areValuesEqual(item, b[index])) + ) + } + // Why: only plain records are safe to walk — anything exotic falls back to reference equality. + if ( + Object.getPrototypeOf(a) !== Object.prototype || + Object.getPrototypeOf(b) !== Object.prototype + ) { + return false + } + const aRecord = a as Record + const bRecord = b as Record + const keys = Object.keys(aRecord) + if (keys.length !== Object.keys(bRecord).length) { + return false + } + return keys.every( + (key) => + Object.prototype.hasOwnProperty.call(bRecord, key) && + areValuesEqual(aRecord[key], bRecord[key]) + ) +} + function areReposEqual(a: Repo, b: Repo): boolean { if (a === b) { return true @@ -20,14 +59,17 @@ function areReposEqual(a: Repo, b: Repo): boolean { if (!Object.prototype.hasOwnProperty.call(b, key)) { return false } - if (a[key] !== b[key]) { + if (!areValuesEqual(a[key], b[key])) { return false } } return true } -export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] { +export function reconcileFetchedRepos( + previous: readonly Repo[], + next: readonly Repo[] +): readonly Repo[] { const previousById = new Map(previous.map((repo) => [getRepoHostIdentity(repo), repo])) let identical = next.length === previous.length const reconciled = next.map((repo, index) => { @@ -41,5 +83,5 @@ export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): identical = false return repo }) - return identical ? (previous as Repo[]) : reconciled + return identical ? previous : reconciled } diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 764a5650657..efd5a998375 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import { workItemsCacheKey } from './github' -import type { Project, ProjectHostSetup } from '../../../../shared/types' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' import { toast } from 'sonner' import { installReposRuntimeRoutingHarness, @@ -52,6 +52,46 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('keeps the repos array identity across a refetch that changes nothing', async () => { + // Why: main rebuilds nested records (hookSettings et al) per list and IPC clones them, so a + // production-shaped repo — not a scalar-only fixture — is what proves reconciliation works. + const hydrated = (): Repo => ({ + ...localRepo, + kind: 'git', + gitUsername: 'octocat', + hookSettings: { mode: 'auto', scripts: { setup: 'echo hi', archive: '' } }, + gitRemoteIdentity: { + canonicalKey: 'github.com/octocat/local', + remoteName: 'origin', + remoteUrl: 'git@github.com:octocat/local.git' + }, + importedExternalWorktreePaths: ['/local/wt'] + }) + reposList.mockImplementation(async () => [hydrated()]) + const store = createTestStore() + await store.getState().fetchRepos() + const reposRef = store.getState().repos + + await store.getState().fetchRepos() + + // Why: identity-keyed renderer memos (repo lookup index, selectors) rebuild on a new array. + expect(store.getState().repos).toBe(reposRef) + expect(store.getState().repos[0]).toBe(reposRef[0]) + }) + + it('replaces the repos array identity when a refetch adds a repo', async () => { + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + await store.getState().fetchRepos() + const reposRef = store.getState().repos + reposList.mockResolvedValue([localRepo, { ...localRepo, id: 'second', path: '/second' }]) + + await store.getState().fetchRepos() + + expect(store.getState().repos).not.toBe(reposRef) + expect(store.getState().repos).toHaveLength(2) + }) + it('fetches repos from the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 23bd1e48652..3adbd0ff766 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -881,9 +881,9 @@ function mergeByIdentity( function mergeFetchedReposForHost( previous: readonly Repo[], - fetched: Repo[], + fetched: readonly Repo[], hostId: string -): Repo[] { +): readonly Repo[] { const fetchedWithProjectGroups = applyInheritedProjectGroups(previous, fetched) const fetchedIdentities = new Set(fetchedWithProjectGroups.map(getRepoHostIdentity)) const preserved = previous.filter((repo) => { @@ -1050,7 +1050,7 @@ function mergeFetchedFolderWorkspacesForHost({ } type FetchedRepoCatalog = { - repos: Repo[] + repos: readonly Repo[] projectHostSetupCompatibility: ProjectHostSetupProjection hostId: ReturnType } @@ -1111,7 +1111,7 @@ function mergeFetchedRepoCatalog( catalog: FetchedRepoCatalog, currentRepos: readonly Repo[] ): { - repos: Repo[] + repos: readonly Repo[] projectHostSetupCompatibility: ProjectHostSetupProjection hostId: ReturnType } { @@ -1620,7 +1620,7 @@ function getFolderWorkspacePathStatusRequestSnapshotForRead( } export type RepoSlice = { - repos: Repo[] + repos: readonly Repo[] projects: Project[] projectHostSetups: ProjectHostSetup[] projectGroups: ProjectGroup[] diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts index ec18aea767d..7b50312d7a5 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.test.ts @@ -31,6 +31,12 @@ describe('reconcileReadoptedSshRepoRows', () => { expect(result.pendingReadoptions).toEqual([]) }) + it('returns the input array when nothing is pruned', () => { + const repos = [repo({ id: 'shared', path: '/local' })] + + expect(reconcileReadoptedSshRepoRows(repos, []).repos).toBe(repos) + }) + it('keeps evidence pending when repos:changed has not delivered the new row yet', () => { const oldSsh = repo({ id: 'shared', connectionId: 'ssh-old' }) diff --git a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts index da87da70291..4b20cfa6edf 100644 --- a/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts +++ b/src/renderer/src/store/slices/superseded-ssh-repo-rows.ts @@ -3,7 +3,7 @@ import type { Repo } from '../../../../shared/types' import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' export type SshRepoReconciliation = { - repos: Repo[] + repos: readonly Repo[] pendingReadoptions: SshRepoReadoption[] } @@ -53,8 +53,10 @@ export function reconcileReadoptedSshRepoRows( } } + // Why: pruning nothing must hand back the input array, or the copy alone would defeat the + // referential stability reconcileFetchedRepos just established upstream. if (prunedOwners.size === 0) { - return { repos: [...repos], pendingReadoptions } + return { repos, pendingReadoptions } } return { repos: repos.filter( diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index cd87892eb16..706fa02de2f 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -14,6 +14,7 @@ import type { WorktreeLineage, WorkspaceLineage, ProjectHostSetup, + Repo, WorktreeMeta } from '../../../../shared/types' import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types' @@ -6066,7 +6067,7 @@ export const createWorktreeSlice: StateCreator const repoIdsWithRemovedOwners = new Set() const survivingRepoIds = new Set() const repoIdsWithSurvivingOwners = new Set() - const survivingRepos: AppState['repos'] = [] + const survivingRepos: Repo[] = [] for (const repo of s.repos) { if (isRemovedRuntimeHostId(getRepoExecutionHostId(repo), removed)) { repoIdsWithRemovedOwners.add(repo.id) diff --git a/src/shared/manual-repo-order.test.ts b/src/shared/manual-repo-order.test.ts index 298ba4a10a1..3044a8d5399 100644 --- a/src/shared/manual-repo-order.test.ts +++ b/src/shared/manual-repo-order.test.ts @@ -27,6 +27,26 @@ describe('manual repo order', () => { expect(applyManualRepoOrder([localBravo, localAlpha], [])).toEqual([localBravo, localAlpha]) }) + it('returns the input array when no overlay exists', () => { + const repos = [localBravo, localAlpha] + + expect(applyManualRepoOrder(repos, [])).toBe(repos) + }) + + it('returns the input array when the saved order moves nothing', () => { + const repos = [localAlpha, remoteCharlie, localBravo, remoteDelta] + + expect(applyManualRepoOrder(repos, getManualRepoOrder(repos))).toBe(repos) + }) + + it('returns a new array when the saved order actually reorders', () => { + const repos = [localBravo, localAlpha] + const reordered = applyManualRepoOrder(repos, getManualRepoOrder([localAlpha, localBravo])) + + expect(reordered).not.toBe(repos) + expect(reordered).toEqual([localAlpha, localBravo]) + }) + it('restores a host-qualified cross-host interleaving', () => { const order = getManualRepoOrder([localAlpha, remoteCharlie, localBravo, remoteDelta]) diff --git a/src/shared/manual-repo-order.ts b/src/shared/manual-repo-order.ts index 1ba8c886cab..9b5bfee39a1 100644 --- a/src/shared/manual-repo-order.ts +++ b/src/shared/manual-repo-order.ts @@ -41,13 +41,15 @@ export function getManualRepoOrder(repos: readonly Repo[]): ManualRepoOrderEntry export function applyManualRepoOrder( repos: readonly Repo[], order: readonly ManualRepoOrderEntry[] | null | undefined -): Repo[] { +): readonly Repo[] { const normalized = normalizeManualRepoOrder(order) + // Why: results flow straight back into `repos`, so a reorder that moves nothing must return + // the input — a fresh array would invalidate every identity-keyed repo memo downstream. if (normalized.length === 0) { - return [...repos] + return repos } const rankByKey = new Map(normalized.map((entry, index) => [getEntryKey(entry), index])) - return repos + const ordered = repos .map((repo, index) => ({ repo, index, @@ -66,4 +68,5 @@ export function applyManualRepoOrder( return a.rank - b.rank || a.index - b.index }) .map(({ repo }) => repo) + return ordered.every((repo, index) => repo === repos[index]) ? repos : ordered } From c578f4e4e236d0d53b4d8ff886a8aaa6f0d8620c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:10:40 -0700 Subject: [PATCH 10/39] fix(e2e): restore the helpers the macOS key-remap spec imports (#13758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #13314 landed terminal-macos-system-key-remap.spec.ts but its final commit, "chore: drop non-mergeable IME e2e scratch files", deleted the three modules it imports. The spec survived; terminal-ime-pane-arena, terminal-ime-cdp-composition and terminal-ime-platform-policy did not. Playwright resolves every spec before running any of them, so the unresolved import is fatal for the whole run, not just that file: `playwright test --list` on main reports "Total: 0 tests in 0 files". All ten scheduled E2E shards have been failing at collection since, and the macOS system-key-remap coverage the PR was for has never run once. No typecheck project includes tests/, which is why this stayed invisible outside the E2E lane. Restored from the commit before the drop. pane-arena and platform-policy come back verbatim; cdp-composition keeps only the four entry points this spec uses, since the rest served specs that were not merged. Its doc comment is retargeted accordingly — the composition-session drivers are the part that went. Collection is back to 528 tests in 222 files and all six remap tests pass. --- tests/e2e/terminal-ime-cdp-composition.ts | 89 +++++++++++++++++++++++ tests/e2e/terminal-ime-pane-arena.ts | 50 +++++++++++++ tests/e2e/terminal-ime-platform-policy.ts | 51 +++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 tests/e2e/terminal-ime-cdp-composition.ts create mode 100644 tests/e2e/terminal-ime-pane-arena.ts create mode 100644 tests/e2e/terminal-ime-platform-policy.ts diff --git a/tests/e2e/terminal-ime-cdp-composition.ts b/tests/e2e/terminal-ime-cdp-composition.ts new file mode 100644 index 00000000000..e035db8bef0 --- /dev/null +++ b/tests/e2e/terminal-ime-cdp-composition.ts @@ -0,0 +1,89 @@ +import type { CDPSession } from '@stablyai/playwright-test' + +/** + * Dispatches the key shapes an input source or a system text substitution produces, through CDP. + * + * Driving these from the browser protocol rather than a native input source is what removes the + * accessibility grant and the system input source that would otherwise force a `@headful`, + * macOS-only gate, so the specs they feed run in the normal headless project on CI. + */ +export type ImeKeyIdentity = { + key: string + code: string + keyCode: number +} + +/** + * A printable keydown whose `key` the input source has already rewritten to the glyph it will + * commit — the shape a CJK source produces for punctuation and full-width digits, which arrive + * with no composition session at all. + */ +export async function dispatchImeRewrittenPrintableKey( + session: CDPSession, + identity: ImeKeyIdentity +): Promise { + await session.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode, + text: identity.key, + unmodifiedText: identity.key + }) + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode + }) +} + +/** + * A printable keydown that still carries the **physical layout key**, followed by the substituted + * glyph arriving through the text system. + * + * This is the macOS shape for full-width punctuation and digits, and for a + * `DefaultKeyBinding.dict` remap: the substitution happens inside `insertText:`, not on the + * keydown, so the keydown Chromium delivers is the plain layout character and the substituted one + * only ever appears in the `input` event. Anything that produces terminal bytes from the keydown + * emits the layout form and destroys the real one. + */ +export async function dispatchImeSubstitutedTextKey( + session: CDPSession, + identity: ImeKeyIdentity, + committedText: string +): Promise { + // rawKeyDown carries no `text`, so Chromium generates no character of its own and the only + // committed text is the one the text system supplies below. + await session.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode, + text: '', + unmodifiedText: '' + }) + await session.send('Input.insertText', { text: committedText }) + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: identity.key, + code: identity.code, + windowsVirtualKeyCode: identity.keyCode, + nativeVirtualKeyCode: identity.keyCode + }) +} + +export async function dispatchPlainEnter(session: CDPSession): Promise { + for (const type of ['rawKeyDown', 'keyUp'] as const) { + await session.send('Input.dispatchKeyEvent', { + type, + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13 + }) + } +} diff --git a/tests/e2e/terminal-ime-pane-arena.ts b/tests/e2e/terminal-ime-pane-arena.ts new file mode 100644 index 00000000000..4462814d06a --- /dev/null +++ b/tests/e2e/terminal-ime-pane-arena.ts @@ -0,0 +1,50 @@ +import type { CDPSession, Page, TestInfo } from '@stablyai/playwright-test' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + attachTerminalImeBoundaryEvidence, + disposeTerminalImeBoundaryProbe, + installTerminalImeBoundaryProbe +} from './terminal-ime-boundary-probe' + +/** A focused terminal pane with a CDP session and the boundary probe already attached. */ +export type TerminalImePaneArena = { + page: Page + session: CDPSession + ptyId: string +} + +export async function openTerminalImePaneArena(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const ptyId = await waitForActivePanePtyId(page) + const session = await page.context().newCDPSession(page) + await focusActiveTerminalInput(page) + await installTerminalImeBoundaryProbe(page) + return { page, session, ptyId } +} + +/** + * `interrupt` sends Ctrl+C only when the test did not reach its end, so a spec that failed + * mid-composition cannot leave a byte reader holding the pane for the next test in the worker. + */ +export async function closeTerminalImePaneArena( + arena: TerminalImePaneArena, + testInfo: TestInfo, + evidenceName: string, + interrupt: boolean +): Promise { + await attachTerminalImeBoundaryEvidence(arena.page, testInfo, evidenceName).catch(() => undefined) + await disposeTerminalImeBoundaryProbe(arena.page).catch(() => undefined) + await arena.session.detach().catch(() => undefined) + if (interrupt) { + await sendToTerminal(arena.page, arena.ptyId, '\x03').catch(() => undefined) + } +} diff --git a/tests/e2e/terminal-ime-platform-policy.ts b/tests/e2e/terminal-ime-platform-policy.ts new file mode 100644 index 00000000000..04adb66f0ea --- /dev/null +++ b/tests/e2e/terminal-ime-platform-policy.ts @@ -0,0 +1,51 @@ +import type { Page } from '@stablyai/playwright-test' + +/** + * Pins the renderer's IME ownership policy to one platform, from any runner. + * + * Every platform-dependent IME decision in the terminal reads `navigator.userAgent` and nothing + * else — the forwarder that owns printable keydowns installs only when the UA reports macOS, the + * candidate-key guards only when it reports desktop Linux, and the standalone `keyCode 229` + * keydown reaches xterm on macOS and Linux but not on Windows. Overriding the UA is therefore the + * whole platform decision, which is what lets the Windows and Linux shapes run headless on the + * Linux CI shards instead of needing three runners. + * + * What it does NOT simulate is the input framework itself. That comes from the recorded traces + * replayed against the pinned policy, because the frameworks disagree on ordering in ways no + * hand-authored sequence would have predicted. + */ +export type ImePlatformPolicy = 'mac' | 'windows' | 'linux' + +const USER_AGENT_BY_POLICY: Record = { + mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/146 Safari/537.36', + windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/146 Safari/537.36', + // X11 rather than Wayland in the UA string: Chromium reports the same token for both, so the + // Wayland traces run under this policy too. + linux: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146 Safari/537.36' +} + +export async function applyImePlatformPolicy(page: Page, policy: ImePlatformPolicy): Promise { + await page.addInitScript((userAgent) => { + Object.defineProperty(navigator, 'userAgent', { + get: () => userAgent, + configurable: true + }) + }, USER_AGENT_BY_POLICY[policy]) + await page.reload({ waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) +} + +/** Fails loudly if the override did not take, so a policy-scoped spec cannot pass on the wrong one. */ +export async function expectImePlatformPolicy( + page: Page, + policy: ImePlatformPolicy +): Promise { + const observed = await page.evaluate(() => ({ + mac: navigator.userAgent.includes('Mac'), + windows: navigator.userAgent.includes('Windows'), + linux: navigator.userAgent.includes('Linux') && !/Android|CrOS/.test(navigator.userAgent) + })) + if (!observed[policy] || (policy !== 'mac' && observed.mac)) { + throw new Error(`IME platform policy '${policy}' did not take: ${JSON.stringify(observed)}`) + } +} From bb08b66b21a9695ceea838884cbf73efd2943e17 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:20:53 -0700 Subject: [PATCH 11/39] perf(runtime): avoid forced SSH hydration on project refresh (#13636) * perf(runtime): avoid forced SSH hydration on project refresh * fix(runtime): backfill SSH states for targets learned without a state read A failed-connect resync labelled a target with no ssh.getState, and the new metadata-refresh gates then skipped it forever: no SSH chip and every worktree mutation to that host failed the connection-generation check. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/renderer/src/hooks/useIpcEvents.ts | 7 +- .../runtime-environment-ssh-state.test.ts | 225 ++++++++++++++++++ .../runtime/runtime-environment-ssh-state.ts | 159 ++++++++++--- 3 files changed, 355 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index cedb239535f..984a8fd833f 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -101,7 +101,8 @@ import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/term import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' import { applyRuntimeEnvironmentSshStateChanged, - hydrateRuntimeEnvironmentSshState + hydrateRuntimeEnvironmentSshState, + refreshRuntimeEnvironmentSshTargetMetadata } from '@/runtime/runtime-environment-ssh-state' import { createRuntimeProjectRefreshScheduler, @@ -907,8 +908,8 @@ export function useIpcEvents(): void { const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({ refresh: async (environmentId) => { - // Why: refresh the env's SSH bucket on (re)connect so a pre-drop snapshot can't keep a reconnect overlay stale. - void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {}) + // Why: project events can reveal target CRUD, but known target states already arrive by push. + void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) await refreshRuntimeProjectWorktreesAndLineage( environmentId, diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts index 46567c5343c..7efb1800084 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts @@ -5,6 +5,7 @@ import { applyRuntimeEnvironmentSshStateChanged, connectRuntimeEnvironmentSshTarget, hydrateRuntimeEnvironmentSshState, + refreshRuntimeEnvironmentSshTargetMetadata, resyncRuntimeEnvironmentSshTargets } from './runtime-environment-ssh-state' import { callRuntimeRpc } from './runtime-rpc-client' @@ -119,6 +120,212 @@ describe('hydrateRuntimeEnvironmentSshState', () => { expect(callRuntimeRpcMock.mock.calls.length).toBeGreaterThan(callsAfterFirst) }) + it('does not rerun when an ordinary refresh joins a forced hydration', async () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-1', label: 'devbox' }]) + let resolveTargets!: (value: { targets: { id: string; label: string }[] }) => void + const targetsPromise = new Promise<{ targets: { id: string; label: string }[] }>((resolve) => { + resolveTargets = resolve + }) + callRuntimeRpcMock.mockImplementation((_target, method) => { + if (method === 'ssh.listTargetSummaries') { + return targetsPromise as never + } + if (method === 'ssh.listRemovedTargetLabels') { + return Promise.resolve({ labels: {} } as never) + } + return Promise.resolve({ state: connState('ssh-1') } as never) + }) + + const forced = hydrateRuntimeEnvironmentSshState(envId, { force: true }) + const ordinary = hydrateRuntimeEnvironmentSshState(envId) + resolveTargets({ targets: [{ id: 'ssh-1', label: 'devbox' }] }) + await Promise.all([forced, ordinary]) + + expect( + callRuntimeRpcMock.mock.calls.filter(([, method]) => method === 'ssh.listTargetSummaries') + ).toHaveLength(1) + expect( + callRuntimeRpcMock.mock.calls.filter(([, method]) => method === 'ssh.listRemovedTargetLabels') + ).toHaveLength(1) + expect( + callRuntimeRpcMock.mock.calls.filter(([, method]) => method === 'ssh.getState') + ).toHaveLength(1) + }) + + it('hydrates without force after the environment bucket is marked stale', async () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-old', label: 'old box' }]) + useAppStore.getState().markEnvironmentSshStateStale(envId) + installRpcResponses({ + targets: [{ id: 'ssh-new', label: 'new box' }], + states: { 'ssh-new': connState('ssh-new') } + }) + + await hydrateRuntimeEnvironmentSshState(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetsHydrated).toBe(true) + expect(bucket?.targetLabels.get('ssh-new')).toBe('new box') + expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected') + }) + + it('refreshes only target summaries when hydrated metadata is unchanged', async () => { + const envId = nextEnvId() + installRpcResponses({ + targets: [{ id: 'ssh-1', label: 'devbox' }], + states: { 'ssh-1': connState('ssh-1') } + }) + await hydrateRuntimeEnvironmentSshState(envId) + callRuntimeRpcMock.mockClear() + + await refreshRuntimeEnvironmentSshTargetMetadata(envId) + + expect(callRuntimeRpcMock.mock.calls.map(([, method]) => method)).toEqual([ + 'ssh.listTargetSummaries' + ]) + }) + + it('reads state for a labelled target the bucket never recorded a state for', async () => { + const envId = nextEnvId() + // A failed-connect resync can label a target without ever reading its state. + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-new', label: 'new box' }]) + installRpcResponses({ + targets: [{ id: 'ssh-new', label: 'new box' }], + states: { 'ssh-new': connState('ssh-new') } + }) + + await refreshRuntimeEnvironmentSshTargetMetadata(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected') + }) + + it('prunes removed targets and fetches state only for newly discovered targets', async () => { + const envId = nextEnvId() + useAppStore.getState().setEnvironmentSshTargetsMetadata(envId, [ + { id: 'ssh-keep', label: 'keep' }, + { id: 'ssh-old', label: 'old' } + ]) + useAppStore + .getState() + .setEnvironmentSshConnectionState(envId, 'ssh-keep', connState('ssh-keep')) + useAppStore.getState().setEnvironmentSshConnectionState(envId, 'ssh-old', connState('ssh-old')) + installRpcResponses({ + targets: [ + { id: 'ssh-keep', label: 'keep' }, + { id: 'ssh-new', label: 'new' } + ], + labels: { 'ssh-old': 'old' }, + states: { 'ssh-new': connState('ssh-new', 'connecting') } + }) + + await refreshRuntimeEnvironmentSshTargetMetadata(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetLabels.has('ssh-old')).toBe(false) + expect(bucket?.connectionStates.has('ssh-old')).toBe(false) + expect(bucket?.removedTargetLabels.get('ssh-old')).toBe('old') + expect(bucket?.connectionStates.get('ssh-keep')?.status).toBe('connected') + expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connecting') + expect(callRuntimeRpcMock.mock.calls.filter(([, method]) => method === 'ssh.getState')).toEqual( + [ + [ + { kind: 'environment', environmentId: envId }, + 'ssh.getState', + { targetId: 'ssh-new' }, + expect.anything() + ] + ] + ) + }) + + it('reruns hydration when a new generation joins an older in-flight request', async () => { + const envId = nextEnvId() + let resolveOlderTargets!: (value: { targets: { id: string; label: string }[] }) => void + const olderTargets = new Promise<{ targets: { id: string; label: string }[] }>((resolve) => { + resolveOlderTargets = resolve + }) + let targetListCallCount = 0 + callRuntimeRpcMock.mockImplementation((_target, method, params) => { + if (method === 'ssh.listTargetSummaries') { + targetListCallCount += 1 + return ( + targetListCallCount === 1 + ? olderTargets + : Promise.resolve({ targets: [{ id: 'ssh-new', label: 'new' }] }) + ) as never + } + if (method === 'ssh.listRemovedTargetLabels') { + return Promise.resolve({ labels: {} } as never) + } + const targetId = (params as { targetId: string }).targetId + return Promise.resolve({ state: connState(targetId) } as never) + }) + + const olderHydration = hydrateRuntimeEnvironmentSshState(envId) + useAppStore.getState().markEnvironmentSshStateStale(envId) + const newerHydration = hydrateRuntimeEnvironmentSshState(envId) + resolveOlderTargets({ targets: [{ id: 'ssh-old', label: 'old' }] }) + await Promise.all([olderHydration, newerHydration]) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(targetListCallCount).toBe(2) + expect(bucket?.targetsHydrated).toBe(true) + expect(bucket?.targetLabels.has('ssh-old')).toBe(false) + expect(bucket?.targetLabels.get('ssh-new')).toBe('new') + expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected') + }) + + it('keeps a failed full refresh full when a metadata refresh queued behind it', async () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-1', label: 'devbox' }]) + useAppStore.getState().markEnvironmentSshStateStale(envId) + let rejectFirstTargets!: (error: Error) => void + const firstTargets = new Promise((_resolve, reject) => { + rejectFirstTargets = reject + }) + let targetListCallCount = 0 + callRuntimeRpcMock.mockImplementation((_target, method) => { + if (method === 'ssh.listTargetSummaries') { + targetListCallCount += 1 + return ( + targetListCallCount === 1 + ? firstTargets + : Promise.resolve({ targets: [{ id: 'ssh-1', label: 'devbox' }] }) + ) as never + } + if (method === 'ssh.listRemovedTargetLabels') { + return Promise.resolve({ labels: {} } as never) + } + return Promise.resolve({ state: connState('ssh-1') } as never) + }) + + const forced = hydrateRuntimeEnvironmentSshState(envId, { force: true }) + const metadata = refreshRuntimeEnvironmentSshTargetMetadata(envId) + rejectFirstTargets(new Error('transient target-list failure')) + await Promise.all([forced, metadata]) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(targetListCallCount).toBe(2) + expect(bucket?.targetsHydrated).toBe(true) + expect(bucket?.connectionStates.get('ssh-1')?.status).toBe('connected') + expect(callRuntimeRpcMock.mock.calls.map(([, method]) => method)).toEqual([ + 'ssh.listTargetSummaries', + 'ssh.listTargetSummaries', + 'ssh.listRemovedTargetLabels', + 'ssh.getState' + ]) + }) + it('leaves the bucket un-hydrated when the host lacks the ssh RPC methods', async () => { const envId = nextEnvId() installRpcResponses({ failListTargets: true }) @@ -368,4 +575,22 @@ describe('resyncRuntimeEnvironmentSshTargets', () => { expect(bucket?.targetLabels.get('ssh-live')).toBe('devbox') expect(bucket?.targetsHydrated).toBe(true) }) + + it('reads the connection state of a host re-added under a new target id', async () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-old', label: 'devbox' }]) + installRpcResponses({ + targets: [{ id: 'ssh-new', label: 'devbox' }], + labels: { 'ssh-old': 'devbox' }, + states: { 'ssh-new': connState('ssh-new') } + }) + + await resyncRuntimeEnvironmentSshTargets(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetLabels.get('ssh-new')).toBe('devbox') + expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected') + }) }) diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts index 07051882292..689b6ad88c6 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -3,6 +3,7 @@ import type { SshConnectionState, SshTargetSummary } from '../../../shared/ssh-t import { callRuntimeRpc } from './runtime-rpc-client' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' +import { sshTargetLabelsEqual } from '@/store/slices/ssh-target-cleanup' /** * Mirrors a remote Orca server's own SSH targets into that environment's @@ -44,7 +45,18 @@ async function syncEnvironmentSshTargetMetadata( generation: number ): Promise { const targets = await fetchEnvironmentSshTargets(environmentId) + if (generation !== getEnvironmentSshStateGeneration(environmentId)) { + return [] + } useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets, generation) + await syncEnvironmentRemovedSshTargetLabels(environmentId, generation) + return generation === getEnvironmentSshStateGeneration(environmentId) ? targets : [] +} + +async function syncEnvironmentRemovedSshTargetLabels( + environmentId: string, + generation: number +): Promise { try { const { labels } = await callRuntimeRpc<{ labels: Record }>( environmentTarget(environmentId), @@ -56,7 +68,6 @@ async function syncEnvironmentSshTargetMetadata( } catch { // Best-effort — a missing map just falls back to the raw target id. } - return targets } async function fetchEnvironmentSshConnectionStates( @@ -65,6 +76,9 @@ async function fetchEnvironmentSshConnectionStates( generation: number ): Promise { for (const target of targets) { + if (generation !== getEnvironmentSshStateGeneration(environmentId)) { + return + } try { const { state } = await callRuntimeRpc<{ state: SshConnectionState | null }>( environmentTarget(environmentId), @@ -84,8 +98,21 @@ async function fetchEnvironmentSshConnectionStates( } } -type HydrationEntry = { promise: Promise; rerunRequested: boolean } -const hydrationsInFlight = new Map() +type SshRefreshKind = 'metadata' | 'full' +type SshRefreshEntry = { + promise: Promise + generation: number + kind: SshRefreshKind + rerunKind: SshRefreshKind | null +} +const sshRefreshesInFlight = new Map() + +function mergeSshRefreshKind( + current: SshRefreshKind | null, + requested: SshRefreshKind +): SshRefreshKind { + return current === 'full' || requested === 'full' ? 'full' : 'metadata' +} async function runEnvironmentSshHydration(environmentId: string): Promise { const generation = getEnvironmentSshStateGeneration(environmentId) @@ -93,6 +120,79 @@ async function runEnvironmentSshHydration(environmentId: string): Promise await fetchEnvironmentSshConnectionStates(environmentId, targets, generation) } +async function runEnvironmentSshTargetMetadataRefresh(environmentId: string): Promise { + const generation = getEnvironmentSshStateGeneration(environmentId) + const bucket = useAppStore.getState().sshStateByEnvironment.get(environmentId) + const targets = await fetchEnvironmentSshTargets(environmentId) + if (generation !== getEnvironmentSshStateGeneration(environmentId)) { + return + } + const metadataChanged = + !bucket?.targetsHydrated || !sshTargetLabelsEqual(bucket.targetLabels, targets) + useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets, generation) + const priorTargetIds = new Set(bucket?.targetLabels.keys() ?? []) + // Why: read states after the write — a resync racing this fetch can label a target that never had one read. + const knownStates = useAppStore.getState().sshStateByEnvironment.get(environmentId) + ?.connectionStates + const needStateRead = targets.filter( + (target) => !priorTargetIds.has(target.id) || !knownStates?.has(target.id) + ) + if (!metadataChanged) { + await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation) + return + } + await syncEnvironmentRemovedSshTargetLabels(environmentId, generation) + await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation) +} + +function requestSshRefreshRerun(entry: SshRefreshEntry, kind: SshRefreshKind): void { + entry.rerunKind = mergeSshRefreshKind(entry.rerunKind, kind) +} + +function startEnvironmentSshRefresh( + environmentId: string, + initialKind: SshRefreshKind +): Promise { + const entry: SshRefreshEntry = { + promise: Promise.resolve(), + generation: getEnvironmentSshStateGeneration(environmentId), + kind: initialKind, + rerunKind: null + } + entry.promise = (async () => { + let nextKind: SshRefreshKind | null = initialKind + let lastError: unknown = null + try { + while (nextKind) { + entry.kind = nextKind + entry.generation = getEnvironmentSshStateGeneration(environmentId) + entry.rerunKind = null + try { + await (entry.kind === 'full' + ? runEnvironmentSshHydration(environmentId) + : runEnvironmentSshTargetMetadataRefresh(environmentId)) + lastError = null + } catch (error) { + lastError = error + if (entry.rerunKind) { + entry.rerunKind = mergeSshRefreshKind(entry.rerunKind, entry.kind) + } + } + nextKind = entry.rerunKind + } + if (lastError) { + throw lastError + } + } finally { + if (sshRefreshesInFlight.get(environmentId) === entry) { + sshRefreshesInFlight.delete(environmentId) + } + } + })() + sshRefreshesInFlight.set(environmentId, entry) + return entry.promise +} + /** * Fetches the environment's SSH targets, removal tombstones, and per-target * connection states into its bucket. Single-flight per environment; a `force` @@ -107,10 +207,11 @@ export async function hydrateRuntimeEnvironmentSshState( environmentId: string, options: { force?: boolean } = {} ): Promise { - const inFlight = hydrationsInFlight.get(environmentId) + const generation = getEnvironmentSshStateGeneration(environmentId) + const inFlight = sshRefreshesInFlight.get(environmentId) if (inFlight) { - if (options.force) { - inFlight.rerunRequested = true + if (options.force || inFlight.generation !== generation) { + requestSshRefreshRerun(inFlight, 'full') } return inFlight.promise } @@ -118,28 +219,21 @@ export async function hydrateRuntimeEnvironmentSshState( if (!options.force && bucket?.targetsHydrated) { return } - const entry: HydrationEntry = { promise: Promise.resolve(), rerunRequested: false } - entry.promise = (async () => { - let lastError: unknown = null - try { - do { - entry.rerunRequested = false - try { - await runEnvironmentSshHydration(environmentId) - lastError = null - } catch (error) { - lastError = error - } - } while (entry.rerunRequested) - if (lastError) { - throw lastError - } - } finally { - hydrationsInFlight.delete(environmentId) - } - })() - hydrationsInFlight.set(environmentId, entry) - return entry.promise + return startEnvironmentSshRefresh(environmentId, 'full') +} + +/** Refreshes target membership without re-reading every known target state. */ +export async function refreshRuntimeEnvironmentSshTargetMetadata( + environmentId: string +): Promise { + const generation = getEnvironmentSshStateGeneration(environmentId) + const inFlight = sshRefreshesInFlight.get(environmentId) + if (inFlight) { + requestSshRefreshRerun(inFlight, inFlight.generation === generation ? 'metadata' : 'full') + return inFlight.promise + } + const bucket = useAppStore.getState().sshStateByEnvironment.get(environmentId) + return startEnvironmentSshRefresh(environmentId, bucket?.targetsHydrated ? 'metadata' : 'full') } /** @@ -196,10 +290,9 @@ export async function connectRuntimeEnvironmentSshTarget( } /** Resyncs the environment's target metadata after a failed connect so a - * stale overlay converges to the ghost/re-adopted state (STA-1468). */ + * stale overlay converges to the ghost/re-adopted state (STA-1468). Full + * hydration, not metadata alone: a host re-added under a new target id must + * land with a connection state or its chip and mutation gate stay dead. */ export async function resyncRuntimeEnvironmentSshTargets(environmentId: string): Promise { - await syncEnvironmentSshTargetMetadata( - environmentId, - getEnvironmentSshStateGeneration(environmentId) - ) + await hydrateRuntimeEnvironmentSshState(environmentId, { force: true }) } From a639bd355eb23a4fd0f3742ba295b2d83271b7b9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:33:17 -0700 Subject: [PATCH 12/39] [perf-remote] perf(codex): skip migration scans for nonshared reattaches (#13654) * perf(codex): skip migration scans for nonshared reattaches * fix(codex): require provenance to skip migration scans and bound ignored launches Drops the exit-match escape hatch that skipped a scan without route provenance, and ages out ignored reattach launches on the existing 60s/256 lease policy so a lost exit can no longer pin an active launch (and its completion marker) for the process lifetime. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/codex/codex-pane-account-registry.ts | 7 + ...odex-session-migration-ignored-launches.ts | 35 ++++ ...dex-session-migration-recent-exits.test.ts | 17 ++ .../codex-session-migration-recent-exits.ts | 60 +++++++ .../codex-session-migration-scheduler.test.ts | 168 ++++++++++++++++++ .../codex-session-migration-scheduler.ts | 73 +++----- .../codex/codex-stale-pane-accounts.test.ts | 12 ++ src/main/index.ts | 16 ++ src/main/ipc/pty.test.ts | 19 +- src/main/ipc/pty.ts | 80 ++++++++- 10 files changed, 437 insertions(+), 50 deletions(-) create mode 100644 src/main/codex/codex-session-migration-ignored-launches.ts create mode 100644 src/main/codex/codex-session-migration-recent-exits.test.ts create mode 100644 src/main/codex/codex-session-migration-recent-exits.ts diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index 359bc3e9e8f..595f1d3e767 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -225,6 +225,13 @@ export function getCodexPaneAccount(ptyId: string): CodexPaneAccountRecord | nul return readRegistry().panes[ptyId] ?? null } +/** `custom-home` stays conservative because it can mask a non-comparable shared-home route. */ +export function isCodexPaneHomeRouteProvenAwayFromSharedHome( + route: CodexPaneHomeRoute | undefined +): boolean { + return route === 'real-home' || route === 'account-home' || route === 'wsl-home' +} + /** * Reports the lane each given PTY launched from, omitting panes with no record. * diff --git a/src/main/codex/codex-session-migration-ignored-launches.ts b/src/main/codex/codex-session-migration-ignored-launches.ts new file mode 100644 index 00000000000..fcddb0393bf --- /dev/null +++ b/src/main/codex/codex-session-migration-ignored-launches.ts @@ -0,0 +1,35 @@ +import { + CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS, + evictStaleLeaseEntries +} from './codex-session-migration-recent-exits' + +type IgnoredLaunch = { recordedAt: number } + +/** Lease ids whose reattach was proven away from the shared home, so their exit owes no scan. */ +export class CodexSessionMigrationIgnoredLaunches { + private readonly launches = new Map() + + add(leaseId: string): void { + const now = Date.now() + this.launches.delete(leaseId) + this.launches.set(leaseId, { recordedAt: now }) + evictStaleLeaseEntries(this.launches, now) + } + + has(leaseId: string): boolean { + const launch = this.launches.get(leaseId) + if (!launch) { + return false + } + // Why: an entry whose exit never arrived must age out, or it absorbs a later exit and strands that launch active. + if (Date.now() - launch.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + this.launches.delete(leaseId) + return false + } + return true + } + + delete(leaseId: string): boolean { + return this.has(leaseId) && this.launches.delete(leaseId) + } +} diff --git a/src/main/codex/codex-session-migration-recent-exits.test.ts b/src/main/codex/codex-session-migration-recent-exits.test.ts new file mode 100644 index 00000000000..84d08768402 --- /dev/null +++ b/src/main/codex/codex-session-migration-recent-exits.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' + +describe('CodexSessionMigrationRecentExits', () => { + it('refreshes a rerecorded ID before evicting the oldest entry', () => { + const exits = new CodexSessionMigrationRecentExits() + for (let index = 0; index < 256; index += 1) { + exits.record(`pty-${index}`, index + 1) + } + + exits.record('pty-0', 500) + exits.record('pty-overflow', 501) + + expect(exits.matchesAfter('pty-0', 499)).toBe(true) + expect(exits.matchesAfter('pty-1', 0)).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-migration-recent-exits.ts b/src/main/codex/codex-session-migration-recent-exits.ts new file mode 100644 index 00000000000..2a92441537b --- /dev/null +++ b/src/main/codex/codex-session-migration-recent-exits.ts @@ -0,0 +1,60 @@ +export const CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS = 60_000 +const MAX_ENTRIES = 256 + +type RecentPtyExit = { sequence: number; recordedAt: number } + +// Why: one aging policy for every lease-keyed migration map, so none can grow for the process lifetime. +export function evictStaleLeaseEntries( + entries: Map, + now: number +): void { + for (const [leaseId, entry] of entries) { + if (now - entry.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + entries.delete(leaseId) + } + } + while (entries.size > MAX_ENTRIES) { + const oldestLeaseId = entries.keys().next().value + if (oldestLeaseId === undefined) { + break + } + entries.delete(oldestLeaseId) + } +} + +export class CodexSessionMigrationRecentExits { + private readonly exits = new Map() + + record(leaseId: string, sequence: number): void { + const now = Date.now() + this.exits.delete(leaseId) + this.exits.set(leaseId, { sequence, recordedAt: now }) + evictStaleLeaseEntries(this.exits, now) + } + + consumeAfter(leaseId: string, startedSequence: number | undefined): RecentPtyExit | null { + const exit = this.exits.get(leaseId) + this.exits.delete(leaseId) + if ( + !exit || + startedSequence === undefined || + exit.sequence <= startedSequence || + Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS + ) { + return null + } + return exit + } + + matchesAfter(leaseId: string, startedSequence: number | undefined): boolean { + const exit = this.exits.get(leaseId) + if (!exit) { + return false + } + if (Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + this.exits.delete(leaseId) + return false + } + return startedSequence !== undefined && exit.sequence > startedSequence + } +} diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index 9f9aed3173c..b495afb8e0e 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -491,6 +491,174 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) }) + it('keeps an ignored reattach exit from poisoning a same-ID launch already starting', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.finishLaunch('stable-pty', 3) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: expect.any(Array), + writeCompletionMarker: false, + writeBoundedCompletionMarker: false + }), + undefined + ) + }) + + it('matches an ignored reattach exit that arrived before its lifecycle callback', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 3) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: expect.any(Array), + writeCompletionMarker: false, + writeBoundedCompletionMarker: false + }), + undefined + ) + }) + + it('keeps a newer same-ID launch active while the ignored incarnation exits', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + scheduler.finishLaunch('stable-pty', 3) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 4) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('releases a stranded active launch once the ignored incarnation ages out', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + // The ignored incarnation never reports its exit, so only the newer launch ever finishes. + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(61_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 3) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('keeps late duplicate ignored callbacks from poisoning reuse', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.finishLaunch('stable-pty', 3) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.beginLaunch('stable-pty', false, new Date(), 4) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(1) + + scheduler.finishLaunch('stable-pty', 5) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(2) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('keeps three exit-before-callback reattaches from stranding a reused ID', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 4) + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 3) + scheduler.beginLaunch('stable-pty', false, new Date(), 5) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 6) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledTimes(2) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + it('does not consume an exit from an earlier stable-id incarnation', async () => { const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 28ce4b991da..9f08d83c5ea 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,4 +1,6 @@ import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import { CodexSessionMigrationIgnoredLaunches } from './codex-session-migration-ignored-launches' +import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' import type { CodexSessionBackfillDate, CodexSessionBackfillOptions @@ -9,11 +11,6 @@ type MigrationRun = ( systemCodexHomePathOverride?: string ) => Promise -const EARLY_PTY_EXIT_RETENTION_MS = 60_000 -const MAX_EARLY_PTY_EXITS = 256 - -type EarlyPtyExit = { sequence: number; recordedAt: number } - export type CodexSessionMigrationScheduler = { beginLaunch( leaseId: string, @@ -21,6 +18,7 @@ export type CodexSessionMigrationScheduler = { startedAt?: Date, startedSequence?: number ): void + ignoreLaunch(leaseId: string, startedSequence: number): void finishLaunch(leaseId: string, exitSequence?: number): void scheduleInitialRun(): void scheduleRun(fullScanRequired?: boolean): void @@ -46,7 +44,10 @@ export function createCodexSessionMigrationScheduler(args: { let pendingFullScan = false let migrationTask: Promise | null = null const activeLaunches = new Map() - const earlyPtyExits = new Map() + // Why: non-shared reattach exits must not masquerade as exit-before-begin races for a reused id. + const ignoredLaunches = new CodexSessionMigrationIgnoredLaunches() + const ignoredPtyExits = new CodexSessionMigrationRecentExits() + const earlyPtyExits = new CodexSessionMigrationRecentExits() let activeRunStopObserved = false let rerunRequested = false @@ -194,18 +195,38 @@ export function createCodexSessionMigrationScheduler(args: { if (args.isQuitting() || activeLaunches.has(leaseId)) { return } - if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) { + if (earlyPtyExits.consumeAfter(leaseId, startedSequence)) { scheduleRun(true) return } activeLaunches.set(leaseId, startedAt) scheduleRun(fullScanRequired) }, + ignoreLaunch(leaseId, startedSequence): void { + if (args.isQuitting() || ignoredLaunches.has(leaseId)) { + return + } + if (ignoredPtyExits.matchesAfter(leaseId, startedSequence)) { + return + } + const earlyExit = earlyPtyExits.consumeAfter(leaseId, startedSequence) + if (earlyExit) { + ignoredPtyExits.record(leaseId, earlyExit.sequence) + return + } + ignoredLaunches.add(leaseId) + }, finishLaunch(leaseId, exitSequence): void { + if (ignoredLaunches.delete(leaseId)) { + if (exitSequence !== undefined) { + ignoredPtyExits.record(leaseId, exitSequence) + } + return + } const startedAt = activeLaunches.get(leaseId) if (!startedAt) { if (exitSequence !== undefined) { - recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence) + earlyPtyExits.record(leaseId, exitSequence) } return } @@ -253,42 +274,6 @@ function compareBackfillDates( return left.join('-').localeCompare(right.join('-')) } -function recordEarlyPtyExit( - exits: Map, - leaseId: string, - sequence: number -): void { - const now = Date.now() - for (const [id, exit] of exits) { - if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) { - exits.delete(id) - } - } - exits.set(leaseId, { sequence, recordedAt: now }) - while (exits.size > MAX_EARLY_PTY_EXITS) { - const oldestLeaseId = exits.keys().next().value - if (oldestLeaseId === undefined) { - break - } - exits.delete(oldestLeaseId) - } -} - -function consumeEarlyPtyExit( - exits: Map, - leaseId: string, - startedSequence: number | undefined -): boolean { - const exit = exits.get(leaseId) - exits.delete(leaseId) - return ( - exit !== undefined && - startedSequence !== undefined && - exit.sequence > startedSequence && - Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS - ) -} - function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 720f2101f81..ebc953a4be7 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -9,6 +9,7 @@ import { getCodexPaneAccount, hasRecordedLegacySharedCodexPane, hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys, recordCodexPaneAccount } from './codex-pane-account-registry' @@ -47,6 +48,17 @@ afterEach(() => { }) describe('codex pane account registry', () => { + it.each([ + ['real-home', true], + ['account-home', true], + ['wsl-home', true], + ['shared-home', false], + ['custom-home', false], + [undefined, false] + ] as const)('classifies whether %s proves a pane avoided the shared home', (route, expected) => { + expect(isCodexPaneHomeRouteProvenAwayFromSharedHome(route)).toBe(expected) + }) + it('survives a process restart so a daemon-backed shell stays attributable', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', diff --git a/src/main/index.ts b/src/main/index.ts index 55180cc9ff8..f49e07988e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -39,7 +39,10 @@ import { shutdownDaemon } from './daemon/daemon-init' import { + type CodexPaneHomeRoute, + getCodexPaneAccount, hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, reconcileCodexPaneAccountsWithLivePtys } from './codex/codex-pane-account-registry' import { closeAllWatchers } from './ipc/filesystem-watcher' @@ -401,10 +404,23 @@ function handleCodexHomePtySpawned(args: { id: string codexHomePath: string | null reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null launchEnv?: NodeJS.ProcessEnv startedAt?: Date startedSequence?: number }): void { + // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. + if (args.reattached && args.startedSequence !== undefined) { + const paneAccount = getCodexPaneAccount(args.id) + const homeRoute = + args.reattachedHomeRoute !== undefined + ? (args.reattachedHomeRoute ?? undefined) + : paneAccount?.homeRoute + if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { + codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) + return + } + } const fullScanRequired = codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { reattached: args.reattached, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d3ec26f91a6..d462541ed72 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -70,6 +70,7 @@ const { clearPaneKeyAliasesForPtyMock, recordCodexPaneAccountMock, forgetCodexPaneAccountMock, + getCodexPaneAccountMock, ensureCodexBackfillRecoveryMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -107,6 +108,7 @@ const { clearPaneKeyAliasesForPtyMock: vi.fn(), recordCodexPaneAccountMock: vi.fn(), forgetCodexPaneAccountMock: vi.fn(), + getCodexPaneAccountMock: vi.fn(), ensureCodexBackfillRecoveryMock: vi.fn(() => Promise.resolve()) })) @@ -221,7 +223,8 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ vi.mock('../codex/codex-pane-account-registry', () => ({ recordCodexPaneAccount: recordCodexPaneAccountMock, - forgetCodexPaneAccount: forgetCodexPaneAccountMock + forgetCodexPaneAccount: forgetCodexPaneAccountMock, + getCodexPaneAccount: getCodexPaneAccountMock })) vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ @@ -410,6 +413,7 @@ describe('registerPtyHandlers', () => { clearPaneKeyAliasesForPtyMock.mockReset() recordCodexPaneAccountMock.mockReset() forgetCodexPaneAccountMock.mockReset() + getCodexPaneAccountMock.mockReset() ensureCodexBackfillRecoveryMock.mockReset() ensureCodexBackfillRecoveryMock.mockResolvedValue(undefined) mainWindow.webContents.on.mockReset() @@ -9092,16 +9096,20 @@ describe('registerPtyHandlers', () => { { label: 'git worktree', worktreeId: 'repo-1::/tmp/live-owner', - cwd: '/tmp/live-owner' + cwd: '/tmp/live-owner', + paneAccountAtStart: { homeRoute: 'shared-home' }, + expectedHomeRouteAtStart: 'shared-home' }, { label: 'folder workspace', worktreeId: 'folder:live-owner', - cwd: '/tmp' + cwd: '/tmp', + paneAccountAtStart: null, + expectedHomeRouteAtStart: null } ])( 'adopts a completed runtime-owned pane before replacement launch preflight ($label)', - async ({ worktreeId, cwd }) => { + async ({ worktreeId, cwd, paneAccountAtStart, expectedHomeRouteAtStart }) => { type StableAdoption = { result: { id: string; incarnationId?: string; isReattach?: boolean } owner: { handle?: string; tabId: string; leafId: string; ptyId: string } @@ -9308,8 +9316,10 @@ describe('registerPtyHandlers', () => { runtime.beginPtyRegistration.mockImplementation(() => { runtimeSecondAdoption ??= spawnController.adoptStablePane(adoptionArgs) }) + getCodexPaneAccountMock.mockReturnValue(paneAccountAtStart) const rendererFirstMount = handlers.get('pty:spawn')!(null, mountArgs) await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(3)) + getCodexPaneAccountMock.mockReturnValue({ homeRoute: 'real-home' }) releaseAttach() await vi.waitFor(() => expect(runtimeSecondAdoption).not.toBeNull()) const pendingRuntimeAdoption = runtimeSecondAdoption @@ -9354,6 +9364,7 @@ describe('registerPtyHandlers', () => { id: 'pty-live-owner', codexHomePath: null, reattached: true, + reattachedHomeRoute: expectedHomeRouteAtStart, startedAt: expect.any(Date), startedSequence: expect.any(Number) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 24786811086..040fccaab99 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -198,7 +198,9 @@ import { } from '../codex-accounts/managed-codex-auth-readiness' import { forgetCodexPaneAccount, - recordCodexPaneAccount + getCodexPaneAccount, + recordCodexPaneAccount, + type CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -1238,6 +1240,7 @@ export type CodexHomePtySpawnedLifecycleArgs = { id: string codexHomePath: string | null reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null launchEnv?: NodeJS.ProcessEnv startedAt?: Date startedSequence?: number @@ -1449,6 +1452,30 @@ function recordCodexPaneAccountForSpawn(args: { recordCodexPaneAccount(args.ptyId, record) } +function snapshotCodexPaneHomeRoutes( + ptyIds: readonly (string | null | undefined)[] +): ReadonlyMap { + const routes = new Map() + for (const ptyId of ptyIds) { + if (!ptyId || routes.has(ptyId)) { + continue + } + routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) + } + return routes +} + +function codexReattachedHomeRouteField( + routes: ReadonlyMap, + ptyId: string, + reattached: boolean +): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { + if (!reattached || !routes.has(ptyId)) { + return {} + } + return { reattachedHomeRoute: routes.get(ptyId) ?? null } +} + function readEnvWithProcessFallback( baseEnv: Record, key: string @@ -4407,6 +4434,12 @@ export function registerPtyHandlers( const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const preAdoptedStablePane = args.adoptedStablePane ?? null + const reattachedCodexHomeRoutes = !args.connectionId + ? snapshotCodexPaneHomeRoutes([ + preAdoptedStablePane?.result.id, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + : new Map() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { await startupPromise @@ -4440,7 +4473,8 @@ export function registerPtyHandlers( codexHomePath: null, reattached: true, startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence + startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true) }) } return result @@ -5115,6 +5149,7 @@ export function registerPtyHandlers( reattached: true, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true), ...(env ? { launchEnv: env } : {}) }) } @@ -5288,6 +5323,11 @@ export function registerPtyHandlers( codexHomePath: selectedCodexHomePath, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + reattachedCodexHomeRoutes, + result.id, + result.isReattach === true + ), ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) }) } @@ -5786,6 +5826,37 @@ export function registerPtyHandlers( ) => { const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined + const initialLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const initialPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + initialLeafId + ? makePaneKey(args.tabId, initialLeafId) + : null + const initialStablePanePtyId = (() => { + try { + return !args.connectionId && initialPaneKey + ? resolveStablePaneOwner( + runtime, + store, + initialPaneKey, + args.worktreeId, + args.connectionId + )?.ptyId + : undefined + } catch { + return undefined + } + })() + const reattachedCodexHomeRoutes = !args.connectionId + ? snapshotCodexPaneHomeRoutes([ + initialStablePanePtyId, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + : new Map() const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -6840,6 +6911,11 @@ export function registerPtyHandlers( codexHomePath: selectedCodexHomePath, startedAt: codexHomeLaunchStartedAt, startedSequence: codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + reattachedCodexHomeRoutes, + result.id, + result.isReattach === true + ), ...(result.isReattach === true ? { reattached: true } : baseEnv From 21d5ca6db3efe6199ea524068aab203386897eff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:33:55 -0700 Subject: [PATCH 13/39] [perf-remote] perf(renderer): dedupe remote status hydration (#13705) * perf(renderer): dedupe remote status hydration * fix(runtime-status): re-list host catalog when the listing goes stale Coverage matching cannot observe catalog edits made by another client or the orca CLI, so gate hydration on catalog listing age too. Co-authored-by: Orca * test(runtime-status): reset the catalog listing clock between tests The staleness guard keeps its last-listed timestamp in module scope, so the TTL test left fake time behind and made every following test in the file depend on its position. Add a reset hook, call it in beforeEach, and pin the TTL invariant on an externally added host rather than the timer alone. Also extracts the coverage predicate in fetchSettings and restores the WHY comment about compat failures clearing on a reachable status. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../store/slices/runtime-status-hydration.ts | 109 ++++++++++ .../store/slices/runtime-status-refresh.ts | 29 +++ .../src/store/slices/runtime-status.test.ts | 193 +++++++++++++++++- .../src/store/slices/runtime-status.ts | 54 ++--- .../src/store/slices/settings.test.ts | 167 +++++++++++++++ src/renderer/src/store/slices/settings.ts | 29 ++- 6 files changed, 532 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/store/slices/runtime-status-hydration.ts create mode 100644 src/renderer/src/store/slices/runtime-status-refresh.ts diff --git a/src/renderer/src/store/slices/runtime-status-hydration.ts b/src/renderer/src/store/slices/runtime-status-hydration.ts new file mode 100644 index 00000000000..b8555a38d41 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-hydration.ts @@ -0,0 +1,109 @@ +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' + +/** Matches the renderer's other catalog-style cache TTLs (checks, work items, Jira). */ +export const RUNTIME_CATALOG_STALE_MS = 60_000 + +let lastCatalogListedAt = 0 + +/** Why: status coverage cannot observe catalog edits made by another client or the + * orca CLI, so an old-enough listing must be re-read even when coverage looks complete. */ +export function isRuntimeCatalogListingStale(): boolean { + return Date.now() - lastCatalogListedAt > RUNTIME_CATALOG_STALE_MS +} + +/** Why: this timestamp is module-global, so without a reset one test's clock leaks + * into every test after it and makes list-count assertions order-dependent. */ +export function resetRuntimeCatalogListingForTests(): void { + lastCatalogListedAt = 0 +} + +type RuntimeStatusHydrationDependencies = { + listEnvironments: () => Promise + getCurrentEnvironments: () => PublicKnownRuntimeEnvironment[] + publishEnvironments: (environments: PublicKnownRuntimeEnvironment[]) => void + refreshEnvironmentStatus: (environmentId: string) => Promise + markCatalogSettled: () => void +} + +function environmentRevisions( + environments: PublicKnownRuntimeEnvironment[] +): ReadonlyMap { + return new Map( + environments.map((environment) => [ + environment.id, + environment.pairingRevision ?? environment.createdAt + ]) + ) +} + +function revisionsMatch( + environments: PublicKnownRuntimeEnvironment[], + expected: ReadonlyMap +): boolean { + const current = environmentRevisions(environments) + if (current.size !== expected.size) { + return false + } + for (const [environmentId, revision] of current) { + if (expected.get(environmentId) !== revision) { + return false + } + } + return true +} + +export function createRuntimeStatusHydration({ + listEnvironments, + getCurrentEnvironments, + publishEnvironments, + refreshEnvironmentStatus, + markCatalogSettled +}: RuntimeStatusHydrationDependencies): () => Promise { + let inFlight: Promise | null = null + let expectedRevisions: ReadonlyMap | null = null + let rerunRequested = false + + return () => { + if (inFlight) { + if (expectedRevisions && !revisionsMatch(getCurrentEnvironments(), expectedRevisions)) { + rerunRequested = true + } + return inFlight + } + const hydration = (async (): Promise => { + // Catalog changes queue a current-catalog pass without duplicating stable overlaps. + do { + rerunRequested = false + const revisionsAtListStart = environmentRevisions(getCurrentEnvironments()) + expectedRevisions = revisionsAtListStart + let environments: PublicKnownRuntimeEnvironment[] + try { + environments = await listEnvironments() + } catch (err) { + console.error('Failed to list runtime environments for status hydration:', err) + markCatalogSettled() + return + } + lastCatalogListedAt = Date.now() + if (!revisionsMatch(getCurrentEnvironments(), revisionsAtListStart)) { + rerunRequested = true + continue + } + expectedRevisions = environmentRevisions(environments) + publishEnvironments(environments) + await Promise.allSettled( + environments.map((environment) => refreshEnvironmentStatus(environment.id)) + ) + } while ( + rerunRequested || + (expectedRevisions && !revisionsMatch(getCurrentEnvironments(), expectedRevisions)) + ) + })() + inFlight = hydration.finally(() => { + inFlight = null + expectedRevisions = null + rerunRequested = false + }) + return inFlight + } +} diff --git a/src/renderer/src/store/slices/runtime-status-refresh.ts b/src/renderer/src/store/slices/runtime-status-refresh.ts new file mode 100644 index 00000000000..5609cb4c5a7 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-status-refresh.ts @@ -0,0 +1,29 @@ +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' + +export async function refreshRuntimeEnvironmentStatus( + environmentId: string, + timeoutMs: number, + publish: (status: RuntimeStatus | null) => void +): Promise { + const expectedEnvironmentRevision = getRuntimeEnvironmentRevision(environmentId) + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environmentId, + timeoutMs + }) + const status = unwrapRuntimeRpcResult(response) + if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { + return false + } + publish(status) + return true + } catch { + if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentRevision) { + return false + } + publish(null) + return false + } +} diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 1462d5ea702..d7aacc3bb96 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -74,6 +74,16 @@ function stubRuntimeEnvironmentApi({ return { getStatus, list } } +function deferred() { + let resolve: (value: T) => void = () => {} + let reject: (reason?: unknown) => void = () => {} + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + beforeEach(() => { clearRuntimeEnvironmentConnectionGenerationsForTests() vi.mocked(toast.warning).mockReset() @@ -361,6 +371,28 @@ describe('runtime-status slice', () => { expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe(1) }) + it.each(['success', 'failure'] as const)( + 'drops a stale refresh %s after the same environment id is re-paired', + async (outcome) => { + const probe = deferred>() + const getStatus = vi.fn().mockReturnValue(probe.promise) + stubRuntimeEnvironmentApi({ getStatus }) + const store = createSliceStore() + store.getState().setRuntimeEnvironments([makeEnvironment({ pairingRevision: 1 })]) + + const refresh = store.getState().refreshRuntimeEnvironmentStatus('env-a') + store.getState().setRuntimeEnvironments([makeEnvironment({ pairingRevision: 2 })]) + if (outcome === 'success') { + probe.resolve(createCompatibleRuntimeStatusResponse('runtime-old')) + } else { + probe.reject(new Error('old connection closed')) + } + + await expect(refresh).resolves.toBe(false) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false) + } + ) + it('advances connection generation after recovery without churning stable status polls', () => { const store = createSliceStore() store.getState().setRuntimeEnvironmentStatus('env-a', { @@ -590,20 +622,169 @@ describe('runtime-status slice', () => { ) }) + it('shares one full catalog and status sweep across overlapping hydrations', async () => { + const environments = [makeEnvironment(), makeEnvironment({ id: 'env-b', name: 'Build Box' })] + const probeA = deferred>() + const probeB = deferred>() + const getStatus = vi.fn(({ selector }: { selector: string }) => + selector === 'env-a' ? probeA.promise : probeB.promise + ) + const list = vi.fn().mockResolvedValue(environments) + stubRuntimeEnvironmentApi({ getStatus, list }) + const store = createSliceStore() + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + const first = store.getState().hydrateRuntimeEnvironmentStatuses() + const second = store.getState().hydrateRuntimeEnvironmentStatuses() + expect(list).toHaveBeenCalledTimes(1) + expect(getStatus).not.toHaveBeenCalled() + await vi.waitFor(() => expect(getStatus).toHaveBeenCalledTimes(2)) + const third = store.getState().hydrateRuntimeEnvironmentStatuses() + + probeA.resolve(createCompatibleRuntimeStatusResponse('runtime-a')) + probeB.reject(new Error('offline')) + await Promise.all([first, second, third]) + unsubscribe() + + expect(list).toHaveBeenCalledTimes(1) + expect(getStatus).toHaveBeenCalledTimes(2) + expect(publications).toBe(3) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( + 'runtime-a' + ) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-b')?.status).toBeNull() + }) + + it('runs a fresh explicit hydration after the shared sweep settles', async () => { + const getStatus = vi + .fn() + .mockResolvedValueOnce(createCompatibleRuntimeStatusResponse('runtime-1')) + .mockResolvedValueOnce(createCompatibleRuntimeStatusResponse('runtime-2')) + const list = vi.fn().mockResolvedValue([makeEnvironment()]) + stubRuntimeEnvironmentApi({ getStatus, list }) + const store = createSliceStore() + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + await store.getState().hydrateRuntimeEnvironmentStatuses() + await store.getState().hydrateRuntimeEnvironmentStatuses() + unsubscribe() + + expect(list).toHaveBeenCalledTimes(2) + expect(getStatus).toHaveBeenCalledTimes(2) + expect(publications).toBe(4) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( + 'runtime-2' + ) + }) + + it('does not share hydration work between stores', async () => { + const list = vi.fn().mockResolvedValue([]) + stubRuntimeEnvironmentApi({ getStatus: vi.fn(), list }) + const firstStore = createSliceStore() + const secondStore = createSliceStore() + + await Promise.all([ + firstStore.getState().hydrateRuntimeEnvironmentStatuses(), + secondStore.getState().hydrateRuntimeEnvironmentStatuses() + ]) + + expect(list).toHaveBeenCalledTimes(2) + }) + + it('queues a current-catalog sweep when the catalog changes during listing', async () => { + const environmentA = makeEnvironment({ pairingRevision: 1 }) + const repairedEnvironmentA = makeEnvironment({ pairingRevision: 2 }) + const firstCatalog = deferred() + const secondCatalog = deferred() + const getStatus = vi + .fn() + .mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-current')) + const list = vi + .fn() + .mockReturnValueOnce(firstCatalog.promise) + .mockReturnValueOnce(secondCatalog.promise) + stubRuntimeEnvironmentApi({ getStatus, list }) + const store = createSliceStore() + store.getState().setRuntimeEnvironments([environmentA]) + + const hydration = store.getState().hydrateRuntimeEnvironmentStatuses() + store.getState().setRuntimeEnvironments([repairedEnvironmentA]) + firstCatalog.resolve([environmentA]) + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2)) + + expect(getStatus).not.toHaveBeenCalled() + expect(store.getState().runtimeEnvironments).toEqual([repairedEnvironmentA]) + + secondCatalog.resolve([repairedEnvironmentA]) + await hydration + + expect(list).toHaveBeenCalledTimes(2) + expect(getStatus).toHaveBeenCalledTimes(1) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(true) + }) + + it('queues one current-catalog sweep when a host is removed during probing', async () => { + const environmentA = makeEnvironment() + const environmentB = makeEnvironment({ id: 'env-b', name: 'Build Box' }) + const firstProbe = deferred>() + const getStatus = vi + .fn() + .mockImplementationOnce(() => firstProbe.promise) + .mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-current')) + const list = vi + .fn() + .mockResolvedValueOnce([environmentA, environmentB]) + .mockResolvedValueOnce([environmentA]) + stubRuntimeEnvironmentApi({ getStatus, list }) + const store = createSliceStore() + + const first = store.getState().hydrateRuntimeEnvironmentStatuses() + await vi.waitFor(() => expect(getStatus).toHaveBeenCalledTimes(2)) + store.getState().setRuntimeEnvironments([environmentA]) + const joined = store.getState().hydrateRuntimeEnvironmentStatuses() + firstProbe.resolve(createCompatibleRuntimeStatusResponse('runtime-old')) + await Promise.all([first, joined]) + + expect(list).toHaveBeenCalledTimes(2) + expect(getStatus).toHaveBeenCalledTimes(3) + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( + 'runtime-current' + ) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(false) + }) + // Why: skill discovery waits for the catalog to settle. A rejected read must // release that wait without claiming the catalog is hydrated — host routing // uses `runtimeEnvironmentCatalogHydrated` to fail closed on an unknown // catalog, and an empty stale list must not be mistaken for "no runtimes". - it('settles but does not hydrate the catalog when the read fails', async () => { - const list = vi.fn().mockRejectedValue(new Error('unreadable environments.json')) + it('settles failed catalog reads and allows a later hydration retry', async () => { + const list = vi + .fn() + .mockRejectedValueOnce(new Error('unreadable environments.json')) + .mockResolvedValueOnce([]) stubRuntimeEnvironmentApi({ getStatus: vi.fn(), list }) const store = createSliceStore() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - await store.getState().hydrateRuntimeEnvironmentStatuses() + try { + await store.getState().hydrateRuntimeEnvironmentStatuses() - expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true) - expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(false) - expect(store.getState().runtimeEnvironments).toEqual([]) + expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true) + expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(false) + expect(store.getState().runtimeEnvironments).toEqual([]) + + await store.getState().hydrateRuntimeEnvironmentStatuses() + expect(list).toHaveBeenCalledTimes(2) + expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(true) + } finally { + consoleError.mockRestore() + } }) it('both settles and hydrates the catalog on a successful read', async () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index f1eab57213c..29c3e556d2a 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -5,12 +5,13 @@ import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-e import type { RuntimeStatus } from '../../../../shared/runtime-types' import { clearRecentRuntimeCompatibilityFailure, - clearRuntimeCompatibilityCache, - unwrapRuntimeRpcResult + clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import { translate } from '@/i18n/i18n' import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' +import { createRuntimeStatusHydration } from './runtime-status-hydration' +import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' /** Live status for one saved runtime environment, as last observed by the * renderer. `status === null` records a probe that failed or timed out so the @@ -323,43 +324,20 @@ export const createRuntimeStatusSlice: StateCreator { - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environmentId, - timeoutMs - }) - const status = unwrapRuntimeRpcResult(response) - // setRuntimeEnvironmentStatus drops any stale compat failure on a non-null + refreshRuntimeEnvironmentStatus: (environmentId, timeoutMs = 10_000) => + refreshRuntimeEnvironmentStatus(environmentId, timeoutMs, (status) => { + // Why: setRuntimeEnvironmentStatus drops any stale compat failure on a non-null // (reachable) status, so a recovered host's reuse-flagged refetches re-probe. get().setRuntimeEnvironmentStatus(environmentId, { status, checkedAt: Date.now() }) - return true - } catch { - get().setRuntimeEnvironmentStatus(environmentId, { - status: null, - checkedAt: Date.now() - }) - return false - } - }, + }), - hydrateRuntimeEnvironmentStatuses: async () => { - let environments: PublicKnownRuntimeEnvironment[] - try { - environments = await window.api.runtimeEnvironments.list() - } catch (err) { - console.error('Failed to list runtime environments for status hydration:', err) - // Why: settled, not hydrated. Skill discovery must stop waiting and fall - // back to the local host, but host routing keeps failing closed on an - // unknown catalog rather than acting on a stale empty list. - set({ runtimeEnvironmentCatalogSettled: true }) - return - } - get().setRuntimeEnvironments(environments) - // Why: fire-and-forget per env; one unreachable server must not block the - // others, and a failure records a null status rather than nothing. - await Promise.allSettled( - environments.map((environment) => get().refreshRuntimeEnvironmentStatus(environment.id)) - ) - } + hydrateRuntimeEnvironmentStatuses: createRuntimeStatusHydration({ + listEnvironments: () => window.api.runtimeEnvironments.list(), + getCurrentEnvironments: () => get().runtimeEnvironments, + publishEnvironments: (environments) => get().setRuntimeEnvironments(environments), + refreshEnvironmentStatus: (environmentId) => + get().refreshRuntimeEnvironmentStatus(environmentId), + // Why: failed reads release catalog waiters without claiming routing is safe. + markCatalogSettled: () => set({ runtimeEnvironmentCatalogSettled: true }) + }) }) diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 612caab0be4..756e6d9baa8 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { createTestStore, makeWorktree } from './store-test-helpers' import type { AppState } from '../types' import type { WorktreeLineage } from '../../../../shared/types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import { toast } from 'sonner' import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, @@ -9,6 +10,10 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { + RUNTIME_CATALOG_STALE_MS, + resetRuntimeCatalogListingForTests +} from './runtime-status-hydration' vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { @@ -37,9 +42,34 @@ const env2Lineage: WorktreeLineage = { createdAt: 1 } +function makeRuntimeEnvironment(id: string): PublicKnownRuntimeEnvironment { + const endpointId = `ws-${id}` + return { + id, + name: id, + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: endpointId, kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], + preferredEndpointId: endpointId + } +} + +function deferred() { + let resolve: (value: T) => void = () => {} + let reject: (reason?: unknown) => void = () => {} + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + beforeEach(() => { delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ clearRuntimeCompatibilityCacheForTests() + resetRuntimeCatalogListingForTests() vi.clearAllMocks() runtimeEnvironmentGetStatus.mockResolvedValue({ id: 'status-rpc-1', @@ -698,4 +728,141 @@ describe('fetchSettings runtime catalog probe', () => { expect(store.getState().settings).not.toBeNull() expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true) }) + + it('coalesces concurrent settings refreshes into one all-host sweep', async () => { + const environments = [makeRuntimeEnvironment('env-a'), makeRuntimeEnvironment('env-b')] + const catalog = deferred() + runtimeEnvironmentList.mockReturnValueOnce(catalog.promise) + const store = createTestStore() + + await Promise.all(Array.from({ length: 10 }, () => store.getState().fetchSettings())) + + expect(settingsGet).toHaveBeenCalledTimes(10) + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentGetStatus).not.toHaveBeenCalled() + + catalog.resolve(environments) + await vi.waitFor(() => expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(2)) + + await store.getState().fetchSettings() + expect(settingsGet).toHaveBeenCalledTimes(11) + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2) + }) + + it('fills status coverage after another path publishes only part of the catalog', async () => { + const environments = [makeRuntimeEnvironment('env-a'), makeRuntimeEnvironment('env-b')] + runtimeEnvironmentList.mockResolvedValue(environments) + const store = createTestStore() + store.getState().setRuntimeEnvironments(environments) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 1 }) + + await store.getState().fetchSettings() + await vi.waitFor(() => expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2)) + + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1) + expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(true) + }) + + it('treats an offline result as checked on later settings refreshes', async () => { + const environments = [makeRuntimeEnvironment('env-a'), makeRuntimeEnvironment('env-b')] + runtimeEnvironmentList.mockResolvedValue(environments) + runtimeEnvironmentGetStatus.mockImplementation(({ selector }: { selector: string }) => + selector === 'env-b' + ? Promise.reject(new Error('offline')) + : Promise.resolve({ + id: 'status-rpc-a', + ok: true, + result: { + runtimeId: 'runtime-a', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + }, + _meta: { runtimeId: 'runtime-a' } + }) + ) + const store = createTestStore() + + await store.getState().fetchSettings() + await vi.waitFor(() => + expect(store.getState().runtimeStatusByEnvironmentId.get('env-b')?.status).toBeNull() + ) + await store.getState().fetchSettings() + + expect(settingsGet).toHaveBeenCalledTimes(2) + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(2) + }) + + it('retries a failed catalog read on the next settings refresh', async () => { + const firstCatalog = deferred() + runtimeEnvironmentList + .mockReturnValueOnce(firstCatalog.promise) + .mockResolvedValueOnce([makeRuntimeEnvironment('env-a')]) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const store = createTestStore() + + try { + await store.getState().fetchSettings() + firstCatalog.reject(new Error('unreadable environments.json')) + await vi.waitFor(() => expect(store.getState().runtimeEnvironmentCatalogSettled).toBe(true)) + expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(false) + + await store.getState().fetchSettings() + await vi.waitFor(() => expect(store.getState().runtimeEnvironmentCatalogHydrated).toBe(true)) + await vi.waitFor(() => expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(1)) + await store.getState().fetchSettings() + + expect(settingsGet).toHaveBeenCalledTimes(3) + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(2) + expect(runtimeEnvironmentGetStatus).toHaveBeenCalledTimes(1) + } finally { + consoleError.mockRestore() + } + }) + + it('uses an authoritative sweep to remove ghost status entries', async () => { + const store = createTestStore() + store.getState().setRuntimeEnvironments([]) + store.getState().setRuntimeEnvironmentStatus('removed-env', { status: null, checkedAt: 1 }) + + await store.getState().fetchSettings() + await vi.waitFor(() => expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(0)) + + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1) + }) + + it('picks up an externally added host once the listing goes stale', async () => { + runtimeEnvironmentList.mockResolvedValue([makeRuntimeEnvironment('env-a')]) + const store = createTestStore() + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000) + + try { + await store.getState().fetchSettings() + await vi.waitFor(() => expect(store.getState().runtimeStatusByEnvironmentId.size).toBe(1)) + + // Another client adds a host; coverage still matches, so only staleness can reveal it. + runtimeEnvironmentList.mockResolvedValue([ + makeRuntimeEnvironment('env-a'), + makeRuntimeEnvironment('env-b') + ]) + await store.getState().fetchSettings() + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(1) + expect(store.getState().runtimeEnvironments.map(({ id }) => id)).toEqual(['env-a']) + + now.mockReturnValue(1_000_000 + RUNTIME_CATALOG_STALE_MS + 1) + await store.getState().fetchSettings() + await vi.waitFor(() => + expect(store.getState().runtimeEnvironments.map(({ id }) => id)).toEqual(['env-a', 'env-b']) + ) + await vi.waitFor(() => + expect(store.getState().runtimeStatusByEnvironmentId.has('env-b')).toBe(true) + ) + expect(runtimeEnvironmentList).toHaveBeenCalledTimes(2) + } finally { + now.mockRestore() + } + }) }) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 670a2556e10..593d1650e1e 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -14,6 +14,7 @@ import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custo import { normalizeTaskProviderSettings } from '../../../../shared/task-providers' import { normalizeOpenInApplications } from '../../../../shared/open-in-applications' import { createSettingsSearchState, type SettingsSearchState } from './settings-search-state' +import { isRuntimeCatalogListingStale } from './runtime-status-hydration' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import { normalizeTuiAgentArgsRecord, @@ -135,6 +136,17 @@ async function persistSettingsUpdates( })) } +/** Every known host has a recorded status entry, and no entry survives for a host that is gone. */ +function hasCompleteRuntimeStatusCoverage( + runtimeEnvironments: AppState['runtimeEnvironments'], + runtimeStatusByEnvironmentId: AppState['runtimeStatusByEnvironmentId'] +): boolean { + return ( + new Set(runtimeEnvironments.map(({ id }) => id)).size === runtimeStatusByEnvironmentId.size && + runtimeEnvironments.every(({ id }) => runtimeStatusByEnvironmentId.has(id)) + ) +} + async function verifyRuntimeEnvironmentReachable(environmentId: string | null): Promise { if (!environmentId) { return @@ -161,11 +173,18 @@ export const createSettingsSlice: StateCreator } catch (err) { console.error('Failed to fetch settings:', err) } - // Why: best-effort boot probe so sidebar host pickers show live runtime - // health before the settings pane is ever opened. Fire-and-forget to keep - // startup off the network round-trips. Runs even when settings fail to load, - // so surfaces waiting on the catalog settling are never stranded pending. - void get().hydrateRuntimeEnvironmentStatuses() + const { runtimeEnvironmentCatalogHydrated, runtimeEnvironments, runtimeStatusByEnvironmentId } = + get() + // Why: settings refreshes are frequent, but only incomplete host coverage needs + // the all-host boot probe. A recorded null still means the host was checked. + if ( + !runtimeEnvironmentCatalogHydrated || + !hasCompleteRuntimeStatusCoverage(runtimeEnvironments, runtimeStatusByEnvironmentId) || + // Why: coverage is blind to catalog edits from another client or the orca CLI. + isRuntimeCatalogListingStale() + ) { + void get().hydrateRuntimeEnvironmentStatuses() + } }, updateSettings: async (updates) => { From 5e3a2d25f7e049515605fbb83d9b8e8e6b083d45 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:05:12 -0700 Subject: [PATCH 14/39] Filter remote workspaces by creating device (#13718) * Filter remote workspaces by creating device * Fix workspace origin filter reconnect behavior * Shorten workspace origin filter label * Refine remote workspace filter UX --------- Co-authored-by: Jinwoo-H --- src/main/ipc/repos.ts | 5 +- .../runtime-environment-transport-routing.ts | 5 +- src/main/ipc/worktree-metadata-merge.ts | 3 + src/main/ipc/worktree-remote.ts | 2 + src/main/ipc/worktrees.ts | 1 + src/main/persistence.ts | 2 + ...t-navigation-isolation.integration.test.ts | 82 ++++++++++++++ src/main/runtime/orca-runtime.ts | 11 +- .../runtime/rpc/methods/client-ui-schemas.ts | 8 +- .../client-ui-workspace-filter-fields.ts | 11 ++ .../runtime/rpc/methods/client-ui.test.ts | 18 ++++ src/main/runtime/rpc/methods/client-ui.ts | 8 +- .../runtime/rpc/methods/folder-workspace.ts | 8 +- src/main/runtime/rpc/methods/repo.test.ts | 3 +- src/main/runtime/rpc/methods/status.ts | 3 +- src/main/runtime/rpc/methods/worktree.test.ts | 1 + src/main/runtime/rpc/methods/worktree.ts | 7 +- .../runtime/rpc/workspace-creator-context.ts | 14 +++ src/main/runtime/runtime-folder-workspace.ts | 3 + src/main/runtime/runtime-rpc.ts | 2 + src/main/worktree-removal-safety.ts | 3 +- src/renderer/src/App.tsx | 3 + .../src/components/WorktreeJumpPalette.tsx | 21 ++++ .../SidebarWorkspaceFilterSection.test.tsx | 49 +++++++++ .../sidebar/SidebarWorkspaceFilterSection.tsx | 65 ++++++++++- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 3 + .../src/components/sidebar/WorktreeList.tsx | 58 ++++++++-- ...branch-visible-under-hide-sleeping.test.ts | 3 + .../sidebar/sidebar-filter-state.test.ts | 15 +++ ...e-visible-workspace-kanban-worktree-ids.ts | 23 ++++ .../sidebar/visible-worktrees.test.ts | 65 +++++++++++ .../components/sidebar/visible-worktrees.ts | 24 +++++ .../workspace-creator-visibility.test.ts | 48 +++++++++ .../sidebar/workspace-creator-visibility.ts | 62 +++++++++++ src/renderer/src/i18n/locales/en.json | 4 +- .../runtime-environment-disconnect-toast.ts | 86 +++++++++++++++ .../src/store/slices/runtime-status.test.ts | 18 ++++ .../src/store/slices/runtime-status.ts | 101 ++++-------------- .../slices/store-session-cascades.test.ts | 4 + src/renderer/src/store/slices/ui.ts | 5 + src/renderer/src/web/web-pairing.test.ts | 9 ++ src/renderer/src/web/web-pairing.ts | 6 ++ src/renderer/src/web/web-preload-api.test.ts | 79 +++++++++++++- src/renderer/src/web/web-preload-api.ts | 43 ++++++-- .../src/web/web-runtime-environment.ts | 19 +++- src/shared/constants.ts | 1 + src/shared/folder-workspace-worktree.test.ts | 11 ++ src/shared/folder-workspace-worktree.ts | 16 ++- src/shared/folder-workspaces.ts | 3 + src/shared/mobile-relay-pairing-offer.test.ts | 10 ++ src/shared/mobile-relay-pairing-offer.ts | 1 + src/shared/runtime-environment-store.test.ts | 30 +++++- src/shared/runtime-environment-store.ts | 7 +- src/shared/runtime-environments.ts | 5 +- src/shared/runtime-types.ts | 3 + src/shared/types.ts | 12 +++ src/shared/workspace-creator-provenance.ts | 21 ++++ 57 files changed, 995 insertions(+), 138 deletions(-) create mode 100644 src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts create mode 100644 src/main/runtime/rpc/workspace-creator-context.ts create mode 100644 src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts create mode 100644 src/renderer/src/components/sidebar/workspace-creator-visibility.ts create mode 100644 src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts create mode 100644 src/shared/workspace-creator-provenance.ts diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index bb1d8e6ff69..0e422d4df77 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -1507,7 +1507,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) - const workspace = store.createFolderWorkspace(args) + const workspace = store.createFolderWorkspace({ + ...args, + creatorProvenance: { kind: 'host' } + }) notifyReposChanged(mainWindow) return workspace } diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 852b97273c5..d144ee087e1 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -66,7 +66,10 @@ export async function getRuntimeEnvironmentStatus( ) } if (response.ok === true) { - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) + markEnvironmentUsed(userDataPath, environment.id, { + runtimeId: response._meta.runtimeId, + pairedDeviceId: response.result.pairedDeviceId + }) reconnectRemoteRuntimeSharedControlConnection(environment.id) } return attachRemoteControlDiagnostics( diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 8c4aa0efead..6b7542d18d4 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -2,6 +2,7 @@ import { basename } from 'node:path' import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' +import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' /** * Merge raw git worktree info with persisted user metadata into a full Worktree. @@ -13,6 +14,7 @@ export function mergeWorktree( defaultDisplayName?: string ): Worktree { const branchShort = git.branch.replace(/^refs\/heads\//, '') + const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance) return { id: `${repoId}::${git.path}`, ...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), @@ -22,6 +24,7 @@ export function mergeWorktree( ...(meta?.projectHostSetupId !== undefined ? { projectHostSetupId: meta.projectHostSetupId } : {}), + ...(creatorProvenance ? { creatorProvenance } : {}), path: git.path, head: git.head, branch: git.branch, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b09b4e71c93..f83c5035976 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1784,6 +1784,7 @@ export async function createRemoteWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'ssh', + creatorProvenance: { kind: 'host' }, orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), @@ -2392,6 +2393,7 @@ export async function createLocalWorktree( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index cc0b4b0452f..ab299057169 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1045,6 +1045,7 @@ function createFolderWorkspace( createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 922a0ced183..9739d18bbfc 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4473,6 +4473,7 @@ export class Store { linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): FolderWorkspace { @@ -4495,6 +4496,7 @@ export class Store { name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`), folderPath, connectionId: input.connectionId ?? group.connectionId ?? null, + ...(input.creatorProvenance ? { creatorProvenance: input.creatorProvenance } : {}), linkedTask, linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(linkedTask, sourceContext) ? sourceContext diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index 1c641334016..50fd09a4639 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -304,6 +304,8 @@ describe('paired runtime navigation isolation', () => { const clientB = await authenticate(offerB.pairingUrl) sessions.push(clientB) expect(offerA.deviceId).not.toBe(offerB.deviceId) + expect(parsePairingCode(offerA.pairingUrl)?.pairedDeviceId).toBe(offerA.deviceId) + expect(parsePairingCode(offerB.pairingUrl)?.pairedDeviceId).toBe(offerB.deviceId) expect(parsePairingCode(offerA.pairingUrl)?.deviceToken).not.toBe( parsePairingCode(offerB.pairingUrl)?.deviceToken ) @@ -318,12 +320,92 @@ describe('paired runtime navigation isolation', () => { focusTerminal, clientA, clientB, + deviceIdA: offerA.deviceId, + deviceIdB: offerB.deviceId, pairingUrlA: offerA.pairingUrl, readerA, readerB } } + it('attributes workspace creation to each authenticated device across reconnect', async () => { + const harness = await startHarness() + const createManagedWorktree = vi + .spyOn(harness.runtime, 'createManagedWorktree') + .mockImplementation(async (args) => { + const creatorProvenance = (args as unknown as Record).creatorProvenance + return { + worktree: { + id: worktreeId(args.name), + creatorProvenance + } + } as never + }) + + send(harness.clientA, { + id: 'status-a', + method: 'status.get' + }) + send(harness.clientB, { + id: 'status-b', + method: 'status.get' + }) + const [statusA, statusB] = await Promise.all([ + harness.readerA.next('status-a'), + harness.readerB.next('status-b') + ]) + expect(statusA).toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdA } + }) + expect(statusB).toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdB } + }) + + send(harness.clientA, { + id: 'create-a', + method: 'worktree.create', + params: { + repo: REPO_ID, + name: 'created-a', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB } + } + }) + send(harness.clientB, { + id: 'create-b', + method: 'worktree.create', + params: { repo: REPO_ID, name: 'created-b' } + }) + await Promise.all([harness.readerA.next('create-a'), harness.readerB.next('create-b')]) + expect(createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'created-a', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdA } + }) + ) + expect(createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'created-b', + creatorProvenance: { kind: 'paired-device', deviceId: harness.deviceIdB } + }) + ) + + await new Promise((resolve) => { + harness.clientA.ws.once('close', () => resolve()) + harness.clientA.ws.close() + }) + const reconnectedA = await authenticate(harness.pairingUrlA) + sessions.push(reconnectedA) + const reconnectedReaderA = createReader(reconnectedA) + readers.push(reconnectedReaderA) + send(reconnectedA, { id: 'status-a2', method: 'status.get' }) + await expect(reconnectedReaderA.next('status-a2')).resolves.toMatchObject({ + ok: true, + result: { pairedDeviceId: harness.deviceIdA } + }) + }) + it('keeps worktree navigation local to each paired runtime client by default', async () => { const harness = await startHarness() const clientSelections = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 424fab261b4..6c0f90c6f6b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -17638,6 +17638,7 @@ export class OrcaRuntimeService { ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), linkedIssue: worktree.linkedIssue, linkedPR, linkedLinearIssue: meta?.linkedLinearIssue ?? null, @@ -17686,6 +17687,7 @@ export class OrcaRuntimeService { ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), + ...(worktree.creatorProvenance ? { creatorProvenance: worktree.creatorProvenance } : {}), linkedIssue: worktree.linkedIssue ?? null, linkedPR: null, linkedLinearIssue: worktree.linkedLinearIssue ?? null, @@ -18360,6 +18362,7 @@ export class OrcaRuntimeService { name?: string folderPath?: string | null connectionId?: string | null + creatorProvenance?: FolderWorkspace['creatorProvenance'] linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] createdWithAgent?: FolderWorkspace['createdWithAgent'] @@ -18388,7 +18391,10 @@ export class OrcaRuntimeService { { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) - const workspace = this.store.createFolderWorkspace(input) + const workspace = this.store.createFolderWorkspace({ + ...input, + creatorProvenance: input.creatorProvenance ?? { kind: 'host' } + }) this.notifyReposChanged() return workspace } @@ -21711,6 +21717,7 @@ export class OrcaRuntimeService { pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance + creatorProvenance?: Worktree['creatorProvenance'] startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste @@ -21777,6 +21784,7 @@ export class OrcaRuntimeService { }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined @@ -22405,6 +22413,7 @@ export class OrcaRuntimeService { : {}), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + creatorProvenance: args.creatorProvenance ?? { kind: 'host' }, ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index d1d692dab41..b52a52f24a7 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -19,6 +19,7 @@ import { } from '../../../../shared/worktree-card-properties' import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' import type { TaskProvider } from '../../../../shared/types' +import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields' import { TaskResumeState } from './task-resume-state-schema' import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance' @@ -214,15 +215,10 @@ const UiUpdateFields = z manualRepoOrder: z .array(z.object({ hostId: z.string(), repoId: z.string() }).strict()) .optional(), - hideDefaultBranchWorkspace: z.boolean().optional(), - hideAutomationGeneratedWorkspaces: z.boolean().optional(), + ...ClientUiWorkspaceFilterFields, // Why: rides App.tsx's debounced writer, so omitting it rejected that entire // payload (sidebar widths, filters, agent acks) for every paired client. showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), - hideCliCreatedWorkspaces: z.boolean().optional(), - hideDetachedHeadWorkspaces: z.boolean().optional(), - alwaysShowDefaultBranchWorkspace: z.boolean().optional(), - filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), editorFontZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts new file mode 100644 index 00000000000..d0239b03ec3 --- /dev/null +++ b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const ClientUiWorkspaceFilterFields = { + hideDefaultBranchWorkspace: z.boolean().optional(), + hideAutomationGeneratedWorkspaces: z.boolean().optional(), + hideCliCreatedWorkspaces: z.boolean().optional(), + hideDetachedHeadWorkspaces: z.boolean().optional(), + hideWorkspacesFromOtherDevices: z.boolean().optional(), + alwaysShowDefaultBranchWorkspace: z.boolean().optional(), + filterRepoIds: z.array(z.string()).optional() +} diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 2a4b17b455e..18c747c242c 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -422,6 +422,24 @@ describe('client UI RPC methods', () => { }) }) + it('does not let a paired client replace the host workspace origin filter', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { + hideWorkspacesFromOtherDevices: true, + sidebarWidth: 280 + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ sidebarWidth: 280 }) + }) + it('accepts persisted literal UI arrays and nested UI state', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index a10f1b66708..540333bf5ac 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -55,9 +55,11 @@ export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'ui.set', params: UiUpdate, - handler: (params, { runtime }) => ({ - ui: runtime.updateUIState(params as Partial) - }) + handler: (params, { runtime }) => { + const { hideWorkspacesFromOtherDevices: _clientLocalFilter, ...hostUpdates } = params + void _clientLocalFilter + return { ui: runtime.updateUIState(hostUpdates as Partial) } + } }), defineMethod({ name: 'ui.recordFeatureInteraction', diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts index baf03ada5d0..9f36b7eb4dc 100644 --- a/src/main/runtime/rpc/methods/folder-workspace.ts +++ b/src/main/runtime/rpc/methods/folder-workspace.ts @@ -5,6 +5,7 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable() @@ -94,8 +95,11 @@ export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [ defineMethod({ name: 'folderWorkspace.create', params: FolderWorkspaceCreate, - handler: async (params, { runtime }) => ({ - folderWorkspace: await runtime.createFolderWorkspace(params) + handler: async (params, context) => ({ + folderWorkspace: await context.runtime.createFolderWorkspace({ + ...params, + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context) + }) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index df5bbff67b8..09991c4103e 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -440,7 +440,8 @@ describe('repo RPC methods', () => { expect(runtime.listFolderWorkspaces).toHaveBeenCalled() expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({ projectGroupId: group.id, - name: 'Refund fix' + name: 'Refund fix', + creatorProvenance: { kind: 'host' } }) expect(runtime.updateFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { comment: 'x' diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index fe9cb78ce45..03d66f84fb1 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -5,10 +5,11 @@ export const STATUS_METHODS: RpcMethod[] = [ defineMethod({ name: 'status.get', params: null, - handler: (_params, { runtime }) => { + handler: (_params, { runtime, pairedDeviceId }) => { const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) return { ...runtime.getStatus(), + ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, remoteUpdateSupport: snapshot.support } diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 3fe4b5da15c..0b5be4da384 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -155,6 +155,7 @@ describe('worktree RPC methods', () => { setupDecision: 'skip', createdWithAgent: undefined, automationProvenance: undefined, + creatorProvenance: { kind: 'host' }, startup: undefined, startupDraft: undefined, lineage: { diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index acc2dab0537..3d2a05b523f 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -7,6 +7,7 @@ import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-pr import { defineMethod, type RpcMethod } from '../core' import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, WorktreeDetectedListParams, @@ -96,11 +97,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.create', params: WorktreeCreate, - handler: async (params, { runtime }) => + handler: async (params, context) => // Why: a mobile create interrupted by a connection migration is retried with // the same clientMutationId; dedupe so the host returns the in-flight/created // worktree instead of spawning a duplicate. No key (desktop/CLI) runs plainly. - runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + context.runtime.dedupeWorktreeCreate(params.repo, params.clientMutationId, async () => { + const { runtime } = context const repo = await runtime.showRepo(params.repo) const automationProvenance = resolveAutomationWorkspaceProvenance({ authority: runtime, @@ -145,6 +147,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ startupAgent: params.startupAgent ?? params.createdWithAgent, createdAt: Date.now() }), + creatorProvenance: resolveRpcWorkspaceCreatorProvenance(context), startup: params.startupCommand ? { command: params.startupCommand, diff --git a/src/main/runtime/rpc/workspace-creator-context.ts b/src/main/runtime/rpc/workspace-creator-context.ts new file mode 100644 index 00000000000..de0b27965fd --- /dev/null +++ b/src/main/runtime/rpc/workspace-creator-context.ts @@ -0,0 +1,14 @@ +import type { WorkspaceCreatorProvenance } from '../../../shared/types' +import type { RpcContext } from './core' + +export function resolveRpcWorkspaceCreatorProvenance( + context: Pick +): WorkspaceCreatorProvenance { + if (context.pairedDeviceId) { + return { kind: 'paired-device', deviceId: context.pairedDeviceId } + } + if (context.clientId || context.clientKind || context.connectionId) { + throw new Error('authenticated_device_identity_missing') + } + return { kind: 'host' } +} diff --git a/src/main/runtime/runtime-folder-workspace.ts b/src/main/runtime/runtime-folder-workspace.ts index ba17a7e03e1..fcbbacbd51d 100644 --- a/src/main/runtime/runtime-folder-workspace.ts +++ b/src/main/runtime/runtime-folder-workspace.ts @@ -1,6 +1,7 @@ import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import type { Repo, Worktree, WorktreeMeta } from '../../shared/types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' export function getRuntimeFolderWorkspaceRootId(repo: Repo): string { return `${repo.id}::${repo.path}` @@ -23,6 +24,7 @@ export function mergeRuntimeFolderWorkspace( worktreeId: string, meta: WorktreeMeta ): Worktree { + const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta.creatorProvenance) return { id: worktreeId, ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), @@ -32,6 +34,7 @@ export function mergeRuntimeFolderWorkspace( ...(meta.projectHostSetupId !== undefined ? { projectHostSetupId: meta.projectHostSetupId } : {}), + ...(creatorProvenance ? { creatorProvenance } : {}), path: repo.path, head: '', branch: '', diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f4d5006303a..8b5a24f15d2 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -715,6 +715,7 @@ export class OrcaRuntimeRpcServer { endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope }) return { @@ -927,6 +928,7 @@ export class OrcaRuntimeRpcServer { endpoint: direct.endpoint, deviceToken: device.token, publicKeyB64, + pairedDeviceId: device.deviceId, scope: 'mobile', relay: relayPairing.relay }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 5aa8d619d92..ef303c18220 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -23,7 +23,8 @@ const ORCA_OWNED_PROVENANCE_META_KEYS = [ 'orcaCreationSource', 'orcaCreationWorkspaceLayout', 'automationProvenance', - 'cliProvenance' + 'cliProvenance', + 'creatorProvenance' ] as const type UnregisteredOrcaCleanupMeta = Pick< WorktreeMeta, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 87ddfd2ee3a..5f9b6567270 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -690,6 +690,7 @@ function App(): React.JSX.Element { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) @@ -1411,6 +1412,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, @@ -1445,6 +1447,7 @@ function App(): React.JSX.Element { hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, showDotfilesByWorktree, filterRepoIds, diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index b03d7026950..af9c798d62f 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -54,6 +54,11 @@ import { isDefaultBranchWorkspace, isSleepingSweepExemptWorkspace } from '@/components/sidebar/visible-worktrees' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment, + isWorkspaceFromOtherDevice +} from '@/components/sidebar/workspace-creator-visibility' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import { @@ -615,6 +620,7 @@ function WorktreeJumpPaletteContent({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) @@ -748,6 +754,13 @@ function WorktreeJumpPaletteContent({ ), [agentStatusByPaneKey, tabsByWorktree] ) + const pairedDeviceIdsByEnvironment = useMemo( + () => + hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + ) // Why: empty-query mirrors sidebar filters so Search opens on the same quiet list; typed search widens to global non-archived scope. const emptyQueryVisibleWorktrees = useMemo( @@ -773,6 +786,12 @@ function WorktreeJumpPaletteContent({ if (hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) { return false } + if ( + hideWorkspacesFromOtherDevices && + isWorkspaceFromOtherDevice(worktree, pairedDeviceIdsByEnvironment) + ) { + return false + } if ( !showSleepingWorkspaces && // Why the exemption here too: Cmd+J re-implements the sidebar's @@ -799,6 +818,8 @@ function WorktreeJumpPaletteContent({ hideCliCreatedWorkspaces, hideDefaultBranchWorkspace, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, ptyIdsByTabId, showSleepingWorkspaces, tabsByWorktree, diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx index a98d881b09f..3afe412fb8f 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx @@ -33,6 +33,12 @@ function setState(overrides: Record = {}): void { setHideCliCreatedWorkspaces: vi.fn(), hideDetachedHeadWorkspaces: false, setHideDetachedHeadWorkspaces: vi.fn(), + hideWorkspacesFromOtherDevices: false, + setHideWorkspacesFromOtherDevices: vi.fn(), + worktreesByRepo: {}, + folderWorkspaces: [], + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map(), alwaysShowDefaultBranchWorkspace: true, setAlwaysShowDefaultBranchWorkspace: vi.fn(), ...overrides @@ -83,4 +89,47 @@ describe('SidebarWorkspaceFilterSection', () => { render() expect(rowLabels()).not.toContain(EXEMPTION_LABEL) }) + + it('shows the other-client filter only when it can hide a known workspace', () => { + setState({ + worktreesByRepo: { + repo: [ + { + id: 'other', + runtimeOwnerEnvironmentId: 'server', + creatorProvenance: { kind: 'paired-device', deviceId: 'other-client' } + } + ] + }, + runtimeEnvironments: [{ id: 'server', pairedDeviceId: 'this-client' }] + }) + render() + + expect(rowLabels()).toContain('Hide other-client workspaces') + }) + + it('hides the other-client filter when every known workspace belongs to this client', () => { + setState({ + worktreesByRepo: { + repo: [ + { + id: 'own', + runtimeOwnerEnvironmentId: 'server', + creatorProvenance: { kind: 'paired-device', deviceId: 'this-client' } + } + ] + }, + runtimeEnvironments: [{ id: 'server', pairedDeviceId: 'this-client' }] + }) + render() + + expect(rowLabels()).not.toContain('Hide other-client workspaces') + }) + + it('keeps an enabled other-client filter available to turn off', () => { + setState({ hideWorkspacesFromOtherDevices: true }) + render() + + expect(rowLabels()).toContain('Hide other-client workspaces') + }) }) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 216009ae8b7..4186da051bb 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,8 +1,21 @@ -import React from 'react' -import { CalendarClock, GitBranch, GitCommitHorizontal, Moon, SquareTerminal } from 'lucide-react' +import React, { useMemo } from 'react' +import { + CalendarClock, + GitBranch, + GitCommitHorizontal, + MonitorSmartphone, + Moon, + SquareTerminal +} from 'lucide-react' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { FilterToggleRow } from './FilterToggleRow' +import { + getPairedDeviceIdsByEnvironment, + isFolderWorkspaceFromOtherDevice, + isWorkspaceFromOtherDevice +} from './workspace-creator-visibility' const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) @@ -17,10 +30,43 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) + const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const setAlwaysShowDefaultBranchWorkspace = useAppStore( (s) => s.setAlwaysShowDefaultBranchWorkspace ) + const showOtherClientFilter = useMemo(() => { + if (hideWorkspacesFromOtherDevices) { + return true + } + const pairedDeviceIds = getPairedDeviceIdsByEnvironment( + runtimeEnvironments, + runtimeStatusByEnvironmentId + ) + const worktrees = getIndexedAllWorktrees(worktreesByRepo) + return ( + worktrees.some( + (worktree) => !worktree.isArchived && isWorkspaceFromOtherDevice(worktree, pairedDeviceIds) + ) || + folderWorkspaces.some((workspace) => { + if (workspace.isArchived) { + return false + } + return isFolderWorkspaceFromOtherDevice(workspace, pairedDeviceIds) + }) + ) + }, [ + folderWorkspaces, + hideWorkspacesFromOtherDevices, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + worktreesByRepo + ]) return ( <> @@ -83,6 +129,21 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter checked={hideCliCreatedWorkspaces} onChange={setHideCliCreatedWorkspaces} /> + {showOtherClientFilter && ( + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.otherClients', + 'Hide other-client workspaces' + )} + ariaLabel={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.otherClientsAria', + 'Hide workspaces created from other Orca clients on shared remote servers' + )} + checked={hideWorkspacesFromOtherDevices} + onChange={setHideWorkspacesFromOtherDevices} + /> + )} } label={translate( diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 1dca41c8915..302d654faea 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -41,6 +41,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const repos = useAppStore((s) => s.repos) @@ -92,6 +93,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM hideAutomationGeneratedWorkspaces || hideCliCreatedWorkspaces || hideDetachedHeadWorkspaces || + hideWorkspacesFromOtherDevices || hasSleepingExemptionFilter || hasRepoFilter || hasHostVisibilityFilter @@ -101,6 +103,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hideCliCreatedWorkspaces ? 1 : 0) + (hideDetachedHeadWorkspaces ? 1 : 0) + + (hideWorkspacesFromOtherDevices ? 1 : 0) + (hasSleepingExemptionFilter ? 1 : 0) + (hasHostVisibilityFilter ? 1 : 0) + selectedCount diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index eb2a06cb89c..4be9c47d1eb 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -124,6 +124,11 @@ import { setVisibleWorktreeIds, sidebarHasActiveFilters } from './visible-worktrees' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + filterFolderWorkspacesFromOtherDevices, + getPairedDeviceIdsByEnvironment +} from './workspace-creator-visibility' import { getCyclicProjectedWorktreeLineageIds, getWorktreeLineageAncestors @@ -5222,6 +5227,7 @@ const WorktreeList = React.memo(function WorktreeList({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) @@ -5310,6 +5316,13 @@ const WorktreeList = React.memo(function WorktreeList({ const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const pairedDeviceIdsByEnvironment = useMemo( + () => + hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + ) const sortEpoch = useAppStore((s) => s.sortEpoch) @@ -5512,6 +5525,8 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope, @@ -5530,6 +5545,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, @@ -5541,7 +5557,8 @@ const WorktreeList = React.memo(function WorktreeList({ sortedIds, worktreeMap, worktreeLineageById, - worktreesByRepo + worktreesByRepo, + pairedDeviceIdsByEnvironment ]) // Why: agentStatusEpoch bumps recompute this memo even when membership and // order are unchanged; keeping the previous identity stops the whole @@ -5630,16 +5647,28 @@ const WorktreeList = React.memo(function WorktreeList({ () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), [defaultHostId, projectGroups, visibleHostIdSet] ) - const visibleFolderWorkspacesForRows = useMemo( - () => - filterFolderWorkspacesForVisibleHosts( - folderWorkspaces, - projectGroups, - visibleHostIdSet, - defaultHostId - ), - [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet] - ) + const visibleFolderWorkspacesForRows = useMemo(() => { + const hostVisibleWorkspaces = filterFolderWorkspacesForVisibleHosts( + folderWorkspaces, + projectGroups, + visibleHostIdSet, + defaultHostId + ) + if (!hideWorkspacesFromOtherDevices) { + return hostVisibleWorkspaces + } + return filterFolderWorkspacesFromOtherDevices( + hostVisibleWorkspaces, + pairedDeviceIdsByEnvironment + ) + }, [ + defaultHostId, + folderWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + projectGroups, + visibleHostIdSet + ]) const repoOrder = useMemo(() => { return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) }, [repos]) @@ -6492,6 +6521,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope @@ -6503,6 +6533,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope @@ -6516,6 +6547,7 @@ const WorktreeList = React.memo(function WorktreeList({ ) const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) const setAlwaysShowDefaultBranchWorkspace = useAppStore( (s) => s.setAlwaysShowDefaultBranchWorkspace ) @@ -6542,6 +6574,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDetachedHeadWorkspaces) { setHideDetachedHeadWorkspaces(false) } + if (actions.resetHideWorkspacesFromOtherDevices) { + setHideWorkspacesFromOtherDevices(false) + } if (actions.resetAlwaysShowDefaultBranchWorkspace) { setAlwaysShowDefaultBranchWorkspace(true) } @@ -6555,6 +6590,7 @@ const WorktreeList = React.memo(function WorktreeList({ setHideAutomationGeneratedWorkspaces, setHideCliCreatedWorkspaces, setHideDetachedHeadWorkspaces, + setHideWorkspacesFromOtherDevices, setAlwaysShowDefaultBranchWorkspace, setVisibleWorkspaceHostIds, filterState diff --git a/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts index 9187dd02e13..9f06dda9df2 100644 --- a/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts +++ b/src/renderer/src/components/sidebar/default-branch-visible-under-hide-sleeping.test.ts @@ -58,6 +58,8 @@ function visibleOptions(overrides: Partial = {}): VisibleOptions hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + pairedDeviceIdsByEnvironment: new Map(), repoMap, workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, @@ -95,6 +97,7 @@ describe('#8873 default-branch workspace under "Hide sleeping"', () => { 'hideAutomationGeneratedWorkspaces', 'hideCliCreatedWorkspaces', 'hideDetachedHeadWorkspaces', + 'hideWorkspacesFromOtherDevices', 'alwaysShowDefaultBranchWorkspace', 'visibleWorkspaceHostIds', 'workspaceHostScope' diff --git a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts index 7fe5d255110..e7cc2a94eba 100644 --- a/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-filter-state.test.ts @@ -39,6 +39,7 @@ function filterState(overrides: Partial = {}): FilterState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, alwaysShowDefaultBranchWorkspace: true, workspaceHostScope: 'all', ...overrides @@ -91,6 +92,12 @@ describe('sidebarHasActiveFilters', () => { expect(sidebarHasActiveFilters(filterState({ hideDetachedHeadWorkspaces: true }))).toBe(true) }) + it('returns true when workspaces from other devices are hidden', () => { + expect(sidebarHasActiveFilters(filterState({ hideWorkspacesFromOtherDevices: true }))).toBe( + true + ) + }) + it('returns true when sleeping workspaces are hidden', () => { expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true) }) @@ -139,6 +146,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) @@ -155,6 +163,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) @@ -170,6 +179,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: true, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) @@ -183,6 +193,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: true, resetHideDetachedHeadWorkspaces: false, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) @@ -196,6 +207,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: true, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: false }) @@ -222,6 +234,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: true }) @@ -237,6 +250,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: false, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: true, resetVisibleWorkspaceHostIds: false }) @@ -260,6 +274,7 @@ describe('computeClearFilterActions', () => { resetHideAutomationGeneratedWorkspaces: true, resetHideCliCreatedWorkspaces: false, resetHideDetachedHeadWorkspaces: false, + resetHideWorkspacesFromOtherDevices: false, resetAlwaysShowDefaultBranchWorkspace: false, resetVisibleWorkspaceHostIds: true }) 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 0e565018667..180ef7d6daf 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 @@ -4,6 +4,11 @@ import type { Repo, Worktree } from '../../../../shared/types' import { computeVisibleWorktreeIds } from './visible-worktrees' import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' +import type { AppState } from '@/store/types' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment +} from './workspace-creator-visibility' type UseVisibleWorkspaceKanbanWorktreeIdsParams = { allWorktrees: readonly Worktree[] @@ -11,6 +16,8 @@ type UseVisibleWorkspaceKanbanWorktreeIdsParams = { } const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() +const EMPTY_RUNTIME_ENVIRONMENTS: AppState['runtimeEnvironments'] = [] +const EMPTY_RUNTIME_STATUS_BY_ENVIRONMENT_ID: AppState['runtimeStatusByEnvironmentId'] = new Map() export function useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees, @@ -22,6 +29,15 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) + const runtimeEnvironments = useAppStore((s) => + s.hideWorkspacesFromOtherDevices ? s.runtimeEnvironments : EMPTY_RUNTIME_ENVIRONMENTS + ) + const runtimeStatusByEnvironmentId = useAppStore((s) => + s.hideWorkspacesFromOtherDevices + ? s.runtimeStatusByEnvironmentId + : EMPTY_RUNTIME_STATUS_BY_ENVIRONMENT_ID + ) const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) @@ -62,6 +78,10 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment: hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope, @@ -81,12 +101,15 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, settings, ptyIdsByTabId, repoMap, + runtimeEnvironments, + runtimeStatusByEnvironmentId, showSleepingWorkspaces, tabsByWorktree, worktreeIdsWithLiveAgent, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index ee212e44ec9..07e2d32a4b3 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { computeVisibleWorktreeIds } from './visible-worktrees' +import { getPairedDeviceIdsByEnvironment } from './workspace-creator-visibility' import type { Repo, TerminalTab, Worktree, WorktreeLineage } from '../../../../shared/types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' @@ -79,6 +80,8 @@ function visibleOptions(overrides: Partial = {}): VisibleOptions hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + pairedDeviceIdsByEnvironment: new Map(), repoMap, workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, @@ -88,6 +91,68 @@ function visibleOptions(overrides: Partial = {}): VisibleOptions } describe('computeVisibleWorktreeIds', () => { + it('prefers the authenticated live device id over cached pairing metadata', () => { + const deviceIds = getPairedDeviceIdsByEnvironment( + [{ id: 'env-1', pairedDeviceId: 'cached-device' } as never], + new Map([ + [ + 'env-1', + { + status: { pairedDeviceId: 'authenticated-device' } as never + } + ] + ]) + ) + + expect(deviceIds.get('env-1')).toBe('authenticated-device') + }) + + it('hides only known workspaces created by another device', () => { + const own = { + ...makeWorktree('own'), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'paired-device' as const, deviceId: 'device-a' } + } + const other = { + ...makeWorktree('other'), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'paired-device' as const, deviceId: 'device-b' } + } + const host = { + ...makeWorktree('host'), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'host' as const } + } + const legacy = { ...makeWorktree('legacy'), runtimeOwnerEnvironmentId: 'env-1' } + + const result = computeVisibleWorktreeIds( + { repo1: [own, other, host, legacy] }, + [own.id, other.id, host.id, legacy.id], + visibleOptions({ + hideWorkspacesFromOtherDevices: true, + pairedDeviceIdsByEnvironment: new Map([['env-1', 'device-a']]) + }) + ) + + expect(result).toEqual([own.id, legacy.id]) + }) + + it('fails open when an older host does not publish the requester device id', () => { + const other = { + ...makeWorktree('other'), + runtimeOwnerEnvironmentId: 'env-1', + creatorProvenance: { kind: 'paired-device' as const, deviceId: 'device-b' } + } + + const result = computeVisibleWorktreeIds( + { repo1: [other] }, + [other.id], + visibleOptions({ hideWorkspacesFromOtherDevices: true }) + ) + + expect(result).toEqual([other.id]) + }) + it('keeps browser-tab worktrees visible when sleeping workspaces are hidden', () => { const wt = makeWorktree('wt-browser') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 38ffcd8c6d5..f85083db406 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -21,6 +21,11 @@ import { } from './worktree-lineage-projection' import { computeRenderedSidebarWorktreeOrder } from './rendered-sidebar-worktree-order' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' +import { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment, + isWorkspaceFromOtherDevice +} from './workspace-creator-visibility' /** * Whether a worktree represents the repo's default-branch row that the @@ -94,6 +99,7 @@ export type SidebarFilterState = { hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean hideDetachedHeadWorkspaces: boolean + hideWorkspacesFromOtherDevices: boolean /** Keeps each project's main workspace out of the "Hide sleeping" sweep; absent means on. */ alwaysShowDefaultBranchWorkspace?: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null @@ -117,6 +123,7 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.hideAutomationGeneratedWorkspaces || state.hideCliCreatedWorkspaces || state.hideDetachedHeadWorkspaces || + state.hideWorkspacesFromOtherDevices || // Why: turning this off is the only way to narrow the list below the // default, so Clear Filters must be able to undo it like any other filter. state.alwaysShowDefaultBranchWorkspace === false || @@ -134,6 +141,7 @@ export type ClearFilterActions = { resetHideAutomationGeneratedWorkspaces: boolean resetHideCliCreatedWorkspaces: boolean resetHideDetachedHeadWorkspaces: boolean + resetHideWorkspacesFromOtherDevices: boolean resetAlwaysShowDefaultBranchWorkspace: boolean resetVisibleWorkspaceHostIds: boolean } @@ -156,6 +164,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, resetHideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, resetHideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + resetHideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices, resetAlwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace === false, resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null || @@ -193,6 +202,8 @@ export function computeVisibleWorktreeIds( hideAutomationGeneratedWorkspaces: boolean hideCliCreatedWorkspaces: boolean hideDetachedHeadWorkspaces: boolean + hideWorkspacesFromOtherDevices: boolean + pairedDeviceIdsByEnvironment: ReadonlyMap // Why optional here, against the "why required" rule above: omitting it // must fail *open*. A caller that forgets the flag then shows an extra row // instead of silently re-hiding the project's entry point, which is the @@ -216,6 +227,12 @@ export function computeVisibleWorktreeIds( // every other valid ancestor can bypass filters so children never orphan. const lineageAncestorById = new Map(all.map((w) => [w.id, w])) + if (opts.hideWorkspacesFromOtherDevices) { + all = all.filter( + (worktree) => !isWorkspaceFromOtherDevice(worktree, opts.pairedDeviceIdsByEnvironment) + ) + } + if (opts.hideDefaultBranchWorkspace) { all = all.filter((w) => !isDefaultBranchWorkspace(w)) } @@ -416,6 +433,13 @@ export function getVisibleWorktreeIds(): string[] { hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment: state.hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment( + state.runtimeEnvironments, + state.runtimeStatusByEnvironmentId + ) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope: state.workspaceHostScope, diff --git a/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts b/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts new file mode 100644 index 00000000000..1cbd5eedb08 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/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/workspace-creator-visibility.ts b/src/renderer/src/components/sidebar/workspace-creator-visibility.ts new file mode 100644 index 00000000000..1cc4e208cfd --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-creator-visibility.ts @@ -0,0 +1,62 @@ +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { FolderWorkspace, Worktree } from '../../../../shared/types' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { normalizeWorkspaceCreatorProvenance } from '../../../../shared/workspace-creator-provenance' + +type RuntimeStatusEntry = { status: RuntimeStatus | null } + +export const EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT: ReadonlyMap = new Map() + +export function getPairedDeviceIdsByEnvironment( + environments: readonly PublicKnownRuntimeEnvironment[], + statuses: ReadonlyMap +): ReadonlyMap { + const result = new Map() + for (const environment of environments) { + const deviceId = + statuses.get(environment.id)?.status?.pairedDeviceId ?? environment.pairedDeviceId + if (deviceId) { + result.set(environment.id, deviceId) + } + } + return result +} + +export function isWorkspaceFromOtherDevice( + worktree: Worktree, + pairedDeviceIdsByEnvironment: ReadonlyMap +): boolean { + const creator = normalizeWorkspaceCreatorProvenance(worktree.creatorProvenance) + if (!creator) { + return false + } + const environmentId = worktree.runtimeOwnerEnvironmentId + if (!environmentId) { + return creator.kind !== 'host' + } + const pairedDeviceId = pairedDeviceIdsByEnvironment.get(environmentId) + if (!pairedDeviceId) { + return false + } + return creator.kind !== 'paired-device' || creator.deviceId !== pairedDeviceId +} + +export function isFolderWorkspaceFromOtherDevice( + workspace: FolderWorkspace, + pairedDeviceIdsByEnvironment: ReadonlyMap +): boolean { + return isWorkspaceFromOtherDevice( + folderWorkspaceToWorktree(workspace), + pairedDeviceIdsByEnvironment + ) +} + +export function filterFolderWorkspacesFromOtherDevices( + workspaces: readonly FolderWorkspace[], + pairedDeviceIdsByEnvironment: ReadonlyMap +): FolderWorkspace[] { + return workspaces.filter( + (workspace) => !isFolderWorkspaceFromOtherDevice(workspace, pairedDeviceIdsByEnvironment) + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e44b76eabd1..0f465675309 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4536,7 +4536,9 @@ "cliCreated": "Hide CLI-created", "detachedHead": "Hide detached HEAD", "keepDefaultBranch": "Except default branch", - "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces" + "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces", + "otherClients": "Hide other-client workspaces", + "otherClientsAria": "Hide workspaces created from other Orca clients on shared remote servers" }, "sidebarHostOptions": { "3e102f111c": "All hosts", diff --git a/src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts b/src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts new file mode 100644 index 00000000000..00ef737ca59 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-environment-disconnect-toast.ts @@ -0,0 +1,86 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { AppState } from '../types' + +const activeRuntimeDisconnectedToasts = new Map() +const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000 + +function getRuntimeDisconnectedToastId(environmentId: string): string { + return `runtime-environment-disconnected:${environmentId}` +} + +export function showRuntimeDisconnectedToast( + environmentId: string, + getState: () => AppState +): void { + const environment = getState().runtimeEnvironments.find((entry) => entry.id === environmentId) + const toastId = getRuntimeDisconnectedToastId(environmentId) + const activation = Symbol(toastId) + const title = environment?.name + ? translate( + 'auto.store.slices.runtime.status.runtimeHostUnreachableNamed', + "Can't reach {{hostName}}", + { hostName: environment.name } + ) + : translate( + 'auto.store.slices.runtime.status.runtimeHostUnreachable', + "Can't reach Orca server" + ) + activeRuntimeDisconnectedToasts.set(toastId, activation) + const clearActiveToast = (): void => { + if (activeRuntimeDisconnectedToasts.get(toastId) === activation) { + activeRuntimeDisconnectedToasts.delete(toastId) + } + } + let retrying = false + const showToast = (duration = RUNTIME_DISCONNECTED_TOAST_DURATION_MS): void => { + toast.warning(title, { + id: toastId, + description: translate( + 'auto.store.slices.runtime.status.runtimeHostDisconnectedDescription', + 'Check that Orca is running on this server and that your network connection is working, then try again.' + ), + duration, + action: { + label: translate('auto.store.slices.runtime.status.tryAgain', 'Try again'), + onClick: (event) => { + // Why: Sonner otherwise deletes the keyed toast after the action callback. + event.preventDefault() + if (retrying) { + return + } + retrying = true + showToast(Number.POSITIVE_INFINITY) + void getState() + .refreshRuntimeEnvironmentStatus(environmentId) + .then((reachable) => { + const stillSaved = getState().runtimeEnvironments.some( + (entry) => entry.id === environmentId + ) + if ( + !reachable && + stillSaved && + activeRuntimeDisconnectedToasts.get(toastId) === activation + ) { + showToast() + } + }) + .finally(() => { + retrying = false + }) + } + }, + onDismiss: clearActiveToast, + onAutoClose: clearActiveToast + }) + } + showToast() +} + +export function dismissRuntimeDisconnectedToast(environmentId: string): void { + const toastId = getRuntimeDisconnectedToastId(environmentId) + if (!activeRuntimeDisconnectedToasts.delete(toastId)) { + return + } + toast.dismiss?.(toastId) +} diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index d7aacc3bb96..49610ff07ce 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -15,6 +15,7 @@ import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { getPairedDeviceIdsByEnvironment } from '@/components/sidebar/workspace-creator-visibility' vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } @@ -183,6 +184,23 @@ describe('runtime-status slice', () => { expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5, connectionGeneration: 1 }) }) + it('retains a learned paired device id after disconnecting a legacy environment', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironments([makeEnvironment()]) + store.getState().setRuntimeEnvironmentStatus('env-a', { + status: makeStatus({ pairedDeviceId: 'client-device' }), + checkedAt: 1 + }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 }) + + expect( + getPairedDeviceIdsByEnvironment( + store.getState().runtimeEnvironments, + store.getState().runtimeStatusByEnvironmentId + ).get('env-a') + ).toBe('client-device') + }) + it('does not toast when the first probe finds a saved server offline', () => { const store = createSliceStore() store.setState({ runtimeEnvironments: [makeEnvironment()] }) diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 29c3e556d2a..fffa3a0bb3d 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,5 +1,4 @@ import type { StateCreator } from 'zustand' -import { toast } from 'sonner' import type { AppState } from '../types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' @@ -8,8 +7,11 @@ import { clearRuntimeCompatibilityCache } from '@/runtime/runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' -import { translate } from '@/i18n/i18n' import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' +import { + dismissRuntimeDisconnectedToast, + showRuntimeDisconnectedToast +} from './runtime-environment-disconnect-toast' import { createRuntimeStatusHydration } from './runtime-status-hydration' import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' @@ -65,85 +67,6 @@ export type RuntimeStatusSlice = { } const connectionGenerationByEnvironment = new Map() -const activeRuntimeDisconnectedToasts = new Map() -const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000 - -function getRuntimeDisconnectedToastId(environmentId: string): string { - return `runtime-environment-disconnected:${environmentId}` -} - -function showRuntimeDisconnectedToast(environmentId: string, getState: () => AppState): void { - const environment = getState().runtimeEnvironments.find((entry) => entry.id === environmentId) - const toastId = getRuntimeDisconnectedToastId(environmentId) - const activation = Symbol(toastId) - const title = environment?.name - ? translate( - 'auto.store.slices.runtime.status.runtimeHostUnreachableNamed', - "Can't reach {{hostName}}", - { hostName: environment.name } - ) - : translate( - 'auto.store.slices.runtime.status.runtimeHostUnreachable', - "Can't reach Orca server" - ) - activeRuntimeDisconnectedToasts.set(toastId, activation) - const clearActiveToast = (): void => { - if (activeRuntimeDisconnectedToasts.get(toastId) === activation) { - activeRuntimeDisconnectedToasts.delete(toastId) - } - } - let retrying = false - const showToast = (duration = RUNTIME_DISCONNECTED_TOAST_DURATION_MS): void => { - toast.warning(title, { - id: toastId, - description: translate( - 'auto.store.slices.runtime.status.runtimeHostDisconnectedDescription', - 'Check that Orca is running on this server and that your network connection is working, then try again.' - ), - duration, - action: { - label: translate('auto.store.slices.runtime.status.tryAgain', 'Try again'), - onClick: (event) => { - // Why: Sonner otherwise deletes the keyed toast after the action callback. - event.preventDefault() - if (retrying) { - return - } - retrying = true - showToast(Number.POSITIVE_INFINITY) - void getState() - .refreshRuntimeEnvironmentStatus(environmentId) - .then((reachable) => { - const stillSaved = getState().runtimeEnvironments.some( - (entry) => entry.id === environmentId - ) - if ( - !reachable && - stillSaved && - activeRuntimeDisconnectedToasts.get(toastId) === activation - ) { - showToast() - } - }) - .finally(() => { - retrying = false - }) - } - }, - onDismiss: clearActiveToast, - onAutoClose: clearActiveToast - }) - } - showToast() -} - -function dismissRuntimeDisconnectedToast(environmentId: string): void { - const toastId = getRuntimeDisconnectedToastId(environmentId) - if (!activeRuntimeDisconnectedToasts.delete(toastId)) { - return - } - toast.dismiss?.(toastId) -} export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number { return connectionGenerationByEnvironment.get(environmentId) ?? 0 @@ -255,6 +178,7 @@ export const createRuntimeStatusSlice: StateCreator { const previous = get().runtimeStatusByEnvironmentId.get(environmentId) + const pairedDeviceId = status.status?.pairedDeviceId?.trim() // Why: a non-null status proves the runtime just answered, so drop any stale // "offline" compat failure before this online transition fires the // reuse-flagged background refetches — a recovered host must re-probe. @@ -280,7 +204,20 @@ export const createRuntimeStatusSlice: StateCreator environment.id === environmentId) + : -1 + const runtimeEnvironments = + environmentIndex >= 0 && + s.runtimeEnvironments[environmentIndex].pairedDeviceId !== pairedDeviceId + ? s.runtimeEnvironments.map((environment, index) => + index === environmentIndex ? { ...environment, pairedDeviceId } : environment + ) + : s.runtimeEnvironments + return { + runtimeStatusByEnvironmentId: next, + ...(runtimeEnvironments !== s.runtimeEnvironments ? { runtimeEnvironments } : {}) + } }) if (options?.suppressDisconnectToast) { dismissRuntimeDisconnectedToast(environmentId) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 06cfa17bfc8..65474236f67 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -1742,6 +1742,8 @@ describe('reconnectPersistedTerminals', () => { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + pairedDeviceIdsByEnvironment: new Map(), repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, @@ -2089,6 +2091,8 @@ describe('reconnectPersistedTerminals', () => { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + pairedDeviceIdsByEnvironment: new Map(), repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 125db58b7d4..1bfa1d68d77 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -897,6 +897,8 @@ export type UISlice = { setHideCliCreatedWorkspaces: (v: boolean) => void hideDetachedHeadWorkspaces: boolean setHideDetachedHeadWorkspaces: (v: boolean) => void + hideWorkspacesFromOtherDevices: boolean + setHideWorkspacesFromOtherDevices: (v: boolean) => void alwaysShowDefaultBranchWorkspace: boolean setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void showDotfilesByWorktree: Record @@ -2090,6 +2092,8 @@ export const createUISlice: StateCreator = (set, get) setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), hideDetachedHeadWorkspaces: false, setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), + hideWorkspacesFromOtherDevices: false, + setHideWorkspacesFromOtherDevices: (v) => set({ hideWorkspacesFromOtherDevices: v }), alwaysShowDefaultBranchWorkspace: true, setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), @@ -2498,6 +2502,7 @@ export const createUISlice: StateCreator = (set, get) hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, + hideWorkspacesFromOtherDevices: ui.hideWorkspacesFromOtherDevices === true, // Why !== false: profiles written before #8873 have no key, and they are // precisely the ones showing the bug, so absence must mean "exempt". alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts index 6840527d227..42721c3c821 100644 --- a/src/renderer/src/web/web-pairing.test.ts +++ b/src/renderer/src/web/web-pairing.test.ts @@ -32,6 +32,15 @@ describe('web pairing input', () => { }) }) + it('preserves optional paired device identity', () => { + expect( + parseWebPairingInput(`orca://pair?code=${encodeOffer({ pairedDeviceId: 'paired-device-a' })}`) + ).toEqual({ + ...offer, + pairedDeviceId: 'paired-device-a' + }) + }) + it.each([ ['wss://proxy.example:443/orca/runtime', 'wss://proxy.example:443/orca/runtime'], ['https://proxy.example/orca/runtime', 'wss://proxy.example/orca/runtime'], diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index f29465cc803..fc58bf9ea08 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -7,6 +7,7 @@ export type WebPairingOffer = { endpoint: string deviceToken: string publicKeyB64: string + pairedDeviceId?: string scope?: DeviceScope } @@ -99,11 +100,16 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null { return null } const scope = parseWebPairingScope(parsed.scope) + const pairedDeviceId = + typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.length > 0 + ? parsed.pairedDeviceId + : null return { v: PAIRING_OFFER_VERSION, endpoint: normalizeWebSocketEndpoint(parsed.endpoint), deviceToken: parsed.deviceToken, publicKeyB64: parsed.publicKeyB64, + ...(pairedDeviceId ? { pairedDeviceId } : {}), ...(scope ? { scope } : {}) } } diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index cc508040e49..0b85d009839 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -281,6 +281,22 @@ describe('web runtime environment identity', () => { await expect(globals.window.api.runtimeEnvironments.list()).resolves.toEqual([]) }) + it('stores paired device identity from a web access link', async () => { + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({ + name: 'Shared server', + pairingCode: encodePairingCode({ pairedDeviceId: 'paired-device-a' }) + }) + + expect(paired.environment.pairedDeviceId).toBe('paired-device-a') + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ pairedDeviceId: 'paired-device-a' }) + }) + it('persists an explicit Active Server choice across unrelated web settings writes', async () => { const globals = installBrowserGlobals('Linux') const { installWebPreloadApi } = await import('./web-preload-api') @@ -363,6 +379,21 @@ describe('web runtime environment identity', () => { ).rejects.toThrow('Unknown Orca runtime environment: web-server-old') }) + it('ignores malformed persisted paired device identity', async () => { + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const stored = JSON.parse( + globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}' + ) as Record + stored.pairedDeviceId = { invalid: true } + globals.storage.setItem('orca.web.runtimeEnvironment.v1', JSON.stringify(stored)) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const [environment] = await globals.window.api.runtimeEnvironments.list() + expect(environment).not.toHaveProperty('pairedDeviceId') + }) + it('keeps pairing while manual disconnect fences passive reconnects', async () => { const calls: string[] = [] const close = vi.fn() @@ -378,7 +409,7 @@ describe('web runtime environment identity', () => { return Promise.resolve({ id: method, ok: true, - result: { runtimeId: 'runtime-1' }, + result: { runtimeId: 'runtime-1', pairedDeviceId: 'paired-device-a' }, _meta: { runtimeId: 'runtime-1' } }) } @@ -431,6 +462,9 @@ describe('web runtime environment identity', () => { ).resolves.toMatchObject({ ok: true }) expect(clientCount).toBe(2) expect(calls).toEqual(['status.get', 'status.get']) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ pairedDeviceId: 'paired-device-a' }) }) it('fences a web runtime response that completes after manual disconnect', async () => { @@ -2360,6 +2394,49 @@ describe('web UI preload API', () => { }) }) + it('keeps the workspace origin filter browser-local across host UI responses', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: method, + ok: true, + result: { + ui: { + hideWorkspacesFromOtherDevices: false, + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 1 } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.ui.set({ hideWorkspacesFromOtherDevices: true }) + expect(runtimeCalls[0]).toEqual({ method: 'ui.set', params: {} }) + await expect(globals.window.api.ui.get()).resolves.toMatchObject({ + hideWorkspacesFromOtherDevices: true + }) + await expect(globals.window.api.ui.recordFeatureInteraction('tasks')).resolves.toMatchObject({ + hideWorkspacesFromOtherDevices: true + }) + expect(JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject({ + hideWorkspacesFromOtherDevices: true + }) + }) + it('union-merges local contextual tour seen ids when ui.get returns stale host state', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 97375f0087c..eb9fa2e7dbb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1483,12 +1483,15 @@ function createRuntimeEnvironmentsApi(): NonNullable['runtim client?.close() } const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true - const nextEnvironment = createStoredWebRuntimeEnvironment({ - name, - offer: parsed.value.pairing, - previousEnvironment: activeEnvironment, - ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) - }) + const nextEnvironment = { + ...createStoredWebRuntimeEnvironment({ + name, + offer: parsed.value.pairing, + previousEnvironment: activeEnvironment, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }), + ...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {}) + } // Why: a browser storage failure must leave the currently active host usable. try { saveStoredWebRuntimeEnvironment(nextEnvironment) @@ -2644,7 +2647,7 @@ function createWebUiApi(): NonNullable['ui']> { ) const local = readLocalWebUIState() const next = { - ...mergeWebUIState(local, result.ui), + ...mergeHostWebUIState(local, result.ui), osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), featureInteractions: mergeFeatureInteractionState( local.featureInteractions, @@ -2666,8 +2669,11 @@ function createWebUiApi(): NonNullable['ui']> { const next = mergeWebUIState(readLocalWebUIState(), updates) writeJson(UI_STORAGE_KEY, next) zoomLevel = next.uiZoomLevel + const { hideWorkspacesFromOtherDevices: _clientLocalWorkspaceFilter, ...hostUpdates } = + updates + void _clientLocalWorkspaceFilter try { - await callRuntimeResult('ui.set', updates, 15_000) + await callRuntimeResult('ui.set', hostUpdates, 15_000) } catch { // Why: unpaired/offline web clients still need local UI persistence. } @@ -2694,7 +2700,7 @@ function createWebUiApi(): NonNullable['ui']> { ) const local = readLocalWebUIState() const next = { - ...mergeWebUIState(local, result.ui), + ...mergeHostWebUIState(local, result.ui), osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), featureInteractions: mergeFeatureInteractionState( local.featureInteractions, @@ -3694,7 +3700,14 @@ function updateEnvironmentFromResponse( return } const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) - activeEnvironment = updateStoredEnvironmentRuntimeId(environment, runtimeId) + const pairedDeviceId = + response.ok && + typeof response.result === 'object' && + response.result !== null && + typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' + ? (response.result as { pairedDeviceId: string }).pairedDeviceId + : undefined + activeEnvironment = updateStoredEnvironmentRuntimeId(environment, runtimeId, pairedDeviceId) } function getStoredSettings(): GlobalSettings { @@ -4004,6 +4017,16 @@ function mergeWebUIState( } } +function mergeHostWebUIState( + local: PersistedUIState, + incoming: PersistedUIState +): PersistedUIState { + return { + ...mergeWebUIState(local, incoming), + hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true + } +} + function mergeFeatureInteractionState( current: PersistedUIState['featureInteractions'], incoming: PersistedUIState['featureInteractions'] diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 4f97c416960..6093a88aed3 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -37,9 +37,18 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment | (environmentId): environmentId is string => typeof environmentId === 'string' ) : [] - const { compatibleEnvironmentIds: _unvalidatedIds, ...environment } = parsed + const pairedDeviceId = + typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.trim().length > 0 + ? parsed.pairedDeviceId.trim() + : null + const { + compatibleEnvironmentIds: _unvalidatedIds, + pairedDeviceId: _unvalidatedDeviceId, + ...environment + } = parsed return { ...environment, + ...(pairedDeviceId ? { pairedDeviceId } : {}), ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}) } } catch { @@ -71,6 +80,7 @@ export function createStoredWebRuntimeEnvironment(args: { updatedAt: now, lastUsedAt: null, runtimeId: null, + ...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}), ...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}), ...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}), preferredEndpointId: `ws-${id}`, @@ -124,17 +134,20 @@ export function getPreferredWebPairingOffer( v: 2, endpoint: endpoint.endpoint, deviceToken: endpoint.deviceToken, - publicKeyB64: endpoint.publicKeyB64 + publicKeyB64: endpoint.publicKeyB64, + ...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {}) } } export function updateStoredEnvironmentRuntimeId( environment: StoredWebRuntimeEnvironment, - runtimeId: string | null + runtimeId: string | null, + pairedDeviceId?: string ): StoredWebRuntimeEnvironment { const next = { ...environment, runtimeId, + ...(pairedDeviceId ? { pairedDeviceId } : {}), updatedAt: Date.now(), lastUsedAt: Date.now() } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d9a17c103f5..8da7cb5b9d1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -480,6 +480,7 @@ export function getDefaultUIState(): PersistedUIState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, alwaysShowDefaultBranchWorkspace: true, showDotfilesByWorktree: {}, filterRepoIds: [], diff --git a/src/shared/folder-workspace-worktree.test.ts b/src/shared/folder-workspace-worktree.test.ts index 0283208cd8f..270e62eb2ce 100644 --- a/src/shared/folder-workspace-worktree.test.ts +++ b/src/shared/folder-workspace-worktree.test.ts @@ -132,6 +132,17 @@ describe('folderWorkspaceToWorktree', () => { }) }) + it('projects runtime ownership from the folder execution host', () => { + const worktree = folderWorkspaceToWorktree( + makeFolderWorkspace({ executionHostId: 'runtime:shared%20server' }) + ) + + expect(worktree).toMatchObject({ + hostId: 'runtime:shared%20server', + runtimeOwnerEnvironmentId: 'shared server' + }) + }) + it('keeps review-style tasks attached only to the folder workspace record', () => { const githubPr = folderWorkspaceToWorktree( makeFolderWorkspace({ diff --git a/src/shared/folder-workspace-worktree.ts b/src/shared/folder-workspace-worktree.ts index 0b427f42407..61000b2e53f 100644 --- a/src/shared/folder-workspace-worktree.ts +++ b/src/shared/folder-workspace-worktree.ts @@ -1,12 +1,19 @@ import type { FolderWorkspace, Worktree } from './types' import { folderWorkspaceKey } from './workspace-scope' -import { toSshExecutionHostId } from './execution-host' +import { parseExecutionHostId, toSshExecutionHostId } from './execution-host' +import { normalizeWorkspaceCreatorProvenance } from './workspace-creator-provenance' export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Worktree { const linkedTask = folderWorkspace.linkedTask + const creatorProvenance = normalizeWorkspaceCreatorProvenance(folderWorkspace.creatorProvenance) + const hostId = + folderWorkspace.executionHostId ?? + (folderWorkspace.connectionId ? toSshExecutionHostId(folderWorkspace.connectionId) : 'local') + const parsedHost = parseExecutionHostId(hostId) return { id: folderWorkspaceKey(folderWorkspace.id), repoId: `folder-workspace:${folderWorkspace.projectGroupId}`, + ...(creatorProvenance ? { creatorProvenance } : {}), displayName: folderWorkspace.name, comment: folderWorkspace.comment, linkedIssue: @@ -39,8 +46,9 @@ export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Wor isBare: false, isSparse: false, isMainWorktree: false, - hostId: - folderWorkspace.executionHostId ?? - (folderWorkspace.connectionId ? toSshExecutionHostId(folderWorkspace.connectionId) : 'local') + hostId, + ...(parsedHost?.kind === 'runtime' + ? { runtimeOwnerEnvironmentId: parsedHost.environmentId } + : {}) } } diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 675117dd3eb..89f12d4477b 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -3,6 +3,7 @@ import { isTuiAgent } from './tui-agent-config' import { normalizeStoredTaskSourceContext } from './task-source-context' import { normalizeWorkspaceLinkedItem } from './workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from './workspace-linked-item-source-context' +import { normalizeWorkspaceCreatorProvenance } from './workspace-creator-provenance' export function normalizeFolderWorkspaceName( name: string | null | undefined, @@ -53,6 +54,7 @@ export function normalizeFolderWorkspaces( const now = Date.now() const linkedTask = normalizeWorkspaceLinkedItem(raw.linkedTask) const linkedTaskSourceContext = normalizeStoredTaskSourceContext(raw.linkedTaskSourceContext) + const creatorProvenance = normalizeWorkspaceCreatorProvenance(raw.creatorProvenance) seen.add(raw.id) workspaces.push({ id: raw.id, @@ -65,6 +67,7 @@ export function normalizeFolderWorkspaces( : raw.connectionId === null ? null : (group?.connectionId ?? null), + ...(creatorProvenance ? { creatorProvenance } : {}), linkedTask, linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch( linkedTask, diff --git a/src/shared/mobile-relay-pairing-offer.test.ts b/src/shared/mobile-relay-pairing-offer.test.ts index d8b78c5622e..bc49ca2f884 100644 --- a/src/shared/mobile-relay-pairing-offer.test.ts +++ b/src/shared/mobile-relay-pairing-offer.test.ts @@ -12,4 +12,14 @@ describe('desktop mobile-relay pairing contract', () => { expect(result.success ? result.data : null).toEqual(fixture.expected) }) } + + it('preserves optional paired device identity', () => { + const fixture = createMobileRelayPairingFixtures(now)[0]! + if (!fixture.expected) { + throw new Error('Expected a valid direct pairing fixture') + } + const payload = { ...fixture.expected, pairedDeviceId: 'paired-device-a' } + + expect(schema.parse(payload)).toMatchObject({ pairedDeviceId: 'paired-device-a' }) + }) }) diff --git a/src/shared/mobile-relay-pairing-offer.ts b/src/shared/mobile-relay-pairing-offer.ts index 11405225974..4f5f2cc3d8c 100644 --- a/src/shared/mobile-relay-pairing-offer.ts +++ b/src/shared/mobile-relay-pairing-offer.ts @@ -75,6 +75,7 @@ export function createPairingOfferSchema(now: () => number = () => Date.now()) { // Why: the desktop's Curve25519 public key is pinned by the pairing // offer, while relayHostId is verified from its decoded bytes later. publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), + pairedDeviceId: z.string().min(1).max(128).optional(), scope: PairingScopeSchema.optional(), relay: relaySchema.optional() }) diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts index 3e34f5846b2..dbf0a6a50b6 100644 --- a/src/shared/runtime-environment-store.test.ts +++ b/src/shared/runtime-environment-store.test.ts @@ -13,12 +13,13 @@ import { updateEnvironmentFromPairingCode } from './runtime-environment-store' -function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { +function pairingCode(endpoint = 'ws://127.0.0.1:6768', pairedDeviceId?: string): string { return encodePairingOffer({ v: 2, endpoint, deviceToken: 'device-token', - publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') + publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'), + ...(pairedDeviceId ? { pairedDeviceId } : {}) }) } @@ -151,6 +152,31 @@ describe('runtime environment store', () => { }) }) + it('persists paired device identity from pairing and status backfill', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + const paired = addEnvironmentFromPairingCode(userDataPath, { + name: 'paired box', + pairingCode: pairingCode('ws://127.0.0.1:6768', 'device-from-offer'), + now: 1_000 + }) + const legacy = addEnvironmentFromPairingCode(userDataPath, { + name: 'legacy box', + pairingCode: pairingCode('ws://192.0.2.10:6768'), + now: 1_000 + }) + + expect(paired.pairedDeviceId).toBe('device-from-offer') + markEnvironmentUsed(userDataPath, legacy.id, { + pairedDeviceId: 'device-from-status', + now: 2_000 + }) + expect(listEnvironments(userDataPath).find((entry) => entry.id === legacy.id)).toMatchObject({ + pairedDeviceId: 'device-from-status', + lastUsedAt: 2_000 + }) + }) + it('rejects an oversized sparse environment store before parsing it', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-bound-')) tempDirs.push(userDataPath) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 85f90f8bef6..e784dc80281 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -176,17 +176,19 @@ const LAST_USED_PERSIST_INTERVAL_MS = 60_000 export function markEnvironmentUsed( userDataPath: string, selector: string, - args: { runtimeId?: string | null; now?: number } = {} + args: { runtimeId?: string | null; pairedDeviceId?: string; now?: number } = {} ): void { const store = readEnvironmentStore(userDataPath) const environment = resolveEnvironmentFromStore(store, selector) const now = args.now ?? Date.now() const runtimeIdChanged = args.runtimeId != null && args.runtimeId !== environment.runtimeId + const pairedDeviceIdChanged = + args.pairedDeviceId != null && args.pairedDeviceId !== environment.pairedDeviceId const lastUsedIsFresh = environment.lastUsedAt != null && now >= environment.lastUsedAt && now - environment.lastUsedAt < LAST_USED_PERSIST_INTERVAL_MS - if (!runtimeIdChanged && lastUsedIsFresh) { + if (!runtimeIdChanged && !pairedDeviceIdChanged && lastUsedIsFresh) { return } const next = store.environments.map((entry) => @@ -194,6 +196,7 @@ export function markEnvironmentUsed( ? { ...entry, runtimeId: args.runtimeId ?? entry.runtimeId, + ...(args.pairedDeviceId ? { pairedDeviceId: args.pairedDeviceId } : {}), lastUsedAt: now, updatedAt: now } diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index f1cfd64085b..b3411eb3f70 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -26,6 +26,7 @@ export const KnownRuntimeEnvironmentSchema = z.object({ createdAt: z.number().finite(), updatedAt: z.number().finite(), pairingRevision: z.number().finite().optional(), + pairedDeviceId: z.string().min(1).optional(), lastUsedAt: z.number().finite().nullable(), runtimeId: z.string().min(1).nullable(), source: RuntimeEnvironmentSourceSchema.optional(), @@ -74,6 +75,7 @@ export function createEnvironmentFromPairingOffer(args: { createdAt: args.now, updatedAt: args.now, pairingRevision: args.now, + ...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}), lastUsedAt: null, runtimeId: args.runtimeId ?? null, ...(args.source ? { source: args.source } : {}), @@ -115,6 +117,7 @@ export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment): v: PAIRING_OFFER_VERSION, endpoint: endpoint.endpoint, deviceToken: endpoint.deviceToken, - publicKeyB64: endpoint.publicKeyB64 + publicKeyB64: endpoint.publicKeyB64, + ...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {}) } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6e012e60b42..af02b02e484 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -67,6 +67,8 @@ export type RuntimeBrowserDriverState = RuntimeTerminalDriverState export type RuntimeStatus = { runtimeId: string + /** Authenticated requester identity. Missing for in-process callers and older hosts. */ + pairedDeviceId?: string rendererGraphEpoch: number graphStatus: RuntimeGraphStatus authoritativeWindowId: number | null @@ -774,6 +776,7 @@ export type RuntimeWorktreePsSummary = { manualOrder?: number lastActivityAt?: number createdAt?: number + creatorProvenance?: Worktree['creatorProvenance'] linkedIssue: number | null linkedPR: { number: number; state: string } | null linkedLinearIssue: string | null diff --git a/src/shared/types.ts b/src/shared/types.ts index 7cdc273df75..0f7a6685f9a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -339,6 +339,8 @@ export type FolderWorkspace = { connectionId?: string | null /** Renderer-owned host stamp for host-qualified folder catalogs. */ executionHostId?: ExecutionHostId | null + /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ + creatorProvenance?: WorkspaceCreatorProvenance linkedTask: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null comment: string @@ -493,6 +495,8 @@ export type Worktree = { hostId?: ExecutionHostId /** Renderer projection of the paired runtime that transports operations to `hostId`. */ runtimeOwnerEnvironmentId?: string + /** Authenticated client that created this workspace. Missing means unknown legacy origin. */ + creatorProvenance?: WorkspaceCreatorProvenance /** Host-specific setup used to create/run this workspace. */ projectHostSetupId?: string displayName: string @@ -571,6 +575,10 @@ export type CliWorkspaceProvenance = { startupAgent?: TuiAgent } +export type WorkspaceCreatorProvenance = + | { kind: 'host' } + | { kind: 'paired-device'; deviceId: string } + export type AutomationWorkspaceProvenance = { kind: 'created-by-automation' automationId: string @@ -623,6 +631,8 @@ export type WorktreeMeta = { hostId?: ExecutionHostId /** See Worktree.projectHostSetupId. Persisted for project-first workspace ownership. */ projectHostSetupId?: string + /** See Worktree.creatorProvenance. */ + creatorProvenance?: WorkspaceCreatorProvenance displayName: string comment: string linkedIssue: number | null @@ -3457,6 +3467,8 @@ export type PersistedUIState = { hideCliCreatedWorkspaces?: boolean /** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */ hideDetachedHeadWorkspaces?: boolean + /** Hide workspaces with known provenance from another paired device or the host UI. */ + hideWorkspacesFromOtherDevices?: boolean /** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */ alwaysShowDefaultBranchWorkspace?: boolean /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ diff --git a/src/shared/workspace-creator-provenance.ts b/src/shared/workspace-creator-provenance.ts new file mode 100644 index 00000000000..541cf3f2a5e --- /dev/null +++ b/src/shared/workspace-creator-provenance.ts @@ -0,0 +1,21 @@ +import type { WorkspaceCreatorProvenance } from './types' + +export function normalizeWorkspaceCreatorProvenance( + value: unknown +): WorkspaceCreatorProvenance | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const candidate = value as { kind?: unknown; deviceId?: unknown } + if (candidate.kind === 'host') { + return { kind: 'host' } + } + if ( + candidate.kind === 'paired-device' && + typeof candidate.deviceId === 'string' && + candidate.deviceId.trim().length > 0 + ) { + return { kind: 'paired-device', deviceId: candidate.deviceId } + } + return undefined +} From 9b79cc1b9e824971481f03b2171a2115280c16b9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:06:03 -0700 Subject: [PATCH 15/39] Add copy button for quick command (#13768) * Add copy button to quick commands with visual feedback Quick command rows now display a copy button that copies the command body to clipboard. The button shows brief visual feedback ("Copied" or "Couldn't copy") and is disabled when the command body is empty. Includes desktop and mobile UI, tests, and full i18n support. * fix(ci): unblock verify for quick-command copy button Key feedback to the copied body so prop changes drop stale labels without setState-in-effect, and mock expo-clipboard in the mobile list test. --- mobile/src/session/QuickCommandRow.tsx | 199 ++++++++++++++++++ mobile/src/session/QuickCommandsList.test.ts | 3 + mobile/src/session/QuickCommandsList.tsx | 86 +------- mobile/src/terminal/quick-commands.ts | 2 + .../components/settings/QuickCommandsList.tsx | 36 +++- .../tab-bar/TabBarQuickCommandAddActions.tsx | 4 +- .../tab-bar/TabBarQuickCommandItem.test.tsx | 195 +++++++++++++++++ .../tab-bar/TabBarQuickCommandItem.tsx | 70 +++++- .../tab-bar/TabBarQuickCommandsMenu.tsx | 2 +- .../use-clipboard-text-copy-feedback.test.ts | 113 ++++++++++ .../hooks/use-clipboard-text-copy-feedback.ts | 83 ++++++++ src/renderer/src/i18n/locales/en.json | 13 +- src/renderer/src/i18n/locales/es.json | 13 +- src/renderer/src/i18n/locales/ja.json | 13 +- src/renderer/src/i18n/locales/ko.json | 13 +- src/renderer/src/i18n/locales/zh.json | 13 +- 16 files changed, 755 insertions(+), 103 deletions(-) create mode 100644 mobile/src/session/QuickCommandRow.tsx create mode 100644 src/renderer/src/components/tab-bar/TabBarQuickCommandItem.test.tsx create mode 100644 src/renderer/src/hooks/use-clipboard-text-copy-feedback.test.ts create mode 100644 src/renderer/src/hooks/use-clipboard-text-copy-feedback.ts diff --git a/mobile/src/session/QuickCommandRow.tsx b/mobile/src/session/QuickCommandRow.tsx new file mode 100644 index 00000000000..401305b298c --- /dev/null +++ b/mobile/src/session/QuickCommandRow.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react' +import { View, Text, Pressable, StyleSheet } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { MobileAgentIcon } from '../components/MobileAgentIcon' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import { + getQuickCommandDisplayPreview, + getTerminalQuickCommandBody, + isAgentQuickCommand +} from '../terminal/quick-commands' + +type QuickCommandRowProps = { + command: TerminalQuickCommand + first: boolean + onLaunch: (command: TerminalQuickCommand) => void + onEdit: (command: TerminalQuickCommand) => void + onDelete: (command: TerminalQuickCommand) => void + disabled: boolean +} + +type CopyFeedback = { + body: string + status: 'copied' | 'failed' +} + +export function QuickCommandRow({ + command, + first, + onLaunch, + onEdit, + onDelete, + disabled +}: QuickCommandRowProps) { + const isAgent = isAgentQuickCommand(command) + const body = getTerminalQuickCommandBody(command) + const canCopy = body.trim().length > 0 + // Why: key feedback to the copied body so a prop change drops stale labels + // without setState-in-effect (react-doctor no-adjust-state-on-prop-change). + const [feedback, setFeedback] = useState(null) + const copyResetTimerRef = useRef | null>(null) + const mountedRef = useRef(true) + const copyStatus: 'idle' | 'copied' | 'failed' = + feedback != null && feedback.body === body ? feedback.status : 'idle' + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + } + } + }, []) + + // Drop any pending reset timer when the body changes; display status is already idle. + useEffect(() => { + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + copyResetTimerRef.current = null + } + }, [body]) + + const handleCopy = async (): Promise => { + if (!canCopy || disabled) { + return + } + try { + await Clipboard.setStringAsync(body) + if (!mountedRef.current) { + return + } + setFeedback({ body, status: 'copied' }) + } catch { + if (!mountedRef.current) { + return + } + setFeedback({ body, status: 'failed' }) + } + if (copyResetTimerRef.current) { + clearTimeout(copyResetTimerRef.current) + } + copyResetTimerRef.current = setTimeout(() => { + copyResetTimerRef.current = null + if (mountedRef.current) { + setFeedback(null) + } + }, 1500) + } + + const copyDisabled = disabled || !canCopy + const copyLabel = + copyStatus === 'copied' + ? 'Copied' + : copyStatus === 'failed' + ? "Couldn't copy" + : canCopy + ? `Copy ${command.label}` + : 'Nothing to copy' + const copyIconColor = + copyStatus === 'copied' + ? colors.statusGreen + : copyStatus === 'failed' + ? colors.statusRed + : colors.textSecondary + + return ( + + [styles.rowMain, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onLaunch(command)} + accessibilityRole="button" + accessibilityLabel={`Run ${command.label}`} + > + + {isAgent ? ( + + ) : ( + + )} + + + + {command.label} + + + {getQuickCommandDisplayPreview(command)} + + + + [ + styles.rowAction, + // Why: row already dims when `disabled`; only dim again for empty body. + !canCopy && styles.disabled, + pressed && !copyDisabled && styles.pressed + ]} + disabled={copyDisabled} + onPress={() => void handleCopy()} + accessibilityRole="button" + accessibilityLabel={copyLabel} + accessibilityState={{ disabled: copyDisabled }} + > + {copyStatus === 'copied' ? ( + + ) : ( + + )} + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onEdit(command)} + accessibilityLabel={`Edit ${command.label}`} + > + + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onDelete(command)} + accessibilityLabel={`Delete ${command.label}`} + > + + + + ) +} + +const styles = StyleSheet.create({ + pressed: { backgroundColor: colors.bgRaised }, + disabled: { opacity: 0.45 }, + row: { flexDirection: 'row', alignItems: 'center' }, + rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle }, + rowMain: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.md, + paddingLeft: spacing.md, + minWidth: 0 + }, + rowIcon: { + width: 26, + height: 26, + borderRadius: 6, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + rowText: { flex: 1, minWidth: 0 }, + rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, + rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, + mono: { fontFamily: typography.monoFamily }, + rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' } +}) diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts index a396932acb6..37e41990a08 100644 --- a/mobile/src/session/QuickCommandsList.test.ts +++ b/mobile/src/session/QuickCommandsList.test.ts @@ -17,6 +17,7 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ Check: 'Check', + Copy: 'Copy', Pencil: 'Pencil', Play: 'Play', Plus: 'Plus', @@ -24,6 +25,8 @@ vi.mock('lucide-react-native', () => ({ Trash2: 'Trash2' })) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) + vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) describe('QuickCommandsList search', () => { diff --git a/mobile/src/session/QuickCommandsList.tsx b/mobile/src/session/QuickCommandsList.tsx index 2c76884d335..e8781163691 100644 --- a/mobile/src/session/QuickCommandsList.tsx +++ b/mobile/src/session/QuickCommandsList.tsx @@ -1,14 +1,11 @@ import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native' -import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' +import { Check, Plus, Search } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' import { MobileAgentIcon } from '../components/MobileAgentIcon' import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' -import { - getQuickCommandDisplayPreview, - isAgentQuickCommand, - supportsTerminalAgentQuickCommand -} from '../terminal/quick-commands' +import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands' +import { QuickCommandRow } from './QuickCommandRow' export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) => supportsTerminalAgentQuickCommand(entry.id) @@ -162,67 +159,6 @@ function QuickCommandGroup({ ) } -function QuickCommandRow({ - command, - first, - onLaunch, - onEdit, - onDelete, - disabled -}: { - command: TerminalQuickCommand - first: boolean - onLaunch: (command: TerminalQuickCommand) => void - onEdit: (command: TerminalQuickCommand) => void - onDelete: (command: TerminalQuickCommand) => void - disabled: boolean -}) { - const isAgent = isAgentQuickCommand(command) - return ( - - [styles.rowMain, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onLaunch(command)} - accessibilityRole="button" - accessibilityLabel={`Run ${command.label}`} - > - - {isAgent ? ( - - ) : ( - - )} - - - - {command.label} - - - {getQuickCommandDisplayPreview(command)} - - - - [styles.rowAction, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onEdit(command)} - accessibilityLabel={`Edit ${command.label}`} - > - - - [styles.rowAction, pressed && !disabled && styles.pressed]} - disabled={disabled} - onPress={() => onDelete(command)} - accessibilityLabel={`Delete ${command.label}`} - > - - - - ) -} - export function QuickCommandAgentPicker({ selected, onSelect @@ -292,15 +228,6 @@ const styles = StyleSheet.create({ group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' }, row: { flexDirection: 'row', alignItems: 'center' }, rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle }, - rowMain: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingVertical: spacing.md, - paddingLeft: spacing.md, - minWidth: 0 - }, rowIcon: { width: 26, height: 26, @@ -309,11 +236,6 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center' }, - rowText: { flex: 1, minWidth: 0 }, - rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, - rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, - mono: { fontFamily: typography.monoFamily }, - rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }, agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary }, addRow: { flexDirection: 'row', diff --git a/mobile/src/terminal/quick-commands.ts b/mobile/src/terminal/quick-commands.ts index 40f92a55439..d6e6b86caf3 100644 --- a/mobile/src/terminal/quick-commands.ts +++ b/mobile/src/terminal/quick-commands.ts @@ -2,6 +2,7 @@ import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' import { applyTerminalQuickCommandMutation, flattenTerminalQuickCommand, + getTerminalQuickCommandBody, isTerminalAgentQuickCommand, MAX_QUICK_COMMANDS, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, @@ -20,6 +21,7 @@ import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents' // — agent-branded labels, native row truncation, and the launch plan — live here. export { isTerminalAgentQuickCommand as isAgentQuickCommand, + getTerminalQuickCommandBody, terminalQuickCommandMatchesRepo as quickCommandMatchesRepo, supportsTerminalAgentQuickCommand, MAX_QUICK_COMMANDS, diff --git a/src/renderer/src/components/settings/QuickCommandsList.tsx b/src/renderer/src/components/settings/QuickCommandsList.tsx index bffc71f2d82..ec377677ee5 100644 --- a/src/renderer/src/components/settings/QuickCommandsList.tsx +++ b/src/renderer/src/components/settings/QuickCommandsList.tsx @@ -1,4 +1,4 @@ -import { Pencil, Trash2 } from 'lucide-react' +import { Check, Copy, Pencil, Trash2 } from 'lucide-react' import type { Repo, TerminalQuickCommand, @@ -10,6 +10,7 @@ import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { useClipboardTextCopyFeedback } from '@/hooks/use-clipboard-text-copy-feedback' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { Badge } from '../ui/badge' @@ -40,6 +41,20 @@ function QuickCommandRow({ onRemove: (command: TerminalQuickCommand) => void }): React.JSX.Element { const scope = getTerminalQuickCommandScope(command) + const body = getTerminalQuickCommandBody(command) + const { canCopy, copyText, status } = useClipboardTextCopyFeedback(body) + + const copyLabel = + status === 'copied' + ? translate('auto.components.settings.QuickCommandsPane.8d525e5f15', 'Copied') + : status === 'failed' + ? translate('auto.components.settings.QuickCommandsPane.53b17a4b1b', "Couldn't copy") + : canCopy + ? translate('auto.components.settings.QuickCommandsPane.a9a564b7e7', 'Copy {{value0}}', { + value0: command.label || 'quick command' + }) + : translate('auto.components.settings.QuickCommandsPane.69a1441a21', 'Nothing to copy') + return (
@@ -67,8 +82,8 @@ function QuickCommandRow({ ) : null} {isTerminalAgentQuickCommand(command) - ? `${getAgentLabel(command.agent)}: ${getTerminalQuickCommandBody(command)}` - : getTerminalQuickCommandBody(command) || + ? `${getAgentLabel(command.agent)}: ${body}` + : body || translate( 'auto.components.settings.QuickCommandsPane.0252ddd578', 'No command text' @@ -83,6 +98,21 @@ function QuickCommandRow({ ? translate('auto.components.settings.QuickCommandsPane.9b3e338d62', 'Enter') : translate('auto.components.settings.QuickCommandsPane.9fcfc29519', 'Insert')}
+ +
} > - +
)} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts index 20613d7a2a1..b0cadf8c4b0 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts @@ -100,6 +100,7 @@ describe('TabGroupSplitLayout', () => { return tabGroupPanelElement.props as { groupId: string worktreeId: string + isVisible: boolean isFocused: boolean hasSplitGroups: boolean reserveClosedExplorerToggleSpace: boolean @@ -112,6 +113,7 @@ describe('TabGroupSplitLayout', () => { expect.objectContaining({ groupId: 'group-1', worktreeId: 'wt-1', + isVisible: false, isFocused: false, hasSplitGroups: false, reserveClosedExplorerToggleSpace: true, @@ -125,6 +127,7 @@ describe('TabGroupSplitLayout', () => { expect.objectContaining({ groupId: 'group-1', worktreeId: 'wt-1', + isVisible: true, isFocused: true, hasSplitGroups: false, reserveClosedExplorerToggleSpace: true, diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index b467e582218..9a97ae673e4 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -182,6 +182,7 @@ function SplitNode({