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} +
+
+

+ {prompt} +

+
+
+ ) +} 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) +})