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 = ''; + 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 @@

-
-
-
- - Workflow Monitor -
-
- - - - -
-
-
-
No active workflows
-
-
- - - @@ -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 - - - - - +
- Von

Workflow Studio

- Independent workflow catalogue, topology visualisation, operational overlays, and proposal-reviewed workflow authoring. + Inspect workflows, monitor live runs, and manage workflow proposals.

@@ -26,7 +14,31 @@

Workflow Studio

-
+
+
+
+ + Workflow Monitor +
+
+ + + + +
+
+
+
No active workflows
+
+
+ +
-
+
- - + - + 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(); + }); +});