Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/backend/server/routes/von_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
session,
send_file,
make_response,
redirect,
url_for,
g,
)
import os
Expand Down Expand Up @@ -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")


Expand Down
43 changes: 32 additions & 11 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -33051,7 +33058,7 @@ function clearWorkflowDefinitionsRetryTimer() {
}

function scheduleWorkflowDefinitionsRetry({ delayMs, silent = true } = {}) {
if (workflowStatusGroupUiState.globallyFurled) {
if (!isWorkflowMonitorTabActive() || workflowStatusGroupUiState.globallyFurled) {
clearWorkflowDefinitionsRetryTimer();
return;
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -33366,7 +33373,7 @@ function startWorkflowStatusStream() {
});
}

function initializeWorkflowStatusPanel() {
export function initializeWorkflowStatusPanel() {
const {
panel,
refreshButton,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions src/frontend/web/von_interface/static/js/tabNavigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
Expand Down Expand Up @@ -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':
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/web/von_interface/static/js/test/chatTab.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<section id="workflowStudioTab" class="tab-content"><div id="workflowStatusPanel"><div id="workflowStatusBody"></div></div></section>';
__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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
initialiseWorkflowStudio,
buildDraftSource,
buildPolicyEditors,
buildWorkflowStudioRequestHeaders,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
54 changes: 36 additions & 18 deletions src/frontend/web/von_interface/static/js/workflowStudioPage.js
Original file line number Diff line number Diff line change
@@ -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 ?? '')
Expand Down Expand Up @@ -280,6 +276,8 @@ const state = {
catalogueStatus: 'loading',
detailStatus: 'idle',
detailRequest: 0,
catalogueRequest: 0,
needsRefresh: false,
graphZoom: 1,
scheduleForm: null,
scheduleReceipt: null,
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions src/frontend/web/von_interface/static/js/workflowStudioTab.js
Original file line number Diff line number Diff line change
@@ -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 = '<div class="error" role="alert">Could not load Workflow Studio. <button type="button" class="btn-mini">Retry</button></div>';
container.querySelector('button').addEventListener('click', () => {
void loadWorkflowStudioTab(container);
});
} finally {
delete container.dataset.loading;
container.setAttribute('aria-busy', 'false');
}
}
Loading
Loading