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