Merge remote-tracking branch 'origin/main' into nwparker/fix-terminal-ime-overlay

This commit is contained in:
Neil
2026-08-29 15:43:11 -07:00
16 changed files with 326 additions and 25 deletions
@@ -113,7 +113,8 @@ describe('AutomationRunHistory unanswered history', () => {
expect(container.textContent).not.toContain('No runs yet.')
// "0 runs" is a count of something nobody managed to read.
expect(container.textContent).not.toContain('0 runs')
expect(container.textContent).toContain('Run history could not be loaded')
expect(container.textContent).toContain('Run history is unavailable from this host')
expect(container.textContent).toContain('does not mean the automation failed or has no runs')
expect(container.textContent).toContain('web-01 is not connected')
})
@@ -167,7 +167,7 @@ export function AutomationRunHistory({
<p className="text-center text-sm text-foreground">
{translate(
'auto.components.automations.AutomationRunHistory.historyUnavailable',
'Run history could not be loaded for this automation.'
'Run history is unavailable from this host. This does not mean the automation failed or has no runs.'
)}
</p>
<AutomationOwnerConflictNotice notice={notice} onRecover={onRecoverHistory} />
@@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AutomationsDetailPane } from './AutomationsDetailPane'
import { makeAutomation } from './automations-page-fixtures'
let root: Root | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(async () => {
await act(async () => root?.unmount())
root = null
document.body.innerHTML = ''
})
async function renderRunsTab(historyUnavailable: boolean): Promise<HTMLButtonElement> {
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
<AutomationsDetailPane
selected={makeAutomation()}
selectedExternal={null}
selectedExternalRunPage={null}
selectedAutomationRunPage={null}
selectedRuns={[]}
selectedRunsNotice={
historyUnavailable
? { message: 'Server update required', recovery: 'update-server', severity: 'failure' }
: null
}
activePaneTab="runs"
relativeNow={0}
externalActionKey={null}
selectedRepoDisplayName="Orca"
selectedRepoDefaultBaseRef={null}
selectedWorkspaceName="Workspace"
selectedHostEntry={null}
hostLabelById={new Map()}
selectedRunNowAvailability={null}
selectedAutomationRunPageWorkspaceDisplay={null}
selectedAutomationRunPageViewState={null}
canRerunSelectedAutomationRunPage={false}
isSelectedAutomationRunPageRerunPending={false}
worktreeMap={new Map()}
fetchExternalAutomationRuns={vi.fn()}
onActivePaneTabChange={vi.fn()}
onClearExternalRunPage={vi.fn()}
onClearAutomationRunPage={vi.fn()}
requestExternalAction={vi.fn()}
openExternalRunPage={vi.fn()}
openEditExternalDialog={vi.fn()}
runNow={vi.fn()}
openEditDialog={vi.fn()}
toggleAutomation={vi.fn()}
requestDeleteAutomation={vi.fn()}
rerunAutomationRun={vi.fn()}
openRunWorkspace={vi.fn()}
openAutomationRunPage={vi.fn()}
onBackToList={vi.fn()}
recoverSelectedRuns={vi.fn()}
/>
)
})
const runsTab = [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((tab) =>
tab.textContent?.includes('Runs')
)
if (!runsTab) {
throw new Error('Runs tab was not rendered')
}
return runsTab
}
describe('AutomationsDetailPane run count', () => {
it('does not report zero when run history is unavailable', async () => {
expect((await renderRunsTab(true)).textContent?.trim()).toBe('Runs')
})
it('reports zero when the host answered with an empty history', async () => {
expect((await renderRunsTab(false)).textContent).toContain('0')
})
})
@@ -213,7 +213,9 @@ export function AutomationsDetailPane({
</TabsTrigger>
<TabsTrigger value="runs" disabled={!selected}>
{translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '}
<span className="text-xs text-muted-foreground">{selectedRuns.length}</span>
{selectedRunsNotice ? null : (
<span className="text-xs text-muted-foreground">{selectedRuns.length}</span>
)}
</TabsTrigger>
</TabsList>
</div>
@@ -19,6 +19,7 @@ import {
mocks,
renderPage,
runtimeHost,
RUNTIME_ID,
RUNTIME_REPO_ID,
RUNTIME_SELF_FILTER,
RUNTIME_WORKSPACE_ID,
@@ -88,7 +89,11 @@ describe('AutomationsPage notice recovery', () => {
})
expect(settings.open).toHaveBeenCalled()
expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
expect(settings.target).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
sectionId: RUNTIME_ID
})
})
it('sends a row Update server to the row own host, not to the unfiltered list', async () => {
@@ -116,6 +121,10 @@ describe('AutomationsPage notice recovery', () => {
// All hosts selects no host, so only the row's captured owner names one.
expect(settings.open).toHaveBeenCalled()
expect(settings.target).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
expect(settings.target).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
sectionId: RUNTIME_ID
})
})
})
@@ -92,6 +92,12 @@ describe('captured owner availability', () => {
const availability = automationActionAvailability(legacy, 'delete')
expect(availability.kind).toBe('blocked')
expect(availability.kind === 'blocked' && availability.block.recovery).toBe('update-server')
expect(availability.kind === 'blocked' && availability.block.message).toContain(
'needs an update'
)
expect(availability.kind === 'blocked' && availability.block.message).toContain(
'Scheduled runs may still continue'
)
})
it('reports a row with no metadata as uncaptured rather than blocked', () => {
@@ -71,7 +71,7 @@ function unfencedBlock(): AutomationActionBlock {
reason: 'unfenced',
message: translate(
'auto.components.automations.capturedOwner.unfenced',
'This host cannot confirm which automation it is changing, so this automation is read-only here.'
'Orca on this host needs an update before you can view run history or manage this automation. Scheduled runs may still continue on the host.'
),
recovery: 'update-server'
}
@@ -35,9 +35,10 @@ const DESKTOP_SSH = entry({
stableRef: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId: 't1' } },
kind: 'ssh'
})
const RUNTIME_ENVIRONMENT_ID = 'gpu'
const RUNTIME_SSH = entry({
stableRef: {
authority: { kind: 'runtime', environmentId: 'gpu' },
authority: { kind: 'runtime', environmentId: RUNTIME_ENVIRONMENT_ID },
selector: { kind: 'ssh', targetId: 't1' }
},
kind: 'ssh'
@@ -65,7 +66,7 @@ describe('automation host recovery', () => {
{ ...RUNTIME_SSH, authorityHealth: 'unavailable' },
target
)
expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith('gpu')
expect(target.connectRuntimeEnvironment).toHaveBeenCalledWith(RUNTIME_ENVIRONMENT_ID)
expect(target.connectSshTarget).not.toHaveBeenCalled()
})
@@ -78,10 +79,15 @@ describe('automation host recovery', () => {
expect(target.retry).toHaveBeenCalledWith(desktopSelf)
})
it('sends a runtime to the servers pane, since nothing here updates a host', () => {
it('deep-links a runtime to its update row in Remote Orca Servers settings', () => {
const target = deps()
runAutomationHostRecovery('update-server', RUNTIME_SSH, target)
expect(target.openSettings).toHaveBeenCalledWith({ pane: 'servers', repoId: null })
expect(target.openSettings).toHaveBeenCalledWith({
pane: 'servers',
repoId: null,
// Runtime environment IDs are nested scroll anchors within the pane.
sectionId: RUNTIME_ENVIRONMENT_ID
})
})
it('sends a stale desktop SSH registration to the SSH pane instead', () => {
@@ -20,10 +20,14 @@ export type AutomationHostRecoveryDeps = {
openSettings: (target: SettingsNavigationTarget) => void
}
/** Where a host's version is managed; the app cannot update a host on its own. */
/** The Remote Orca Servers pane owns each runtime's version status and update action. */
function versionSettingsTarget(entry: AutomationHostCatalogEntry): SettingsNavigationTarget {
if (entry.stableRef.authority.kind === 'runtime') {
return { pane: 'servers', repoId: null }
return {
pane: 'servers',
repoId: null,
sectionId: entry.stableRef.authority.environmentId
}
}
// A desktop SSH host with no registration generation is a stale registration,
// repaired by re-adding the target rather than by updating anything.
@@ -248,8 +248,8 @@ export function useAutomationHostCatalog(
void window.api.runtimeEnvironments.connect({ selector: environmentId })
},
openSettings: (target) => {
openSettingsPage()
openSettingsTarget(target)
openSettingsPage()
}
}),
[controller, openSettingsPage, openSettingsTarget, pairingRevision]
@@ -3,6 +3,10 @@ import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
describe('exceedsMarkdownRichModeSizeLimit', () => {
it('uses a 600 KB default rich-mode ceiling', () => {
expect(RICH_MARKDOWN_MAX_SIZE_BYTES).toBe(600 * 1024)
})
it('allows markdown at the rich-mode byte limit', () => {
expect(exceedsMarkdownRichModeSizeLimit('a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES))).toBe(false)
})
@@ -120,6 +120,10 @@ import {
getLinearAgentSkillNavInstallStatus
} from '@/lib/agent-skill-nav-install-status'
import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance'
import {
watchForSettingsDeepLinkTarget,
type SettingsDeepLinkTargetWatch
} from './settings-deep-link-target-watcher'
import { translate } from '@/i18n/i18n'
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
import { getRepoHostIdentity } from '../../store/slices/repo-host-identity'
@@ -277,6 +281,13 @@ function cancelPendingSettingsSubsectionScrollFrame(
}
}
function cancelPendingSettingsDeepLinkTargetWatch(
watchRef: MutableRefObject<SettingsDeepLinkTargetWatch | null>
): void {
watchRef.current?.cancel()
watchRef.current = null
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
@@ -399,6 +410,7 @@ function Settings(): React.JSX.Element {
const pendingNavSectionRef = useRef<string | null>(null)
const pendingScrollTargetRef = useRef<string | null>(null)
const pendingSubsectionScrollFrameRef = useRef<number | null>(null)
const pendingScrollTargetWatchRef = useRef<SettingsDeepLinkTargetWatch | null>(null)
const repoHooksRequestSeqRef = useRef(0)
const shortcutsEscapeConfirmUntilRef = useRef(0)
const sourceControlAiWriteQueueRef = useRef<Promise<void>>(Promise.resolve())
@@ -446,6 +458,7 @@ function Settings(): React.JSX.Element {
}
// Why: cancel pending subsection jumps with the scroll container so a stale deep-link frame can't run after close.
cancelPendingSettingsSubsectionScrollFrame(pendingSubsectionScrollFrameRef)
cancelPendingSettingsDeepLinkTargetWatch(pendingScrollTargetWatchRef)
}, [])
useEffect(() => {
@@ -1043,6 +1056,8 @@ function Settings(): React.JSX.Element {
useEffect(() => {
const scrollTargetId = pendingScrollTargetRef.current
const pendingNavSectionId = pendingNavSectionRef.current
// Why: this pass re-decides whether to wait for the target, so drop any watch armed by the previous one.
cancelPendingSettingsDeepLinkTargetWatch(pendingScrollTargetWatchRef)
// Why: subsection deep links clear a stale filter that could hide the target row; pane-level links keep it to force-open the matching section.
if (
@@ -1069,6 +1084,13 @@ function Settings(): React.JSX.Element {
if (scrollTargetId !== pendingNavSectionId) {
// Why: target can arrive before the lazy section mounts; keep pending refs until it does.
if (!getSettingsScrollTarget(scrollTargetId, container)) {
// Why: async panes (the server list, say) mount rows after this pass, and nothing else re-runs the effect.
pendingScrollTargetWatchRef.current = watchForSettingsDeepLinkTarget({
root: container,
isTargetPresent: () =>
getSettingsScrollTarget(scrollTargetId, contentScrollRef.current) !== null,
onTargetPresent: () => setPendingNavRequestTick((tick) => tick + 1)
})
return
}
const scrollToSubsection = (): void => {
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { watchForSettingsDeepLinkTarget } from './settings-deep-link-target-watcher'
function flushMutations(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('watchForSettingsDeepLinkTarget', () => {
it('reports the target once the async rows render', async () => {
const container = document.createElement('div')
document.body.append(container)
const onTargetPresent = vi.fn()
watchForSettingsDeepLinkTarget({
root: container,
isTargetPresent: () => container.querySelector('[data-settings-section="env-1"]') !== null,
onTargetPresent
})
const row = document.createElement('div')
row.dataset.settingsSection = 'env-1'
container.append(row)
await flushMutations()
expect(onTargetPresent).toHaveBeenCalledTimes(1)
})
it('keeps waiting while unrelated rows render', async () => {
const container = document.createElement('div')
document.body.append(container)
const onTargetPresent = vi.fn()
watchForSettingsDeepLinkTarget({
root: container,
isTargetPresent: () => container.querySelector('[data-settings-section="env-1"]') !== null,
onTargetPresent
})
const other = document.createElement('div')
other.dataset.settingsSection = 'env-2'
container.append(other)
await flushMutations()
expect(onTargetPresent).not.toHaveBeenCalled()
const row = document.createElement('div')
row.dataset.settingsSection = 'env-1'
container.append(row)
await flushMutations()
expect(onTargetPresent).toHaveBeenCalledTimes(1)
})
it('reports at most once even as more rows arrive', async () => {
const container = document.createElement('div')
document.body.append(container)
const onTargetPresent = vi.fn()
watchForSettingsDeepLinkTarget({
root: container,
isTargetPresent: () => container.querySelector('[data-settings-section="env-1"]') !== null,
onTargetPresent
})
const row = document.createElement('div')
row.dataset.settingsSection = 'env-1'
container.append(row)
await flushMutations()
container.append(document.createElement('div'))
await flushMutations()
expect(onTargetPresent).toHaveBeenCalledTimes(1)
})
it('stops reporting after cancel', async () => {
const container = document.createElement('div')
document.body.append(container)
const onTargetPresent = vi.fn()
const watch = watchForSettingsDeepLinkTarget({
root: container,
isTargetPresent: () => true,
onTargetPresent
})
watch.cancel()
container.append(document.createElement('div'))
await flushMutations()
expect(onTargetPresent).not.toHaveBeenCalled()
})
it('falls back to the document body when the pane has no scroll container yet', async () => {
const onTargetPresent = vi.fn()
watchForSettingsDeepLinkTarget({
root: null,
isTargetPresent: () => document.querySelector('[data-settings-section="env-1"]') !== null,
onTargetPresent
})
const row = document.createElement('div')
row.dataset.settingsSection = 'env-1'
document.body.append(row)
await flushMutations()
expect(onTargetPresent).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,54 @@
/**
* A deep link can name a row that does not exist yet: panes such as Remote Orca
* Servers render their rows only after an async fetch resolves. The scroll
* effect parks its target in a ref, but nothing re-runs it when those rows
* finally mount, so this watcher reports the moment they do.
*/
/** Give up eventually; a target that never mounts must not leave an observer alive. */
const TARGET_WAIT_TIMEOUT_MS = 5000
export type SettingsDeepLinkTargetWatch = {
cancel: () => void
}
export function watchForSettingsDeepLinkTarget(args: {
/** Subtree to observe; falls back to the document body when the pane has no scroll container yet. */
root: HTMLElement | null
isTargetPresent: () => boolean
onTargetPresent: () => void
}): SettingsDeepLinkTargetWatch {
const root = args.root ?? (typeof document === 'undefined' ? null : document.body)
if (!root || typeof MutationObserver === 'undefined') {
return { cancel: () => {} }
}
let settled = false
let observer: MutationObserver | null = null
let timeout: number | null = null
const finish = (): void => {
if (settled) {
return
}
settled = true
observer?.disconnect()
observer = null
if (timeout !== null) {
window.clearTimeout(timeout)
timeout = null
}
}
observer = new MutationObserver(() => {
if (settled || !args.isTargetPresent()) {
return
}
finish()
args.onTargetPresent()
})
observer.observe(root, { subtree: true, childList: true })
timeout = window.setTimeout(finish, TARGET_WAIT_TIMEOUT_MS)
return { cancel: finish }
}
+2 -2
View File
@@ -15405,7 +15405,7 @@
"53fc5f07ab": "Run history",
"a00e38d1a3": "n/a",
"fdb3caa8fb": "known",
"historyUnavailable": "Run history could not be loaded for this automation."
"historyUnavailable": "Run history is unavailable from this host. This does not mean the automation failed or has no runs."
},
"AutomationRunPageFrame": {
"40a511bed4": "Run context",
@@ -15727,7 +15727,7 @@
},
"capturedOwner": {
"orphan": "This automation has no host to run on. Delete it, or re-add the host it belongs to.",
"unfenced": "This host cannot confirm which automation it is changing, so this automation is read-only here."
"unfenced": "Orca on this host needs an update before you can view run history or manage this automation. Scheduled runs may still continue on the host."
},
"hostStatus": {
"authority": {
+7 -10
View File
@@ -83,16 +83,13 @@ export const getDefaultTerminalRightClickToPaste = (
platform = typeof process !== 'undefined' ? process.platform : ''
): boolean => platform === 'win32'
/** Why: ProseMirror renders the whole document — no virtualization — so opening
* one blocks the main thread for as long as it takes to build the DOM. Measured
* in a packaged build (M-series) on a doc with a code block every ~570 bytes,
* blocking mount / median keystroke: 300 KB 1.7 s / 59 ms · 600 KB 4.2 s / 201 ms
* · 900 KB 8.8 s / 431 ms. The same 300 KB as pure prose is 0.8 s / 16 ms, so
* node-view count drives the cost far more than byte size — but bytes are the
* only thing cheap enough to test before parsing. The blocking mount is what
* pins this ceiling; past it, fall back to source mode (Monaco) with a per-file
* "Open anyway" escape hatch. Real headroom needs #7056. */
export const RICH_MARKDOWN_MAX_SIZE_BYTES = 300 * 1024
/** Why: ProseMirror renders the whole document without virtualization. After the
* parser/highlighter work in #17134/#17147/#17158, M-series Electron measurements
* on distinct code blocks every ~600 bytes put visible mount / longest task at
* 300 KB 0.70 / 0.66 s · 450 KB 1.09 / 1.02 s · 600 KB 1.49 / 1.41 s, with
* 600 KB typing at 38 ms median / 39 ms p95. Bytes remain the only cheap
* pre-parse guard; larger files use source mode with an "Open anyway" escape hatch. */
export const RICH_MARKDOWN_MAX_SIZE_BYTES = 600 * 1024
export const DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS = 1000
export const MIN_EDITOR_AUTO_SAVE_DELAY_MS = 250