diff --git a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx index 7742f74b26a..41d839e615a 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx @@ -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') }) diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index cd2ece0d7dc..13395dae6ad 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -167,7 +167,7 @@ export function AutomationRunHistory({

{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.' )}

diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx new file mode 100644 index 00000000000..740f1130405 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsDetailPane.run-count.test.tsx @@ -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 { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + + ) + }) + const runsTab = [...container.querySelectorAll('[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') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 7eb2ff13c1d..0affe311f2c 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -213,7 +213,9 @@ export function AutomationsDetailPane({ {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} - {selectedRuns.length} + {selectedRunsNotice ? null : ( + {selectedRuns.length} + )} diff --git a/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx b/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx index 503d0877ed5..d99cfb91dac 100644 --- a/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.notice-recovery.test.tsx @@ -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 + }) }) }) diff --git a/src/renderer/src/components/automations/automation-captured-owner.test.ts b/src/renderer/src/components/automations/automation-captured-owner.test.ts index ffc998a5b36..ca22b0286e0 100644 --- a/src/renderer/src/components/automations/automation-captured-owner.test.ts +++ b/src/renderer/src/components/automations/automation-captured-owner.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/automations/automation-captured-owner.ts b/src/renderer/src/components/automations/automation-captured-owner.ts index 761dd787e78..7a95b517fe5 100644 --- a/src/renderer/src/components/automations/automation-captured-owner.ts +++ b/src/renderer/src/components/automations/automation-captured-owner.ts @@ -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' } diff --git a/src/renderer/src/components/automations/automation-host-recovery.test.ts b/src/renderer/src/components/automations/automation-host-recovery.test.ts index 8d6419fb128..871f9343114 100644 --- a/src/renderer/src/components/automations/automation-host-recovery.test.ts +++ b/src/renderer/src/components/automations/automation-host-recovery.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/automations/automation-host-recovery.ts b/src/renderer/src/components/automations/automation-host-recovery.ts index bc71f08ff18..d443d485b1d 100644 --- a/src/renderer/src/components/automations/automation-host-recovery.ts +++ b/src/renderer/src/components/automations/automation-host-recovery.ts @@ -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. diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts index bd7cd8dbb67..e40a4393e20 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog.ts +++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts @@ -248,8 +248,8 @@ export function useAutomationHostCatalog( void window.api.runtimeEnvironments.connect({ selector: environmentId }) }, openSettings: (target) => { - openSettingsPage() openSettingsTarget(target) + openSettingsPage() } }), [controller, openSettingsPage, openSettingsTarget, pairingRevision] diff --git a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts index 616e298b175..3539ffc565f 100644 --- a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts @@ -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) }) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index fb928aa2ceb..f51a7fe22f5 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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 +): 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(null) const pendingScrollTargetRef = useRef(null) const pendingSubsectionScrollFrameRef = useRef(null) + const pendingScrollTargetWatchRef = useRef(null) const repoHooksRequestSeqRef = useRef(0) const shortcutsEscapeConfirmUntilRef = useRef(0) const sourceControlAiWriteQueueRef = useRef>(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 => { diff --git a/src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts b/src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts new file mode 100644 index 00000000000..f3d87ee07c6 --- /dev/null +++ b/src/renderer/src/components/settings/settings-deep-link-target-watcher.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/renderer/src/components/settings/settings-deep-link-target-watcher.ts b/src/renderer/src/components/settings/settings-deep-link-target-watcher.ts new file mode 100644 index 00000000000..30d7ee1b103 --- /dev/null +++ b/src/renderer/src/components/settings/settings-deep-link-target-watcher.ts @@ -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 } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 23f03820b50..6660bb6c200 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 15c47bdd968..96c7901cc08 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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