From bcf6f80013cbc7bd72720df515602b79b49f6f15 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 11 Sep 2026 11:19:33 +0200 Subject: [PATCH 1/4] Execution auto open generated outputs --- .../dev/vml/es/acm/core/gui/SpaSettings.java | 16 ++++++++++++++++ .../components/ExecutionReviewOutputsButton.tsx | 14 ++++++++++++-- ui.frontend/src/hooks/execution.ts | 4 +++- ui.frontend/src/pages/ExecutionView.tsx | 13 ++++++++++--- ui.frontend/src/types/main.ts | 2 ++ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java index e2a0bca71..9486906af 100644 --- a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java +++ b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java @@ -7,6 +7,7 @@ import org.osgi.service.metatype.annotations.AttributeDefinition; import org.osgi.service.metatype.annotations.Designate; import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.osgi.service.metatype.annotations.Option; @Component(service = SpaSettings.class, immediate = true) @Designate(ocd = SpaSettings.Config.class) @@ -18,6 +19,8 @@ public class SpaSettings implements Serializable { private int executionCodeOutputChunkSize; + private String executionReviewOutputsPolicy; + private long scriptStatsLimit; @Activate @@ -26,6 +29,7 @@ protected void activate(Config config) { this.appStateInterval = config.appStateInterval(); this.executionPollInterval = config.executionPollInterval(); this.executionCodeOutputChunkSize = config.executionCodeOutputChunkSize(); + this.executionReviewOutputsPolicy = config.executionReviewOutputsPolicy(); this.scriptStatsLimit = config.scriptStatsLimit(); } @@ -45,6 +49,10 @@ public long getScriptStatsLimit() { return scriptStatsLimit; } + public String getExecutionReviewOutputsPolicy() { + return executionReviewOutputsPolicy; + } + @ObjectClassDefinition(name = "AEM Content Manager - SPA Settings") public @interface Config { @@ -61,6 +69,14 @@ public long getScriptStatsLimit() { @AttributeDefinition(name = "Execution Code Output Chunk Size", description = "In bytes. Default is 2 MB.") int executionCodeOutputChunkSize() default 2 * 1024 * 1024; + @AttributeDefinition( + name = "Execution Review Outputs Policy", + description = + "Controls if the review outputs dialog opens automatically after a script execution succeeds with generated outputs. " + + "Manual: user opens it explicitly via the 'Review' button. Auto: it opens by itself once outputs are ready.", + options = {@Option(label = "Manual", value = "manual"), @Option(label = "Auto", value = "auto")}) + String executionReviewOutputsPolicy() default "auto"; + @AttributeDefinition( name = "Script Stats Limit", description = diff --git a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx index 9b35ad17b..760453dda 100644 --- a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx +++ b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx @@ -8,7 +8,7 @@ import Help from '@spectrum-icons/workflow/Help'; import Info from '@spectrum-icons/workflow/Info'; import Preview from '@spectrum-icons/workflow/Preview'; import Print from '@spectrum-icons/workflow/Print'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Execution } from '../types/execution.ts'; import { FileOutput, Output, OutputNames, TextOutput } from '../types/output.ts'; import { ToastTimeoutQuick } from '../utils/spectrum.ts'; @@ -18,16 +18,26 @@ import Markdown from './Markdown.tsx'; interface ExecutionReviewOutputsButtonProps extends Omit, 'onPress'> { execution: Execution; + autoOpen?: boolean; } -const ExecutionReviewOutputsButton: React.FC = ({ execution, ...buttonProps }) => { +const ExecutionReviewOutputsButton: React.FC = ({ execution, autoOpen = false, ...buttonProps }) => { const [dialogOpen, setDialogOpen] = useState(false); + const autoOpenedRef = useRef(false); const outputs = execution.outputs || {}; const outputValues = Object.values(outputs); const outputFiles = outputValues.filter((output) => output.type === 'FILE') as FileOutput[]; const outputTexts = outputValues.filter((output) => output.type === 'TEXT') as TextOutput[]; + // Opens the dialog once per execution when it just completed with outputs to review + useEffect(() => { + if (autoOpen && outputValues.length > 0 && !autoOpenedRef.current) { + autoOpenedRef.current = true; + setDialogOpen(true); + } + }, [autoOpen, outputValues.length]); + const handleOpenDialog = () => { setDialogOpen(true); }; diff --git a/ui.frontend/src/hooks/execution.ts b/ui.frontend/src/hooks/execution.ts index 9cf411dfd..001b93f1d 100644 --- a/ui.frontend/src/hooks/execution.ts +++ b/ui.frontend/src/hooks/execution.ts @@ -14,6 +14,7 @@ export const useExecutionPolling = (executionId: string | undefined | null, poll const [executing, setExecuting] = useState(!!executionId); const [loading, setLoading] = useState(true); const [wasPending, setWasPending] = useState(false); + const [justCompleted, setJustCompleted] = useState(false); const formatter = useFormatter(); const pollExecutionState = async (executionId: string) => { @@ -37,6 +38,7 @@ export const useExecutionPolling = (executionId: string | undefined | null, poll const recentlyCompleted = formatter.isRecent(queuedExecution.endDate, 2 * pollInterval); if (recentlyCompleted || wasPending) { + setJustCompleted(true); if (queuedExecution.status === ExecutionStatus.FAILED) { ToastQueue.negative('Code execution failed!', { timeout: ToastTimeoutQuick }); } else if (queuedExecution.status === ExecutionStatus.SKIPPED) { @@ -63,7 +65,7 @@ export const useExecutionPolling = (executionId: string | undefined | null, poll executing && executionId ? appState.spaSettings.executionPollInterval : null, ); - return { execution, setExecution, executing, setExecuting, loading }; + return { execution, setExecution, executing, setExecuting, loading, justCompleted }; }; export const pollExecutionPending = async (executionId: string, pollInterval: number): Promise => { diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index c0b5ed694..f36979efb 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -27,7 +27,7 @@ import { useExecutionPolling } from '../hooks/execution'; import { useFormatter } from '../hooks/formatter'; import { useNavigationTab } from '../hooks/navigation'; import { isExecutableConsole, isExecutableScript } from '../types/executable.ts'; -import { isExecutionPending } from '../types/execution.ts'; +import { ExecutionStatus, isExecutionPending } from '../types/execution.ts'; import { GROOVY_LANGUAGE_ID } from '../utils/monaco/groovy.ts'; import { LOG_LANGUAGE_ID } from '../utils/monaco/log.ts'; import { ToastTimeoutQuick } from '../utils/spectrum.ts'; @@ -37,7 +37,7 @@ const ExecutionView = () => { const { executionId } = useParams<{ executionId: string }>(); const formatter = useFormatter(); const [autoscrollOutput, setAutoscrollOutput] = useState(true); - const { execution, setExecution, loading } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval); + const { execution, setExecution, loading, justCompleted } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval); const [selectedTab, handleTabChange] = useNavigationTab('details'); const navigate = useNavigate(); @@ -62,6 +62,13 @@ const ExecutionView = () => { const executionOutput = ((execution.output ?? '') + '\n' + (execution.error ?? '')).trim(); + // Auto-open review dialog only for a script (not raw console) execution just finished successfully + const autoOpenReviewOutputs = + appState.spaSettings.executionReviewOutputsPolicy === 'auto' && + isExecutableScript(execution.executable.id) && + execution.status === ExecutionStatus.SUCCEEDED && + justCompleted; + const onCopyExecutableCode = () => { navigator.clipboard .writeText(execution.executable.content) @@ -170,7 +177,7 @@ const ExecutionView = () => { - + diff --git a/ui.frontend/src/types/main.ts b/ui.frontend/src/types/main.ts index 045ca28d3..6f16760b0 100644 --- a/ui.frontend/src/types/main.ts +++ b/ui.frontend/src/types/main.ts @@ -53,6 +53,7 @@ export const StateDefault: State = { appStateInterval: 3000, executionPollInterval: 1400, scriptStatsLimit: 20, + executionReviewOutputsPolicy: 'manual', }, healthStatus: { healthy: true, @@ -88,6 +89,7 @@ export type SpaSettings = { appStateInterval: number; executionPollInterval: number; scriptStatsLimit: number; + executionReviewOutputsPolicy: 'manual' | 'auto'; }; export type InstanceSettings = { From 0e5803c1186df312bb4c15312a41bb40f0499011 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 11 Sep 2026 11:20:25 +0200 Subject: [PATCH 2/4] Minor --- core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java index 9486906af..e02d5dfe3 100644 --- a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java +++ b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java @@ -73,7 +73,8 @@ public String getExecutionReviewOutputsPolicy() { name = "Execution Review Outputs Policy", description = "Controls if the review outputs dialog opens automatically after a script execution succeeds with generated outputs. " - + "Manual: user opens it explicitly via the 'Review' button. Auto: it opens by itself once outputs are ready.", + + "Manual: user opens it explicitly via the 'Review' button. Auto: it opens by itself once outputs are ready. " + + "Applies to script executions only; console executions always stay manual.", options = {@Option(label = "Manual", value = "manual"), @Option(label = "Auto", value = "auto")}) String executionReviewOutputsPolicy() default "auto"; From 832a04c021c6e27cb84c3b5c2d4905853307dda2 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 11 Sep 2026 11:46:00 +0200 Subject: [PATCH 3/4] Tab switching bug fix --- ui.frontend/src/pages/ExecutionView.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index f36979efb..74f1d48f7 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -6,7 +6,7 @@ import Copy from '@spectrum-icons/workflow/Copy'; import FileCode from '@spectrum-icons/workflow/FileCode'; import History from '@spectrum-icons/workflow/History'; import Print from '@spectrum-icons/workflow/Print'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import CodeEditor from '../components/CodeEditor.tsx'; import ExecutableIdValue from '../components/ExecutableIdValue'; @@ -40,6 +40,7 @@ const ExecutionView = () => { const { execution, setExecution, loading, justCompleted } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval); const [selectedTab, handleTabChange] = useNavigationTab('details'); const navigate = useNavigate(); + const autoOpenedOutputsIdRef = useRef(null); if (loading) { return ( @@ -62,12 +63,17 @@ const ExecutionView = () => { const executionOutput = ((execution.output ?? '') + '\n' + (execution.error ?? '')).trim(); - // Auto-open review dialog only for a script (not raw console) execution just finished successfully - const autoOpenReviewOutputs = + // Auto-open review dialog only once per execution - guards against remounts on tab switching re-triggering it + const autoOpenReview = appState.spaSettings.executionReviewOutputsPolicy === 'auto' && isExecutableScript(execution.executable.id) && execution.status === ExecutionStatus.SUCCEEDED && - justCompleted; + justCompleted && + autoOpenedOutputsIdRef.current !== execution.id; + + if (autoOpenReview) { + autoOpenedOutputsIdRef.current = execution.id; + } const onCopyExecutableCode = () => { navigator.clipboard @@ -177,7 +183,7 @@ const ExecutionView = () => { - + From acba8a7a64ad74e77711cc66584fd4c2b5a019f0 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 11 Sep 2026 12:57:04 +0200 Subject: [PATCH 4/4] Hardening --- ui.frontend/src/hooks/execution.ts | 25 ++++++++++++++++++++++++- ui.frontend/src/pages/ExecutionView.tsx | 18 +++--------------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/ui.frontend/src/hooks/execution.ts b/ui.frontend/src/hooks/execution.ts index 001b93f1d..3566eb8de 100644 --- a/ui.frontend/src/hooks/execution.ts +++ b/ui.frontend/src/hooks/execution.ts @@ -1,6 +1,7 @@ import { ToastQueue } from '@react-spectrum/toast'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useInterval } from 'react-use'; +import { isExecutableScript } from '../types/executable'; import { Execution, ExecutionStatus, isExecutionPending } from '../types/execution'; import { QueueOutput } from '../types/main'; import { apiRequest } from '../utils/api'; @@ -68,6 +69,28 @@ export const useExecutionPolling = (executionId: string | undefined | null, poll return { execution, setExecution, executing, setExecuting, loading, justCompleted }; }; +// Signals a script execution just succeeded with a 'auto' review policy, exactly once per execution id +export const useExecutionReviewAutoOpen = (execution: Execution | null, justCompleted: boolean): boolean => { + const appState = useAppState(); + const autoOpenedIdRef = useRef(null); + + const autoOpen = + !!execution && + appState.spaSettings.executionReviewOutputsPolicy === 'auto' && + isExecutableScript(execution.executable.id) && + execution.status === ExecutionStatus.SUCCEEDED && + justCompleted && + autoOpenedIdRef.current !== execution.id; + + useEffect(() => { + if (autoOpen && execution) { + autoOpenedIdRef.current = execution.id; + } + }, [autoOpen, execution]); + + return autoOpen; +}; + export const pollExecutionPending = async (executionId: string, pollInterval: number): Promise => { let queuedExecution: Execution | null = null; diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index 74f1d48f7..de470eb44 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -6,7 +6,7 @@ import Copy from '@spectrum-icons/workflow/Copy'; import FileCode from '@spectrum-icons/workflow/FileCode'; import History from '@spectrum-icons/workflow/History'; import Print from '@spectrum-icons/workflow/Print'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import CodeEditor from '../components/CodeEditor.tsx'; import ExecutableIdValue from '../components/ExecutableIdValue'; @@ -23,7 +23,7 @@ import Toggle from '../components/Toggle.tsx'; import ThreeColumnBar from '../components/ThreeColumnBar'; import UserInfo from '../components/UserInfo'; import { useAppState } from '../hooks/app.ts'; -import { useExecutionPolling } from '../hooks/execution'; +import { useExecutionPolling, useExecutionReviewAutoOpen } from '../hooks/execution'; import { useFormatter } from '../hooks/formatter'; import { useNavigationTab } from '../hooks/navigation'; import { isExecutableConsole, isExecutableScript } from '../types/executable.ts'; @@ -40,7 +40,7 @@ const ExecutionView = () => { const { execution, setExecution, loading, justCompleted } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval); const [selectedTab, handleTabChange] = useNavigationTab('details'); const navigate = useNavigate(); - const autoOpenedOutputsIdRef = useRef(null); + const autoOpenReview = useExecutionReviewAutoOpen(execution, justCompleted); if (loading) { return ( @@ -63,18 +63,6 @@ const ExecutionView = () => { const executionOutput = ((execution.output ?? '') + '\n' + (execution.error ?? '')).trim(); - // Auto-open review dialog only once per execution - guards against remounts on tab switching re-triggering it - const autoOpenReview = - appState.spaSettings.executionReviewOutputsPolicy === 'auto' && - isExecutableScript(execution.executable.id) && - execution.status === ExecutionStatus.SUCCEEDED && - justCompleted && - autoOpenedOutputsIdRef.current !== execution.id; - - if (autoOpenReview) { - autoOpenedOutputsIdRef.current = execution.id; - } - const onCopyExecutableCode = () => { navigator.clipboard .writeText(execution.executable.content)