diff --git a/README.md b/README.md
index 3bd278e4..ccad9737 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,21 @@
## What's new in this fork
+### 2026-06-19 — Multi-tab workflow: status, notifications, shortcuts
+
+Builds on the in-app tabs to make working with several scripts at once smoother.
+
+- **Per-tab status indicator**: each tab shows the aggregated status of its
+ script's executions — a spinner while running, a check when finished, an error
+ icon on failure — so background tabs are readable at a glance.
+- **Completion notifications**: an in-app snackbar when a script finishes or
+ fails, plus a native browser notification when the app isn't focused (permission
+ requested from the Execute click).
+- **Keyboard shortcuts**: `Ctrl`/`Cmd`+`Enter` to run the current script,
+ `Alt`+`←`/`→` to switch tabs, `Alt`+`W` to close the active tab.
+- **Replay last execution**: a one-click button to re-run a script with the
+ parameters of its most recent execution (shown when history exists).
+
### 2026-06-19 — UI refresh, dark mode and in-app tabs
A visual refresh of the main app, a dark theme, and the ability to keep several
diff --git a/web-src/src/main-app/MainApp.vue b/web-src/src/main-app/MainApp.vue
index fce8176a..62835d35 100644
--- a/web-src/src/main-app/MainApp.vue
+++ b/web-src/src/main-app/MainApp.vue
@@ -18,6 +18,7 @@
+
@@ -31,6 +32,9 @@ import FaviconManager from './components/FaviconManager';
import MainAppSidebar from './components/MainAppSidebar';
import MainAppContent from './components/scripts/MainAppContent';
import ScriptTabs from './components/scripts/ScriptTabs';
+import ExecutionNotifier from './components/ExecutionNotifier';
+import {scriptNameToHash} from './utils/model_helper';
+import {useScriptTabsStore} from '@/main-app/stores/scriptTabs';
import {usePageStore} from '@/main-app/stores/page'
import {useAuthStore} from '@/common/stores/auth'
import {useServerConfigStore} from '@/main-app/stores/serverConfig'
@@ -44,6 +48,7 @@ export default {
MainAppSidebar,
MainAppContent,
ScriptTabs,
+ ExecutionNotifier,
AppWelcomePanel,
DocumentTitleManager,
FaviconManager
@@ -68,6 +73,44 @@ export default {
this.$router.afterEach((to) => {
this.$refs.appLayout.setSidebarVisibility(false);
});
+
+ window.addEventListener('keydown', this.handleTabShortcuts);
+ },
+
+ beforeUnmount() {
+ window.removeEventListener('keydown', this.handleTabShortcuts);
+ },
+
+ methods: {
+ // Tab navigation shortcuts. Uses Alt-based combos: Ctrl+W / Ctrl+Tab are
+ // reserved by the browser and can't be reliably intercepted.
+ handleTabShortcuts(event) {
+ if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ const tabsStore = useScriptTabsStore();
+ const openTabs = tabsStore.openTabs;
+ const activeScript = useScriptsStore().selectedScript;
+
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
+ if (openTabs.length === 0) {
+ return;
+ }
+ event.preventDefault();
+ const step = event.key === 'ArrowRight' ? 1 : -1;
+ let index = openTabs.indexOf(activeScript);
+ index = index === -1 ? 0 : (index + step + openTabs.length) % openTabs.length;
+ this.$router.push('/' + scriptNameToHash(openTabs[index]));
+ } else if (event.key === 'w' || event.key === 'W') {
+ if (isEmptyString(activeScript)) {
+ return;
+ }
+ event.preventDefault();
+ const nextScript = tabsStore.closeTab(activeScript);
+ this.$router.push(nextScript ? '/' + scriptNameToHash(nextScript) : '/');
+ }
+ }
}
}
diff --git a/web-src/src/main-app/components/ExecutionNotifier.vue b/web-src/src/main-app/components/ExecutionNotifier.vue
new file mode 100644
index 00000000..694afa7f
--- /dev/null
+++ b/web-src/src/main-app/components/ExecutionNotifier.vue
@@ -0,0 +1,91 @@
+
+
+ {{ snackbarText }}
+
+ Close
+
+
+
+
+
diff --git a/web-src/src/main-app/components/scripts/ScriptTabs.vue b/web-src/src/main-app/components/scripts/ScriptTabs.vue
index 28161bbe..083323b2 100644
--- a/web-src/src/main-app/components/scripts/ScriptTabs.vue
+++ b/web-src/src/main-app/components/scripts/ScriptTabs.vue
@@ -14,6 +14,25 @@
class="script-tab"
@click="activate(scriptName)"
>
+
+ check_circle
+ error
+
{{ scriptName }}
import {useScriptTabsStore} from '@/main-app/stores/scriptTabs'
import {useScriptsStore} from '@/main-app/stores/scripts'
+import {useExecutionsStore} from '@/main-app/stores/executions'
import {scriptNameToHash} from '@/main-app/utils/model_helper'
export default {
@@ -41,6 +61,9 @@ export default {
}
},
methods: {
+ statusFor(scriptName) {
+ return useExecutionsStore().getScriptStatus(scriptName)
+ },
activate(scriptName) {
if (scriptName === this.activeScript) {
return
@@ -78,6 +101,19 @@ export default {
color: var(--primary-color);
}
+.tab-status {
+ margin-right: 6px;
+ flex-shrink: 0;
+}
+
+.tab-status.status-finished {
+ color: var(--primary-color);
+}
+
+.tab-status.status-error {
+ color: var(--error-color);
+}
+
.tab-label {
max-width: 18ch;
overflow: hidden;
diff --git a/web-src/src/main-app/components/scripts/script-view.vue b/web-src/src/main-app/components/scripts/script-view.vue
index ed3ea2f5..3818685c 100644
--- a/web-src/src/main-app/components/scripts/script-view.vue
+++ b/web-src/src/main-app/components/scripts/script-view.vue
@@ -11,6 +11,16 @@
@click="executeScript">
Execute
+
+ Replay
+
0);
},
@@ -294,11 +317,49 @@ export default {
return true;
},
+ handleExecuteShortcut: function (event) {
+ if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) {
+ return;
+ }
+ if (!this.enableExecuteButton || this.scheduleMode) {
+ return;
+ }
+ event.preventDefault();
+ this.executeScript();
+ },
+
+ requestNotificationPermission: function () {
+ // Called from the Execute click (a user gesture) so the browser allows the
+ // permission prompt. Used by ExecutionNotifier for background completions.
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
+ Notification.requestPermission().catch(() => {});
+ }
+ },
+
executeScript: function () {
if (!this.validatePreExecution()) {
return;
}
+ this.requestNotificationPermission();
+ this.startExecution();
+ },
+
+ replayLastExecution: function () {
+ const values = getMostRecentValues(this.selectedScript);
+ if (isNull(values)) {
+ return;
+ }
+
+ // Restore the last run's values into the form, then execute. reloadModel
+ // sets parameterValues synchronously, so startExecution picks them up.
+ useScriptSetupStore().reloadModel({
+ values: deepCloneObject(values),
+ forceAllowedValues: true,
+ scriptName: this.selectedScript
+ });
+
+ this.requestNotificationPermission();
this.startExecution();
},
diff --git a/web-src/src/main-app/stores/executions.js b/web-src/src/main-app/stores/executions.js
index ebb783f0..6b3d5f7c 100644
--- a/web-src/src/main-app/stores/executions.js
+++ b/web-src/src/main-app/stores/executions.js
@@ -25,6 +25,25 @@ export const useExecutionsStore = defineStore('executions', {
},
actions: {
+ // Aggregated status of all executions for a given script, by priority:
+ // running > error > finished > none. Used by the tab status indicators.
+ getScriptStatus(scriptName) {
+ const statuses = Object.values(this.executors)
+ .filter(e => e.state.scriptName === scriptName)
+ .map(e => e.state.status)
+
+ if (statuses.includes(STATUS_EXECUTING) || statuses.includes(STATUS_INITIALIZING)) {
+ return 'running'
+ }
+ if (statuses.includes(STATUS_ERROR) || statuses.includes(STATUS_DISCONNECTED)) {
+ return 'error'
+ }
+ if (statuses.includes(STATUS_FINISHED)) {
+ return 'finished'
+ }
+ return null
+ },
+
init() {
axiosInstance.get('executions/active')
.then(({data: activeExecutionIds}) => {
diff --git a/web-src/tests/unit/main-app/components/ExecutionNotifier_test.js b/web-src/tests/unit/main-app/components/ExecutionNotifier_test.js
new file mode 100644
index 00000000..8a5d38f4
--- /dev/null
+++ b/web-src/tests/unit/main-app/components/ExecutionNotifier_test.js
@@ -0,0 +1,73 @@
+'use strict';
+import ExecutionNotifier from '@/main-app/components/ExecutionNotifier';
+import {mount} from '@vue/test-utils';
+import {createPinia, setActivePinia} from 'pinia';
+import {useExecutionsStore} from '@/main-app/stores/executions';
+import {attachToDocument} from '../../test_utils';
+
+function executor(id, scriptName, status) {
+ return {state: {id, scriptName, status}};
+}
+
+describe('Test ExecutionNotifier', function () {
+ let notifier;
+ let executionsStore;
+ let pinia;
+
+ beforeEach(function () {
+ pinia = createPinia();
+ setActivePinia(pinia);
+ executionsStore = useExecutionsStore();
+ notifier = mount(ExecutionNotifier, {
+ attachTo: attachToDocument(),
+ global: {plugins: [pinia]}
+ });
+ });
+
+ afterEach(function () {
+ notifier.unmount();
+ });
+
+ it('notifies when a running execution finishes', function () {
+ executionsStore.executors = {1: executor(1, 'myScript', 'executing')};
+ notifier.vm.prevStatuses = {1: 'executing'};
+
+ executionsStore.executors[1].state.status = 'finished';
+ notifier.vm.checkTransitions();
+
+ expect(notifier.vm.snackbar).toBe(true);
+ expect(notifier.vm.snackbarText).toBe('myScript finished');
+ expect(notifier.vm.snackbarColor).toBe('primary');
+ });
+
+ it('notifies a failure when a running execution errors', function () {
+ executionsStore.executors = {1: executor(1, 'myScript', 'executing')};
+ notifier.vm.prevStatuses = {1: 'executing'};
+
+ executionsStore.executors[1].state.status = 'error';
+ notifier.vm.checkTransitions();
+
+ expect(notifier.vm.snackbar).toBe(true);
+ expect(notifier.vm.snackbarText).toBe('myScript failed');
+ expect(notifier.vm.snackbarColor).toBe('error');
+ });
+
+ it('does not notify for a status change that is not a completion', function () {
+ executionsStore.executors = {1: executor(1, 'myScript', 'initializing')};
+ notifier.vm.prevStatuses = {1: 'initializing'};
+
+ executionsStore.executors[1].state.status = 'executing';
+ notifier.vm.checkTransitions();
+
+ expect(notifier.vm.snackbar).toBe(false);
+ });
+
+ it('does not re-notify when the status is unchanged', function () {
+ executionsStore.executors = {1: executor(1, 'myScript', 'finished')};
+ notifier.vm.prevStatuses = {1: 'finished'};
+
+ notifier.vm.checkTransitions();
+
+ expect(notifier.vm.snackbar).toBe(false);
+ });
+});
diff --git a/web-src/tests/unit/main-app/components/scripts/ScriptTabs_test.js b/web-src/tests/unit/main-app/components/scripts/ScriptTabs_test.js
new file mode 100644
index 00000000..289e10c9
--- /dev/null
+++ b/web-src/tests/unit/main-app/components/scripts/ScriptTabs_test.js
@@ -0,0 +1,85 @@
+'use strict';
+import ScriptTabs from '@/main-app/components/scripts/ScriptTabs';
+import {mount} from '@vue/test-utils';
+import {createPinia, setActivePinia} from 'pinia';
+import {useScriptsStore} from '@/main-app/stores/scripts';
+import {useExecutionsStore} from '@/main-app/stores/executions';
+import {useScriptTabsStore} from '@/main-app/stores/scriptTabs';
+import {attachToDocument} from '../../../test_utils';
+import router from '@/main-app/router/router';
+
+describe('Test ScriptTabs', function () {
+ let pinia;
+
+ function mountWith(openTabs, selectedScript, statusFn) {
+ pinia = createPinia();
+ setActivePinia(pinia);
+
+ useScriptTabsStore().openTabs = openTabs;
+ useScriptsStore().selectedScript = selectedScript;
+ vi.spyOn(useExecutionsStore(), 'getScriptStatus').mockImplementation(statusFn);
+
+ return mount(ScriptTabs, {
+ attachTo: attachToDocument(),
+ global: {plugins: [pinia, router]}
+ });
+ }
+
+ afterEach(function () {
+ vi.restoreAllMocks();
+ });
+
+ it('renders one tab per open script', function () {
+ const tabs = mountWith(['a', 'b'], 'a', () => null);
+ expect(tabs.findAll('.script-tab')).toHaveLength(2);
+ expect(tabs.text()).toContain('a');
+ expect(tabs.text()).toContain('b');
+ tabs.unmount();
+ });
+
+ it('is hidden when no tabs are open', function () {
+ const tabs = mountWith([], null, () => null);
+ expect(tabs.find('.script-tabs').exists()).toBe(false);
+ tabs.unmount();
+ });
+
+ it('shows a spinner for a running script', function () {
+ const tabs = mountWith(['a'], 'a', () => 'running');
+ expect(tabs.find('.tab-status.v-progress-circular').exists()).toBe(true);
+ tabs.unmount();
+ });
+
+ it('shows a check icon for a finished script', function () {
+ const tabs = mountWith(['a'], 'a', () => 'finished');
+ const icon = tabs.find('.tab-status.status-finished');
+ expect(icon.exists()).toBe(true);
+ expect(icon.text()).toBe('check_circle');
+ tabs.unmount();
+ });
+
+ it('shows an error icon for a failed script', function () {
+ const tabs = mountWith(['a'], 'a', () => 'error');
+ const icon = tabs.find('.tab-status.status-error');
+ expect(icon.exists()).toBe(true);
+ expect(icon.text()).toBe('error');
+ tabs.unmount();
+ });
+
+ it('shows no status icon for an idle script', function () {
+ const tabs = mountWith(['a'], 'a', () => null);
+ expect(tabs.find('.tab-status').exists()).toBe(false);
+ tabs.unmount();
+ });
+
+ it('closing a tab removes it from the store', async function () {
+ const tabs = mountWith(['a', 'b'], 'b', () => null);
+ const store = useScriptTabsStore();
+
+ // close the non-active tab "a" via its close icon
+ const firstTab = tabs.findAll('.script-tab')[0];
+ await firstTab.find('.tab-close').trigger('click');
+
+ expect(store.openTabs).toEqual(['b']);
+ tabs.unmount();
+ });
+});
diff --git a/web-src/tests/unit/main-app/components/scripts/script-view_replay_test.js b/web-src/tests/unit/main-app/components/scripts/script-view_replay_test.js
new file mode 100644
index 00000000..c9568c08
--- /dev/null
+++ b/web-src/tests/unit/main-app/components/scripts/script-view_replay_test.js
@@ -0,0 +1,100 @@
+'use strict';
+
+vi.mock('@/common/utils/parameterHistory', () => ({
+ getMostRecentValues: vi.fn(),
+ saveParameterHistory: vi.fn(),
+ loadParameterHistory: vi.fn(() => []),
+ shouldUseHistoricalValues: vi.fn(() => false),
+ removeParameterHistoryEntry: vi.fn(),
+ toggleFavoriteEntry: vi.fn()
+}));
+
+import ScriptView from '@/main-app/components/scripts/script-view';
+import {getMostRecentValues} from '@/common/utils/parameterHistory';
+import {mount} from '@vue/test-utils';
+import {createPinia, setActivePinia} from 'pinia';
+import {useScriptConfigStore} from '@/main-app/stores/scriptConfig';
+import {useExecutionsStore} from '@/main-app/stores/executions';
+import {useScriptsStore} from '@/main-app/stores/scripts';
+import {useScriptSetupStore} from '@/main-app/stores/scriptSetup';
+import {attachToDocument} from '../../../test_utils';
+
+describe('Test ScriptView replay', function () {
+ let pinia;
+
+ beforeEach(function () {
+ pinia = createPinia();
+ setActivePinia(pinia);
+
+ const scriptConfigStore = useScriptConfigStore();
+ scriptConfigStore.scriptConfig = {name: 'abc', description: ''};
+ scriptConfigStore.loading = false;
+
+ useScriptsStore().selectedScript = 'abc';
+
+ vi.spyOn(useExecutionsStore(), 'selectExecutor').mockImplementation((executor) => {
+ useExecutionsStore().currentExecutor = executor;
+ });
+
+ getMostRecentValues.mockReset();
+ });
+
+ afterEach(function () {
+ vi.restoreAllMocks();
+ });
+
+ function mountView() {
+ return mount(ScriptView, {attachTo: attachToDocument(), global: {plugins: [pinia]}});
+ }
+
+ it('hides the Replay button when there is no history', async function () {
+ getMostRecentValues.mockReturnValue(null);
+ const view = mountView();
+ await view.vm.$nextTick();
+
+ expect(view.vm.hasLastExecution).toBe(false);
+ expect(view.find('.button-replay').exists()).toBe(false);
+ view.unmount();
+ });
+
+ it('shows the Replay button when history exists', async function () {
+ getMostRecentValues.mockReturnValue({p1: '1'});
+ const view = mountView();
+ await view.vm.$nextTick();
+
+ expect(view.vm.hasLastExecution).toBe(true);
+ expect(view.find('.button-replay').exists()).toBe(true);
+ view.unmount();
+ });
+
+ it('replays with the last execution values', async function () {
+ getMostRecentValues.mockReturnValue({p1: '1', p2: 'x'});
+ const reload = vi.spyOn(useScriptSetupStore(), 'reloadModel').mockImplementation(() => {});
+ const start = vi.spyOn(useExecutionsStore(), 'startExecution').mockImplementation(() => {});
+
+ const view = mountView();
+ await view.vm.$nextTick();
+
+ view.vm.replayLastExecution();
+
+ expect(reload).toHaveBeenCalledWith(expect.objectContaining({
+ values: {p1: '1', p2: 'x'},
+ scriptName: 'abc'
+ }));
+ expect(start).toHaveBeenCalled();
+ view.unmount();
+ });
+
+ it('does nothing when there is no history to replay', async function () {
+ getMostRecentValues.mockReturnValue(null);
+ const start = vi.spyOn(useExecutionsStore(), 'startExecution').mockImplementation(() => {});
+
+ const view = mountView();
+ await view.vm.$nextTick();
+
+ view.vm.replayLastExecution();
+
+ expect(start).not.toHaveBeenCalled();
+ view.unmount();
+ });
+});
diff --git a/web-src/tests/unit/main-app/stores/executions_status_test.js b/web-src/tests/unit/main-app/stores/executions_status_test.js
new file mode 100644
index 00000000..2ce92854
--- /dev/null
+++ b/web-src/tests/unit/main-app/stores/executions_status_test.js
@@ -0,0 +1,75 @@
+import {createPinia, setActivePinia} from 'pinia';
+import {useExecutionsStore} from '@/main-app/stores/executions';
+import {
+ STATUS_DISCONNECTED,
+ STATUS_ERROR,
+ STATUS_EXECUTING,
+ STATUS_FINISHED,
+ STATUS_INITIALIZING
+} from '@/main-app/stores/scriptExecutor';
+
+function executor(id, scriptName, status) {
+ return {state: {id, scriptName, status}};
+}
+
+describe('Test executions getScriptStatus', function () {
+ let store;
+
+ beforeEach(function () {
+ setActivePinia(createPinia());
+ store = useExecutionsStore();
+ });
+
+ it('returns null when the script has no executors', function () {
+ store.executors = {1: executor(1, 'other', STATUS_FINISHED)};
+ expect(store.getScriptStatus('myScript')).toBeNull();
+ });
+
+ it('returns running while executing', function () {
+ store.executors = {1: executor(1, 'myScript', STATUS_EXECUTING)};
+ expect(store.getScriptStatus('myScript')).toBe('running');
+ });
+
+ it('returns running while initializing', function () {
+ store.executors = {1: executor(1, 'myScript', STATUS_INITIALIZING)};
+ expect(store.getScriptStatus('myScript')).toBe('running');
+ });
+
+ it('returns finished when the only execution finished', function () {
+ store.executors = {1: executor(1, 'myScript', STATUS_FINISHED)};
+ expect(store.getScriptStatus('myScript')).toBe('finished');
+ });
+
+ it('returns error on error or disconnected', function () {
+ store.executors = {1: executor(1, 'myScript', STATUS_ERROR)};
+ expect(store.getScriptStatus('myScript')).toBe('error');
+
+ store.executors = {1: executor(1, 'myScript', STATUS_DISCONNECTED)};
+ expect(store.getScriptStatus('myScript')).toBe('error');
+ });
+
+ it('prioritises running over error and finished', function () {
+ store.executors = {
+ 1: executor(1, 'myScript', STATUS_FINISHED),
+ 2: executor(2, 'myScript', STATUS_ERROR),
+ 3: executor(3, 'myScript', STATUS_EXECUTING)
+ };
+ expect(store.getScriptStatus('myScript')).toBe('running');
+ });
+
+ it('prioritises error over finished', function () {
+ store.executors = {
+ 1: executor(1, 'myScript', STATUS_FINISHED),
+ 2: executor(2, 'myScript', STATUS_ERROR)
+ };
+ expect(store.getScriptStatus('myScript')).toBe('error');
+ });
+
+ it('only considers executors of the requested script', function () {
+ store.executors = {
+ 1: executor(1, 'other', STATUS_EXECUTING),
+ 2: executor(2, 'myScript', STATUS_FINISHED)
+ };
+ expect(store.getScriptStatus('myScript')).toBe('finished');
+ });
+});