diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py
index c8bc53e56..58c44beef 100644
--- a/src/backend/server/routes/von_routes.py
+++ b/src/backend/server/routes/von_routes.py
@@ -7,6 +7,8 @@
session,
send_file,
make_response,
+ redirect,
+ url_for,
g,
)
import os
@@ -11333,7 +11335,13 @@ def serve_page():
@von_bp.route("/workflow-studio")
def serve_workflow_studio_page():
- """Serve the independent workflow studio surface."""
+ """Keep existing Studio bookmarks on the authenticated application surface."""
+ return redirect(url_for("von.serve_page", _anchor="workflowStudioTab"))
+
+
+@von_bp.route("/workflow-studio/content")
+def serve_workflow_studio_content():
+ """Serve Studio markup on first tab activation; APIs retain actor checks."""
return render_template("workflow_studio.html")
diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js
index 467f2da6b..0acd0264a 100644
--- a/src/frontend/web/von_interface/static/js/chatTab.js
+++ b/src/frontend/web/von_interface/static/js/chatTab.js
@@ -1187,9 +1187,16 @@ function shouldMaintainSharedConversationStream(sessionId) {
});
}
+function isWorkflowMonitorTabActive() {
+ const { panel } = getWorkflowStatusElements();
+ if (!panel) return false;
+ const tab = panel.closest('.tab-content');
+ return !tab || tab.classList.contains('active');
+}
+
function shouldMaintainWorkflowStatusStream() {
const { panel } = getWorkflowStatusElements();
- if (!panel || typeof EventSource === 'undefined') return false;
+ if (!panel || !isWorkflowMonitorTabActive() || typeof EventSource === 'undefined') return false;
return isDocumentVisibleForRealtimeConnections();
}
@@ -32801,7 +32808,7 @@ function applyWorkflowStatusUpdate(payload) {
async function refreshWorkflowStatusSnapshot({ silent = false, preserveRetryAttempt = false } = {}) {
const { panel } = getWorkflowStatusElements();
- if (!panel) return;
+ if (!panel || !isWorkflowMonitorTabActive()) return;
if (workflowStatusStreamState.loading) return;
if (shouldDeferWorkflowStatusSnapshotRefresh({ silent })) return;
@@ -32944,7 +32951,7 @@ function parseWorkflowStatusSnapshotRetryAfterSeconds(payload, response) {
function shouldAutoRefreshWorkflowStatusSnapshot() {
const { panel } = getWorkflowStatusElements();
- if (!panel || workflowDefinitionsState.visible || workflowStatusGroupUiState.globallyFurled) return false;
+ if (!panel || !isWorkflowMonitorTabActive() || workflowDefinitionsState.visible || workflowStatusGroupUiState.globallyFurled) return false;
return isDocumentVisibleForRealtimeConnections();
}
@@ -33051,7 +33058,7 @@ function clearWorkflowDefinitionsRetryTimer() {
}
function scheduleWorkflowDefinitionsRetry({ delayMs, silent = true } = {}) {
- if (workflowStatusGroupUiState.globallyFurled) {
+ if (!isWorkflowMonitorTabActive() || workflowStatusGroupUiState.globallyFurled) {
clearWorkflowDefinitionsRetryTimer();
return;
}
@@ -33115,7 +33122,7 @@ function applyWorkflowDefinitionsRetryableState({
async function refreshAvailableWorkflowDefinitions({ silent = false } = {}) {
const { panel } = getWorkflowStatusElements();
- if (!panel) return;
+ if (!panel || !isWorkflowMonitorTabActive()) return;
if (workflowDefinitionsState.loading) return;
clearWorkflowDefinitionsRetryTimer();
@@ -33366,7 +33373,7 @@ function startWorkflowStatusStream() {
});
}
-function initializeWorkflowStatusPanel() {
+export function initializeWorkflowStatusPanel() {
const {
panel,
refreshButton,
@@ -33379,9 +33386,9 @@ function initializeWorkflowStatusPanel() {
if (workflowStatusPanelInitialised) return;
workflowStatusPanelInitialised = true;
ensureWorkflowCapabilityIndexStatusSubscription();
- // Keep the diagnostic surface compact on first load; users can unfurl it
- // when they need the full operational detail.
- workflowStatusGroupUiState.globallyFurled = true;
+ // Studio is the dedicated monitoring surface; show live runs on opening.
+ workflowStatusGroupUiState.globallyFurled = false;
+ document.addEventListener('von:tab-activated', syncWorkflowMonitorTabActivity);
const {
closeButton: closeEpisodesButton,
copyJsonButton: copyEpisodesJsonButton
@@ -33470,6 +33477,22 @@ function initializeWorkflowStatusPanel() {
void refreshWorkflowStatusSnapshot({ silent: true });
}
+function syncWorkflowMonitorTabActivity() {
+ if (!isWorkflowMonitorTabActive()) {
+ stopWorkflowStatusStream('workflow_studio_hidden');
+ clearWorkflowStatusSnapshotRetryTimer();
+ clearWorkflowDefinitionsRetryTimer();
+ setWorkflowEpisodesPopupVisible(false);
+ return;
+ }
+ startWorkflowStatusStream();
+ if (workflowDefinitionsState.visible) {
+ void refreshAvailableWorkflowDefinitions({ silent: true });
+ } else {
+ void refreshWorkflowStatusSnapshot({ silent: true });
+ }
+}
+
function startIncomingInvitePolling() {
if (!isDocumentVisibleForRealtimeConnections()) {
return;
@@ -34243,8 +34266,6 @@ export function initializeChatTab() {
// Load initial unread count for badge
loadUnreadCount();
- // Phase 5: Workflow monitor panel
- initializeWorkflowStatusPanel();
handleRealtimeConnectionsVisibilityChange();
// Load annotation toggle state from localStorage (default: false)
diff --git a/src/frontend/web/von_interface/static/js/tabNavigation.js b/src/frontend/web/von_interface/static/js/tabNavigation.js
index 364529e91..3db311aac 100644
--- a/src/frontend/web/von_interface/static/js/tabNavigation.js
+++ b/src/frontend/web/von_interface/static/js/tabNavigation.js
@@ -117,6 +117,12 @@ async function loadTabContent(tabId, contentElement) {
return;
}
+ if (tabId === 'workflowStudioTab') {
+ const { loadWorkflowStudioTab } = await import('./workflowStudioTab.js');
+ await loadWorkflowStudioTab(contentElement);
+ return;
+ }
+
// Prevent duplicate parallel loads
if (contentElement.dataset.loading === 'true') {
console.log(`loadTabContent: ${tabId} already loading, skipping.`);
@@ -278,6 +284,9 @@ export async function loadTabData(tabId) {
await showMessagesTab();
}
break;
+ case 'workflowStudioTab':
+ // Studio loads once through loadTabContent and retains its editor state.
+ break;
case 'chatTab':
case 'settingsTab':
case 'annotationTab':
diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js
index 03e0a421c..812cf4f44 100644
--- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js
+++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js
@@ -1586,6 +1586,19 @@ describe('workflow monitor definitions refresh contention handling', () => {
});
describe('workflow monitor active snapshot degradation handling', () => {
+ test('does not fetch snapshots or definitions while the Studio tab is inactive', async () => {
+ document.body.innerHTML = '';
+ __testOnly_resetWorkflowStatusState();
+ __testOnly_resetWorkflowDefinitionsState();
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ items: [] }) });
+ await __testOnly_refreshWorkflowStatusSnapshot();
+ await __testOnly_refreshAvailableWorkflowDefinitions();
+ expect(global.fetch).not.toHaveBeenCalled();
+ document.getElementById('workflowStudioTab').classList.add('active');
+ await __testOnly_refreshWorkflowStatusSnapshot();
+ expect(global.fetch.mock.calls.some(([url]) => url.startsWith('/api/workflows/instances?'))).toBe(true);
+ });
+
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
diff --git a/src/frontend/web/von_interface/static/js/test/workflowStudioPage.test.js b/src/frontend/web/von_interface/static/js/test/workflowStudioPage.test.js
index b9ba7c280..c9583a33e 100644
--- a/src/frontend/web/von_interface/static/js/test/workflowStudioPage.test.js
+++ b/src/frontend/web/von_interface/static/js/test/workflowStudioPage.test.js
@@ -1,4 +1,5 @@
import {
+ initialiseWorkflowStudio,
buildDraftSource,
buildPolicyEditors,
buildWorkflowStudioRequestHeaders,
@@ -148,7 +149,7 @@ describe('actor schedule operations', () => {
return reply({});
});
try {
- document.dispatchEvent(new Event('DOMContentLoaded'));
+ await initialiseWorkflowStudio();
await flush();
document.querySelector('[data-view="operations"]').click();
document.querySelector('[data-action="create-schedule"]').click();
@@ -209,6 +210,14 @@ describe('actor schedule operations', () => {
await flush();
expect(document.getElementById('workflowStudioSummary').textContent).toBe('Loaded description');
consoleError.mockRestore();
+ loadedCard.click();
+ await flush();
+ document.dispatchEvent(new CustomEvent('orgSwitchStarted'));
+ resolveOther();
+ await flush();
+ expect(document.getElementById('workflowStudioTitle').textContent).toBe('Select a workflow');
+ expect(document.getElementById('workflowStudioCatalogue').textContent).not.toContain('Loaded description');
+ expect(document.querySelector('[data-action="create-schedule"]')).toBeNull();
} finally {
jest.restoreAllMocks();
delete global.fetch;
diff --git a/src/frontend/web/von_interface/static/js/workflowStudioPage.js b/src/frontend/web/von_interface/static/js/workflowStudioPage.js
index 3b53efa19..f1a8de040 100644
--- a/src/frontend/web/von_interface/static/js/workflowStudioPage.js
+++ b/src/frontend/web/von_interface/static/js/workflowStudioPage.js
@@ -1,8 +1,4 @@
import { ensureUniqueWindowSessionId, getWindowSessionId, WINDOW_SESSION_HEADER } from './apiService.js';
-import {
- syncNamespaceFromLocalStorage,
- syncOrgContextFromLocalStorage
-} from './utils/sessionScopedStorage.js';
function escapeHtml(value) {
return String(value ?? '')
@@ -280,6 +276,8 @@ const state = {
catalogueStatus: 'loading',
detailStatus: 'idle',
detailRequest: 0,
+ catalogueRequest: 0,
+ needsRefresh: false,
graphZoom: 1,
scheduleForm: null,
scheduleReceipt: null,
@@ -1134,11 +1132,13 @@ function renderAll() {
}
async function loadCatalogue({ selectFirst = false } = {}) {
+ const request = ++state.catalogueRequest;
state.catalogueStatus = 'loading';
renderCatalogue();
setConnectionBadge('Loading', 'loading');
try {
const data = await fetchJson(`/api/workflow-studio/catalogue?include_designs=${state.showDesigns ? 'true' : 'false'}&limit=300`);
+ if (request !== state.catalogueRequest) return;
state.catalogue = asArray(data.items);
state.catalogueStatus = 'ready';
if (!state.selectedWorkflowId && selectFirst && state.catalogue[0]?.workflow_id) {
@@ -1147,6 +1147,7 @@ async function loadCatalogue({ selectFirst = false } = {}) {
renderCatalogue();
setConnectionBadge('Ready', 'ready');
} catch (error) {
+ if (request !== state.catalogueRequest) return;
state.catalogueStatus = 'error';
renderCatalogue();
console.error('Workflow studio catalogue failed:', error);
@@ -1673,24 +1674,41 @@ function bindEvents() {
});
}
-async function initialiseWorkflowStudio() {
- // Keep the standalone studio page aligned with the main Von window-scoped context model.
- syncOrgContextFromLocalStorage();
- syncNamespaceFromLocalStorage();
- await ensureUniqueWindowSessionId?.();
+export async function initialiseWorkflowStudio() {
+ // The tab shares the authenticated parent window and its current scope.
cacheElements();
bindEvents();
+ const clearScope = () => {
+ ++state.catalogueRequest;
+ ++state.detailRequest;
+ state.catalogue = [];
+ state.selectedWorkflowId = '';
+ state.workflowDetail = null;
+ state.draftSpec = null;
+ state.preview = null;
+ state.scheduleReceipt = null;
+ state.scheduleForm = null;
+ state.detailStatus = 'idle';
+ state.catalogueStatus = 'loading';
+ state.needsRefresh = true;
+ setStatusBanner('', 'info');
+ renderAll();
+ };
+ const refreshIfActive = () => {
+ if (state.needsRefresh && document.getElementById('workflowStudioTab')?.classList.contains('active')) {
+ state.needsRefresh = false;
+ void loadCatalogue();
+ }
+ };
+ document.addEventListener('orgSwitchStarted', clearScope);
+ document.addEventListener('orgSwitched', () => { clearScope(); refreshIfActive(); });
+ document.addEventListener('authStatusChanged', (event) => {
+ if (event.detail?.authenticated === false) clearScope();
+ });
+ document.addEventListener('von:tab-activated', refreshIfActive);
renderAll();
await loadCatalogue({ selectFirst: true });
- if (state.selectedWorkflowId) {
- await loadWorkflowDetail(state.selectedWorkflowId);
- }
-}
-
-if (typeof document !== 'undefined') {
- document.addEventListener('DOMContentLoaded', () => {
- void initialiseWorkflowStudio();
- });
+ if (state.selectedWorkflowId) await loadWorkflowDetail(state.selectedWorkflowId);
}
export {
diff --git a/src/frontend/web/von_interface/static/js/workflowStudioTab.js b/src/frontend/web/von_interface/static/js/workflowStudioTab.js
new file mode 100644
index 000000000..f59f92096
--- /dev/null
+++ b/src/frontend/web/von_interface/static/js/workflowStudioTab.js
@@ -0,0 +1,30 @@
+// The Studio document and modules are requested only when its tab is activated.
+export async function loadWorkflowStudioTab(container) {
+ if (container.dataset.initialized === 'true' || container.dataset.loading === 'true') return;
+ container.dataset.loading = 'true';
+ container.setAttribute('aria-busy', 'true');
+ try {
+ const response = await fetch(container.dataset.src);
+ if (!response.ok) throw new Error(`Workflow Studio returned HTTP ${response.status}`);
+ const html = await response.text();
+ const [studio, monitor] = await Promise.all([
+ import('./workflowStudioPage.js'),
+ import('./chatTab.js')
+ ]);
+ container.innerHTML = html;
+ monitor.initializeWorkflowStatusPanel();
+ // Initialise synchronously before the catalogue request; returning to the
+ // tab keeps the user's selection and any draft edits.
+ void studio.initialiseWorkflowStudio();
+ container.dataset.initialized = 'true';
+ } catch (error) {
+ console.error('Could not open Workflow Studio:', error);
+ container.innerHTML = '
Could not load Workflow Studio.
';
+ container.querySelector('button').addEventListener('click', () => {
+ void loadWorkflowStudioTab(container);
+ });
+ } finally {
+ delete container.dataset.loading;
+ container.setAttribute('aria-busy', 'false');
+ }
+}
diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css
index b77bec247..7d77100d9 100644
--- a/src/frontend/web/von_interface/static/styles.css
+++ b/src/frontend/web/von_interface/static/styles.css
@@ -17534,13 +17534,30 @@ body.settings-body {
/* Workflow Studio */
+.tab-button[data-tab="workflowStudioTab"] {
+ font: inherit;
+}
+
+#workflowStudioTab {
+ padding: 0;
+}
+
+.workflow-studio-body > .workflow-status-panel {
+ margin: 16px 20px 0;
+}
+
+.workflow-studio-body .workflow-status-header,
+.workflow-studio-body .workflow-status-actions {
+ flex-wrap: wrap;
+}
+
.workflow-studio-body,
.workflow-studio-body * {
box-sizing: border-box;
}
.workflow-studio-body {
- min-height: 100vh;
+ min-height: 0;
padding: 0;
background:
radial-gradient(circle at top left, rgba(236, 253, 245, 0.8), transparent 32%),
@@ -17577,19 +17594,6 @@ body.settings-body {
color: #475569;
}
-.workflow-studio-backlink {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 70px;
- padding: 10px 14px;
- border-radius: 999px;
- background: #dbeafe;
- color: #1d4ed8;
- font-weight: 700;
- text-decoration: none;
-}
-
.workflow-studio-header-actions {
display: flex;
gap: 10px;
@@ -17823,24 +17827,6 @@ body.settings-body {
color: #fff;
}
-.workflow-studio-launch-link {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- margin-left: auto;
- padding: 9px 14px;
- border-radius: 999px;
- background: #ccfbf1;
- color: #0f766e;
- font-weight: 700;
- text-decoration: none;
- border: 1px solid rgba(15, 118, 110, 0.24);
-}
-
-.workflow-studio-launch-link:hover {
- background: #99f6e4;
-}
-
.workflow-studio-status-banner {
padding: 12px 16px;
border-radius: 16px;
diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html
index e20ad1d8b..6622c4fd4 100644
--- a/src/frontend/web/von_interface/templates/chat_tab.html
+++ b/src/frontend/web/von_interface/templates/chat_tab.html
@@ -151,30 +151,6 @@
-
-
Older conversation available
-
-
@@ -178,6 +178,10 @@
+
+
Loading Workflow Studio…
+
+
diff --git a/src/frontend/web/von_interface/templates/workflow_studio.html b/src/frontend/web/von_interface/templates/workflow_studio.html
index 1f256f3d8..dd1499831 100644
--- a/src/frontend/web/von_interface/templates/workflow_studio.html
+++ b/src/frontend/web/von_interface/templates/workflow_studio.html
@@ -1,22 +1,10 @@
-
-
-
-
-
-
-
Workflow Studio
-
-
-
-
-
+
-
+
+
+
-
-
+
-
+
diff --git a/tests/backend/test_workflows_routes.py b/tests/backend/test_workflows_routes.py
index a7fd7a3f3..7567c180d 100644
--- a/tests/backend/test_workflows_routes.py
+++ b/tests/backend/test_workflows_routes.py
@@ -2606,6 +2606,19 @@ def test_workflow_definitions_list_refresh_in_progress_without_stale_returns_503
assert resp.headers.get("Retry-After") == "1"
+def test_workflow_studio_bookmark_opens_tab_and_fragment_contains_monitor(app_client):
+ response = app_client.get("/von/workflow-studio")
+ assert response.status_code == 302
+ assert response.headers["Location"] == "/von/#workflowStudioTab"
+ response = app_client.get("/von/workflow-studio/content")
+ assert response.status_code == 200
+ html = response.get_data(as_text=True)
+ assert 'id="workflowStatusPanel"' in html
+ assert 'id="workflowEpisodesPopup"' in html
+ assert 'id="workflowStudioCatalogue"' in html
+ assert ' ({
+ initialiseWorkflowStudio: jest.fn(),
+}));
+jest.mock('../../src/frontend/web/von_interface/static/js/chatTab.js', () => ({
+ initializeWorkflowStatusPanel: jest.fn(),
+}));
+
+const { loadWorkflowStudioTab } = require('../../src/frontend/web/von_interface/static/js/workflowStudioTab.js');
+const { initialiseWorkflowStudio } = require('../../src/frontend/web/von_interface/static/js/workflowStudioPage.js');
+const { initializeWorkflowStatusPanel } = require('../../src/frontend/web/von_interface/static/js/chatTab.js');
+
+describe('Workflow Studio tab loading', () => {
+ let container;
+ beforeEach(() => {
+ jest.clearAllMocks();
+ document.body.innerHTML = '';
+ container = document.getElementById('workflowStudioTab');
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, text: async () => '' });
+ });
+
+ test('waits for activation and preserves edits across repeat or concurrent activation', async () => {
+ expect(fetch).not.toHaveBeenCalled();
+ await Promise.all([loadWorkflowStudioTab(container), loadWorkflowStudioTab(container)]);
+ container.querySelector('input').value = 'unsaved edit';
+ await loadWorkflowStudioTab(container);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(fetch).toHaveBeenCalledWith('/von/workflow-studio/content');
+ expect(initialiseWorkflowStudio).toHaveBeenCalledTimes(1);
+ expect(initializeWorkflowStatusPanel).toHaveBeenCalledTimes(1);
+ expect(container.querySelector('input').value).toBe('unsaved edit');
+ expect(container.getAttribute('aria-busy')).toBe('false');
+ });
+
+ test('an unsuccessful first load remains retryable', async () => {
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
+ fetch.mockResolvedValueOnce({ ok: false, status: 503 });
+ await loadWorkflowStudioTab(container);
+ expect(container.textContent).toContain('Retry');
+ expect(initialiseWorkflowStudio).not.toHaveBeenCalled();
+ await loadWorkflowStudioTab(container);
+ expect(container.dataset.initialized).toBe('true');
+ expect(initialiseWorkflowStudio).toHaveBeenCalledTimes(1);
+ error.mockRestore();
+ });
+});