├── .github └── workflows │ └── publish.yml ├── LICENSE ├── README.md ├── extension ├── .gitignore ├── README.md ├── components.json ├── package-lock.json ├── package.json ├── src │ ├── assets │ │ ├── react.svg │ │ └── tailwind.css │ ├── components │ │ └── ui │ │ │ └── button.tsx │ ├── entrypoints │ │ ├── background.ts │ │ ├── content.ts │ │ └── sidepanel │ │ │ ├── components │ │ │ ├── error-view.tsx │ │ │ ├── event-viewer.tsx │ │ │ ├── initial-view.tsx │ │ │ ├── logina-view.tsx │ │ │ ├── recording-view.tsx │ │ │ └── stopped-view.tsx │ │ │ ├── context │ │ │ └── workflow-provider.tsx │ │ │ ├── index.html │ │ │ └── index.tsx │ ├── lib │ │ ├── message-bus-types.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── workflow-types.ts │ └── public │ │ ├── icon │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 32.png │ │ └── 48.png │ │ └── wxt.svg ├── tsconfig.json ├── vite.config.ts └── wxt.config.ts ├── static └── workflow-use.png ├── ui ├── .eslintrc.cjs ├── .github │ └── dependabot.yml ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── browseruse.png │ └── favicon.ico ├── src │ ├── App.tsx │ ├── components │ │ ├── log-viewer.tsx │ │ ├── no-workflow-message.tsx │ │ ├── node-config-menu.tsx │ │ ├── play-button.tsx │ │ ├── sidebar.tsx │ │ ├── workflow-item.tsx │ │ └── workflow-layout.tsx │ ├── index.css │ ├── lib │ │ └── api │ │ │ ├── index.ts │ │ │ └── openapi.json │ ├── main.tsx │ ├── types │ │ ├── log-viewer.types.ts │ │ ├── node-config-menu.types.ts │ │ ├── play-button.types.ts │ │ ├── sidebar.types.ts │ │ └── workflow-layout.types.ts │ ├── utils │ │ └── json-to-flow.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── workflows ├── .env.example ├── .gitignore ├── .python-version ├── .vscode ├── launch.json └── settings.json ├── README.md ├── backend ├── api.py ├── routers.py ├── service.py └── views.py ├── cli.py ├── examples ├── example.workflow.json └── runner.py ├── pyproject.toml ├── uv.lock └── workflow_use ├── __init__.py ├── builder ├── prompts.py ├── service.py └── tests │ └── build_workflow.py ├── controller ├── service.py ├── utils.py └── views.py ├── mcp ├── service.py └── tests │ └── test_tools.py ├── recorder ├── recorder.py ├── service.py └── views.py ├── schema └── views.py └── workflow ├── prompts.py ├── service.py ├── tests ├── run_workflow.py └── test_extract.py └── views.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: publish 10 | 11 | on: 12 | release: 13 | types: [published] # publish full release to PyPI when a release is created on Github 14 | schedule: 15 | - cron: "0 17 * * FRI" # tag a pre-release on Github every Friday at 5 PM UTC 16 | 17 | permissions: 18 | contents: write 19 | id-token: write 20 | 21 | jobs: 22 | tag_pre_release: 23 | if: github.event_name == 'schedule' 24 | runs-on: ubuntu-latest 25 | defaults: 26 | run: 27 | working-directory: workflows 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Create pre-release tag 31 | run: | 32 | git fetch --tags 33 | latest_tag=$(git tag --list --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$' | head -n 1) 34 | if [ -z "$latest_tag" ]; then 35 | new_tag="v0.1.0rc1" 36 | else 37 | new_tag=$(echo $latest_tag | awk -F'rc' '{print $1 "rc" $2+1}') 38 | fi 39 | git tag $new_tag 40 | git push origin $new_tag 41 | 42 | publish_to_pypi: 43 | if: github.event_name == 'release' 44 | runs-on: ubuntu-latest 45 | defaults: 46 | run: 47 | working-directory: workflows 48 | steps: 49 | - uses: actions/checkout@v4 50 | - name: Set up Python 51 | uses: actions/setup-python@v5 52 | with: 53 | python-version: "3.x" 54 | - uses: astral-sh/setup-uv@v5 55 | - run: uv run ruff check --no-fix --select PLE # check only for syntax errors 56 | - run: uv build 57 | - run: uv publish --trusted-publishing always 58 | - name: Push to stable branch (if stable release) 59 | if: startsWith(github.ref_name, 'v') && !contains(github.ref_name, 'rc') 60 | run: | 61 | git checkout -b stable 62 | git push origin stable 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Workflow Use logo - a product by Browser Use. 3 | 4 | 5 |
6 | 7 |

Deterministic, Self Healing Workflows (RPA 2.0)

8 | 9 | [![GitHub stars](https://img.shields.io/github/stars/browser-use/workflow-use?style=social)](https://github.com/browser-use/workflow-use/stargazers) 10 | [![Discord](https://img.shields.io/discord/1303749220842340412?color=7289DA&label=Discord&logo=discord&logoColor=white)](https://link.browser-use.com/discord) 11 | [![Cloud](https://img.shields.io/badge/Cloud-☁️-blue)](https://cloud.browser-use.com) 12 | [![Twitter Follow](https://img.shields.io/twitter/follow/Gregor?style=social)](https://x.com/gregpr07) 13 | [![Twitter Follow](https://img.shields.io/twitter/follow/Magnus?style=social)](https://x.com/mamagnus00) 14 | 15 | ⚙️ **Workflow Use** is the easiest way to create and execute deterministic workflows with variables which fallback to [Browser Use](https://github.com/browser-use/browser-use) if a step fails. You just _show_ the recorder the workflow, we automatically generate the workflow. 16 | 17 | ❗ This project is in very early development so we don't recommend using this in production. Lots of things will change and we don't have a release schedule yet. Originally, the project was born out of customer demand to make Browser Use more reliable and deterministic. 18 | 19 | # Quick start 20 | 21 | ```bash 22 | git clone https://github.com/browser-use/workflow-use 23 | ``` 24 | 25 | ## Build the extension 26 | 27 | ```bash 28 | cd extension && npm install && npm run build 29 | ``` 30 | 31 | ## Setup workflow environment 32 | 33 | ```bash 34 | cd .. && cd workflows 35 | uv sync 36 | source .venv/bin/activate # for mac / linux 37 | playwright install chromium 38 | cp .env.example .env # add your OPENAI_API_KEY to the .env file 39 | ``` 40 | 41 | 42 | ## Run workflow as tool 43 | 44 | ```bash 45 | python cli.py run-as-tool examples/example.workflow.json --prompt "fill the form with example data" 46 | ``` 47 | 48 | ## Run workflow with predefined variables 49 | 50 | ```bash 51 | python cli.py run-workflow examples/example.workflow.json 52 | ``` 53 | 54 | ## Record your own workflow 55 | 56 | ```bash 57 | python cli.py create-workflow 58 | ``` 59 | 60 | ## See all commands 61 | 62 | ```bash 63 | python cli.py --help 64 | ``` 65 | 66 | # Usage from python 67 | 68 | Running the workflow files is as simple as: 69 | 70 | ```python 71 | from workflow_use import Workflow 72 | 73 | workflow = Workflow.load_from_file("example.workflow.json") 74 | result = asyncio.run(workflow.run_as_tool("I want to search for 'workflow use'")) 75 | ``` 76 | 77 | ## Launch the GUI 78 | 79 | The Workflow UI provides a visual interface for managing, viewing, and executing workflows. 80 | 81 | ### Option 1: Using the CLI command (Recommended) 82 | 83 | The easiest way to start the GUI is with the built-in CLI command: 84 | 85 | ```bash 86 | cd workflows 87 | python cli.py launch-gui 88 | ``` 89 | 90 | This command will: 91 | - Start the backend server (FastAPI) 92 | - Start the frontend development server 93 | - Automatically open http://localhost:5173 in your browser 94 | - Capture logs to the `./tmp/logs` directory 95 | 96 | Press Ctrl+C to stop both servers when you're done. 97 | 98 | ### Option 2: Start servers separately 99 | 100 | Alternatively, you can start the servers individually: 101 | 102 | #### Start the backend server 103 | 104 | ```bash 105 | cd workflows 106 | uvicorn backend.api:app --reload 107 | ``` 108 | 109 | #### Start the frontend development server 110 | 111 | ```bash 112 | cd ui 113 | npm install 114 | npm run dev 115 | ``` 116 | 117 | Once both servers are running, you can access the Workflow GUI at http://localhost:5173 in your browser. The UI allows you to: 118 | 119 | - Visualize workflows as interactive graphs 120 | - Execute workflows with custom input parameters 121 | - Monitor workflow execution logs in real-time 122 | - Edit workflow metadata and details 123 | 124 | # Demos 125 | 126 | ## Workflow Use filling out form instantly 127 | 128 | https://github.com/user-attachments/assets/cf284e08-8c8c-484a-820a-02c507de11d4 129 | 130 | ## Gregor's explanation 131 | 132 | https://github.com/user-attachments/assets/379e57c7-f03e-4eb9-8184-521377d5c0f9 133 | 134 | # Features 135 | 136 | - 🔁 **Record Once, Reuse Forever**: Record browser interactions once and replay them indefinitely. 137 | - ⏳ **Show, don't prompt**: No need to spend hours prompting Browser Use to do the same thing over and over again. 138 | - ⚙️ **Structured & Executable Workflows**: Converts recordings into deterministic, fast, and reliable workflows which automatically extract variables from forms. 139 | - 🪄 **Human-like Interaction Understanding**: Intelligently filters noise from recordings to create meaningful workflows. 140 | - 🔒 **Enterprise-Ready Foundation**: Built for future scalability with features like self-healing and workflow diffs. 141 | 142 | # Vision and roadmap 143 | 144 | Show computer what it needs to do once, and it will do it over and over again without any human intervention. 145 | 146 | ## Workflows 147 | 148 | - [ ] Nice way to use the `.json` files inside python code 149 | - [ ] Improve LLM fallback when step fails (currently really bad) 150 | - [ ] Self healing, if it fails automatically agent kicks in and updates the workflow file 151 | - [ ] Better support for LLM steps 152 | - [ ] Take output from previous steps and use it as input for next steps 153 | - [ ] Expose workflows as MCP tools 154 | - [ ] Use Browser Use to automatically create workflows from websites 155 | 156 | ## Developer experience 157 | 158 | - [ ] Improve CLI 159 | - [ ] Improve extension 160 | - [ ] Step editor 161 | 162 | ## Agent 163 | 164 | - [ ] Allow Browser Use to use the workflows as MCP tools 165 | - [ ] Use workflows as website caching layer 166 | -------------------------------------------------------------------------------- /extension/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | stats-*.json 14 | .wxt 15 | web-ext.config.ts 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /extension/README.md: -------------------------------------------------------------------------------- 1 | # WXT + React 2 | 3 | This template should help get you started developing with React in WXT. 4 | -------------------------------------------------------------------------------- /extension/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "assets/tailwind.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-use-workflow-recorder", 3 | "description": "record and enchance workflows for browser use", 4 | "private": true, 5 | "version": "0.0.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "wxt", 9 | "dev:firefox": "wxt -b firefox", 10 | "build": "wxt build", 11 | "build:firefox": "wxt build -b firefox", 12 | "zip": "wxt zip", 13 | "zip:firefox": "wxt zip -b firefox", 14 | "compile": "tsc --noEmit", 15 | "postinstall": "wxt prepare" 16 | }, 17 | "dependencies": { 18 | "@radix-ui/react-slot": "^1.2.0", 19 | "@tailwindcss/vite": "^4.1.5", 20 | "class-variance-authority": "^0.7.1", 21 | "clsx": "^2.1.1", 22 | "lucide-react": "^0.507.0", 23 | "react": "^19.1.0", 24 | "react-dom": "^19.1.0", 25 | "rrweb": "^2.0.0-alpha.4", 26 | "tailwind-merge": "^3.2.0", 27 | "tailwindcss": "^4.1.5" 28 | }, 29 | "devDependencies": { 30 | "@types/chrome": "^0.0.318", 31 | "@types/node": "^22.15.3", 32 | "@types/react": "^19.1.2", 33 | "@types/react-dom": "^19.1.3", 34 | "@wxt-dev/module-react": "^1.1.3", 35 | "tw-animate-css": "^1.2.9", 36 | "typescript": "^5.8.3", 37 | "wxt": "^0.20.6" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /extension/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /extension/src/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --radius-sm: calc(var(--radius) - 4px); 8 | --radius-md: calc(var(--radius) - 2px); 9 | --radius-lg: var(--radius); 10 | --radius-xl: calc(var(--radius) + 4px); 11 | --color-background: var(--background); 12 | --color-foreground: var(--foreground); 13 | --color-card: var(--card); 14 | --color-card-foreground: var(--card-foreground); 15 | --color-popover: var(--popover); 16 | --color-popover-foreground: var(--popover-foreground); 17 | --color-primary: var(--primary); 18 | --color-primary-foreground: var(--primary-foreground); 19 | --color-secondary: var(--secondary); 20 | --color-secondary-foreground: var(--secondary-foreground); 21 | --color-muted: var(--muted); 22 | --color-muted-foreground: var(--muted-foreground); 23 | --color-accent: var(--accent); 24 | --color-accent-foreground: var(--accent-foreground); 25 | --color-destructive: var(--destructive); 26 | --color-border: var(--border); 27 | --color-input: var(--input); 28 | --color-ring: var(--ring); 29 | --color-chart-1: var(--chart-1); 30 | --color-chart-2: var(--chart-2); 31 | --color-chart-3: var(--chart-3); 32 | --color-chart-4: var(--chart-4); 33 | --color-chart-5: var(--chart-5); 34 | --color-sidebar: var(--sidebar); 35 | --color-sidebar-foreground: var(--sidebar-foreground); 36 | --color-sidebar-primary: var(--sidebar-primary); 37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 38 | --color-sidebar-accent: var(--sidebar-accent); 39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 40 | --color-sidebar-border: var(--sidebar-border); 41 | --color-sidebar-ring: var(--sidebar-ring); 42 | } 43 | 44 | :root { 45 | --radius: 0.625rem; 46 | --background: oklch(1 0 0); 47 | --foreground: oklch(0.145 0 0); 48 | --card: oklch(1 0 0); 49 | --card-foreground: oklch(0.145 0 0); 50 | --popover: oklch(1 0 0); 51 | --popover-foreground: oklch(0.145 0 0); 52 | --primary: oklch(0.205 0 0); 53 | --primary-foreground: oklch(0.985 0 0); 54 | --secondary: oklch(0.97 0 0); 55 | --secondary-foreground: oklch(0.205 0 0); 56 | --muted: oklch(0.97 0 0); 57 | --muted-foreground: oklch(0.556 0 0); 58 | --accent: oklch(0.97 0 0); 59 | --accent-foreground: oklch(0.205 0 0); 60 | --destructive: oklch(0.577 0.245 27.325); 61 | --border: oklch(0.922 0 0); 62 | --input: oklch(0.922 0 0); 63 | --ring: oklch(0.708 0 0); 64 | --chart-1: oklch(0.646 0.222 41.116); 65 | --chart-2: oklch(0.6 0.118 184.704); 66 | --chart-3: oklch(0.398 0.07 227.392); 67 | --chart-4: oklch(0.828 0.189 84.429); 68 | --chart-5: oklch(0.769 0.188 70.08); 69 | --sidebar: oklch(0.985 0 0); 70 | --sidebar-foreground: oklch(0.145 0 0); 71 | --sidebar-primary: oklch(0.205 0 0); 72 | --sidebar-primary-foreground: oklch(0.985 0 0); 73 | --sidebar-accent: oklch(0.97 0 0); 74 | --sidebar-accent-foreground: oklch(0.205 0 0); 75 | --sidebar-border: oklch(0.922 0 0); 76 | --sidebar-ring: oklch(0.708 0 0); 77 | } 78 | 79 | .dark { 80 | --background: oklch(0.145 0 0); 81 | --foreground: oklch(0.985 0 0); 82 | --card: oklch(0.205 0 0); 83 | --card-foreground: oklch(0.985 0 0); 84 | --popover: oklch(0.205 0 0); 85 | --popover-foreground: oklch(0.985 0 0); 86 | --primary: oklch(0.922 0 0); 87 | --primary-foreground: oklch(0.205 0 0); 88 | --secondary: oklch(0.269 0 0); 89 | --secondary-foreground: oklch(0.985 0 0); 90 | --muted: oklch(0.269 0 0); 91 | --muted-foreground: oklch(0.708 0 0); 92 | --accent: oklch(0.269 0 0); 93 | --accent-foreground: oklch(0.985 0 0); 94 | --destructive: oklch(0.704 0.191 22.216); 95 | --border: oklch(1 0 0 / 10%); 96 | --input: oklch(1 0 0 / 15%); 97 | --ring: oklch(0.556 0 0); 98 | --chart-1: oklch(0.488 0.243 264.376); 99 | --chart-2: oklch(0.696 0.17 162.48); 100 | --chart-3: oklch(0.769 0.188 70.08); 101 | --chart-4: oklch(0.627 0.265 303.9); 102 | --chart-5: oklch(0.645 0.246 16.439); 103 | --sidebar: oklch(0.205 0 0); 104 | --sidebar-foreground: oklch(0.985 0 0); 105 | --sidebar-primary: oklch(0.488 0.243 264.376); 106 | --sidebar-primary-foreground: oklch(0.985 0 0); 107 | --sidebar-accent: oklch(0.269 0 0); 108 | --sidebar-accent-foreground: oklch(0.985 0 0); 109 | --sidebar-border: oklch(1 0 0 / 10%); 110 | --sidebar-ring: oklch(0.556 0 0); 111 | } 112 | 113 | @layer base { 114 | * { 115 | @apply border-border outline-ring/50; 116 | } 117 | body { 118 | @apply bg-background text-foreground; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /extension/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean; 47 | }) { 48 | const Comp = asChild ? Slot : "button"; 49 | 50 | return ( 51 | 56 | ); 57 | } 58 | 59 | export { Button, buttonVariants }; 60 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/components/error-view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useWorkflow } from "../context/workflow-provider"; 3 | import { Button } from "@/components/ui/button"; 4 | 5 | export const ErrorView: React.FC = () => { 6 | const { error, fetchWorkflowData, startRecording } = useWorkflow(); 7 | 8 | const handleRetry = () => { 9 | // Try fetching data again 10 | fetchWorkflowData(); 11 | }; 12 | 13 | const handleStartNew = () => { 14 | // Reset state and start recording 15 | startRecording(); 16 | }; 17 | 18 | return ( 19 |
20 |

21 | An Error Occurred 22 |

23 |

24 | {error || "An unexpected error occurred."} 25 |

26 |
27 | 30 | 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/components/event-viewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import { 3 | ClickStep, 4 | InputStep, 5 | KeyPressStep, 6 | NavigationStep, 7 | ScrollStep, 8 | Step, 9 | } from "../../../lib/workflow-types"; // Adjust path as needed 10 | import { useWorkflow } from "../context/workflow-provider"; 11 | 12 | // Helper to get the specific screenshot for a step 13 | const getScreenshot = (step: Step): string | undefined => { 14 | if ("screenshot" in step) { 15 | return step.screenshot; 16 | } 17 | return undefined; 18 | }; 19 | 20 | // Component to render a single step as a card 21 | const StepCard: React.FC<{ 22 | step: Step; 23 | index: number; 24 | isSelected: boolean; 25 | onSelect: () => void; 26 | }> = ({ step, index, isSelected, onSelect }) => { 27 | const screenshot = getScreenshot(step); 28 | const canShowScreenshot = ["click", "input", "key_press"].includes(step.type); 29 | 30 | // --- Step Summary Renderer (Top part of the card) --- 31 | const renderStepSummary = (step: Step) => { 32 | switch (step.type) { 33 | case "click": { 34 | const s = step as ClickStep; 35 | return ( 36 |
37 | 🖱️ 38 | 39 | Click on {s.elementTag} 40 | {s.elementText && `: "${s.elementText}"`} 41 | 42 |
43 | ); 44 | } 45 | case "input": { 46 | const s = step as InputStep; 47 | return ( 48 |
49 | ⌨️ 50 | 51 | Input into {s.elementTag}: "{s.value}" 52 | 53 |
54 | ); 55 | } 56 | case "key_press": { 57 | const s = step as KeyPressStep; 58 | return ( 59 |
60 | 🔑 61 | 62 | Press {s.key} on {s.elementTag || "document"} 63 | 64 |
65 | ); 66 | } 67 | case "navigation": { 68 | const s = step as NavigationStep; 69 | return ( 70 |
71 | 🧭 72 | Navigate: {s.url} 73 |
74 | ); 75 | } 76 | case "scroll": { 77 | const s = step as ScrollStep; 78 | return ( 79 |
80 | ↕️ 81 | 82 | Scroll to ({s.scrollX}, {s.scrollY}) 83 | 84 |
85 | ); 86 | } 87 | default: 88 | return <>{(step as any).type}; // Fallback 89 | } 90 | }; 91 | 92 | // --- Step Detail Renderer (Collapsible section or part of card body) --- 93 | const renderStepDetailsContent = (step: Step) => { 94 | const baseInfo = ( 95 | <> 96 |

97 | Timestamp:{" "} 98 | {new Date(step.timestamp).toLocaleString()} 99 |

100 | {step.url && ( 101 |

102 | URL: {step.url} 103 |

104 | )} 105 | {/* Tab ID might be less relevant now, could remove */} 106 | {/*

Tab ID: {step.tabId}

*/} 107 | 108 | ); 109 | 110 | let specificInfo = null; 111 | 112 | switch (step.type) { 113 | case "click": 114 | case "input": 115 | case "key_press": { 116 | const s = step as ClickStep | InputStep | KeyPressStep; // Union type 117 | specificInfo = ( 118 | <> 119 | {(s as ClickStep | InputStep).frameUrl && 120 | (s as ClickStep | InputStep).frameUrl !== s.url && ( 121 |

122 | Frame URL:{" "} 123 | {(s as ClickStep | InputStep).frameUrl} 124 |

125 | )} 126 | {s.xpath && ( 127 |

128 | XPath: {s.xpath} 129 |

130 | )} 131 | {s.cssSelector && ( 132 |

133 | CSS: {s.cssSelector} 134 |

135 | )} 136 | {s.elementTag && ( 137 |

138 | Element: {s.elementTag} 139 |

140 | )} 141 | {(s as ClickStep).elementText && ( 142 |

143 | Text: {(s as ClickStep).elementText} 144 |

145 | )} 146 | {(s as InputStep).value && ( 147 |

148 | Value: {(s as InputStep).value} 149 |

150 | )} 151 | {(s as KeyPressStep).key && ( 152 |

153 | Key: {(s as KeyPressStep).key} 154 |

155 | )} 156 | 157 | ); 158 | break; 159 | } 160 | case "navigation": { 161 | // Base info already has URL 162 | break; 163 | } 164 | case "scroll": { 165 | const s = step as ScrollStep; 166 | specificInfo = ( 167 | <> 168 |

169 | Target ID: {s.targetId} 170 |

171 |

172 | Scroll X: {s.scrollX} 173 |

174 |

175 | Scroll Y: {s.scrollY} 176 |

177 | 178 | ); 179 | break; 180 | } 181 | default: 182 | specificInfo = ( 183 |

Details not available for type: {(step as any).type}

184 | ); 185 | } 186 | 187 | return ( 188 |
189 | {baseInfo} 190 | {specificInfo} 191 |
192 | ); 193 | }; 194 | 195 | return ( 196 |
208 | {/* Card Content using Flexbox */} 209 |
210 | {/* Left side: Summary and Details */} 211 |
212 |
213 | {renderStepSummary(step)} 214 |
215 | {renderStepDetailsContent(step)} 216 |
217 | 218 | {/* Right side: Screenshot (if available) */} 219 | {canShowScreenshot && screenshot && ( 220 |
221 | {`Screenshot 227 |
228 | )} 229 |
230 |
231 | ); 232 | }; 233 | 234 | // Main EventViewer component using the new card layout 235 | export const EventViewer: React.FC = () => { 236 | const { workflow, currentEventIndex, selectEvent, recordingStatus } = 237 | useWorkflow(); 238 | const steps = workflow?.steps || []; 239 | const scrollContainerRef = useRef(null); // Ref for the scrollable div 240 | 241 | // Effect to scroll selected card into view 242 | useEffect(() => { 243 | if (recordingStatus !== "recording") { 244 | // Only scroll selection when not recording 245 | const element = document.getElementById( 246 | `event-item-${currentEventIndex}` 247 | ); 248 | element?.scrollIntoView({ behavior: "smooth", block: "center" }); 249 | } 250 | }, [currentEventIndex, recordingStatus]); // Add recordingStatus dependency 251 | 252 | // Effect to scroll to bottom when new steps are added during recording 253 | useEffect(() => { 254 | if (recordingStatus === "recording" && scrollContainerRef.current) { 255 | const { current: container } = scrollContainerRef; 256 | // Use setTimeout to allow DOM update before scrolling 257 | setTimeout(() => { 258 | container.scrollTop = container.scrollHeight; 259 | console.log("Scrolled to bottom due to new event during recording"); 260 | }, 0); 261 | } 262 | // Depend on the number of steps and recording status 263 | }, [steps.length, recordingStatus]); 264 | 265 | if (!workflow || !workflow.steps || workflow.steps.length === 0) { 266 | return ( 267 |
268 | No events recorded yet. 269 |
270 | ); 271 | } 272 | 273 | return ( 274 | // Assign the ref to the scrollable container 275 |
276 | {" "} 277 | {/* Single scrollable container */} 278 | {steps.map((step, index) => ( 279 | selectEvent(index)} 285 | /> 286 | ))} 287 |
288 | ); 289 | }; 290 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/components/initial-view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useWorkflow } from "../context/workflow-provider"; 3 | import { Button } from "@/components/ui/button"; // Reverted to alias path 4 | 5 | export const InitialView: React.FC = () => { 6 | const { startRecording } = useWorkflow(); 7 | 8 | return ( 9 |
10 |

Record a Workflow

11 | 12 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/components/logina-view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Simple loading spinner component 4 | const Spinner: React.FC = () => ( 5 | 11 | 19 | 24 | 25 | ); 26 | 27 | export const LoadingView: React.FC = () => { 28 | return ( 29 |
30 | 31 |

Loading workflow data...

32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/components/recording-view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useWorkflow } from "../context/workflow-provider"; 3 | import { Button } from "@/components/ui/button"; 4 | import { EventViewer } from "./event-viewer"; // Import EventViewer 5 | 6 | export const RecordingView: React.FC = () => { 7 | const { stopRecording, workflow } = useWorkflow(); 8 | const stepCount = workflow?.steps?.length || 0; 9 | 10 | return ( 11 |
12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | Recording ({stepCount} steps) 20 | 21 |
22 | 25 |
26 |
27 | {/* EventViewer will now take full available space within this div */} 28 | 29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/components/stopped-view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useWorkflow } from "../context/workflow-provider"; 3 | import { Button } from "@/components/ui/button"; 4 | import { EventViewer } from "./event-viewer"; 5 | 6 | export const StoppedView: React.FC = () => { 7 | const { discardAndStartNew, workflow } = useWorkflow(); 8 | 9 | const downloadJson = () => { 10 | if (!workflow) return; 11 | 12 | // Sanitize workflow name for filename 13 | const safeName = workflow.name 14 | ? workflow.name.replace(/[^a-z0-9\.\-\_]/gi, "_").toLowerCase() 15 | : "workflow"; 16 | 17 | const blob = new Blob([JSON.stringify(workflow, null, 2)], { 18 | type: "application/json", 19 | }); 20 | const url = URL.createObjectURL(blob); 21 | const a = document.createElement("a"); 22 | a.href = url; 23 | // Generate filename e.g., my_workflow_name_2023-10-27_10-30-00.json 24 | const timestamp = new Date() 25 | .toISOString() 26 | .replace(/[:.]/g, "-") 27 | .slice(0, 19); 28 | // Use sanitized name instead of domain 29 | a.download = `${safeName}_${timestamp}.json`; 30 | document.body.appendChild(a); 31 | a.click(); 32 | document.body.removeChild(a); 33 | URL.revokeObjectURL(url); 34 | }; 35 | 36 | return ( 37 |
38 |
39 |

Recording Finished

40 |
41 | 44 | 53 |
54 |
55 |
56 | 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/context/workflow-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | ReactNode, 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useState, 8 | } from "react"; 9 | import { Workflow } from "../../../lib/workflow-types"; // Adjust path as needed 10 | 11 | type WorkflowState = { 12 | workflow: Workflow | null; 13 | recordingStatus: string; // e.g., 'idle', 'recording', 'stopped', 'error' 14 | currentEventIndex: number; 15 | isLoading: boolean; 16 | error: string | null; 17 | }; 18 | 19 | type WorkflowContextType = WorkflowState & { 20 | startRecording: () => void; 21 | stopRecording: () => void; 22 | discardAndStartNew: () => void; 23 | selectEvent: (index: number) => void; 24 | fetchWorkflowData: (isPolling?: boolean) => void; // Add optional flag 25 | }; 26 | 27 | const WorkflowContext = createContext( 28 | undefined 29 | ); 30 | 31 | interface WorkflowProviderProps { 32 | children: ReactNode; 33 | } 34 | 35 | const POLLING_INTERVAL = 2000; // Fetch every 2 seconds during recording 36 | 37 | export const WorkflowProvider: React.FC = ({ 38 | children, 39 | }) => { 40 | const [workflow, setWorkflow] = useState(null); 41 | const [recordingStatus, setRecordingStatus] = useState("idle"); // 'idle', 'recording', 'stopped', 'error' 42 | const [currentEventIndex, setCurrentEventIndex] = useState(0); 43 | const [isLoading, setIsLoading] = useState(true); 44 | const [error, setError] = useState(null); 45 | 46 | const fetchWorkflowData = useCallback(async (isPolling: boolean = false) => { 47 | if (!isPolling) { 48 | setIsLoading(true); 49 | } 50 | try { 51 | const data = await chrome.runtime.sendMessage({ 52 | type: "GET_RECORDING_DATA", 53 | }); 54 | console.log( 55 | "Received workflow data from background (polling=" + isPolling + "):", 56 | data 57 | ); 58 | if (data && data.workflow && data.recordingStatus) { 59 | // Always update workflow when fetching (polling or not) 60 | setWorkflow(data.workflow); 61 | 62 | // If NOT polling, update status and index based on fetched data 63 | // If polling, we primarily rely on the broadcast message for status changes 64 | if (!isPolling) { 65 | setRecordingStatus(data.recordingStatus); 66 | setCurrentEventIndex( 67 | data.workflow.steps ? data.workflow.steps.length - 1 : 0 68 | ); 69 | } else { 70 | // If polling, ensure index is valid (e.g., after step deletion) 71 | // Only adjust index based on current workflow state from polling 72 | setCurrentEventIndex((prevIndex) => 73 | Math.min(prevIndex, (data.workflow.steps?.length || 1) - 1) 74 | ); 75 | // Do NOT set recordingStatus from polling fetch, wait for broadcast message 76 | } 77 | // Clear error on successful fetch 78 | setError(null); 79 | } else { 80 | console.warn( 81 | "Received invalid/incomplete data structure from GET_RECORDING_DATA" 82 | ); 83 | if (!isPolling) { 84 | setWorkflow(null); 85 | setRecordingStatus("idle"); 86 | setCurrentEventIndex(0); 87 | } 88 | } 89 | } catch (err: any) { 90 | console.error( 91 | "Error fetching workflow data (polling=" + isPolling + "):", 92 | err 93 | ); 94 | // Only set error state if it wasn't a background poll error 95 | // and the status isn't already error 96 | if (!isPolling) { 97 | setError(`Failed to load workflow data: ${err.message}`); 98 | setRecordingStatus("error"); 99 | setWorkflow(null); 100 | } 101 | } finally { 102 | if (!isPolling) { 103 | setIsLoading(false); 104 | } 105 | } 106 | // Remove state dependencies to stabilize the function reference 107 | }, []); 108 | 109 | useEffect(() => { 110 | // Initial fetch on mount 111 | fetchWorkflowData(false); 112 | 113 | // Listener for status updates pushed from the background script 114 | const messageListener = (message: any, sender: any, sendResponse: any) => { 115 | console.log("Sidepanel received message:", message); 116 | if (message.type === "recording_status_updated") { 117 | console.log( 118 | "Recording status updated message received:", 119 | message.payload 120 | ); 121 | const newStatus = message.payload.status; 122 | // Use functional update to get previous status reliably 123 | setRecordingStatus((prevStatus) => { 124 | // If status changed from non-stopped/idle to stopped or idle, fetch final data 125 | if ( 126 | newStatus !== prevStatus && 127 | (newStatus === "stopped" || newStatus === "idle") 128 | ) { 129 | fetchWorkflowData(false); // Fetch final data, show loading 130 | } 131 | return newStatus; // Return the new status to update the state 132 | }); 133 | } 134 | }; 135 | chrome.runtime.onMessage.addListener(messageListener); 136 | 137 | // --- Polling Logic --- 138 | let pollingIntervalId: NodeJS.Timeout | null = null; 139 | if (recordingStatus === "recording") { 140 | pollingIntervalId = setInterval(() => { 141 | fetchWorkflowData(true); // Fetch updates in the background (polling) 142 | }, POLLING_INTERVAL); 143 | console.log(`Polling started (Interval ID: ${pollingIntervalId})`); 144 | } 145 | // --- End Polling Logic --- 146 | 147 | // Cleanup listener and interval 148 | return () => { 149 | chrome.runtime.onMessage.removeListener(messageListener); 150 | if (pollingIntervalId) { 151 | clearInterval(pollingIntervalId); 152 | console.log( 153 | `Polling stopped (Cleared Interval ID: ${pollingIntervalId})` 154 | ); 155 | } 156 | }; 157 | // Keep dependencies: fetchWorkflowData is stable now, 158 | // recordingStatus dependency correctly handles interval setup/teardown. 159 | }, [fetchWorkflowData, recordingStatus]); 160 | 161 | // startRecording, stopRecording, discardAndStartNew, selectEvent remain largely the same 162 | // but ensure isLoading is handled appropriately 163 | const startRecording = useCallback(() => { 164 | setError(null); 165 | setIsLoading(true); 166 | chrome.runtime.sendMessage({ type: "START_RECORDING" }, (response) => { 167 | // Loading state will be turned off by the fetchWorkflowData triggered 168 | // by the recording_status_updated message, or on error here. 169 | if (chrome.runtime.lastError) { 170 | console.error("Error starting recording:", chrome.runtime.lastError); 171 | setError( 172 | `Failed to start recording: ${chrome.runtime.lastError.message}` 173 | ); 174 | setRecordingStatus("error"); 175 | setIsLoading(false); // Stop loading on error 176 | } else { 177 | console.log("Start recording acknowledged by background."); 178 | // State updates happen via broadcast + fetch 179 | } 180 | }); 181 | }, []); // No dependencies needed 182 | 183 | const stopRecording = useCallback(() => { 184 | setError(null); 185 | setIsLoading(true); 186 | chrome.runtime.sendMessage({ type: "STOP_RECORDING" }, (response) => { 187 | // Loading state will be turned off by the fetchWorkflowData triggered 188 | // by the recording_status_updated message, or on error here. 189 | if (chrome.runtime.lastError) { 190 | console.error("Error stopping recording:", chrome.runtime.lastError); 191 | setError( 192 | `Failed to stop recording: ${chrome.runtime.lastError.message}` 193 | ); 194 | setRecordingStatus("error"); 195 | setIsLoading(false); // Stop loading on error 196 | } else { 197 | console.log("Stop recording acknowledged by background."); 198 | // State updates happen via broadcast + fetch 199 | } 200 | }); 201 | }, []); // No dependencies needed 202 | 203 | const discardAndStartNew = useCallback(() => { 204 | startRecording(); 205 | }, [startRecording]); 206 | 207 | const selectEvent = useCallback((index: number) => { 208 | setCurrentEventIndex(index); 209 | }, []); 210 | 211 | const value = { 212 | workflow, 213 | recordingStatus, 214 | currentEventIndex, 215 | isLoading, 216 | error, 217 | startRecording, 218 | stopRecording, 219 | discardAndStartNew, 220 | selectEvent, 221 | fetchWorkflowData, 222 | }; 223 | 224 | return ( 225 | 226 | {children} 227 | 228 | ); 229 | }; 230 | 231 | export const useWorkflow = (): WorkflowContextType => { 232 | const context = useContext(WorkflowContext); 233 | if (context === undefined) { 234 | throw new Error("useWorkflow must be used within a WorkflowProvider"); 235 | } 236 | return context; 237 | }; 238 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Default Popup Title 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /extension/src/entrypoints/sidepanel/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | // import vite tailwind css 5 | import "@/assets/tailwind.css"; 6 | 7 | import { ErrorView } from "./components/error-view"; 8 | import { InitialView } from "./components/initial-view"; 9 | import { LoadingView } from "./components/logina-view"; 10 | import { RecordingView } from "./components/recording-view"; 11 | import { StoppedView } from "./components/stopped-view"; 12 | import { WorkflowProvider, useWorkflow } from "./context/workflow-provider"; 13 | 14 | const AppContent: React.FC = () => { 15 | const { recordingStatus, isLoading, error } = useWorkflow(); 16 | 17 | if (isLoading) { 18 | return ; 19 | } 20 | 21 | if (error) { 22 | return ; 23 | } 24 | 25 | switch (recordingStatus) { 26 | case "recording": 27 | return ; 28 | case "stopped": 29 | return ; 30 | case "idle": 31 | default: 32 | return ; 33 | } 34 | }; 35 | 36 | const SidepanelApp: React.FC = () => { 37 | return ( 38 | 39 | 40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 | ); 48 | }; 49 | 50 | const rootElement = document.getElementById("root"); 51 | if (!rootElement) { 52 | throw new Error("Root element not found"); 53 | } 54 | 55 | const root = ReactDOM.createRoot(rootElement); 56 | root.render(); 57 | -------------------------------------------------------------------------------- /extension/src/lib/message-bus-types.ts: -------------------------------------------------------------------------------- 1 | import { Workflow } from "./workflow-types"; // Assuming Workflow is in this path 2 | 3 | // Types for events sent via HTTP to the Python server 4 | 5 | export interface HttpWorkflowUpdateEvent { 6 | type: "WORKFLOW_UPDATE"; 7 | timestamp: number; 8 | payload: Workflow; 9 | } 10 | 11 | export interface HttpRecordingStartedEvent { 12 | type: "RECORDING_STARTED"; 13 | timestamp: number; 14 | payload: { 15 | message: string; 16 | }; 17 | } 18 | 19 | export interface HttpRecordingStoppedEvent { 20 | type: "RECORDING_STOPPED"; 21 | timestamp: number; 22 | payload: { 23 | message: string; 24 | }; 25 | } 26 | 27 | // If you plan to send other types of events, like TERMINATE_COMMAND, define them here too 28 | // export interface HttpTerminateCommandEvent { 29 | // type: "TERMINATE_COMMAND"; 30 | // timestamp: number; 31 | // payload: { 32 | // reason?: string; // Optional reason for termination 33 | // }; 34 | // } 35 | 36 | export type HttpEvent = 37 | | HttpWorkflowUpdateEvent 38 | | HttpRecordingStartedEvent 39 | | HttpRecordingStoppedEvent; 40 | // | HttpTerminateCommandEvent; // Add other event types to the union if defined 41 | -------------------------------------------------------------------------------- /extension/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface StoredCustomClickEvent { 2 | timestamp: number; 3 | url: string; 4 | frameUrl: string; 5 | xpath: string; 6 | cssSelector?: string; 7 | elementTag: string; 8 | elementText: string; 9 | tabId: number; 10 | messageType: "CUSTOM_CLICK_EVENT"; 11 | screenshot?: string; 12 | } 13 | 14 | export interface StoredCustomInputEvent { 15 | timestamp: number; 16 | url: string; 17 | frameUrl: string; 18 | xpath: string; 19 | cssSelector?: string; 20 | elementTag: string; 21 | value: string; 22 | tabId: number; 23 | messageType: "CUSTOM_INPUT_EVENT"; 24 | screenshot?: string; 25 | } 26 | 27 | export interface StoredCustomSelectEvent { 28 | timestamp: number; 29 | url: string; 30 | frameUrl: string; 31 | xpath: string; 32 | cssSelector?: string; 33 | elementTag: string; 34 | selectedValue: string; 35 | selectedText: string; 36 | tabId: number; 37 | messageType: "CUSTOM_SELECT_EVENT"; 38 | screenshot?: string; 39 | } 40 | 41 | export interface StoredCustomKeyEvent { 42 | timestamp: number; 43 | url: string; 44 | frameUrl: string; 45 | key: string; 46 | xpath?: string; // XPath of focused element 47 | cssSelector?: string; 48 | elementTag?: string; 49 | tabId: number; 50 | messageType: "CUSTOM_KEY_EVENT"; 51 | screenshot?: string; 52 | } 53 | 54 | export interface StoredTabEvent { 55 | timestamp: number; 56 | tabId: number; 57 | messageType: 58 | | "CUSTOM_TAB_CREATED" 59 | | "CUSTOM_TAB_UPDATED" 60 | | "CUSTOM_TAB_ACTIVATED" 61 | | "CUSTOM_TAB_REMOVED"; 62 | url?: string; 63 | openerTabId?: number; 64 | windowId?: number; 65 | changeInfo?: chrome.tabs.TabChangeInfo; // Relies on chrome types 66 | isWindowClosing?: boolean; 67 | index?: number; 68 | title?: string; 69 | } 70 | 71 | export interface StoredRrwebEvent { 72 | type: number; // rrweb EventType (consider importing if needed) 73 | data: any; 74 | timestamp: number; 75 | tabId: number; 76 | messageType: "RRWEB_EVENT"; 77 | } 78 | 79 | export type StoredEvent = 80 | | StoredCustomClickEvent 81 | | StoredCustomInputEvent 82 | | StoredCustomSelectEvent 83 | | StoredCustomKeyEvent 84 | | StoredTabEvent 85 | | StoredRrwebEvent; 86 | 87 | // --- Data Structures --- 88 | 89 | export interface TabData { 90 | info: { url?: string; title?: string }; 91 | events: StoredEvent[]; 92 | } 93 | 94 | export interface RecordingData { 95 | [tabId: number]: TabData; 96 | } 97 | -------------------------------------------------------------------------------- /extension/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /extension/src/lib/workflow-types.ts: -------------------------------------------------------------------------------- 1 | // --- Workflow Format --- 2 | 3 | export interface Workflow { 4 | steps: Step[]; 5 | name: string; // Consider how to populate these fields 6 | description: string; // Consider how to populate these fields 7 | version: string; // Consider how to populate these fields 8 | input_schema: []; 9 | } 10 | 11 | export type Step = 12 | | NavigationStep 13 | | ClickStep 14 | | InputStep 15 | | KeyPressStep 16 | | ScrollStep; 17 | // Add other step types here as needed, e.g., SelectStep, TabCreatedStep etc. 18 | 19 | export interface BaseStep { 20 | type: string; 21 | timestamp: number; 22 | tabId: number; 23 | url?: string; // Made optional as not all original events have it directly 24 | } 25 | 26 | export interface NavigationStep extends BaseStep { 27 | type: "navigation"; 28 | url: string; // Navigation implies a URL change 29 | screenshot?: string; // Optional in source 30 | } 31 | 32 | export interface ClickStep extends BaseStep { 33 | type: "click"; 34 | url: string; 35 | frameUrl: string; 36 | xpath: string; 37 | cssSelector?: string; // Optional in source 38 | elementTag: string; 39 | elementText: string; 40 | screenshot?: string; // Optional in source 41 | } 42 | 43 | export interface InputStep extends BaseStep { 44 | type: "input"; 45 | url: string; 46 | frameUrl: string; 47 | xpath: string; 48 | cssSelector?: string; // Optional in source 49 | elementTag: string; 50 | value: string; 51 | screenshot?: string; // Optional in source 52 | } 53 | 54 | export interface KeyPressStep extends BaseStep { 55 | type: "key_press"; 56 | url?: string; // Can be missing if key press happens without element focus? Source is optional. 57 | frameUrl?: string; // Might be missing 58 | key: string; 59 | xpath?: string; // Optional in source 60 | cssSelector?: string; // Optional in source 61 | elementTag?: string; // Optional in source 62 | screenshot?: string; // Optional in source 63 | } 64 | 65 | export interface ScrollStep extends BaseStep { 66 | type: "scroll"; // Changed from scroll_update for simplicity 67 | targetId: number; // The rrweb ID of the element being scrolled 68 | scrollX: number; 69 | scrollY: number; 70 | // Note: url might be missing if scroll happens on initial load before meta event? 71 | } 72 | 73 | // Potential future step types based on StoredEvent 74 | // export interface SelectStep extends BaseStep { ... } 75 | // export interface TabCreatedStep extends BaseStep { ... } 76 | // export interface TabActivatedStep extends BaseStep { ... } 77 | // export interface TabRemovedStep extends BaseStep { ... } 78 | -------------------------------------------------------------------------------- /extension/src/public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browser-use/workflow-use/fc823f70f1996c12f18a818a9676aac674daa154/extension/src/public/icon/128.png -------------------------------------------------------------------------------- /extension/src/public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browser-use/workflow-use/fc823f70f1996c12f18a818a9676aac674daa154/extension/src/public/icon/16.png -------------------------------------------------------------------------------- /extension/src/public/icon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browser-use/workflow-use/fc823f70f1996c12f18a818a9676aac674daa154/extension/src/public/icon/32.png -------------------------------------------------------------------------------- /extension/src/public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browser-use/workflow-use/fc823f70f1996c12f18a818a9676aac674daa154/extension/src/public/icon/48.png -------------------------------------------------------------------------------- /extension/src/public/wxt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "jsx": "react-jsx", 6 | "baseUrl": ".", 7 | "paths": { 8 | "@/*": [ 9 | "./src/*" 10 | ] 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /extension/vite.config.ts: -------------------------------------------------------------------------------- 1 | // vite.config.ts 2 | import { defineConfig } from "vite"; 3 | import path from "path"; 4 | import tailwindcss from "@tailwindcss/vite"; 5 | 6 | export default defineConfig({ 7 | plugins: [tailwindcss()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src/"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /extension/wxt.config.ts: -------------------------------------------------------------------------------- 1 | // wxt.config.ts 2 | import { defineConfig } from "wxt"; 3 | import baseViteConfig from "./vite.config"; 4 | 5 | import { mergeConfig } from "vite"; 6 | 7 | // See https://wxt.dev/api/config.html 8 | export default defineConfig({ 9 | modules: ["@wxt-dev/module-react"], 10 | srcDir: "src", 11 | vite: () => 12 | mergeConfig(baseViteConfig, { 13 | // WXT-specific overrides (optional) 14 | }), 15 | manifest: { 16 | permissions: ["tabs", "sidePanel", ""], 17 | host_permissions: ["http://127.0.0.1/*"], 18 | // options_page: "options.html", 19 | // action: { 20 | // default_popup: "popup.html", 21 | // }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /static/workflow-use.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browser-use/workflow-use/fc823f70f1996c12f18a818a9676aac674daa154/static/workflow-use.png -------------------------------------------------------------------------------- /ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /ui/.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # docs: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: 'npm' 7 | directory: '/' 8 | schedule: 9 | interval: 'daily' 10 | allow: 11 | - dependency-name: '@xyflow/react' 12 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | src/lib/api/apigen.d.ts 27 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Browser Use Workflow Visualizer 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "workflow-use-ui", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 9 | "preview": "vite preview", 10 | "get-openapi": "curl http://localhost:${BACKEND_PORT:-8000}/openapi.json -o ./src/lib/api/openapi.json", 11 | "type-gen": "npx openapi-typescript ./src/lib/api/openapi.json -o ./src/lib/api/apigen.d.ts", 12 | "type-gen-update": "rm -rf ./src/lib/api/apigen.d.ts && npm run get-openapi && npm run type-gen", 13 | "postinstall": "test -n \"$NOYARNPOSTINSTALL\" || npm run type-gen" 14 | }, 15 | "dependencies": { 16 | "@tailwindcss/vite": "^4.1.7", 17 | "@xyflow/react": "^12.5.1", 18 | "openapi-fetch": "^0.14.0", 19 | "openapi-react-query": "^0.5.0", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "tailwindcss": "^4.1.7", 23 | "zod": "^3.24.4" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^22.15.19", 27 | "@types/react": "^18.2.53", 28 | "@types/react-dom": "^18.2.18", 29 | "@typescript-eslint/eslint-plugin": "^6.20.0", 30 | "@typescript-eslint/parser": "^6.20.0", 31 | "@vitejs/plugin-react": "^4.2.1", 32 | "eslint": "^8.56.0", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "eslint-plugin-react-refresh": "^0.4.5", 35 | "openapi-typescript": "^7.8.0", 36 | "typescript": "^5.3.3", 37 | "vite": "^5.0.12" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ui/public/browseruse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browser-use/workflow-use/fc823f70f1996c12f18a818a9676aac674daa154/ui/public/browseruse.png -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/browser-use/workflow-use/fc823f70f1996c12f18a818a9676aac674daa154/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ReactFlowProvider } from "@xyflow/react"; 2 | import "@xyflow/react/dist/style.css"; 3 | import WorkflowLayout from "./components/workflow-layout"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | 6 | // Create a client 7 | const queryClient = new QueryClient(); 8 | 9 | export default function App() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/components/log-viewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { LogViewerProps } from "../types/log-viewer.types"; 3 | 4 | const LogViewer: React.FC = ({ 5 | taskId, 6 | initialPosition, 7 | onStatusChange, 8 | onError, 9 | onCancel, 10 | onClose, 11 | }) => { 12 | const [isCancelling, setIsCancelling] = useState(false); 13 | const [logs, setLogs] = useState([]); 14 | const [position, setPosition] = useState(initialPosition); 15 | const [status, setStatus] = useState("running"); 16 | const [error, setError] = useState(null); 17 | const [polling, setPolling] = useState(true); 18 | 19 | const logContainerRef = useRef(null); 20 | const pollingIntervalRef = useRef(null); 21 | 22 | useEffect(() => { 23 | if (logContainerRef.current) { 24 | logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; 25 | } 26 | }, [logs]); 27 | 28 | useEffect(() => { 29 | return () => { 30 | setPolling(false); 31 | }; 32 | }, []); 33 | 34 | useEffect(() => { 35 | if (!taskId) return; 36 | 37 | const pollLogs = async () => { 38 | try { 39 | const response = await fetch( 40 | `http://127.0.0.1:8000/api/workflows/logs/${taskId}?position=${position}` 41 | ); 42 | 43 | if (!response.ok) { 44 | throw new Error(`Error fetching logs: ${response.status}`); 45 | } 46 | 47 | const data = await response.json(); 48 | 49 | if (data.logs && data.logs.length > 0) { 50 | setLogs((prevLogs) => [...prevLogs, ...data.logs]); 51 | } 52 | 53 | setPosition(data.log_position); 54 | 55 | if (data.status && data.status !== status) { 56 | setStatus(data.status); 57 | onStatusChange?.(data.status); 58 | 59 | if (data.status === "failed" && data.error) { 60 | setError(data.error); 61 | onError?.(data.error); 62 | } 63 | } 64 | } catch (err) { 65 | console.error("Error polling logs:", err); 66 | } 67 | }; 68 | 69 | pollLogs(); 70 | 71 | pollingIntervalRef.current = setInterval(() => { 72 | if (polling) { 73 | pollLogs(); 74 | } 75 | }, 2000); 76 | 77 | return () => { 78 | if (pollingIntervalRef.current) { 79 | clearInterval(pollingIntervalRef.current); 80 | } 81 | }; 82 | }, [taskId, position, status, polling, onStatusChange, onError]); 83 | 84 | const cancelWorkflow = async () => { 85 | if (!taskId || isCancelling || status !== "running") return; 86 | 87 | setIsCancelling(true); 88 | 89 | try { 90 | const response = await fetch( 91 | `http://127.0.0.1:8000/api/workflows/tasks/${taskId}/cancel`, 92 | { 93 | method: "POST", 94 | headers: { 95 | "Content-Type": "application/json", 96 | }, 97 | } 98 | ); 99 | 100 | const data = await response.json(); 101 | 102 | if (response.ok && data.success) { 103 | // The status will be updated through the polling mechanism 104 | if (onCancel) { 105 | onCancel(); 106 | } 107 | } else { 108 | console.error( 109 | "Failed to cancel workflow:", 110 | data.message || "Unknown error" 111 | ); 112 | } 113 | } catch (err) { 114 | console.error("Error cancelling workflow:", err); 115 | } finally { 116 | setIsCancelling(false); 117 | } 118 | }; 119 | 120 | const downloadLogs = () => { 121 | if (logs.length === 0) return; 122 | 123 | const textContent = logs.join(""); 124 | 125 | const blob = new Blob([textContent], { type: "text/plain" }); 126 | 127 | const url = URL.createObjectURL(blob); 128 | 129 | const link = document.createElement("a"); 130 | link.href = url; 131 | link.download = taskId 132 | ? `workflow-logs-${taskId}.txt` 133 | : "workflow-logs.txt"; 134 | 135 | document.body.appendChild(link); 136 | link.click(); 137 | document.body.removeChild(link); 138 | 139 | URL.revokeObjectURL(url); 140 | }; 141 | 142 | const formatLog = (log: string, index: number) => { 143 | const timestampMatch = log.match(/^\[(.*?)\]/); 144 | 145 | if (timestampMatch) { 146 | const timestamp = timestampMatch[0]; 147 | const message = log.substring(timestamp.length); 148 | 149 | return ( 150 |
151 | {timestamp} 152 | {message} 153 |
154 | ); 155 | } 156 | 157 | return ( 158 |
159 | {log} 160 |
161 | ); 162 | }; 163 | 164 | return ( 165 |
166 |
167 |
Workflow Execution Logs
168 |
169 |
170 | {status === "running" && ( 171 | 197 | )} 198 | {logs.length > 0 && ( 199 | 222 | )} 223 |
224 |
239 | Status: {status.charAt(0).toUpperCase() + status.slice(1)} 240 |
241 |
242 |
243 | 244 |
248 | {logs.length > 0 ? ( 249 | logs.map((log, index) => formatLog(log, index)) 250 | ) : ( 251 |
252 | Waiting for logs... 253 |
254 | )} 255 | 256 | {error && ( 257 |
258 | Error: {error} 259 |
260 | )} 261 |
262 | 263 | {/* Close button at the bottom */} 264 |
265 | 272 |
273 |
274 | ); 275 | }; 276 | 277 | export default LogViewer; 278 | -------------------------------------------------------------------------------- /ui/src/components/no-workflow-message.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NoWorkflowsMessage: React.FC = () => { 4 | return ( 5 |
6 | Browser Use Logo 11 |

No Workflows Found

12 |

13 | To get started with Workflow Use, you need to first create a workflow 14 | or place an existing workflow file in the workflows/tmp folder. 15 |

16 |

17 | Once you've added a workflow file, refresh this page to visualize and interact with it. For more information, checkout out the documentation below. 18 |

19 | 25 | Learn More 26 | 27 |
28 | ); 29 | }; 30 | 31 | export default NoWorkflowsMessage; 32 | -------------------------------------------------------------------------------- /ui/src/components/play-button.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import LogViewer from "./log-viewer"; 3 | import { PlayButtonProps, InputField } from "../types/play-button.types"; 4 | 5 | export const PlayButton: React.FC = ({ 6 | workflowName, 7 | workflowMetadata, 8 | }) => { 9 | const [showModal, setShowModal] = useState(false); 10 | const [showLogViewer, setShowLogViewer] = useState(false); 11 | const [isRunning, setIsRunning] = useState(false); 12 | const [error, setError] = useState(null); 13 | const [inputFields, setInputFields] = useState([]); 14 | const [taskId, setTaskId] = useState(null); 15 | const [logPosition, setLogPosition] = useState(0); 16 | const [workflowStatus, setWorkflowStatus] = useState("idle"); 17 | 18 | const openModal = () => { 19 | if (!workflowName) return; 20 | 21 | setShowModal(true); 22 | setError(null); 23 | 24 | if (workflowMetadata && workflowMetadata.input_schema) { 25 | const fields = workflowMetadata.input_schema.map((input: any) => ({ 26 | name: input.name, 27 | type: input.type, 28 | required: input.required, 29 | value: input.type === "boolean" ? false : "", 30 | })); 31 | setInputFields(fields); 32 | } else { 33 | setInputFields([]); 34 | } 35 | }; 36 | 37 | const closeModal = () => { 38 | setShowModal(false); 39 | 40 | if (!isRunning) { 41 | resetState(); 42 | } 43 | }; 44 | 45 | const closeLogViewer = () => { 46 | setShowLogViewer(false); 47 | resetState(); 48 | }; 49 | 50 | const resetState = () => { 51 | setIsRunning(false); 52 | setError(null); 53 | setInputFields([]); 54 | setTaskId(null); 55 | setLogPosition(0); 56 | setWorkflowStatus("idle"); 57 | }; 58 | 59 | const handleInputChange = (index: number, value: any) => { 60 | const updatedFields = [...inputFields]; 61 | if (updatedFields[index]) { 62 | updatedFields[index].value = value; 63 | setInputFields(updatedFields); 64 | } 65 | }; 66 | 67 | const executeWorkflow = async () => { 68 | if (!workflowName) return; 69 | 70 | const missingInputs = inputFields.filter( 71 | (field) => field.required && !field.value 72 | ); 73 | if (missingInputs.length > 0) { 74 | setError( 75 | `Missing required inputs: ${missingInputs 76 | .map((f) => f.name) 77 | .join(", ")}` 78 | ); 79 | return; 80 | } 81 | 82 | setIsRunning(true); 83 | setError(null); 84 | setTaskId(null); 85 | setLogPosition(0); 86 | setWorkflowStatus("idle"); 87 | 88 | try { 89 | const inputs: Record = {}; 90 | inputFields.forEach((field) => { 91 | inputs[field.name] = field.value; 92 | }); 93 | 94 | const response = await fetch( 95 | "http://127.0.0.1:8000/api/workflows/execute", 96 | { 97 | method: "POST", 98 | headers: { 99 | "Content-Type": "application/json", 100 | }, 101 | body: JSON.stringify({ 102 | name: workflowName, 103 | inputs, 104 | }), 105 | } 106 | ); 107 | 108 | const data = await response.json(); 109 | setTaskId(data.task_id); 110 | setLogPosition(data.log_position); 111 | setIsRunning(true); 112 | setShowLogViewer(true); 113 | setShowModal(false); 114 | } catch (err) { 115 | console.error("Failed to execute workflow:", err); 116 | setError("An error occurred while executing the workflow"); 117 | } 118 | }; 119 | 120 | const handleStatusChange = (status: string) => { 121 | setWorkflowStatus(status); 122 | 123 | if ( 124 | status === "completed" || 125 | status === "failed" || 126 | status === "cancelled" 127 | ) { 128 | setIsRunning(false); 129 | } 130 | }; 131 | 132 | const handleCancelWorkflow = () => { 133 | setWorkflowStatus("cancelling"); 134 | }; 135 | 136 | const handleWorkflowError = (errorMessage: string) => { 137 | setError(errorMessage); 138 | }; 139 | 140 | if (!workflowName) return null; 141 | 142 | return ( 143 |
144 | {/* play button */} 145 | 152 | 153 | {/* parameter‑input modal */} 154 | {showModal && ( 155 |
156 |
157 | {/* header */} 158 |
159 |

160 | Execute Workflow: {workflowMetadata?.name || workflowName} 161 |

162 | 168 |
169 | 170 | {/* content */} 171 |
172 | {error && ( 173 |
174 | {error} 175 |
176 | )} 177 | 178 | {inputFields.length ? ( 179 |
180 |

181 | Input Parameters 182 |

183 | 184 | {inputFields.map((f, i) => ( 185 |
186 | 195 | 196 | {f.type === "boolean" ? ( 197 | 201 | handleInputChange(i, e.target.checked) 202 | } 203 | /> 204 | ) : ( 205 | handleInputChange(i, e.target.value)} 209 | className="w-full rounded border border-gray-600 bg-[#333] px-3 py-2 text-sm text-white" 210 | /> 211 | )} 212 |
213 | ))} 214 |
215 | ) : ( 216 |

No input parameters required for this workflow.

217 | )} 218 | 219 |
220 | 226 | 233 |
234 |
235 |
236 |
237 | )} 238 | 239 | {/* log viewer */} 240 | {showLogViewer && taskId && ( 241 |
242 |
243 |
244 |
245 | Workflow Execution 246 | {workflowStatus !== "running" && 247 | ` (${workflowStatus 248 | .charAt(0) 249 | .toUpperCase()}${workflowStatus.slice(1)})`} 250 |
251 |
252 | 253 |
254 | 262 |
263 |
264 |
265 | )} 266 |
267 | ); 268 | }; 269 | -------------------------------------------------------------------------------- /ui/src/components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import WorkflowItem from "./workflow-item"; 3 | import { WorkflowMetadata } from "../types/workflow-layout.types"; 4 | 5 | interface SidebarProps { 6 | workflows: string[]; 7 | onSelect: (workflow: string) => void; 8 | selected: string | null; 9 | workflowMetadata: WorkflowMetadata | null; 10 | onUpdateMetadata: (metadata: WorkflowMetadata) => Promise; 11 | allWorkflowsMetadata?: Record; 12 | } 13 | 14 | export const Sidebar: React.FC = ({ 15 | workflows, 16 | onSelect, 17 | selected, 18 | workflowMetadata, 19 | onUpdateMetadata, 20 | allWorkflowsMetadata = {}, 21 | }) => ( 22 | 51 | ); 52 | 53 | export default Sidebar; 54 | -------------------------------------------------------------------------------- /ui/src/components/workflow-item.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ChangeEvent } from "react"; 2 | import { 3 | WorkflowMetadata, 4 | WorkflowItemProps, 5 | } from "../types/workflow-layout.types"; 6 | 7 | const WorkflowItem: React.FC = ({ 8 | id, 9 | selected, 10 | metadata, 11 | onSelect, 12 | onUpdateMetadata, 13 | }) => { 14 | const [isEditing, setIsEditing] = useState(false); 15 | const [edited, setEdited] = useState(null); 16 | const [submitting, setSubmitting] = useState(false); 17 | 18 | const displayName = () => { 19 | if (metadata) 20 | return ( 21 | <> 22 | {metadata.name} 23 | {metadata.version && ( 24 | 25 | v{metadata.version} 26 | 27 | )} 28 | 29 | ); 30 | 31 | return ( 32 | 33 | Loading workflow… 34 | {id} 35 | 36 | ); 37 | }; 38 | 39 | const change = 40 | (field: keyof WorkflowMetadata) => 41 | (e: ChangeEvent) => 42 | edited && setEdited({ ...edited, [field]: e.target.value }); 43 | 44 | const save = async () => { 45 | if (!edited) return; 46 | setSubmitting(true); 47 | await onUpdateMetadata(edited).finally(() => setSubmitting(false)); 48 | setIsEditing(false); 49 | }; 50 | 51 | const editForm = metadata && ( 52 |
53 | {(["name", "version"] as (keyof WorkflowMetadata)[]).map((f) => ( 54 | 64 | ))} 65 | 66 |