From a158fef12b2fd80ae61cd60a21e36a8583fcddbf Mon Sep 17 00:00:00 2001 From: "Emrullah Y." <47065385+Sangaibisi@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:46:04 +0300 Subject: [PATCH 1/8] fix(recorder): load the recorder extension on modern Chrome / browser-use 0.13 Two independent bugs made recording capture zero events: 1. browser-use's default extensions append their own --load-extension flag after the profile args. Chrome only honors the last occurrence, so the recorder extension was silently dropped. Disable default extensions for the recording profile so our flag wins. 2. Branded Google Chrome 137+ removed support for --load-extension entirely. When browser-use picks the installed Chrome, the extension never loads and no events reach the recording server. Prefer a Playwright-bundled Chromium / Chrome for Testing binary when one is available, overridable via WORKFLOW_USE_RECORDER_BROWSER. Co-Authored-By: Claude Fable 5 --- workflows/workflow_use/recorder/service.py | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/workflows/workflow_use/recorder/service.py b/workflows/workflow_use/recorder/service.py index 0fef2201..33e9c124 100644 --- a/workflows/workflow_use/recorder/service.py +++ b/workflows/workflow_use/recorder/service.py @@ -1,5 +1,6 @@ import asyncio import json +import os import pathlib from typing import Optional @@ -22,6 +23,45 @@ USER_DATA_DIR = SCRIPT_DIR / 'user_data_dir' +def _find_extension_capable_browser() -> str | None: + """Find a Chromium binary that still honors --load-extension. + + Branded Google Chrome 137+ silently ignores --load-extension, so the + recorder extension never loads there and no events reach the recording + server. Prefer an explicit override, then Playwright's bundled + Chromium/Chrome for Testing, then fall back to browser-use's default. + """ + override = os.environ.get('WORKFLOW_USE_RECORDER_BROWSER') + if override: + return override + + playwright_caches = [ + pathlib.Path.home() / 'Library/Caches/ms-playwright', # macOS + pathlib.Path.home() / '.cache/ms-playwright', # Linux + pathlib.Path(os.environ.get('LOCALAPPDATA', '')) / 'ms-playwright', # Windows + ] + binary_globs = [ + 'chromium-*/chrome-mac*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', + 'chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium', + 'chromium-*/chrome-linux/chrome', + 'chromium-*/chrome-win/chrome.exe', + ] + for cache in playwright_caches: + if not cache.is_dir(): + continue + for pattern in binary_globs: + matches = sorted(cache.glob(pattern), reverse=True) # newest revision first + if matches: + return str(matches[0]) + + print( + '[Service] WARNING: No Playwright Chromium found. If recording captures no ' + 'events, branded Google Chrome may be ignoring --load-extension (137+); ' + 'set WORKFLOW_USE_RECORDER_BROWSER to a Chromium/Chrome for Testing binary.' + ) + return None + + class RecordingService: def __init__(self): self.event_queue: asyncio.Queue[RecorderEvent] = asyncio.Queue() @@ -117,6 +157,10 @@ async def _launch_browser_and_wait(self): profile = BrowserProfile( headless=False, user_data_dir=str(USER_DATA_DIR.resolve()), + executable_path=_find_extension_capable_browser(), + # browser-use's default extensions add a second --load-extension flag + # which overrides ours — the recorder extension must win. + enable_default_extensions=False, args=[ f'--disable-extensions-except={str(EXT_DIR.resolve())}', f'--load-extension={str(EXT_DIR.resolve())}', From cab60bba7dd52e58df29cd7e40d61cba46886070 Mon Sep 17 00:00:00 2001 From: "Emrullah Y." <47065385+Sangaibisi@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:46:19 +0300 Subject: [PATCH 2/8] fix(schema): stop rejecting incremental recording updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkflowDefinitionSchema required the last step to be extract / extract_page_content. The extension streams WORKFLOW_UPDATE events step-by-step while the user records, so virtually every update ended in click/input/navigation and was rejected with 422 by the recording server — the recorder never received any workflow data and create-workflow hung forever after the browser closed. Relax the validator: an extract-terminated workflow cannot be enforced at parse time without breaking recording, and no-AI workflows are valid without a trailing extract step. Co-Authored-By: Claude Fable 5 --- workflows/workflow_use/schema/views.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/workflows/workflow_use/schema/views.py b/workflows/workflow_use/schema/views.py index 42f9ec14..91f0d0c6 100644 --- a/workflows/workflow_use/schema/views.py +++ b/workflows/workflow_use/schema/views.py @@ -239,22 +239,8 @@ class WorkflowDefinitionSchema(BaseModel): @validator('steps') def validate_ends_with_extract(cls, steps: List[WorkflowStep]) -> List[WorkflowStep]: - """Validate that the workflow ends with an extract step.""" - if not steps: - raise ValueError('Workflow must have at least one step') - - last_step = steps[-1] - # Check if last step is an extract step - # We need to check the 'type' attribute from the step dict/model - step_type = getattr(last_step, 'type', None) - - if step_type not in ['extract', 'extract_page_content']: - raise ValueError( - f'Workflow must end with an extract step (extract or extract_page_content). ' - f'Current last step type: {step_type}. ' - f'AI processing is always needed at the end of a workflow.' - ) - + """Recordings arrive step-by-step, so an extract-terminated workflow + cannot be enforced here — it would reject every incremental update.""" return steps # Add loader from json file From c3999dfe8da2ca4ed009dcee45013c1e7a792864 Mon Sep 17 00:00:00 2001 From: "Emrullah Y." <47065385+Sangaibisi@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:46:19 +0300 Subject: [PATCH 3/8] fix(workflow): repair deterministic replay on browser-use 0.13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _run_deterministic_step stripped cssSelector/xpath from step params, but the click/input/key_press/select_change action models require cssSelector — every selector-based deterministic action failed validation. Keep those fields (extras are ignored via extra='ignore'). - Semantic key_press crashed on named keys: dict.get's default is evaluated eagerly, so ord('ENTER') raised before the lookup. - The hand-rolled Input.dispatchKeyEvent call used a stale CDP session and failed with 'method not found'. Use page.press(), which handles named keys, combos and session setup. - The key_press verifier required the pressed element to still be visible, so keys that navigate (Enter on a search box) always failed verification and were re-pressed. Treat a URL change as success. - An empty semantic mapping usually means the page is mid-navigation; retry extraction a few times before failing the step. Co-Authored-By: Claude Fable 5 --- .../workflow/semantic_executor.py | 59 ++++++------------- workflows/workflow_use/workflow/service.py | 4 +- 2 files changed, 21 insertions(+), 42 deletions(-) diff --git a/workflows/workflow_use/workflow/semantic_executor.py b/workflows/workflow_use/workflow/semantic_executor.py index 69ad9f59..e663408c 100644 --- a/workflows/workflow_use/workflow/semantic_executor.py +++ b/workflows/workflow_use/workflow/semantic_executor.py @@ -159,43 +159,10 @@ async def _element_press_key(self, element, key: str): await element.focus() await asyncio.sleep(0.05) - # Get the page to send key events + # Page.press handles named keys, combos, and CDP session setup — + # hand-rolled dispatchKeyEvent hit 'method not found' on a stale session. page = await self.browser.get_current_page() - - # Send key event through CDP - key_map = { - 'Enter': {'key': 'Enter', 'code': 'Enter', 'keyCode': 13}, - 'Tab': {'key': 'Tab', 'code': 'Tab', 'keyCode': 9}, - 'Escape': {'key': 'Escape', 'code': 'Escape', 'keyCode': 27}, - 'ArrowDown': {'key': 'ArrowDown', 'code': 'ArrowDown', 'keyCode': 40}, - 'ArrowUp': {'key': 'ArrowUp', 'code': 'ArrowUp', 'keyCode': 38}, - } - - key_info = key_map.get(key, {'key': key, 'code': f'Key{key.upper()}', 'keyCode': ord(key.upper())}) - - # Send keydown - await page._client.send.Input.dispatchKeyEvent( - params={ - 'type': 'keyDown', - 'key': key_info['key'], - 'code': key_info['code'], - 'windowsVirtualKeyCode': key_info['keyCode'], - }, - session_id=page._session_id, - ) - - await asyncio.sleep(0.05) - - # Send keyup - await page._client.send.Input.dispatchKeyEvent( - params={ - 'type': 'keyUp', - 'key': key_info['key'], - 'code': key_info['code'], - 'windowsVirtualKeyCode': key_info['keyCode'], - }, - session_id=page._session_id, - ) + await page.press(key) except Exception as e: raise Exception(f'Failed to press key {key}: {e}') @@ -209,8 +176,15 @@ async def _element_text_content(self, element) -> str: async def _refresh_semantic_mapping(self) -> None: """Refresh the semantic mapping for the current page.""" - page = await self.browser.get_current_page() - self.current_mapping = await self.semantic_extractor.extract_semantic_mapping(page) + # An empty mapping usually means the page is mid-navigation — retry + # briefly instead of failing the step on a half-loaded document. + for attempt in range(4): + page = await self.browser.get_current_page() + self.current_mapping = await self.semantic_extractor.extract_semantic_mapping(page) + if self.current_mapping: + break + logger.info(f'Semantic mapping empty (attempt {attempt + 1}/4), waiting for page to settle...') + await asyncio.sleep(2) logger.info(f'Refreshed semantic mapping with {len(self.current_mapping)} elements') # Print detailed mapping for debugging @@ -1792,10 +1766,15 @@ async def keypress_executor(): logger.info(msg) return ActionResult(extracted_content=msg, include_in_memory=True) + pre_press_url = await page.get_url() + async def keypress_verifier(): - # For key presses, just verify the element is still accessible - # (More specific verification could be added based on the key and context) + # Keys like Enter often navigate — the element disappearing is then + # success, not failure. Treat a URL change as the key taking effect. try: + current_page = await self.browser.get_current_page() + if await current_page.get_url() != pre_press_url: + return True elements = await self._get_elements_by_selector(selector_to_use) if not elements: return False diff --git a/workflows/workflow_use/workflow/service.py b/workflows/workflow_use/workflow/service.py index 1a6c325b..859e66da 100644 --- a/workflows/workflow_use/workflow/service.py +++ b/workflows/workflow_use/workflow/service.py @@ -144,14 +144,14 @@ async def _run_deterministic_step(self, step: DeterministicWorkflowStep, step_in # Filter out workflow metadata fields that shouldn't be passed to browser-use ActionModel # Note: 'type' is NOT filtered here because some actions (like navigation) need it in their params + # cssSelector/xpath must pass through: click/input/key_press/select_change + # action models require cssSelector (extras are ignored via extra='ignore'). workflow_metadata_fields = { 'description', 'output', 'agent_reasoning', 'page_context_url', 'page_context_title', - 'cssSelector', - 'xpath', 'elementTag', 'elementHash', # These are workflow-specific selector fields 'selectorStrategies', # Multi-strategy selectors From 19ec1828948e963104e82f8a394ac63d34776116 Mon Sep 17 00:00:00 2001 From: "Emrullah Y." <47065385+Sangaibisi@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:46:19 +0300 Subject: [PATCH 4/8] fix(cli,backend): run without BROWSER_USE_API_KEY for no-AI workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.py crashed with NameError when LLM init failed and the user declined to enter an API key: llm_instance was left undefined. Degrade gracefully — no-AI commands don't need an LLM. - backend imported Browser from the pre-0.13 module path (browser_use.browser.browser) and crashed on startup; and it instantiated ChatBrowserUse eagerly, so the API served 500s without BROWSER_USE_API_KEY even for listing/executing deterministic workflows. Make the LLM optional. Co-Authored-By: Claude Fable 5 --- workflows/backend/service.py | 9 ++++++--- workflows/cli.py | 13 ++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/workflows/backend/service.py b/workflows/backend/service.py index dc3eaa01..1993736d 100644 --- a/workflows/backend/service.py +++ b/workflows/backend/service.py @@ -6,7 +6,7 @@ import aiofiles import yaml -from browser_use.browser.browser import Browser +from browser_use import Browser from browser_use.llm import ChatBrowserUse from workflow_use.controller.service import WorkflowController @@ -41,8 +41,11 @@ def __init__(self) -> None: self.log_dir: Path = self.tmp_dir / 'logs' self.log_dir.mkdir(exist_ok=True, parents=True) - # LLM / workflow executor - self.llm_instance = ChatBrowserUse(model='bu-latest') + # LLM / workflow executor (optional — deterministic runs work without it) + try: + self.llm_instance = ChatBrowserUse(model='bu-latest') + except ValueError: + self.llm_instance = None self.browser_instance = Browser() self.controller_instance = WorkflowController() diff --git a/workflows/cli.py b/workflows/cli.py index 49470857..9cf3462f 100644 --- a/workflows/cli.py +++ b/workflows/cli.py @@ -32,17 +32,16 @@ ) # Default LLM instance to None -llm_instance: BaseChatModel +llm_instance: BaseChatModel | None = None +page_extraction_llm: BaseChatModel | None = None try: llm_instance = ChatBrowserUse(model='bu-latest') page_extraction_llm = ChatBrowserUse(model='bu-latest') except Exception as e: - typer.secho(f'Error initializing LLM: {e}. Would you like to set your BROWSER_USE_API_KEY?', fg=typer.colors.RED) - set_browser_use_api_key = input('Set BROWSER_USE_API_KEY? (y/n): ') - if set_browser_use_api_key.lower() == 'y': - os.environ['BROWSER_USE_API_KEY'] = input('Enter your BROWSER_USE_API_KEY: ') - llm_instance = ChatBrowserUse(model='bu-latest') - page_extraction_llm = ChatBrowserUse(model='bu-latest') + typer.secho( + f'LLM not available ({e}). Continuing without LLM — no-AI commands still work.', + fg=typer.colors.YELLOW, + ) builder_service = BuilderService(llm=llm_instance) if llm_instance else None # recorder_service = RecorderService() # Placeholder From e823384cf272c359c777d5d5e4de5ae09f19d814 Mon Sep 17 00:00:00 2001 From: "Emrullah Y." <47065385+Sangaibisi@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:52:17 +0300 Subject: [PATCH 5/8] fix(ui): show workflow names in sidebar without requiring selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar items only received metadata for the currently selected workflow, so every other row rendered a permanent 'Loading workflow…' placeholder until clicked. Fetch metadata for all listed workflows and pass it through the existing allWorkflowsMetadata prop. Co-Authored-By: Claude Fable 5 --- ui/src/components/workflow-layout.tsx | 37 ++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/ui/src/components/workflow-layout.tsx b/ui/src/components/workflow-layout.tsx index fa884af4..f9152f2d 100644 --- a/ui/src/components/workflow-layout.tsx +++ b/ui/src/components/workflow-layout.tsx @@ -25,7 +25,7 @@ import Sidebar from "./sidebar"; import { NodeConfigMenu } from "./node-config-menu"; import { PlayButton } from "./play-button"; import NoWorkflowsMessage from "./no-workflow-message"; -import { $api } from "../lib/api"; +import { $api, fetchClient } from "../lib/api"; const WorkflowLayout: React.FC = () => { const [selected, setSelected] = useState(null); @@ -37,6 +37,9 @@ const WorkflowLayout: React.FC = () => { const [savedNodePositions, setSavedNodePositions] = useState< Record> >({}); + const [allWorkflowsMetadata, setAllWorkflowsMetadata] = useState< + Record + >({}); const { fitView } = useReactFlow(); // ----- Queries using $api ----- @@ -159,6 +162,37 @@ const WorkflowLayout: React.FC = () => { } }, [workflows, selected]); + // Fetch metadata for every workflow so sidebar items show their real + // names instead of a permanent "Loading workflow…" placeholder + useEffect(() => { + if (!workflows.length) return; + let cancelled = false; + (async () => { + const entries = await Promise.all( + workflows.map(async (name) => { + try { + const { data } = await fetchClient.GET("/api/workflows/{name}", { + params: { path: { name } }, + }); + if (!data) return null; + const parsed = typeof data === "string" ? JSON.parse(data) : data; + return [name, parsed as WorkflowMetadata] as const; + } catch { + return null; + } + }) + ); + if (!cancelled) { + setAllWorkflowsMetadata( + Object.fromEntries(entries.filter((e): e is NonNullable => e !== null)) + ); + } + })(); + return () => { + cancelled = true; + }; + }, [workflows]); + const isLoading = isLoadingWorkflows || isLoadingSelectedWorkflow; if (isLoading) { @@ -183,6 +217,7 @@ const WorkflowLayout: React.FC = () => { onSelect={setSelected} selected={selected} workflowMetadata={workflowMetadata} + allWorkflowsMetadata={allWorkflowsMetadata} onUpdateMetadata={async (metadata: WorkflowMetadata) => { if (selected) { await updateWorkflowMetadata(selected, metadata); From e384d18b693aa97cae96ac1882db98661d4491bb Mon Sep 17 00:00:00 2001 From: "Emrullah Y." <47065385+Sangaibisi@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:01:27 +0300 Subject: [PATCH 6/8] fix(backend): share one WorkflowService instance across requests get_service() constructed a fresh WorkflowService per request, so in-memory task state (active_tasks, cancel_events) was lost immediately: /tasks/{id}/status always 404'd and cancel never worked. Co-Authored-By: Claude Fable 5 --- workflows/backend/routers.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/workflows/backend/routers.py b/workflows/backend/routers.py index be8c6c9b..bcc545e6 100644 --- a/workflows/backend/routers.py +++ b/workflows/backend/routers.py @@ -5,6 +5,7 @@ from .service import WorkflowService from .views import ( + RecordingStatusResponse, WorkflowCancelResponse, WorkflowExecuteRequest, WorkflowExecuteResponse, @@ -18,9 +19,16 @@ router = APIRouter(prefix='/api/workflows') +# Single shared service: per-request instances would lose in-memory task +# and recording state, breaking /tasks/{id}/status and recording control. +_service: WorkflowService | None = None + def get_service() -> WorkflowService: - return WorkflowService() + global _service + if _service is None: + _service = WorkflowService() + return _service @router.get('', response_model=WorkflowListResponse) @@ -117,3 +125,21 @@ async def cancel_workflow(task_id: str): if not result.success and result.message == 'Task not found': raise HTTPException(status_code=404, detail=f'Task {task_id} not found') return result + + +@router.post('/recordings/start', response_model=RecordingStatusResponse) +async def start_recording(): + service = get_service() + return await service.start_recording() + + +@router.post('/recordings/stop', response_model=RecordingStatusResponse) +async def stop_recording(): + service = get_service() + return await service.stop_recording() + + +@router.get('/recordings/status', response_model=RecordingStatusResponse) +async def recording_status(): + service = get_service() + return service.recording_status() From 329da1840daa7d37d72f84b7652378d8f92ebc6f Mon Sep 17 00:00:00 2001 From: "Emrullah Y." <47065385+Sangaibisi@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:01:28 +0300 Subject: [PATCH 7/8] feat(gui): record new workflows from the GUI Adds a 'Record New Workflow' button to the sidebar. The backend gains recording endpoints (/recordings/start|stop|status) that drive RecordingService in-process, convert the captured recording via convert_recorded_workflow_to_semantic, and save it into ./tmp so it appears in the list immediately. Stopping with zero captured steps cancels the session outright instead of hitting the recorder's wait-forever finalizer path. Co-Authored-By: Claude Fable 5 --- ui/src/components/record-button.tsx | 125 ++++++++++++++++++++++++++ ui/src/components/sidebar.tsx | 5 ++ ui/src/components/workflow-layout.tsx | 12 +++ workflows/backend/service.py | 87 ++++++++++++++++++ workflows/backend/views.py | 6 ++ 5 files changed, 235 insertions(+) create mode 100644 ui/src/components/record-button.tsx diff --git a/ui/src/components/record-button.tsx b/ui/src/components/record-button.tsx new file mode 100644 index 00000000..44f1bc38 --- /dev/null +++ b/ui/src/components/record-button.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +const API_BASE = "http://localhost:8000"; + +type RecordingStatus = { + status: "idle" | "recording" | "saving" | "done" | "error" | "no_data"; + message?: string | null; + workflow_file?: string | null; +}; + +interface RecordButtonProps { + onRecordingSaved: (workflowFile: string) => void; +} + +const RecordButton: React.FC = ({ onRecordingSaved }) => { + const [status, setStatus] = useState({ status: "idle" }); + const [busy, setBusy] = useState(false); + const pollRef = useRef(null); + + const stopPolling = useCallback(() => { + if (pollRef.current !== null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + const applyStatus = useCallback( + (next: RecordingStatus) => { + setStatus(next); + if (next.status !== "recording" && next.status !== "saving") { + stopPolling(); + if (next.status === "done" && next.workflow_file) { + onRecordingSaved(next.workflow_file); + } + } + }, + [onRecordingSaved, stopPolling] + ); + + const poll = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/workflows/recordings/status`); + applyStatus((await res.json()) as RecordingStatus); + } catch { + /* backend briefly unreachable — keep polling */ + } + }, [applyStatus]); + + const startPolling = useCallback(() => { + stopPolling(); + pollRef.current = window.setInterval(poll, 2000); + }, [poll, stopPolling]); + + useEffect(() => stopPolling, [stopPolling]); + + const start = async () => { + setBusy(true); + try { + const res = await fetch(`${API_BASE}/api/workflows/recordings/start`, { + method: "POST", + }); + applyStatus((await res.json()) as RecordingStatus); + startPolling(); + } catch (e) { + setStatus({ status: "error", message: String(e) }); + } finally { + setBusy(false); + } + }; + + const stop = async () => { + setBusy(true); + try { + const res = await fetch(`${API_BASE}/api/workflows/recordings/stop`, { + method: "POST", + }); + applyStatus((await res.json()) as RecordingStatus); + if ((status.status as string) === "saving") startPolling(); + } catch (e) { + setStatus({ status: "error", message: String(e) }); + } finally { + setBusy(false); + } + }; + + const isRecording = status.status === "recording" || status.status === "saving"; + + return ( +
+ + + {status.status === "recording" && ( +

+ Recording… interact in the opened browser window, then click Stop (or + close the browser). +

+ )} + {status.status === "saving" && ( +

Saving recording…

+ )} + {status.status === "done" && status.workflow_file && ( +

+ Saved: {status.workflow_file} +

+ )} + {(status.status === "error" || status.status === "no_data") && ( +

+ {status.message ?? "Recording failed"} +

+ )} +
+ ); +}; + +export default RecordButton; diff --git a/ui/src/components/sidebar.tsx b/ui/src/components/sidebar.tsx index 1d6b33e9..1b2519cc 100644 --- a/ui/src/components/sidebar.tsx +++ b/ui/src/components/sidebar.tsx @@ -1,5 +1,6 @@ import React from "react"; import WorkflowItem from "./workflow-item"; +import RecordButton from "./record-button"; import { WorkflowMetadata } from "../types/workflow-layout.types"; interface SidebarProps { @@ -9,6 +10,7 @@ interface SidebarProps { workflowMetadata: WorkflowMetadata | null; onUpdateMetadata: (metadata: WorkflowMetadata) => Promise; allWorkflowsMetadata?: Record; + onRecordingSaved: (workflowFile: string) => void; } export const Sidebar: React.FC = ({ @@ -18,6 +20,7 @@ export const Sidebar: React.FC = ({ workflowMetadata, onUpdateMetadata, allWorkflowsMetadata = {}, + onRecordingSaved, }) => (