From 4ee41fede2df4b61977c22cb0349ffdd6ec808da Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:59:30 -0700
Subject: [PATCH] fix(automations): reveal full prompt from detail view
(#16067)
---
.../automations/AutomationDetail.tsx | 20 +-
.../AutomationPromptDisclosure.test.tsx | 234 ++++++++++++++++++
.../AutomationPromptDisclosure.tsx | 93 +++++++
src/renderer/src/i18n/locales/en.json | 4 +
src/renderer/src/i18n/locales/es.json | 4 +
src/renderer/src/i18n/locales/ja.json | 4 +
src/renderer/src/i18n/locales/ko.json | 4 +
src/renderer/src/i18n/locales/zh.json | 4 +
.../e2e/automation-prompt-disclosure.spec.ts | 158 ++++++++++++
9 files changed, 510 insertions(+), 15 deletions(-)
create mode 100644 src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx
create mode 100644 src/renderer/src/components/automations/AutomationPromptDisclosure.tsx
create mode 100644 tests/e2e/automation-prompt-disclosure.spec.ts
diff --git a/src/renderer/src/components/automations/AutomationDetail.tsx b/src/renderer/src/components/automations/AutomationDetail.tsx
index ef491c816c6..c1ed6165295 100644
--- a/src/renderer/src/components/automations/AutomationDetail.tsx
+++ b/src/renderer/src/components/automations/AutomationDetail.tsx
@@ -16,6 +16,7 @@ import {
import type { AutomationTargetAvailability } from './automation-target-availability'
import { getAutomationSourceDisplay } from './automation-source-display'
import { translate } from '@/i18n/i18n'
+import { AutomationPromptDisclosure } from './AutomationPromptDisclosure'
type AutomationDetailProps = {
automation: Automation | null
@@ -301,21 +302,10 @@ export function AutomationDetail({
/>
-
-
- {translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')}
-
-
-
-
- {translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')}
-
-
- {automation.prompt}
-
-
-
-
+
)
}
diff --git a/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx
new file mode 100644
index 00000000000..6ac693aea82
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationPromptDisclosure.test.tsx
@@ -0,0 +1,234 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+import type React from 'react'
+import { act, cleanup, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Automation } from '../../../../shared/automations-types'
+import { i18n } from '@/i18n/i18n'
+import { AutomationDetail } from './AutomationDetail'
+import { AutomationPromptDisclosure } from './AutomationPromptDisclosure'
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}>
+}))
+
+let promptNaturalHeight = 0
+let resizeCallback: ResizeObserverCallback | null = null
+
+class PromptResizeObserver implements ResizeObserver {
+ constructor(callback: ResizeObserverCallback) {
+ resizeCallback = callback
+ }
+
+ disconnect(): void {}
+ observe(): void {}
+ unobserve(): void {}
+}
+
+function makeAutomation(
+ updatedAt: number,
+ prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}`
+): Automation {
+ return {
+ id: 'synthetic-automation',
+ name: 'Synthetic automation',
+ prompt,
+ precheck: null,
+ agentId: 'codex',
+ projectId: 'synthetic-project',
+ executionTargetType: 'local',
+ executionTargetId: 'local',
+ schedulerOwner: 'local_host_service',
+ workspaceMode: 'existing',
+ workspaceId: 'synthetic-workspace',
+ baseBranch: null,
+ reuseSession: false,
+ timezone: 'UTC',
+ rrule: 'FREQ=DAILY',
+ dtstart: 1,
+ enabled: false,
+ nextRunAt: 2,
+ missedRunPolicy: 'run_once_within_grace',
+ missedRunGraceMinutes: 720,
+ createdAt: 1,
+ updatedAt
+ }
+}
+
+const detailCallbacks = {
+ onRunNow: vi.fn(),
+ onEdit: vi.fn(),
+ onToggle: vi.fn(),
+ onDelete: vi.fn()
+}
+
+describe('AutomationPromptDisclosure', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('en')
+ vi.stubGlobal('ResizeObserver', PromptResizeObserver)
+ vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(
+ function (this: HTMLElement) {
+ if (this.tagName !== 'P') {
+ return 0
+ }
+ return this.classList.contains('line-clamp-4')
+ ? Math.min(promptNaturalHeight, 80)
+ : promptNaturalHeight
+ }
+ )
+ vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(
+ function (this: HTMLElement) {
+ return this.tagName === 'P' ? promptNaturalHeight : 0
+ }
+ )
+ })
+
+ afterEach(async () => {
+ cleanup()
+ resizeCallback = null
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ await i18n.changeLanguage('en')
+ })
+
+ it('leaves a short prompt fully readable without a disclosure control', () => {
+ promptNaturalHeight = 40
+ render()
+
+ expect(screen.getByText('Synthetic short prompt.')).toBeVisible()
+ expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
+ })
+
+ it('labels the prompt correctly in Spanish', async () => {
+ promptNaturalHeight = 40
+ await i18n.changeLanguage('es')
+ render()
+
+ expect(screen.getByText('Prompt')).toBeVisible()
+ expect(screen.queryByText('Inmediato')).not.toBeInTheDocument()
+ })
+
+ it('reveals the complete long prompt from the keyboard and keeps it selectable', async () => {
+ promptNaturalHeight = 240
+ const prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}\nSYNTHETIC-END-MARKER`
+ const user = userEvent.setup()
+ render()
+
+ const content = screen.getByText(/SYNTHETIC-END-MARKER/)
+ const toggle = screen.getByRole('button', { name: 'Show more' })
+ expect(screen.getAllByText('Prompt')).toHaveLength(1)
+ expect(content).toHaveClass('line-clamp-4', 'select-text', '[overflow-wrap:anywhere]')
+ expect(toggle).toHaveAttribute('aria-expanded', 'false')
+ expect(toggle).toHaveAttribute('aria-controls', content.id)
+ expect(toggle).toHaveAttribute('data-variant', 'ghost')
+ expect(toggle).toHaveAttribute('data-size', 'xs')
+ expect(toggle).not.toHaveClass('h-auto', 'p-0')
+
+ await user.tab()
+ expect(toggle).toHaveFocus()
+ await user.keyboard('{Enter}')
+
+ expect(screen.getByRole('button', { name: 'Show less' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ )
+ expect(content).not.toHaveClass('line-clamp-4')
+ expect(content).toHaveTextContent('SYNTHETIC-END-MARKER')
+
+ await user.click(screen.getByRole('button', { name: 'Show less' }))
+ expect(screen.getByRole('button', { name: 'Show more' })).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ )
+ expect(content).toHaveClass('line-clamp-4')
+
+ await user.click(screen.getByRole('button', { name: 'Show more' }))
+ act(() => resizeCallback?.([], {} as ResizeObserver))
+ expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus()
+
+ await user.click(screen.getByRole('button', { name: 'Show less' }))
+ expect(screen.getByRole('button', { name: 'Show more' })).toHaveFocus()
+ expect(content).toHaveClass('line-clamp-4')
+ })
+
+ it('offers disclosure after a narrow resize makes the prompt overflow', () => {
+ promptNaturalHeight = 60
+ render()
+ expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
+
+ promptNaturalHeight = 180
+ act(() => resizeCallback?.([], {} as ResizeObserver))
+
+ expect(screen.getByRole('button', { name: 'Show more' })).toBeVisible()
+ })
+
+ it('moves focus to the fully visible prompt when resizing removes the disclosure', () => {
+ promptNaturalHeight = 180
+ render()
+ const content = screen.getByText('Synthetic prompt that fits after widening.')
+ screen.getByRole('button', { name: 'Show more' }).focus()
+
+ promptNaturalHeight = 60
+ act(() => resizeCallback?.([], {} as ResizeObserver))
+
+ expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
+ expect(content).toHaveFocus()
+ })
+
+ it('preserves expansion and focus across unrelated automation updates', async () => {
+ promptNaturalHeight = 240
+ const user = userEvent.setup()
+ const { rerender } = render(
+
+ )
+
+ await user.click(screen.getByRole('button', { name: 'Show more' }))
+ const showLess = screen.getByRole('button', { name: 'Show less' })
+ showLess.focus()
+
+ rerender(
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus()
+
+ promptNaturalHeight = 40
+ rerender(
+
+ )
+
+ expect(screen.getByText('Synthetic replacement prompt.')).toBeVisible()
+ expect(screen.queryByRole('button', { name: 'Show less' })).not.toBeInTheDocument()
+ })
+})
diff --git a/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx b/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx
new file mode 100644
index 00000000000..b5eefc7dd74
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationPromptDisclosure.tsx
@@ -0,0 +1,93 @@
+import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+
+export function AutomationPromptDisclosure({ prompt }: { prompt: string }): React.JSX.Element {
+ const contentId = useId()
+ const expandedRef = useRef(false)
+ const toggleRef = useRef(null)
+ const [promptElement, setPromptElement] = useState(null)
+ const [expanded, setExpanded] = useState(false)
+ const [overflows, setOverflows] = useState(false)
+
+ const measureOverflow = useCallback((element: HTMLParagraphElement) => {
+ const nextOverflows = element.scrollHeight > element.clientHeight + 1
+ if (!nextOverflows && document.activeElement === toggleRef.current) {
+ element.focus({ preventScroll: true })
+ }
+ setOverflows((current) => (current === nextOverflows ? current : nextOverflows))
+ }, [])
+
+ useEffect(() => {
+ if (!promptElement || expanded) {
+ return
+ }
+
+ const updateOverflow = () => {
+ if (!expandedRef.current) {
+ measureOverflow(promptElement)
+ }
+ }
+ updateOverflow()
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', updateOverflow)
+ return () => window.removeEventListener('resize', updateOverflow)
+ }
+
+ const observer = new ResizeObserver(updateOverflow)
+ observer.observe(promptElement)
+ return () => observer.disconnect()
+ }, [expanded, measureOverflow, promptElement])
+
+ const toggleExpanded = (): void => {
+ const nextExpanded = !expanded
+ expandedRef.current = nextExpanded
+ setExpanded(nextExpanded)
+ }
+
+ return (
+
+
+
+ {translate('auto.components.automations.AutomationEditorDialog.058c23cb3f', 'Prompt')}
+
+ {overflows || expanded ? (
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 291cc9395de..c117b08e172 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -15008,6 +15008,10 @@
"0e1de0358b": "Month",
"77e96bded6": "Weekday"
},
+ "AutomationPromptDisclosure": {
+ "showLess": "Show less",
+ "showMore": "Show more"
+ },
"AutomationDetail": {
"007c8ad874": "Prompt",
"a1d52c2189": "Usage coverage",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 27136599f39..9f1ef9ca5cc 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -13612,6 +13612,10 @@
"0e1de0358b": "Mes",
"77e96bded6": "Día de la semana"
},
+ "AutomationPromptDisclosure": {
+ "showLess": "Mostrar menos",
+ "showMore": "Mostrar más"
+ },
"AutomationDetail": {
"007c8ad874": "Inmediato",
"a1d52c2189": "Cobertura de uso",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 1a2f3e9f95a..508c9d9bec3 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13612,6 +13612,10 @@
"0e1de0358b": "月",
"77e96bded6": "曜日"
},
+ "AutomationPromptDisclosure": {
+ "showLess": "折りたたむ",
+ "showMore": "さらに表示"
+ },
"AutomationDetail": {
"007c8ad874": "プロンプト",
"a1d52c2189": "使用範囲",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 40e5469d0ae..2384a7996fc 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13641,6 +13641,10 @@
"0e1de0358b": "월",
"77e96bded6": "요일"
},
+ "AutomationPromptDisclosure": {
+ "showLess": "접기",
+ "showMore": "더 보기"
+ },
"AutomationDetail": {
"007c8ad874": "프롬프트",
"a1d52c2189": "사용 범위",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 6ad65d655e8..fdd53f16ed9 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13632,6 +13632,10 @@
"0e1de0358b": "月",
"77e96bded6": "星期"
},
+ "AutomationPromptDisclosure": {
+ "showLess": "收起",
+ "showMore": "显示更多"
+ },
"AutomationDetail": {
"007c8ad874": "提示词",
"a1d52c2189": "使用范围",
diff --git a/tests/e2e/automation-prompt-disclosure.spec.ts b/tests/e2e/automation-prompt-disclosure.spec.ts
new file mode 100644
index 00000000000..4137f8495c4
--- /dev/null
+++ b/tests/e2e/automation-prompt-disclosure.spec.ts
@@ -0,0 +1,158 @@
+import { test, expect } from './helpers/orca-app'
+import { waitForSessionReady } from './helpers/store'
+
+const SHORT_NAME = 'synthetic-short-prompt-demo'
+const RESIZE_NAME = 'synthetic-resize-prompt-demo'
+const LONG_NAME = 'visual-proof-long-prompt-demo'
+const END_MARKER = 'SYNTHETIC-END-MARKER'
+const RESIZE_PROMPT = `Synthetic resize focus validation. ${'placeholder '.repeat(24)}`
+
+test('automation detail keeps short prompts readable and reveals a very long prompt at narrow width', async ({
+ orcaPage
+}) => {
+ await waitForSessionReady(orcaPage)
+ await orcaPage.setViewportSize({ width: 820, height: 700 })
+
+ await orcaPage.evaluate(
+ async ({ shortName, resizeName, resizePrompt, longName, endMarker }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is not available')
+ }
+ const repo = store.getState().repos[0]
+ if (!repo) {
+ throw new Error('Seeded test repo is not available')
+ }
+ const base = {
+ agentId: 'codex' as const,
+ projectId: repo.id,
+ workspaceMode: 'new_per_run' as const,
+ reuseSession: false,
+ timezone: 'UTC',
+ rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
+ dtstart: Date.now(),
+ enabled: false,
+ missedRunGraceMinutes: 720
+ }
+ await window.api.automations.create({
+ ...base,
+ name: shortName,
+ prompt: 'Synthetic short prompt.'
+ })
+ await window.api.automations.create({
+ ...base,
+ name: longName,
+ prompt: [
+ 'Synthetic prompt preview validation only.',
+ '',
+ ...Array.from(
+ { length: 20 },
+ (_, index) =>
+ `Synthetic step ${index + 1}: inspect placeholder input and summarize placeholder output.`
+ ),
+ '',
+ `UNBROKEN_SYNTHETIC_${'X'.repeat(240)}`,
+ '',
+ endMarker
+ ].join('\n')
+ })
+ await window.api.automations.create({
+ ...base,
+ name: resizeName,
+ prompt: resizePrompt
+ })
+ store.getState().openAutomationsPage()
+ },
+ {
+ shortName: SHORT_NAME,
+ resizeName: RESIZE_NAME,
+ resizePrompt: RESIZE_PROMPT,
+ longName: LONG_NAME,
+ endMarker: END_MARKER
+ }
+ )
+
+ await orcaPage.getByRole('button', { name: new RegExp(`^${SHORT_NAME}`) }).click()
+ await expect(orcaPage.getByText('Synthetic short prompt.')).toBeVisible()
+ await expect(orcaPage.getByRole('button', { name: 'Show more' })).toHaveCount(0)
+
+ await orcaPage.getByRole('button', { name: 'All automations' }).click()
+ await orcaPage.getByRole('button', { name: new RegExp(`^${RESIZE_NAME}`) }).click()
+ const resizePrompt = orcaPage.getByText(RESIZE_PROMPT)
+ const resizeToggle = orcaPage.getByRole('button', { name: 'Show more' })
+ await expect(resizeToggle).toBeVisible()
+ await resizeToggle.focus()
+ await orcaPage.setViewportSize({ width: 1400, height: 700 })
+ await expect(resizeToggle).toHaveCount(0)
+ await expect(resizePrompt).toBeFocused()
+
+ await orcaPage.setViewportSize({ width: 820, height: 700 })
+ await orcaPage.getByRole('button', { name: 'All automations' }).click()
+ await orcaPage.getByRole('button', { name: new RegExp(`^${LONG_NAME}`) }).click()
+
+ const prompt = orcaPage.getByText(new RegExp(END_MARKER))
+ const showMore = orcaPage.getByRole('button', { name: 'Show more' })
+ await expect(showMore).toBeVisible()
+ expect(await showMore.getAttribute('aria-controls')).toBe(await prompt.getAttribute('id'))
+ const collapsedMetrics = await prompt.evaluate((element) => ({
+ clientHeight: element.clientHeight,
+ scrollHeight: element.scrollHeight,
+ lineClamp: getComputedStyle(element).webkitLineClamp
+ }))
+ expect(collapsedMetrics.scrollHeight).toBeGreaterThan(collapsedMetrics.clientHeight)
+ expect(collapsedMetrics.lineClamp).toBe('4')
+
+ await showMore.focus()
+ await orcaPage.keyboard.press('Enter')
+ await expect(orcaPage.getByRole('button', { name: 'Show less' })).toBeFocused()
+
+ const expandedMetrics = await prompt.evaluate((element) => ({
+ clientHeight: element.clientHeight,
+ scrollHeight: element.scrollHeight,
+ scrollWidth: element.scrollWidth,
+ clientWidth: element.clientWidth,
+ lineClamp: getComputedStyle(element).webkitLineClamp,
+ overflowWrap: getComputedStyle(element).overflowWrap
+ }))
+ expect(expandedMetrics.clientHeight).toBeGreaterThan(80)
+ expect(expandedMetrics.scrollHeight).toBeLessThanOrEqual(expandedMetrics.clientHeight + 1)
+ expect(expandedMetrics.scrollWidth).toBeLessThanOrEqual(expandedMetrics.clientWidth + 1)
+ expect(expandedMetrics.lineClamp).toBe('none')
+ expect(expandedMetrics.overflowWrap).toBe('anywhere')
+
+ const markerProof = await prompt.evaluate(async (element, marker) => {
+ const text = element.firstChild
+ if (!(text instanceof Text)) {
+ throw new Error('Prompt text node is unavailable')
+ }
+ const start = text.data.indexOf(marker)
+ if (start === -1) {
+ throw new Error('Prompt end marker is unavailable')
+ }
+ const range = document.createRange()
+ range.setStart(text, start)
+ range.setEnd(text, start + marker.length)
+ const selection = window.getSelection()
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ const scrollContainer = element.closest('[role="tabpanel"]')
+ if (!(scrollContainer instanceof HTMLElement)) {
+ throw new Error('Automation overview scroll container is unavailable')
+ }
+ scrollContainer.scrollTop +=
+ range.getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().bottom + 16
+ await new Promise(requestAnimationFrame)
+ const markerRect = range.getBoundingClientRect()
+ const containerRect = scrollContainer.getBoundingClientRect()
+ return {
+ selectedText: selection?.toString(),
+ markerTop: markerRect.top,
+ markerBottom: markerRect.bottom,
+ visibleTop: containerRect.top,
+ visibleBottom: containerRect.bottom
+ }
+ }, END_MARKER)
+ expect(markerProof.selectedText).toBe(END_MARKER)
+ expect(markerProof.markerTop).toBeGreaterThanOrEqual(markerProof.visibleTop)
+ expect(markerProof.markerBottom).toBeLessThanOrEqual(markerProof.visibleBottom)
+})