fix(automations): reveal full prompt from detail view (#16067)

This commit is contained in:
Jinwoo Hong
2026-08-23 19:59:30 -07:00
committed by GitHub
parent 55258f34ad
commit 4ee41fede2
9 changed files with 510 additions and 15 deletions
@@ -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({
/>
</div>
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="border-b border-border/50 px-3 py-2 text-sm font-medium">
{translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')}
</div>
<div className="px-3 py-3">
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase text-muted-foreground">
{translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')}
</div>
<p className="mt-1 line-clamp-4 whitespace-pre-wrap text-sm text-foreground">
{automation.prompt}
</p>
</div>
</div>
</div>
<AutomationPromptDisclosure
key={`${automation.id}:${automation.prompt}`}
prompt={automation.prompt}
/>
</div>
)
}
@@ -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(<AutomationPromptDisclosure prompt="Synthetic short prompt." />)
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(<AutomationPromptDisclosure prompt="Synthetic short prompt." />)
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(<AutomationPromptDisclosure prompt={prompt} />)
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(<AutomationPromptDisclosure prompt="Synthetic prompt that reflows at narrow widths." />)
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(<AutomationPromptDisclosure prompt="Synthetic prompt that fits after widening." />)
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(
<AutomationDetail
automation={makeAutomation(1)}
runs={[]}
projectName="Synthetic project"
workspaceName="Synthetic workspace"
projectDefaultBaseRef={null}
runNowAvailability={null}
now={1}
{...detailCallbacks}
/>
)
await user.click(screen.getByRole('button', { name: 'Show more' }))
const showLess = screen.getByRole('button', { name: 'Show less' })
showLess.focus()
rerender(
<AutomationDetail
automation={makeAutomation(2)}
runs={[]}
projectName="Synthetic project"
workspaceName="Synthetic workspace"
projectDefaultBaseRef={null}
runNowAvailability={null}
now={2}
{...detailCallbacks}
/>
)
expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus()
promptNaturalHeight = 40
rerender(
<AutomationDetail
automation={makeAutomation(3, 'Synthetic replacement prompt.')}
runs={[]}
projectName="Synthetic project"
workspaceName="Synthetic workspace"
projectDefaultBaseRef={null}
runNowAvailability={null}
now={3}
{...detailCallbacks}
/>
)
expect(screen.getByText('Synthetic replacement prompt.')).toBeVisible()
expect(screen.queryByRole('button', { name: 'Show less' })).not.toBeInTheDocument()
})
})
@@ -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<HTMLButtonElement>(null)
const [promptElement, setPromptElement] = useState<HTMLParagraphElement | null>(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 (
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2">
<div className="text-sm font-medium">
{translate('auto.components.automations.AutomationEditorDialog.058c23cb3f', 'Prompt')}
</div>
{overflows || expanded ? (
<Button
ref={toggleRef}
type="button"
variant="ghost"
size="xs"
className="-mr-2 text-muted-foreground hover:text-foreground"
aria-expanded={expanded}
aria-controls={contentId}
onClick={toggleExpanded}
>
{expanded
? translate(
'auto.components.automations.AutomationPromptDisclosure.showLess',
'Show less'
)
: translate(
'auto.components.automations.AutomationPromptDisclosure.showMore',
'Show more'
)}
</Button>
) : null}
</div>
<div className="min-w-0 px-3 py-3">
<p
ref={setPromptElement}
id={contentId}
tabIndex={-1}
className={cn(
'select-text whitespace-pre-wrap text-sm text-foreground [overflow-wrap:anywhere]',
!expanded && 'line-clamp-4'
)}
>
{prompt}
</p>
</div>
</div>
)
}
+4
View File
@@ -15008,6 +15008,10 @@
"0e1de0358b": "Month",
"77e96bded6": "Weekday"
},
"AutomationPromptDisclosure": {
"showLess": "Show less",
"showMore": "Show more"
},
"AutomationDetail": {
"007c8ad874": "Prompt",
"a1d52c2189": "Usage coverage",
+4
View File
@@ -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",
+4
View File
@@ -13612,6 +13612,10 @@
"0e1de0358b": "月",
"77e96bded6": "曜日"
},
"AutomationPromptDisclosure": {
"showLess": "折りたたむ",
"showMore": "さらに表示"
},
"AutomationDetail": {
"007c8ad874": "プロンプト",
"a1d52c2189": "使用範囲",
+4
View File
@@ -13641,6 +13641,10 @@
"0e1de0358b": "월",
"77e96bded6": "요일"
},
"AutomationPromptDisclosure": {
"showLess": "접기",
"showMore": "더 보기"
},
"AutomationDetail": {
"007c8ad874": "프롬프트",
"a1d52c2189": "사용 범위",
+4
View File
@@ -13632,6 +13632,10 @@
"0e1de0358b": "月",
"77e96bded6": "星期"
},
"AutomationPromptDisclosure": {
"showLess": "收起",
"showMore": "显示更多"
},
"AutomationDetail": {
"007c8ad874": "提示词",
"a1d52c2189": "使用范围",
@@ -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)
})