chore: import upstream snapshot with attribution
Checks (magui2.0) / python-lint (push) Failing after 1s
Checks (magui2.0) / python-test (push) Failing after 0s
Checks (magui2.0) / frontend-lint (push) Failing after 0s
CodeQL Advanced / Analyze (actions) (push) Failing after 1s
Checks (magui2.0) / python-format (push) Failing after 1s
CodeQL Advanced / Analyze (python) (push) Failing after 0s
Checks (magui2.0) / python-pyright (push) Failing after 1s
Checks (magui2.0) / frontend-format (push) Failing after 1s
Checks (magui2.0) / frontend-typecheck (push) Failing after 0s
Checks (magui2.0) / frontend-test (push) Failing after 2s
CodeQL Advanced / Analyze (javascript-typescript) (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:24:32 +08:00
commit 25576b0be6
388 changed files with 76588 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
name: Bug report
description: Report a bug or unexpected behavior
title: "[bug] "
labels: [bug]
body:
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear, concise description of what's wrong.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: The minimum sequence that triggers the bug.
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What actually happened. Include error messages, screenshots, or log excerpts.
validations:
required: true
- type: input
id: version
attributes:
label: MagenticLite version
description: Output of `magentic-ui --version`.
placeholder: 0.2.0
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- Linux
- macOS
- Windows
- Windows (WSL)
- Other
validations:
required: true
- type: input
id: python
attributes:
label: Python version
placeholder: "3.12"
validations:
required: true
- type: input
id: model
attributes:
label: Model client / provider
description: Which orchestrator / web-surfer model you're running.
- type: textarea
id: context
attributes:
label: Additional context
description: Config snippets (with secrets redacted), recent changes, workarounds tried.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Question or discussion
url: https://github.com/microsoft/magentic-ui/discussions
about: For usage questions and general discussion, please use Discussions instead of an issue.
@@ -0,0 +1,29 @@
name: Feature request
description: Suggest a new feature or improvement
title: "[feature] "
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: Describe the use case or pain point. What are you trying to do?
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: How would you like it to work? UI, API, or behavior.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches you thought about and why they're not ideal.
- type: textarea
id: context
attributes:
label: Additional context
description: Mockups, links to similar projects, related issues.
+79
View File
@@ -0,0 +1,79 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/" # project root (where pyproject.toml and uv.lock live)
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Suppress major-version PRs for packages that need to track an external baseline.
ignore:
# Keep @types/node on the current Node major (24) to match the runtime baseline
# declared in package.json (>=24); bump it manually when we intentionally raise
# that baseline to a new major.
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
# TypeScript majors often introduce stricter narrowing across the whole project.
# Bump manually when we're ready to handle the fallout.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
# Vite 8 swaps the bundler/transform stack to Rolldown + Oxc. Wait for the
# ecosystem (vitest, @tailwindcss/vite, @vitejs/plugin-react) to stabilize
# — re-evaluate around vite 8.2 or when we hit a feature we need.
- dependency-name: "vite"
update-types: ["version-update:semver-major"]
# @vitejs/plugin-react v6 drops Vite 7 support; bump in lockstep with vite.
- dependency-name: "@vitejs/plugin-react"
update-types: ["version-update:semver-major"]
# ESLint 10 majors can ripple through plugins (typescript-eslint,
# eslint-plugin-react, etc.); bump manually once peer deps are aligned.
- dependency-name: "eslint"
update-types: ["version-update:semver-major"]
- dependency-name: "@eslint/js"
update-types: ["version-update:semver-major"]
# Test/build tooling that we intentionally pin to its current major to
# avoid noisy PRs without real benefit:
# - jsdom: vitest's environment; follow vitest's recommended version
# - globals: ESLint plugin support; staying on 16.x is fine
# - rollup-plugin-visualizer: dev-only build analyzer
- dependency-name: "jsdom"
update-types: ["version-update:semver-major"]
- dependency-name: "globals"
update-types: ["version-update:semver-major"]
- dependency-name: "rollup-plugin-visualizer"
update-types: ["version-update:semver-major"]
# Project doesn't use Redux — package will be removed from package.json
# in a separate PR. Suppress all updates in the meantime.
- dependency-name: "@redux-devtools/extension"
update-types:
- "version-update:semver-major"
- "version-update:semver-minor"
- "version-update:semver-patch"
# Group routine version bumps so we don't drown in PRs.
# Security updates always open as standalone PRs regardless of grouping.
groups:
frontend-dev-dependencies:
dependency-type: "development"
update-types:
- "minor"
- "patch"
frontend-prod-dependencies:
dependency-type: "production"
update-types:
- "minor"
- "patch"
# Quiet down dev-only major bumps (vite, jsdom, rollup plugins, etc.)
# into a single weekly PR. Prod majors stay standalone for individual review.
frontend-dev-majors:
dependency-type: "development"
update-types:
- "major"
+27
View File
@@ -0,0 +1,27 @@
## Related Issue
<!-- Link the related issue, e.g., Fixes #123 -->
## Summary
<!-- Briefly explain what this PR does and why. -->
## Changes
<!-- List the key changes in this PR. -->
- ...
## How to Verify
<!-- Steps for reviewers to test or validate this change. -->
1. ...
2. ...
3. ...
## Checklist
- [ ] Tests added or updated (if applicable)
- [ ] Documentation updated (if needed)
- [ ] Verified using the steps above
+195
View File
@@ -0,0 +1,195 @@
name: Checks (magui2.0)
on:
push:
branches:
- main
- 'release/**'
pull_request:
branches:
- main
- 'release/**'
schedule:
- cron: "0 3 * * 1" # Every Monday at 03:00 UTC
permissions:
contents: read
jobs:
# Python checks
python-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
version: "0.7.11"
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- run: uv sync --all-extras
working-directory: .
- name: Run format check
run: |
source .venv/bin/activate
poe fmt --check
python-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
version: "0.7.11"
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- run: uv sync --all-extras
working-directory: .
- name: Run lint
run: |
source .venv/bin/activate
poe lint
python-pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
version: "0.7.11"
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- run: uv sync --all-extras
working-directory: .
- name: Run pyright
run: |
source .venv/bin/activate
poe pyright
python-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
version: "0.7.11"
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- run: uv sync --all-extras
working-directory: .
- name: Get Playwright version
id: playwright-version
run: |
source .venv/bin/activate
echo "version=$(playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
run: |
source .venv/bin/activate
playwright install --with-deps
- name: Run tests
run: |
source .venv/bin/activate
pytest tests --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload coverage artifact
uses: actions/upload-artifact@v7
with:
name: coverage-magentic-ui
path: coverage.xml
# Frontend checks
frontend-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm install -g pnpm@10.24.0
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Format check
run: pnpm run format:check
working-directory: frontend
frontend-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm install -g pnpm@10.24.0
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Lint
run: pnpm run lint
working-directory: frontend
frontend-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm install -g pnpm@10.24.0
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Type check
run: pnpm run type-check
working-directory: frontend
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm install -g pnpm@10.24.0
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: "frontend/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Test
run: pnpm run test:run
working-directory: frontend
# Turn on this step after open source.
# codecov:
# runs-on: ubuntu-latest
# needs: [python-test]
# steps:
# - uses: actions/checkout@v7
# - uses: actions/download-artifact@v4
# with:
# name: coverage-magentic-ui
# path: ./
# - uses: codecov/codecov-action@v5
# with:
# files: coverage.xml
# flags: unittests
# name: codecov-umbrella
# fail_ci_if_error: true
# token: ${{ secrets.CODECOV_TOKEN }}
+102
View File
@@ -0,0 +1,102 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main", "release/**" ]
pull_request:
branches: [ "main", "release/**" ]
schedule:
- cron: '34 1 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v7
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+76
View File
@@ -0,0 +1,76 @@
name: Build & Release Wheel
on:
workflow_dispatch:
inputs:
create_release:
description: "Create a GitHub Release with the wheel"
type: boolean
default: false
overwrite_existing:
description: "Overwrite existing release with same version tag"
type: boolean
default: false
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10
- name: Install frontend dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Build frontend
working-directory: frontend
run: pnpm build
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build wheel
run: |
pip install build
python -m build --wheel
- name: Create GitHub Release
if: ${{ inputs.create_release }}
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION=$(python -c "exec(open('src/magentic_ui/version.py').read()); print(VERSION)")
TAG="v$VERSION"
# Mark as prerelease when the version has an alpha/beta/rc suffix.
PRERELEASE_FLAG=""
if [[ "$VERSION" =~ (a|b|rc)[0-9]+$ ]]; then
PRERELEASE_FLAG="--prerelease"
fi
# Check if release already exists
if gh release view "$TAG" >/dev/null 2>&1; then
if [ "${{ inputs.overwrite_existing }}" != "true" ]; then
echo "::error::Release $TAG already exists. Bump version in version.py or enable 'Overwrite existing release'."
exit 1
fi
echo "Overwriting existing release $TAG..."
gh release delete "$TAG" --yes --cleanup-tag
fi
gh release create "$TAG" dist/*.whl \
--generate-notes \
$PRERELEASE_FLAG
+232
View File
@@ -0,0 +1,232 @@
# VS Code - ignore user-specific settings but keep shared config
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
# User config file — see config.yaml.example at repo root
/config.yaml
config/
# AI assistant local docs
.claude/
CLAUDE.md
.copilot/
.github/copilot-instructions.md
.github/frontend/
.github/skills/
.github/plans/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
!frontend/src/lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
scratch.py
.DS_Store
.magentic_ui_state.json
debug
data
runs
node_modules
# Frontend
pnpm-debug.log*
# Autogen Studio
database.sqlite
.cache/*
src/magentic_ui/backend/web/files/user/*
src/magentic_ui/backend/test
src/magentic_ui/backend/database/alembic.ini
src/magentic_ui/backend/database/alembic/*
src/magentic_ui/backend/web/files/ui/*
OAI_CONFIG_LIST
scratch/
src/magentic_ui/backend/web/workdir/*
src/magentic_ui/backend/web/ui/*
src/magentic_ui/backend/web/skills/user/*
.release.sh
.nightly.sh
notebooks/test
notebooks/work_dir/*
notebooks/test.db
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Task centric memory related db and logs
**/memory_bank/
**/pagelogs/
+1
View File
@@ -0,0 +1 @@
3.12
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"vitest.explorer"
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"eslint.workingDirectories": ["frontend"],
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"css.lint.unknownAtRules": "ignore",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"typescript.tsdk": "frontend/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"tailwindCSS.includeLanguages": {
"typescriptreact": "html"
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^'\"`]*)(?:'|\"|`)"]
],
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
+9
View File
@@ -0,0 +1,9 @@
# Microsoft Open Source Code of Conduct
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
Resources:
- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
+92
View File
@@ -0,0 +1,92 @@
# Contributing to MagenticLite
Thank you for your interest in MagenticLite! We genuinely value your ideas, bug reports, and engagement with the project. This document explains how community contributions work and why we've structured things the way we have.
## Code of Conduct
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
For more information, see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact <opencode@microsoft.com> with any additional questions or comments.
## How We Work: Issues First
**GitHub Issues are the primary way to contribute to MagenticLite.** Whether you've found a bug, have a feature idea, or want to propose a change to the codebase, please start with an issue before writing any code.
This isn't bureaucracy — it's how we avoid duplicated effort, catch design problems early, and make sure contributions align with where the project is headed. An issue that sparks a great discussion is a valuable contribution in its own right.
### Reporting a Bug
Open a new issue using the **Bug Report** template. Please include:
- A clear description of what you expected vs. what happened
- Steps to reproduce the problem
- Your environment (OS, Python version, relevant dependencies)
- Any relevant logs or screenshots
The more context you provide, the faster we can triage and address it.
### Suggesting a Feature or Improvement
Open a new issue using the **Feature Request** template. Describe the problem you're trying to solve, not just the solution you have in mind — this helps us understand the need and explore the design space together.
If you have a concrete proposal (an API shape, a workflow change, an architecture sketch), include it. We love detailed proposals.
### Asking Questions
If you're not sure whether something is a bug or you just need help, open a **Discussion** rather than an issue. The Discussions tab is a great place for open-ended questions and community conversation.
---
## Pull Requests
MagenticLite is a research prototype in active development. To maintain quality and protect the integrity of the codebase, **we currently limit code contributions via pull requests to a vetted set of contributors.**
This is a deliberate choice, not an oversight. Open-source AI projects are increasingly targeted by supply chain attacks — where malicious code is introduced through seemingly legitimate contributions. High-profile incidents like the xz Utils backdoor have made the entire ecosystem more cautious, and we think that caution is warranted, especially for a project that runs agentic AI workflows on users' machines. We'd rather be transparent about this policy than leave contributors wondering why their PRs sit unreviewed.
We know this isn't the fully open model some contributors prefer, and we appreciate your understanding.
### A Note on AI-Generated Code
If you are a vetted contributor submitting a PR, please disclose if substantial portions of the code were generated by an AI coding assistant. We don't prohibit AI-assisted development, but we need to know so we can apply the appropriate level of scrutiny. Code is code regardless of how it was produced — the review bar is the same either way.
---
## Other Ways to Contribute
You don't need to write code to make a meaningful impact on MagenticLite:
- **Triage issues** — comment on open issues with reproduction steps, additional context, or a note that you can or cannot reproduce a problem. This directly accelerates our work.
- **Improve documentation** — if something in the docs is unclear, missing, or wrong, open an issue describing the gap (or, if you're a vetted contributor, a PR).
- **Share your experience** — open a Discussion about how you're using MagenticLite, what's working, and what isn't. That feedback shapes our roadmap.
- **Answer questions** — if you know the answer to someone's question in Discussions, jump in.
---
## Contributor License Agreement (CLA)
If you are a vetted contributor and submit a pull request, you'll need to agree to a Contributor License Agreement declaring that you have the right to grant us the rights to use your contribution. For details, visit <https://opensource.microsoft.com/pdf/microsoft-contribution-license-agreement.pdf>.
When you submit a pull request, a CLA bot will automatically determine whether you need to sign and will guide you through the process. You only need to do this once across all Microsoft repos.
---
## Running Tests and Checks
All code changes must pass the continuous integration checks. You can run them locally with:
```bash
poe check
```
See the `tests` directory for examples of how tests are structured.
---
## Security Vulnerabilities
Please do **not** file security vulnerabilities as public GitHub issues. Report them privately using [GitHub's private vulnerability reporting](https://github.com/microsoft/magentic-ui/security/advisories/new) or follow the instructions in [SECURITY.md](./SECURITY.md).
---
## Questions?
Open a [Discussion](https://github.com/microsoft/magentic-ui/discussions) or comment on a relevant issue. Thank you for helping make MagenticLite better.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Microsoft
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
Magentic-UI
Copyright (c) Microsoft Corporation.
This product includes software developed at Microsoft Corporation
(https://www.microsoft.com/).
===============================================================================
Third-party software licenses
===============================================================================
-------------------------------------------------------------------------------
SWE-agent v0.7
-------------------------------------------------------------------------------
Portions of `src/magentic_ui/teams/omniagent/tools/` are derived from
SWE-agent v0.7:
https://github.com/SWE-agent/SWE-agent/tree/v0.7/config/commands
MIT License
Copyright (c) 2024 John Yang, Carlos E. Jimenez, Alexander Wettig,
Shunyu Yao, Karthik Narasimhan, Ofir Press
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+103
View File
@@ -0,0 +1,103 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/_assets/magentic-lite-logo-dark.png">
<img src="docs/_assets/magentic-lite-logo-light.png" alt="MagenticLite" width="420">
</picture>
</p>
<p align="center">
<em>Big tasks. Small models.</em>
</p>
---
**MagenticLite** is the next generation of Magentic-UI — an agentic application from **Microsoft AI Frontiers**, redesigned to do more with less. It pairs an on-device-friendly orchestrator model ([MagenticBrain](https://aka.ms/MagenticBrain-foundry)) with a specialized browser-use model ([Fara](https://aka.ms/fara-foundry)) so you can automate real work without depending on frontier-scale compute.
- **Built for small models, efficient by design.** Strong agentic performance without heavy compute — no frontier-scale models required.
- **Works across the browser and your local file system.** Web research, form filling, file management — in one workflow.
- **Keeps you in the loop and in control.** Steer, approve, or take over at any point. MagenticLite stops and checks in before taking critical actions.
- **Safe by design.** Browser sessions run inside a lightweight VM sandbox ([Quicksand](https://microsoft.github.io/quicksand/)) so the agent can't reach the rest of your machine without your say-so.
## See it in action
Click any task below to expand and watch MagenticLite handle it end to end.
<details open>
<summary><b>Fill expense forms</b></summary>
https://github.com/user-attachments/assets/a982b60d-127e-4310-96e6-9f094dffb7f0
</details>
<details>
<summary><b>Find prices for recipe ingredients</b></summary>
https://github.com/user-attachments/assets/7ff87917-752f-4e40-b319-8036e08ab8f9
</details>
<details>
<summary><b>Find and book a restaurant</b></summary>
https://github.com/user-attachments/assets/ee5f967e-ee1b-4554-b329-36b56b595aa4
</details>
<details>
<summary><b>Organize local files</b></summary>
https://github.com/user-attachments/assets/aff385dd-f856-4db4-8f26-08fd0f940aa4
</details>
## Quick start
These steps get you running on macOS or Windows (WSL). Need more detail or a different platform? See the [Installation Guide](./docs/installation.md).
### 1. Install MagenticLite
```bash
# Create a project directory
mkdir magentic-lite && cd magentic-lite
# Create and activate a virtual environment
uv venv --python=3.12 --seed .venv
source .venv/bin/activate
# Install the latest 0.2.x release from PyPI
uv pip install "magentic_ui>=0.2.0"
```
### 2. Run
```bash
magentic-ui --port 8081
```
Open <http://127.0.0.1:8081/> and follow the in-app onboarding to connect a model endpoint. If you don't have one yet, see the [Model Hosting Guide](./docs/model-hosting-guide.md).
> **Looking for the previous Magentic-UI 0.1 release** (optimized to run with frontier models)? It lives on the [`magentic-ui-0.1`](https://github.com/microsoft/magentic-ui/tree/magentic-ui-0.1) branch.
## Documentation
| Doc | What's in it |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| [Installation](./docs/installation.md) | Supported platforms, macOS / WSL prerequisites, install + run commands |
| [Build from source](./docs/build-from-source.md) | How to run MagenticLite from a clone of this repo (`uv sync`, `pnpm dev`, etc.) |
| [Model hosting guide](./docs/model-hosting-guide.md) | End-to-end walkthrough of standing up a model endpoint and connecting MagenticLite to it |
| [Configuration](./docs/configuration.md) | Sandbox, agent mode, tool approval, full `config.yaml` example |
| [Troubleshooting](./docs/troubleshooting.md) | Common errors and how to fix them |
| [Limitations](./docs/limitations.md) | Tasks and usage patterns MagenticLite doesn't handle well today |
| [Transparency Note](./docs/TRANSPARENCY_NOTE.md) | Intended uses, responsible-use guidance, risks, and mitigations |
## License
Microsoft, and any contributors, grant you a license to any code in the repository under the [MIT License](https://opensource.org/licenses/MIT). See the [LICENSE](LICENSE) file.
Microsoft, Windows, Microsoft Azure, and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at <http://go.microsoft.com/fwlink/?LinkID=254653>.
Any use of third-party trademarks or logos are subject to those third-party's policies.
Privacy information can be found at <https://go.microsoft.com/fwlink/?LinkId=521839>.
Microsoft and any contributors reserve all other rights, whether under their respective copyrights, patents, or trademarks, whether by implication, estoppel, or otherwise.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`microsoft/magentic-ui`
- 原始仓库:https://github.com/microsoft/magentic-ui
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+41
View File
@@ -0,0 +1,41 @@
<!-- BEGIN MICROSOFT SECURITY.MD V0.0.9 BLOCK -->
## Security
Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin).
If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below.
## Reporting Security Issues
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report).
If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp).
You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc).
Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
* Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
* Full paths of source file(s) related to the manifestation of the issue
* The location of the affected source code (tag/branch/commit or direct URL)
* Any special configuration required to reproduce the issue
* Step-by-step instructions to reproduce the issue
* Proof-of-concept or exploit code (if possible)
* Impact of the issue, including how an attacker might exploit the issue
This information will help us triage your report more quickly.
If you find any issues, please flag by emailing magui@service.microsoft.com.
## Preferred Languages
We prefer all communications to be in English.
## Policy
Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd).
<!-- END MICROSOFT SECURITY.MD BLOCK -->
+14
View File
@@ -0,0 +1,14 @@
# Support
## How to file issues and get help
This project uses GitHub Issues to track bugs and feature requests. Please search the existing
issues before filing new issues to avoid duplicates. For new issues, file your bug or
feature request as a new Issue.
For help and questions about using this project, please post questions to GitHub Issues as
above and assign them the label "question".
## Microsoft Support Policy
Support for MagenticLite is limited to the resources listed above.
+84
View File
@@ -0,0 +1,84 @@
# Magentic-UI config template
#
# Copy this file to `config.yaml` at the repo root and edit to your needs.
# `config.yaml` is gitignored.
#
# `magentic-ui` picks up `./config.yaml` automatically if `--config` isn't
# passed explicitly.
#
# API keys: set OPENAI_API_KEY (or AZURE_OPENAI_API_KEY) as env vars instead
# of putting them in this file. _ai_client.py falls back to the env var when
# `api_key` is omitted from the yaml.
# ---------------------------------------------------------------------------
# Reusable model client snippets (YAML anchors)
# ---------------------------------------------------------------------------
# OmniAgent orchestrator — plans and produces the final answer.
model_config_omni: &omni
provider: OpenAIChatCompletionClient
config:
model: your-orchestrator-model
base_url: https://api.openai.com/v1 # or your own endpoint
# api_key: set via OPENAI_API_KEY env var
max_retries: 5
# Fara web surfer — drives the browser via CDP.
model_config_fara: &fara
provider: OpenAIChatCompletionClient
config:
model: your-web-surfer-model
base_url: https://api.openai.com/v1 # or your own endpoint
# api_key: set via OPENAI_API_KEY env var
max_retries: 5
# Optional Azure example (defined but unused by default). Swap *omni or
# *fara below for *azure_example to route either agent through an
# Azure-hosted model instead.
model_config_azure_example: &azure_example
provider: AzureOpenAIChatCompletionClient
config:
model: your-azure-model
api_version: "2024-12-01-preview"
azure_endpoint: https://your-resource.openai.azure.com/
azure_deployment: your-deployment-name
azure_ad_token_provider:
config:
scopes:
- "https://your-token-scope.example.com/.default"
max_retries: 5
# ---------------------------------------------------------------------------
# MagenticUIConfig
# ---------------------------------------------------------------------------
# Model clients used by the agent pipeline.
model_client_configs:
orchestrator: *omni
web_surfer: *fara
# Sandbox configuration.
sandbox:
type: quicksand # "quicksand" | "null"
# memory: "6G"
# cpus: 3
# pool_size: 5
# Agent mode:
# "all" — OmniAgent + Fara (default; both local tasks and web browsing)
# "omniagent_only" — OmniAgent only (local tasks only)
# "websurfer_only" — Fara only (web browsing only; orchestrator skipped)
agent_mode: all
# Harness behavior knobs. Many of these are also editable from Settings →
# General at runtime. Any field set here will overwrite the user's UI value
# on the next backend restart with this --config — this is the same
# behavior as `agent_mode`, `approval_policy`, etc.
#
# harness_config:
# orchestrator:
# approval_policy: require_approval_untrusted
# temperature: 0.6
# max_rounds: 30 # OmniAgent rounds per batch (default 100)
# web_surfer:
# max_rounds: 100 # Browser use actions per batch (default 100)
+172
View File
@@ -0,0 +1,172 @@
# MagenticLite (aka Magentic 2.0) Transparency Notes
## Overview
MagenticLite (aka Magentic 2.0) is a powerful open-source agentic application that works with you to help you complete tasks across the web browser and your local file system. Built as the successor to [Magentic-UI](https://www.microsoft.com/en-us/research/publication/magentic-ui-report/), MagenticLite was optimized to work with small language models (SLMs) — making it leaner, faster, and more accessible without sacrificing capability.
Unlike its predecessor, which required SOTA frontier models to achieve desired performance, MagenticLite delivers strong agentic capabilities at a fraction of the cost and compute, while keeping you in control at critical steps.
> Looking for the previous version of Magentic-UI optimized to run with frontier models? It lives on the `magentic-ui-0.1.x` branch.
---
## What Can MagenticLite Do?
MagenticLite operates its own browser and can access folders that the user grants it access to, working fluidly across both to complete useful, real-world tasks. It can handle a wide range of tasks — from web research and form filling, to file management, data analysis, and code writing and execution.
Every reasoning trace and action is fully visible to the user. Users can steer the agent at any point, either in natural language or by directly taking control of the browser, ensuring they remain in the driver's seat throughout.
---
## Intended Uses
MagenticLite is a research prototype best suited to explore, experience, and deploy agentic assistance for tasks that require web navigation and local file system interaction.
> **MagenticLite should always be used with human supervision.**
Examples of tasks it can accomplish:
- Fill online forms and make bookings on your behalf
- Research and analyze information across the browser and your local file system
- Manage and analyze your local file system
- Complete simple coding tasks locally
MagenticLite is being shared with the research community to foster further research on agentic systems that keep people in control. It is intended to be used by domain experts who are independently capable of evaluating the quality of outputs, safety issues, and potential harms before acting on them.
---
## Out-of-Scope Uses
We do not recommend using MagenticLite in commercial or real-world applications without further testing and development. It is being released for research purposes.
MagenticLite may not work as expected if used with models other than the recommended setup: **Fara1.5** for browser use and **MagenticBrain** for orchestration and coding.
MagenticLite is **not** well suited for tasks that:
- Rely on audio or video data
- Involve long-duration tasks (e.g., summarizing 100+ papers)
- Require real-time fast actions such as playing online games
MagenticLite should always be used with a human in the loop. It was not designed or evaluated for all possible downstream purposes. Developers should consider its inherent limitations as they select use cases, and evaluate and mitigate for accuracy, safety, and fairness concerns specific to each intended downstream use.
MagenticLite should **not** be used in:
- Highly regulated domains or high-stakes situations where inaccurate outputs could suggest actions that lead to injury or negatively impact an individual's health, legal, financial, or life opportunities
- High-risk decision making (e.g., in law enforcement, legal, finance, or healthcare)
---
## How to Get Started
To begin using MagenticLite, follow instructions in the README page or check our installation guide under `/docs`.
---
## Evaluation
MagenticLite was evaluated on its ability to solve complex agentic tasks, both in standard benchmark settings and on a custom evaluation dataset designed around priority use cases.
### Evaluation Methods
Evaluations were driven by hero use cases reflecting real everyday tasks — including form filling, browser research, and file system management. A custom evaluation dataset was built around these scenarios to measure performance on tasks that reflect actual user value, complementing standard benchmarks rather than simply optimizing for them.
**Recommended models for evaluation:** MagenticLite was developed and tested using **Fara1.5** and **MagenticBrain** as the recommended model configuration. These are the models on which all evaluations and safety testing were conducted. Users may substitute other models, but performance, safety behavior, and benchmark results are not guaranteed outside of the tested configuration.
In addition to quality performance testing, MagenticLite was assessed from a Responsible AI perspective. Based on these results, mitigations were implemented to minimize susceptibility to misuse. See the [Risks and Mitigations](#risks-and-mitigations) section below.
---
## Limitations
- MagenticLite was developed for research and experimental purposes. Further testing and validation are needed before considering its application in commercial or real-world scenarios.
- MagenticLite was designed and tested primarily using the **English language**. Performance in other languages may vary and should be assessed by someone who is both an expert in the expected outputs and a native speaker of that language.
- Outputs generated by AI may include factual errors, fabrication, or speculation. Users are responsible for assessing the accuracy of generated content. All decisions leveraging outputs of the system should be made with human oversight and not be based solely on system outputs.
- All evaluations and safety testing — including critical point handling, XPIA, and code-harm tests — were conducted on the **Fara1.5 + MagenticBrain** configuration. Performance and safety behavior have not been tested on other model combinations.
- Users with limited GPU capacity may run only one of the two models, but not all use cases will be unlocked. Users who run MagenticLite without Fara should be aware that critical point detection for browser actions relies on Fara's trained behavior.
- MagenticLite inherits any biases, errors, or omissions produced by the underlying model used.
- There has not been a systematic effort to ensure that all deployment configurations are protected from all security vulnerabilities such as indirect prompt injection attacks.
- MagenticLite plans one step at a time rather than committing to a full upfront plan, making it more adaptive and easier to course-correct — a deliberate design choice for SLM-based orchestration.
A list of tasks and usage patterns that are not well supported is documented in `docs/limitations.md`.
---
## Best Practices
MagenticLite is a highly capable agent, proficient at interacting with websites, operating over local files, and writing or executing Python code. Like all LLM-based systems, it can and will make mistakes. To safely operate MagenticLite:
- **Always run it within Quicksand**, a Python wrapper for QEMU VM that provides strong isolation boundaries and is available as part of this open-source release. Strictly limit its access to only essential resources — avoid exposing unnecessary files, folders, or credentials to the agent.
- **Avoid logging into websites** through the agent unnecessarily.
- **Never share sensitive data** you would not confidently send to external providers. MagenticLite shares browser screenshots with model providers, including all data entered on websites within its browser session.
- **Ensure careful human oversight:** meticulously review proposed actions and monitor progress before giving approval.
- **Approach outputs with appropriate skepticism** — MagenticLite can hallucinate, misattribute sources, or be misled by deceptive or low-quality online content.
We strongly encourage users to pair MagenticLite with models that support robust Responsible AI mitigations. For reference on responsible AI best practices:
- [Announcing new AI safety & Responsible AI features in Azure](https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/announcing-new-ai-safety-amp-responsible-ai-features-in-azure/ba-p/3983686)
- [Azure OpenAI Overview](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/overview)
- [Azure OpenAI Transparency Note](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/transparency-note)
- [OpenAI Usage Policies](https://openai.com/policies/usage-policies)
- [Azure OpenAI Code of Conduct](https://learn.microsoft.com/en-us/legal/cognitive-services/openai/code-of-conduct)
Users are reminded to be mindful of data privacy concerns and are encouraged to review the privacy policies associated with any models and data storage solutions interfacing with MagenticLite. It is the user's responsibility to ensure that the use of MagenticLite complies with relevant data protection regulations and organizational guidelines.
---
## Risks and Mitigations
The risk surface spans the files and websites the agents have access to. The two primary risk categories are:
- **Data leakage from prompt injection.** Untrusted content encountered in the browser or in user-supplied files may attempt to manipulate the agent into exfiltrating data or taking unintended actions. MagenticLite shares browser screenshots with model providers, including any data entered on websites within its session.
- **Undesired or destructive actions** across the user's local file system, the operated browser, or executed code — including irreversible operations the user did not intend to authorize.
### Mitigations
MagenticLite mitigates these risks through a layered approach:
- **Sandboxed execution environment.** All browser sessions and code execution happen inside Quicksand, a Python wrapper around a QEMU VM that provides strong isolation boundaries from the host system. This limits the blast radius of both prompt injection and undesired actions.
- **Human intervention at critical points in the browser.** Fara's training surfaces actions that warrant user approval (e.g., transactions, irreversible submissions, login flows), pausing for explicit user confirmation before proceeding.
- **Code action classification at the MagenticLite harness level.** The allow / require_approval / deny tables categorize every tool call and bash command from the Orchestrator model, blocking destructive operations and routing risky ones through user approval.
- **User guidance on limiting data exposure.** We recommend that users:
- Grant the agent access to only the folders strictly necessary for the task
- Avoid logging into websites through the agent unless required
- Never share sensitive data they would not confidently send to external model providers
- Use the recommended model configuration (Fara1.5 + MagenticBrain), where critical point detection has been tested
Users who choose to disable Fara, substitute alternative models, or run MagenticLite outside the sandboxed VM forfeit one or more of these mitigations and should evaluate the residual risk for their use case.
### How Critical Point Detection Works
In Magentic-UI 0.1, critical point detection was handled by prompting the configured frontier model (e.g., GPT-4o) to flag actions that warranted user review. This prompting-based safety layer no longer exists in this release. Instead, critical point detection has been moved closer to where each kind of action originates:
**Browser actions → handled by Fara's trained behavior.**
The need for human intervention in browser use is rarely black-and-white and contains many gray areas. Rather than rely on post-hoc prompting, we trained Fara on traces that include correctly calibrated critical points, so the model itself learns when to surface an action for user approval. This generalizes better than prompting and avoids the latency overhead of routing each step through a separate large model. Users who do not run Fara should be aware that this safeguard depends on Fara's training and is not provided by MagenticLite itself.
**Code and tool actions → handled by the MagenticLite harness.**
MagenticBrain works with a discrete set of tools and bash commands, which makes a deterministic, list-based approach well-suited. The harness applies an **allow / require_approval / deny** classification to every tool call and bash command before execution:
| Classification | Description |
|---|---|
| **Allow** | Low-risk, reversible actions execute automatically (e.g., reading a file, listing a directory) |
| **Require approval** | Actions with potential side effects pause and prompt the user for explicit approval before running |
| **Deny** | Destructive or irreversible actions (e.g., recursive deletes outside the working folder, certain network operations) are blocked outright |
This split — model-level for browser, harness-level for code — reflects the different shapes of the two action spaces: continuous and ambiguous in the browser, discrete and enumerable in code.
> **Important note for users familiar with Magentic-UI 0.1:** Because critical point detection is no longer provided by MagenticLite itself, running this release with a browser-use model other than Fara, or with a coding model that bypasses the harness, will not give you the same safety behavior that was tested and approved in 0.1. We strongly recommend the recommended model configuration described above.
---
## License
**MIT License**
Nothing disclosed here, including the Out of Scope Uses section, should be interpreted as or deemed a restriction or modification to the license the code is released under.
---
## Contact
We welcome feedback and collaboration from our audience. If you have suggestions, questions, or observe unexpected/offensive behavior in our technology, please contact us at [magui@service.microsoft.com](mailto:magui@service.microsoft.com).
If the team receives reports of undesired behavior or identifies issues independently, we will update this repository with appropriate mitigations.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+99
View File
@@ -0,0 +1,99 @@
# Build from Source
This guide is for anyone who wants to run MagenticLite from a clone of this
repo — for example to make local changes to the backend or frontend, to test
an unreleased commit, or to dig into the code.
If you only want to run MagenticLite, the released `magentic_ui` package on
PyPI is what you want — see [Installation](./installation.md) instead.
## Prerequisites
Platform-level prerequisites (Homebrew / WSL2 / KVM / `uv` / Python 3.12) are
the same as a regular install — follow the [Installation guide](./installation.md#supported-platforms)
through the **macOS** or **Windows (WSL)** prerequisites section, then come
back here.
In addition, building the frontend requires:
- **Node.js v24 or later**
- **pnpm v10+**
```bash
# Install Node via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install 24
# Install pnpm
npm install -g pnpm
```
## Clone the repo
```bash
git clone https://github.com/microsoft/magentic-ui.git
cd magentic-ui
```
## Backend setup
```bash
uv venv --python=3.12 --seed .venv
source .venv/bin/activate
uv sync --all-extras
```
`uv sync` installs MagenticLite in editable mode along with all dev
dependencies. The Quicksand VM image is downloaded automatically the first time
you launch `magentic-ui`; you don't need to run a separate install step for it.
## Frontend setup
The frontend is a Vite + React app under `frontend/`. Production builds are
written into `src/magentic_ui/backend/web/ui/`, where the backend serves them as
static files.
```bash
cd frontend
pnpm install
```
You then have two ways to run the UI, depending on what you're working on.
### Option 1: Production-style run
Build the frontend once into the backend's static directory, then launch the
backend and let it serve the bundle. Use this when you're working only on
backend code.
```bash
# from frontend/
pnpm build # outputs to ../src/magentic_ui/backend/web/ui/
cd ..
magentic-ui --port 8081
```
Open <http://127.0.0.1:8081/>. Re-run `pnpm build` whenever the frontend
changes.
### Option 2: Frontend dev mode
Run the Vite dev server with hot reload, and run the backend separately. Use
this when you're iterating on the UI.
```bash
# Terminal 1 — backend
source .venv/bin/activate
magentic-ui --port 8081
# Terminal 2 — frontend dev server
cd frontend
pnpm dev # serves at http://localhost:5173
```
Open <http://localhost:5173/>. The Vite dev server proxies API and WebSocket
calls to the backend on port 8081.
For UI component conventions, see
[`frontend/src/components/ui/README.md`](../frontend/src/components/ui/README.md).
+145
View File
@@ -0,0 +1,145 @@
# Configuration
For most users, the **Settings** panel inside the app is all you need. It walks you through model endpoints during the first-launch onboarding flow, and lets you change everything later from **Settings → Models** (and the other Settings tabs).
If you'd rather use a YAML file — for example to share a setup across machines or check it into source control — the same options are also exposed via `config.yaml`. The repo ships a [`config.yaml.example`](../config.yaml.example) at the project root that you can copy as a starting point:
```bash
cp config.yaml.example config.yaml
# then edit config.yaml to taste, and pass it on launch:
magentic-ui --port 8081 --config config.yaml
```
Each option below shows both the YAML key and (where applicable) the equivalent place in the UI.
## How configuration is stored
MagenticLite keeps your effective configuration in a local database, not in the YAML file. There are three ways that database can be populated, and the rule is simple — **whichever source wrote last wins, and the result persists across restarts**:
- **Onboarding UI** — runs once on first launch and writes your answers to the database.
- **Settings UI** — change anything at any time; the new values overwrite the database immediately.
- **YAML file via `--config`** — at startup, MagenticLite reads `config.yaml` and **merges** it into the database (only fields you explicitly set are overwritten; the rest are left alone). This means starting with `--config` every time effectively pins those YAML fields back to your file values on every launch, regardless of what the UI changed in between.
A handful of **Settings → General** options — the display preferences like theme, "show reasoning details", "show tool call details" — are saved to your browser's local storage instead of the backend database. They're per-browser, not per-installation, and they don't show up in `config.yaml`. Other items in the same panel (e.g. agent step limits) do write to the database like the rest of Settings.
```bash
magentic-ui --port 8081 --config config.yaml
```
To start fresh — clear the saved model endpoints and re-run the onboarding flow — pass `--reset-config`:
```bash
magentic-ui --port 8081 --reset-config
```
This only clears the model endpoints (orchestrator and browser-use); other configuration (sandbox, agent mode, tool approval) is preserved.
You can combine the two: `--reset-config` first clears the model endpoints, then `--config` (if also passed) seeds them from your YAML file.
## Model clients
`model_client_configs` tells MagenticLite which model serves which agent role. There are two roles:
- `orchestrator` — used in the `all` and `omniagent_only` agent modes.
- `web_surfer` — used in the `all` and `websurfer_only` agent modes.
Each entry is an OpenAI-compatible client config — any server that speaks `/v1/chat/completions` (vLLM, an OpenAI-compatible managed endpoint, your own gateway, …) will work. The full set of fields:
```yaml
model_client_configs:
orchestrator:
provider: OpenAIChatCompletionClient
config:
model: <model id the server expects>
base_url: <https://your-endpoint/v1>
api_key: <bearer token; leave as a placeholder if your server requires none>
max_retries: 5
model_info:
vision: false
function_calling: false
json_output: true
family: unknown
structured_output: false
multiple_system_messages: false
web_surfer:
provider: OpenAIChatCompletionClient
config:
model: <model id the server expects>
base_url: <https://your-endpoint/v1>
api_key: <bearer token>
max_retries: 5
model_info:
vision: true # browser-use models are vision-language
function_calling: false
json_output: true
family: unknown
structured_output: false
multiple_system_messages: false
```
Notes:
- `model_info` describes the capabilities of the model behind the endpoint. The values shown above are the ones MagenticLite has been tested with for the orchestrator (text-only) and browser-use (vision) roles; use them as-is unless you have a reason to differ for your specific model.
- **MagenticLite is tuned for the recommended models ([MagenticBrain](https://aka.ms/MagenticBrain-foundry) for the orchestrator, [Fara](https://aka.ms/fara-foundry) for browser use).** Pointing the same fields at a different model will probably work, but expect to tweak prompts and run your own evals; the orchestrator and browser-use code paths are not generic across arbitrary models.
- **Azure OpenAI** is supported via `config.yaml` only (the in-app Settings UI doesn't expose it yet): set `provider: AzureOpenAIChatCompletionClient` and use Azure-specific keys (`azure_endpoint`, `azure_deployment`, `api_version`, `azure_ad_token_provider`) under `config`. See [`config.yaml.example`](../config.yaml.example) for a worked example.
- If you don't have an endpoint to point at yet, see the [Model Hosting Guide](./model-hosting-guide.md) for one end-to-end way to stand one up.
## Agent mode
`agent_mode` controls which agents are active. It can also be changed in **Settings → Models** without restarting. The three modes let you trade capability for setup cost — you can run with both agents, or with only one of them if that's all your task needs.
| Mode | Description |
| ---------------- | ------------------------------------------------------------------------------------------- |
| `all` | Orchestrator + Browser use — capable of both local tasks and web browsing (default) |
| `omniagent_only` | Orchestrator only — local tasks only; only `model_client_configs.orchestrator` required |
| `websurfer_only` | Browser use only — web browsing tasks only; only `model_client_configs.web_surfer` required |
Which mode to pick:
- **`all`** is the default and gives you the full product. You need both an orchestrator endpoint and a browser-use endpoint.
- **`omniagent_only`** is useful if you only want local file / code-execution work and don't have a browser-use endpoint to point at. The agent can't use web browser.
- **`websurfer_only`** is useful if you only want web automation and don't have an orchestrator endpoint to point at. The agent can't read or write local files.
```yaml
agent_mode: all
```
## Sandbox
`sandbox.type` controls how agent code runs:
| Type | Description |
| ----------- | ------------------------------------------------------------- |
| `quicksand` | Lightweight QEMU VM with browser isolation (recommended) |
| `null` | No isolation — agent runs on host directly (dev/testing only) |
```yaml
sandbox:
type: quicksand
```
See [Quicksand browser architecture](./dev/quicksand-browser-architecture.md) for the technical details and environment variables.
## Tool approval
MagenticLite's safety harness prompts the user before executing potentially dangerous tool calls. Three policies are available:
| Policy | Behavior |
| ---------------------------- | --------------------------------------------------------------------------- |
| `auto_approve` | Execute all tool calls without prompting (eval / trusted setups only) |
| `require_approval_untrusted` | Prompt before tool calls deemed untrusted; auto-approve read-only (default) |
| `require_approval_all` | Prompt before every tool call |
Set the policy in YAML:
```yaml
harness_config:
orchestrator:
approval_policy: require_approval_untrusted
```
## Next steps
- [Model Hosting Guide](./model-hosting-guide.md) — stand up a model endpoint to point `model_client_configs` at.
- [Troubleshooting](./troubleshooting.md) — what to do when something doesn't work.
+135
View File
@@ -0,0 +1,135 @@
# Installation
This guide walks you through installing MagenticLite. It covers macOS and Windows (WSL); Linux is similar to the Windows/WSL path.
## Supported platforms
| Platform | Status | Notes |
| ------------------ | -------------------------------- | ------------------------------------------------------------------ |
| macOS ARM64 | ✅ tested | Apple Silicon. |
| Windows x64 + WSL2 | ✅ tested | Run everything inside the Ubuntu shell. Requires KVM enabled. |
| Linux x64 (native) | ⚠️ untested but expected to work | Same path as WSL2 minus the `wsl --install` step. |
| Windows ARM64 | ❌ not supported | Not currently supported. Support may be added in a future release. |
Pick the section that matches your machine and follow it end to end. The "Install and run" steps at the bottom apply to both platforms.
## Install prerequisites on macOS
Tested on macOS ARM64 (Apple Silicon).
```bash
# Install Homebrew if you don't have it
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# If you just installed Homebrew, add it to your shell PATH
# (Apple Silicon path; for Intel Macs use /usr/local/bin/brew)
eval "$(/opt/homebrew/bin/brew shellenv)"
# Python 3.12+
brew install python@3.12
# uv (Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env # add uv to PATH for this shell
```
Skip ahead to [Install and run MagenticLite](#install-and-run-magenticlite).
## Install prerequisites on Windows (WSL)
Tested on Windows 11 x64 with WSL2 + Ubuntu.
### 1. Install WSL and Ubuntu
In **PowerShell as Administrator**:
```powershell
wsl --install
```
Reboot if prompted, then launch Ubuntu — either from the Start menu, or by running `wsl` (or `ubuntu`) in a new PowerShell window — and complete the first-time user setup. **Every command from here on runs inside the Ubuntu (WSL) shell.**
### 2. Enable KVM
```bash
sudo usermod -aG kvm $USER
```
Close and reopen the WSL terminal for the group change to take effect.
KVM gives the [Quicksand](https://microsoft.github.io/quicksand/) VM hardware acceleration. Without it the VM falls back to software emulation, which is significantly slower.
### 3. Install tools
```bash
# uv (Python package manager) — installed first because we use uv to manage Python
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env # add uv to PATH for this shell
# Python 3.12 (managed by uv; the apt python3.12 package isn't available on Ubuntu 22.04)
uv python install 3.12
```
## Install and run MagenticLite
Once the platform-specific prerequisites are in place, the install + run steps are the same on both platforms.
```bash
# Create a project directory
mkdir magentic-lite && cd magentic-lite
# Create and activate a virtual environment
uv venv --python=3.12 --seed .venv
source .venv/bin/activate
# Install the latest 0.2.x release from PyPI
uv pip install "magentic_ui>=0.2.0"
# Run
magentic-ui --port 8081
```
Then visit <http://127.0.0.1:8081/> in your browser.
For subsequent runs:
```bash
cd magentic-lite
source .venv/bin/activate
magentic-ui --port 8081
```
> Only one MagenticLite instance can run at a time on the same port (default 8081).
## Coming from Magentic-UI 0.1.x?
If you previously ran a 0.1.x release of Magentic-UI on the same machine, two things to know:
- **Pin the version when you install.** PyPI still hosts the 0.1.x line under the same `magentic_ui` package name, so a plain `uv pip install magentic_ui` may pick up an older release. Pin to a 0.2.x version explicitly:
```bash
uv pip install "magentic_ui>=0.2.0"
```
- **Use a fresh data directory.** MagenticLite (0.2.x) does not migrate the 0.1.x database. To keep the two installs side-by-side, point this run at a different `--appdir`:
```bash
magentic-ui --port 8081 --appdir ~/.magentic-lite
```
Without `--appdir`, MagenticLite uses the same default data directory as 1.0, which can lead to confusing state.
## A note on running MagenticLite as a shared service
MagenticLite is designed to be installed and run **locally on your own machine** by the same person who uses it. We don't recommend hosting it as a shared service for other users:
- **Concurrent multi-user sessions weren't a design goal**, so the UX degrades when several people share one instance.
- **The app exposes host-level capabilities to whoever can reach it** — most notably the file-system mounting controls used for browser uploads and downloads. Running it as a multi-user service effectively grants every user of that service the same file-system access as the host account.
If you do choose to host it, treat the resulting URL as you would shell access to the host machine.
## Next steps
- [Model Hosting Guide](./model-hosting-guide.md) — get a model endpoint to point MagenticLite at.
- [Configuration](./configuration.md) — sandbox, agent mode, and tool approval policies.
- [Troubleshooting](./troubleshooting.md) — common issues and fixes.
+12
View File
@@ -0,0 +1,12 @@
# Limitations
MagenticLite is a research prototype. Some things it doesn't handle well today:
- **Summarization is limited.** Tasks that ask MagenticLite to read a long source and produce a faithful summary often miss important content or oversimplify.
- **Long multi-turn conversations degrade.** Quality drops as the conversation history grows, especially for tasks that require keeping many earlier details in mind.
- **Steering may not always stick.** Even when you successfully redirect the browser-use agent ([Fara](https://aka.ms/fara-foundry)) mid-task, [MagenticBrain](https://aka.ms/MagenticBrain-foundry) may ignore your correction once Fara returns and dispatch Fara back to the original sub-task. The agent's overall plan can be stubborn that way.
- **Very large files or contexts don't fit.** Tasks that require reading or producing very large documents (well beyond a typical chat-sized prompt) will fail or truncate.
- **Uploading files inside the browser isn't supported.** The browser-use agent cannot complete flows that require attaching a file from your local disk through a webpage's upload control.
- **Images aren't supported as task inputs.** Any task that hinges on the agent opening an image file you provide (e.g. "describe this picture", "extract text from this screenshot") will not succeed.
If you hit something not on this list and aren't sure whether it's expected, please file an issue.
+84
View File
@@ -0,0 +1,84 @@
# Model Hosting Guide
MagenticLite talks to models through an **OpenAI-compatible `/v1/chat/completions` endpoint**. This guide walks through hosting the recommended models with **Microsoft Foundry Managed Compute** on Azure.
After deployment, you end up with three values to paste into MagenticLite's onboarding (or into **Settings → Models**): an OpenAI-compatible URL, a model name, and an API key.
> **Each model needs its own endpoint.** MagenticLite uses one model for the orchestrator role and another for browser use. In Foundry, that means **two deployments**, each with its own URL and key.
---
## Microsoft Foundry Managed Compute
### Prerequisites
- An [Azure subscription](https://azure.microsoft.com/free/) with a valid payment method. Free and trial subscriptions don't work for GPU deployments.
- A **hub-based** project in Foundry. The newer "Foundry project" type does not support Managed Compute. If you don't have one, create it from the [Foundry portal](https://ai.azure.com/) under **+ New project → Hub-based project**. Pick a region with H100 or A100 inventory (East US 2 and Sweden Central are good defaults).
- Quota for enough dedicated vCPUs in the chosen region. **Standard_NC24ads_A100_v4** is a good VM SKU for both [Fara1.5-9B](https://aka.ms/fara-foundry) and [MagenticBrain](https://aka.ms/MagenticBrain-foundry) for testing and typical single-user use, and each instance consumes 24 vCPUs from the quota family. In [Azure Quotas](https://portal.azure.com/#view/Microsoft_Azure_Capacity/QuotaMenuBlade/~/overview), select **Machine learning**, then request **Standard NCADSA100v4 Family Cluster Dedicated vCPUs** in the same region as your Foundry project. For the usual two-deployment setup with Fara and MagenticBrain running concurrently at instance count 1, request 48 dedicated vCPUs. Larger A100 or H100 SKUs also work if you want extra headroom or have them readily available, but they cost more. Approval can take 2448 hours.
### 1. Deploy the model
You'll repeat this once per model role you want to use (browser use and/or orchestrator).
1. Open the model card in [Foundry Explore models](https://ai.azure.com/explore/models): [Fara1.5-9B](https://aka.ms/fara-foundry) for browser use or [MagenticBrain-14B](https://aka.ms/MagenticBrain-foundry) for orchestration.
2. On the model card, click **Use this model**. If Foundry asks you to select a project, choose an existing hub-based project or create a new one. For a new project, keep the default hub unless you already have a shared hub for this work, and pick a region with GPU inventory such as East US 2 or Sweden Central.
If project creation fails with a `Microsoft.Resources/subscriptions/resourcegroups/write` authorization error, your account can see the model but cannot create the Azure resource group behind the Foundry project. Use an existing project where you have access, or ask the subscription owner to grant you a role such as Contributor on the subscription or target resource group, then refresh your credentials and try again.
3. Continue to the deployment wizard. If you're presented with purchase options, pick **Managed Compute**.
4. Configure the deployment:
| Field | Value |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| Endpoint name | anything, e.g. `fara-15-9b-magentic-lite`. Becomes part of the URL. |
| Deployment name | anything, e.g. `fara1-5-9b-1` or `magenticbrain-14b-1`. This is for tracking the deployment in Foundry. |
| Virtual machine | **Standard_NC24ads_A100_v4**. Larger A100 or H100 SKUs also work, but they are usually unnecessary for testing. |
| Instance count | **1**. Foundry may default to 3 instances; reduce it to 1 for testing or typical single-user use to avoid unnecessary cost. |
Both Fara and MagenticBrain are served by vLLM under the hood, so the deployed endpoint exposes a fully OpenAI-compatible `/v1/chat/completions` route — text and vision-language requests both work.
5. Click **Deploy**.
Provisioning takes ~1520 minutes per model: Foundry allocates the VM, pulls the container, and warms up vLLM. **Billing starts when the VM is allocated**, not when the endpoint reaches `Healthy`.
### 2. Connect MagenticLite
For each deployment, open **Models + endpoints** in your Foundry project and click into the deployment:
- **REST endpoint** (Details tab): copy the endpoint through `/v1`, for example `https://<endpoint-name>.<region>.inference.ml.azure.com/v1`.
- **Model ID** (deployment details): use the model name segment after `/models/`, for example `Fara1.5-9B` or `MagenticBrain-14B`. Do not use the deployment name here.
- **Primary key** (Consume tab): the API key Foundry generated for that endpoint.
Open MagenticLite and fill in the **Browser use model** card (and/or the **Orchestrator** card). On first launch this is part of the onboarding flow; if you've already onboarded, find the same fields under **Settings → Models**.
| Field | Browser use model (Fara) | Orchestrator model (MagenticBrain) |
| ------------ | ------------------------------------------------------------- | -------------------------------------------------------------- |
| Endpoint URL | `https://<fara-endpoint>.<region>.inference.ml.azure.com/v1` | `https://<brain-endpoint>.<region>.inference.ml.azure.com/v1` |
| Model Name | `Fara1.5-9B` | `MagenticBrain-14B` |
| API Key | the primary key from the Fara endpoint's Consume tab | the primary key from the MagenticBrain endpoint's Consume tab |
Click **Verify & Save**. See [Verification fails](#verification-fails) below if you hit an error.
### 3. Idle behavior and cost
Foundry Managed Compute deployments **do not scale to zero**. The VM stays allocated and billed by the hour for as long as the deployment exists, whether or not traffic is flowing. An A100 deployment in East US 2 runs roughly $34 per hour at list price (H100 is roughly twice that); check the [Azure VM pricing page](https://azure.microsoft.com/pricing/details/virtual-machines/linux/) for current rates in your region. Multiply by the number of deployments you keep running.
To stop the meter, **delete the deployment** from the **Models + endpoints** page. Redeploying from the catalog later takes the same ~1520 minutes.
---
## Verification fails
When you click **Verify & Save**, MagenticLite sends a probe request to the endpoint:
- During onboarding, a successful verification finishes the onboarding flow and sends you to the sample-tasks page.
- In Settings, the button updates to **Connection Verified** (with a check icon) once the endpoint responds.
If verification fails, the banner usually pinpoints the problem:
| Symptom (banner) | Likely cause |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `Endpoint returned HTTP 401` or `403` | API Key field is empty or wrong (different endpoints return one or the other for the same problem) |
| `Connection refused — is the server running?` or other network errors | Endpoint URL is wrong (typo in the host, missing `https://`, VPN/firewall issue) |
+87
View File
@@ -0,0 +1,87 @@
# Troubleshooting
## Model endpoint cold starts
If you're using a managed model endpoint that scales to zero when idle (Hugging Face Inference Endpoints with scale-to-zero, for example), the **first call after the endpoint has been idle** may take **3090 seconds** while the platform brings a replica back up. During that window you may see:
- a `503` response or a `Verify & Save` error in MagenticLite's Settings, or
- the first chat turn appearing to hang.
Wait a minute and try again. Subsequent requests respond at normal speed until the endpoint scales to zero again.
See the [Model Hosting Guide](./model-hosting-guide.md#a3-scale-to-zero-and-cold-starts) for the full explanation.
## Settings → Models verification fails
When you click **Verify & Save**, MagenticLite sends a probe request to the endpoint. Common failures:
| Symptom (banner) | Likely cause |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `Endpoint returned HTTP 401` or `403` | API Key field is empty or wrong (different endpoints return one or the other for the same problem) |
| `Endpoint returned HTTP 503` on the first attempt | Cold start — see the section above; wait a minute and click Verify again |
| `Connection refused — is the server running?` or other network errors | Endpoint URL is wrong (typo in the host, missing `https://`, VPN/firewall issue) |
Re-check the values against the source you copied them from (the Hugging Face dashboard, the Foundry deployment page, etc.) and try again.
## Installation and runtime
### `magentic-ui` command not found
Your virtual environment isn't activated. Re-activate it and try again:
```bash
deactivate # if another env is active
source .venv/bin/activate
magentic-ui --port 8081
```
### Port 8081 already in use
Another MagenticLite process is already running on the same port. Stop it, or run on a different port:
```bash
magentic-ui --port 8082
```
### Quicksand VM fails to start (Linux/WSL2)
- Confirm KVM is available: `[ -e /dev/kvm ] && echo ok` should print `ok`.
- Confirm your user is in the `kvm` group: `groups | grep -q kvm && echo ok`. If not, run `sudo usermod -aG kvm $USER` and restart your shell.
- Without KVM the VM falls back to software emulation, which is significantly slower but should still work.
### Browser viewer is blank or unresponsive
- Make sure the Quicksand VM is healthy (check the MagenticLite logs for `quicksand` errors).
- Check that any local firewall isn't blocking the noVNC port the app picks for the embedded browser viewer.
- Restart MagenticLite — the browser is recreated on each session.
## Coming from Magentic-UI 0.1.x?
The 0.1.x line of Magentic-UI is still on PyPI under the same `magentic_ui` package name, and its on-disk data lives in the same default app directory. A few things to be aware of:
### `pip install` picks up Magentic-UI 0.1.x instead of MagenticLite
A plain `uv pip install magentic_ui` (no version pin) may resolve to a 0.1.x release. Pin to a 0.2.x version explicitly:
```bash
uv pip install "magentic_ui>=0.2.0"
```
(Adjust the version to whatever 0.2.x release you intend to run.)
### MagenticLite reads / writes my old Magentic-UI 0.1.x data
MagenticLite (0.2.x) doesn't migrate the 0.1.x database. By default both versions use the same app directory, which can lead to confusing state. To keep them separate, point MagenticLite at a different `--appdir`:
```bash
magentic-ui --port 8081 --appdir ~/.magentic-lite
```
## Still having issues?
- Re-check the [Installation Guide](./installation.md) and [Configuration](./configuration.md).
- Search [GitHub Issues](https://github.com/microsoft/magentic-ui/issues) for similar problems.
- Open a new issue and include:
1. A detailed description of your problem
2. Information about your system (OS, hardware acceleration support, MagenticLite version)
3. Steps to reproduce the issue
+1
View File
@@ -0,0 +1 @@
24
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
pnpm-lock.yaml
*.min.js
+8
View File
@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components/ui",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+25
View File
@@ -0,0 +1,25 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
}
)
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/magentic-logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MagenticLite</title>
<script>
// Apply dark mode before first paint to prevent FOUC.
// Reads from Zustand persist storage (ui-storage).
try {
var s = JSON.parse(localStorage.getItem('ui-storage') || '{}');
var dark = s.state && s.state.darkMode;
if (dark === undefined) dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@10.24.0",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.3.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.2",
"@types/prismjs": "^1.26.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/ui": "^4.1.9",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"prettier": "^3.8.4",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup-plugin-visualizer": "^6.0.11",
"tailwindcss": "^4.3.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.62.0",
"vite": "^7.3.5",
"vitest": "^4.1.9"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@radix-ui/react-checkbox": "^1.3.5",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.1",
"@radix-ui/react-tabs": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.10",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"prismjs": "^1.30.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router": "^7.18.0",
"react-router-dom": "^7.18.0",
"react-vnc": "^3.2.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},
"pnpm": {
"overrides": {
"yaml@<2.8.3": "^2.8.3",
"ws@<8.21.0": "^8.21.0",
"js-yaml@<4.2.0": "^4.2.0",
"@babel/core@<7.29.6": "^7.29.6",
"esbuild@<0.28.1": "^0.28.1"
}
}
}
+6701
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
<svg width="55" height="33" viewBox="0 0 55 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6638 5.94574C53.6532 6.36653 52.7365 6.98835 51.9678 7.77426C51.1992 8.56027 50.5942 9.49446 50.1889 10.5214C50.1451 10.6232 50.0728 10.7099 49.9809 10.7707C49.8891 10.8316 49.7817 10.864 49.6719 10.864C49.5622 10.864 49.4548 10.8316 49.3629 10.7707C49.2711 10.7099 49.1987 10.6232 49.1548 10.5214C48.7498 9.49424 48.1451 8.55994 47.3764 7.77392C46.6077 6.9879 45.6908 6.36619 44.6801 5.94574C44.5821 5.90005 44.4992 5.82691 44.441 5.73507C44.3828 5.64323 44.3519 5.53638 44.3519 5.4273C44.3519 5.3181 44.3828 5.21125 44.441 5.11941C44.4992 5.02757 44.5821 4.95443 44.6801 4.90885C45.6899 4.4893 46.6063 3.86904 47.3749 3.08482C48.1436 2.30048 48.7488 1.36797 49.1548 0.342608C49.1987 0.2408 49.2711 0.154112 49.3629 0.093296C49.4548 0.032368 49.5622 0 49.6719 0C49.7817 0 49.8891 0.032368 49.9809 0.093296C50.0728 0.154112 50.1451 0.2408 50.1889 0.342608C50.5953 1.36786 51.2006 2.30014 51.9693 3.08437C52.7379 3.8687 53.6541 4.48907 54.6638 4.90885C54.7618 4.95443 54.8446 5.02757 54.9029 5.11941C54.961 5.21125 54.9919 5.3181 54.9919 5.4273C54.9919 5.53638 54.961 5.64323 54.9029 5.73507C54.8446 5.82691 54.7618 5.90005 54.6638 5.94574Z" fill="url(#paint0_linear_46_1332)"/>
<path d="M16.0111 19.9622L9.86747 29.9658C9.05239 31.2619 7.80825 32.15 6.40511 32.4373C5.00197 32.7245 3.55308 32.3878 2.37292 31.5001C1.19278 30.6124 0.376623 29.2455 0.101616 27.696C-0.17339 26.1465 0.114948 24.5395 0.904042 23.2239L9.46599 9.27706C10.1914 8.09624 11.1668 7.12968 12.3072 6.4616C13.4476 5.79363 14.7184 5.44431 16.0087 5.44431C17.299 5.44431 18.5697 5.79363 19.7101 6.4616C20.8505 7.12968 21.8259 8.09624 22.5513 9.27706L26.1742 15.1767C22.468 9.13997 16.0111 19.9622 16.0111 19.9622Z" fill="url(#paint1_linear_46_1332)"/>
<path d="M29.6824 31.5544C29.0939 31.9973 28.4323 32.3074 27.7355 32.4672C27.0386 32.6271 26.3201 32.6333 25.6211 32.4858C24.922 32.3383 24.2561 32.0398 23.6613 31.6074C23.0664 31.1751 22.5545 30.6172 22.1545 29.9658L16.011 19.9623C16.011 19.9623 22.4679 9.13999 26.1752 15.1767L31.118 23.2186C31.5187 23.8701 31.7994 24.6026 31.9442 25.3743C32.0889 26.146 32.0947 26.9416 31.9615 27.7157C31.8282 28.49 31.5583 29.2274 31.1672 29.8861C30.7762 30.5448 30.2716 31.1117 29.6824 31.5544Z" fill="url(#paint2_linear_46_1332)"/>
<path d="M37.2698 19.9622L31.1275 29.9658C30.3124 31.2619 29.0683 32.15 27.6651 32.4373C26.262 32.7245 24.813 32.3878 23.6329 31.5001C22.4528 30.6124 21.6366 29.2455 21.3617 27.696C21.0866 26.1465 21.375 24.5395 22.164 23.2239L30.7272 9.27706C31.4525 8.09624 32.4278 7.12968 33.5681 6.4616C34.7084 5.79352 35.979 5.44431 37.2692 5.44431C38.5595 5.44431 39.8301 5.79352 40.9704 6.4616C42.1107 7.12968 43.0861 8.09624 43.8114 9.27706L47.4353 15.1767C43.7267 9.13997 37.2698 19.9622 37.2698 19.9622Z" fill="url(#paint3_linear_46_1332)"/>
<path d="M50.9415 31.5544C49.7533 32.4471 48.2934 32.7812 46.8828 32.4832C45.4721 32.1854 44.2261 31.2799 43.4183 29.9658L37.2748 19.9623C37.2748 19.9623 43.7316 9.13999 47.4402 15.1767L52.3818 23.2186C52.7824 23.8703 53.063 24.6032 53.2074 25.3751C53.3519 26.147 53.3574 26.9428 53.2236 27.717C53.0899 28.4912 52.8194 29.2288 52.4278 29.8872C52.0362 30.5457 51.5311 31.1123 50.9415 31.5544Z" fill="url(#paint4_linear_46_1332)"/>
<defs>
<linearGradient id="paint0_linear_46_1332" x1="44.3416" y1="5.42629" x2="55.0022" y2="5.42629" gradientUnits="userSpaceOnUse">
<stop stop-color="#922185"/>
<stop offset="1" stop-color="#FB81FF"/>
</linearGradient>
<linearGradient id="paint1_linear_46_1332" x1="6.42814" y1="32.5669" x2="17.9896" y2="17.4489" gradientUnits="userSpaceOnUse">
<stop stop-color="#F68CE0"/>
<stop offset="1" stop-color="#D33FD8"/>
</linearGradient>
<linearGradient id="paint2_linear_46_1332" x1="19.9994" y1="16.5926" x2="29.8831" y2="28.1771" gradientUnits="userSpaceOnUse">
<stop stop-color="#912084"/>
<stop offset="1" stop-color="#C947B9"/>
</linearGradient>
<linearGradient id="paint3_linear_46_1332" x1="26.2454" y1="34.042" x2="38.2434" y2="17.1959" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9AE2"/>
<stop offset="1" stop-color="#D440D8"/>
</linearGradient>
<linearGradient id="paint4_linear_46_1332" x1="41.2596" y1="16.5926" x2="51.1433" y2="28.1771" gradientUnits="userSpaceOnUse">
<stop stop-color="#901E82"/>
<stop offset="1" stop-color="#C947B9"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+406
View File
@@ -0,0 +1,406 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BrowserRouter,
Routes,
Route,
useNavigate,
useParams,
useLocation,
Navigate,
} from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ErrorBoundary, ConnectionStatusBanner } from '@/components/common'
import { Header, type AppLayout } from '@/components/layout'
import { SessionView, Dashboard, DashboardTabs } from '@/components/session'
import { TooltipProvider } from '@/components/ui/tooltip'
import { OnboardingPage } from '@/pages/OnboardingPage'
import { SampleTasksPage } from '@/pages/SampleTasksPage'
import { getOnboardingStatus } from '@/api/onboarding'
import {
useResponsiveLayout,
WebSocketManagerProvider,
useEnsureSessionData,
useBackendHealthPolling,
type ActiveRun,
} from '@/hooks'
import { useSessionList, useSessionsWithStatus } from '@/api'
import { useUIStore, useNotificationStore } from '@/stores'
import { ACTIVE_SESSION_STATUSES, ACTIVE_RUN_STATUSES, type ServerRunStatus } from '@/types'
import { DRAFT_SESSION_ID, isDraftSession } from '@/lib/constants'
import { findInvalidSessionRedirect } from '@/lib/sessionRedirect'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 1,
},
},
})
/**
* Hook to create a new session and navigate to it.
* Creates a local draft session (not persisted until first message is sent).
* If a draft already exists, navigates to it instead of creating another.
*/
function useNewSession() {
const navigate = useNavigate()
const createDraftSession = useUIStore((s) => s.createDraftSession)
const draftSession = useUIStore((s) => s.draftSession)
return useCallback(() => {
// If a draft already exists, just navigate to it
if (draftSession) {
navigate(`/sessions/${DRAFT_SESSION_ID}`)
return
}
createDraftSession()
navigate(`/sessions/${DRAFT_SESSION_ID}`)
}, [createDraftSession, draftSession, navigate])
}
/**
* Merges the in-memory draft session (if any) to the top of the API session list.
* Draft is cleared externally (ChatView on promote, SessionDialogs on delete).
*/
function useSessionListWithDraft() {
const { sessions: apiSessions, isLoading, error } = useSessionList()
const draftSession = useUIStore((s) => s.draftSession)
const sessions = useMemo(() => {
if (!draftSession) return apiSessions
return [draftSession, ...apiSessions]
}, [apiSessions, draftSession])
return { sessions, isLoading, error }
}
/**
* Dashboard page - shows all sessions in a grid
*/
function DashboardPage() {
const navigate = useNavigate()
const handleNewSession = useNewSession()
// Tab state from store
const activeTab = useUIStore((s) => s.activeTab)
const setActiveTab = useUIStore((s) => s.setActiveTab)
// Fetch sessions from API (includes draft session at top if exists)
const { sessions, isLoading, error } = useSessionListWithDraft()
// Filter sessions based on active tab
const filteredSessions = useMemo(() => {
return sessions.filter((session) => {
switch (activeTab) {
case 'active':
return ACTIVE_SESSION_STATUSES.includes(session.status)
case 'stopped':
return session.status === 'completed' || session.status === 'stopped'
case 'all':
default:
return true
}
})
}, [sessions, activeTab])
const handleSessionClick = useCallback(
(id: number) => {
navigate(`/sessions/${id}`)
},
[navigate]
)
return (
<div className="bg-background flex h-screen flex-col overflow-hidden">
{/* Header with tabs */}
<ErrorBoundary>
<Header
layout="dashboard"
bottomSlot={<DashboardTabs activeTab={activeTab} onTabChange={setActiveTab} />}
onNewSession={handleNewSession}
/>
</ErrorBoundary>
<ConnectionStatusBanner />
{/* Main content */}
<main className="flex flex-1 flex-col overflow-auto">
<ErrorBoundary>
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-lg">Loading sessions...</p>
</div>
) : error ? (
// Real error surfaces via the global ConnectionStatusBanner.
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-lg">No sessions to show</p>
</div>
) : (
<Dashboard sessions={filteredSessions} onSessionClick={handleSessionClick} />
)}
</ErrorBoundary>
</main>
</div>
)
}
/**
* Session page - shows session view with optional sidebar
*/
function SessionPage() {
const navigate = useNavigate()
const { sessionId: sessionIdParam } = useParams<{ sessionId: string }>()
const handleNewSession = useNewSession()
// Parse URL param to number (URL params are always strings)
const sessionId = sessionIdParam ? parseInt(sessionIdParam, 10) : undefined
const draftSession = useUIStore((s) => s.draftSession)
useEnsureSessionData(sessionId)
// Sidebar state from store (persists user preference)
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const autoShowSidebar = useUIStore((s) => s.autoShowSidebar)
const setSelectedSessionId = useUIStore((s) => s.setSelectedSessionId)
// Sync selected session ID to store (for notification filtering)
useEffect(() => {
setSelectedSessionId(sessionId)
// Clear on unmount (user leaves session page)
return () => setSelectedSessionId(undefined)
}, [sessionId, setSelectedSessionId])
// Responsive layout: auto-show/hide sidebar based on window width
const { allowSidebar } = useResponsiveLayout()
// Auto-show sidebar when screen becomes wide enough (if user didn't manually close it)
useEffect(() => {
if (allowSidebar) {
autoShowSidebar()
}
}, [allowSidebar, autoShowSidebar])
// Fetch sessions from API (with draft merged in)
const { sessions, isLoading, error } = useSessionListWithDraft()
// If the URL points to a session that doesn't exist — either the draft
// sentinel after the in-memory draft was cleared (e.g., page refresh), or
// a real session ID that isn't in the API list (e.g., deleted session,
// hand-typed ID, or stale link) — redirect to the top of the sidebar
// (the draft if one exists, otherwise the most recent real session), or
// to the dashboard if the list is empty. Without this, ChatView would
// fall through to its empty-state and leak sample prompts for a real
// but missing session. See issue #582.
useEffect(() => {
const target = findInvalidSessionRedirect({
sessionId,
draftSession,
sessions,
isLoading,
error,
})
if (target !== null) {
navigate(target, { replace: true })
}
}, [sessionId, draftSession, sessions, isLoading, error, navigate])
// Compute layout based on sidebar state
const layout: AppLayout = sidebarOpen && allowSidebar ? 'sidebar-show' : 'sidebar-hide'
const handleBackToDashboard = useCallback(() => {
navigate('/')
}, [navigate])
const handleSessionSelect = useCallback(
(id: number) => {
navigate(`/sessions/${id}`)
},
[navigate]
)
const handleSessionDeselect = useCallback(() => {
navigate('/sessions')
}, [navigate])
return (
<div className="bg-background flex h-screen flex-col overflow-hidden">
{/* Header */}
<ErrorBoundary>
<Header
layout={layout}
onNewSession={handleNewSession}
newSessionDisabled={isDraftSession(sessionId)}
onToggleSidebar={toggleSidebar}
onBackToDashboard={handleBackToDashboard}
/>
</ErrorBoundary>
<ConnectionStatusBanner />
{/* Main content area */}
<main className="flex flex-1 overflow-hidden">
<ErrorBoundary>
<SessionView
sessions={sessions}
selectedSessionId={sessionId}
showSidebar={sidebarOpen && allowSidebar}
isLoading={isLoading}
error={error}
onSessionSelect={handleSessionSelect}
onSessionDeselect={handleSessionDeselect}
/>
</ErrorBoundary>
</main>
</div>
)
}
/**
* Route guard: redirects to /onboarding if onboarding not completed.
* Fetches status on mount and re-fetches when entering /sample-tasks
* (so that a preceding completeOnboarding() call is picked up).
* Exempt paths (/onboarding, /sample-tasks) bypass the redirect.
*/
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const location = useLocation()
const [status, setStatus] = useState<'loading' | 'needs-onboarding' | 'ready'>('loading')
const isExempt = (path: string) => path === '/onboarding' || path === '/sample-tasks'
// Fetch onboarding status when status is 'loading'
useEffect(() => {
if (status !== 'loading') return
let cancelled = false
getOnboardingStatus()
.then((result) => {
if (!cancelled) {
setStatus(result.onboarding_completed ? 'ready' : 'needs-onboarding')
}
})
.catch(() => {
if (!cancelled) setStatus('ready') // let user through on error
})
return () => {
cancelled = true
}
}, [status])
// Re-fetch status when entering /sample-tasks (onboarding just completed).
const prevPathRef = useRef(location.pathname)
useEffect(() => {
const entering =
location.pathname === '/sample-tasks' && prevPathRef.current !== '/sample-tasks'
prevPathRef.current = location.pathname
if (entering && status !== 'loading') {
setStatus('loading')
}
}, [location.pathname]) // eslint-disable-line react-hooks/exhaustive-deps
if (status === 'loading') {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-lg">Loading...</p>
</div>
)
}
if (status === 'needs-onboarding' && !isExempt(location.pathname)) {
return <Navigate to="/onboarding" replace />
}
if (status === 'ready' && location.pathname === '/onboarding') {
return <Navigate to="/" replace />
}
return <>{children}</>
}
/**
* App routes
*/
function AppRoutes() {
return (
<OnboardingGuard>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/sample-tasks" element={<SampleTasksPage />} />
<Route path="/sessions" element={<SessionPage />} />
<Route path="/sessions/:sessionId" element={<SessionPage />} />
</Routes>
</OnboardingGuard>
)
}
/**
* WebSocket manager wrapper
* Provides WebSocket connections for all active sessions
*/
function WebSocketWrapper({ children }: { children: React.ReactNode }) {
const { data: sessions, isLoading } = useSessionsWithStatus()
const cleanupInvalidSessions = useNotificationStore((s) => s.cleanupInvalidSessions)
const selectedSessionId = useUIStore((s) => s.selectedSessionId)
// Poll /health while the backend is unreachable so the banner
// dismisses on recovery even on pages with no other queries.
useBackendHealthPolling()
// Track if we've done initial sync
const hasSyncedRef = useRef(false)
// Clean up stale notifications on initial load
// This handles: page refresh, sessions deleted while offline
// IMPORTANT: Wait for data to actually load before cleanup
useEffect(() => {
if (isLoading || hasSyncedRef.current || !sessions) return
hasSyncedRef.current = true
// Get valid session IDs from API
const validSessionIds = sessions.map((s) => s.session_id)
// Remove notifications for sessions that no longer exist
cleanupInvalidSessions(validSessionIds)
}, [sessions, isLoading, cleanupInvalidSessions])
// Extract runs that need WebSocket connections (see ACTIVE_RUN_STATUSES for details)
const activeRuns: ActiveRun[] = useMemo(() => {
if (!sessions) return []
return sessions
.filter((session) => session.latest_run) // Has a run
.filter((session) =>
ACTIVE_RUN_STATUSES.includes(session.latest_run?.status as ServerRunStatus)
)
.map((session) => ({
runId: String(session.latest_run!.run_id),
sessionId: session.session_id,
sessionName: session.name,
status: session.latest_run!.status as ServerRunStatus,
}))
}, [sessions])
return (
<WebSocketManagerProvider activeRuns={activeRuns} selectedSessionId={selectedSessionId}>
{children}
</WebSocketManagerProvider>
)
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={700}>
<BrowserRouter>
<WebSocketWrapper>
<AppRoutes />
</WebSocketWrapper>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
)
}
export default App
+51
View File
@@ -0,0 +1,51 @@
/** Client for the `/api/settings/agents` endpoints. */
import { useQuery, useMutation, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { apiClient } from './client'
export interface OrchestratorAgentSettings {
max_rounds: number
}
export interface WebSurferAgentSettings {
max_rounds: number
}
export interface AgentSettings {
orchestrator: OrchestratorAgentSettings
web_surfer: WebSurferAgentSettings
}
export function getAgentSettings(): Promise<AgentSettings> {
return apiClient.get<AgentSettings>('/settings/agents')
}
export function updateAgentSettings(payload: AgentSettings): Promise<AgentSettings> {
return apiClient.put<AgentSettings>('/settings/agents', payload)
}
const agentSettingsKeys = {
all: ['settings', 'agents'] as const,
}
export function useAgentSettings() {
return useQuery({
queryKey: agentSettingsKeys.all,
queryFn: getAgentSettings,
staleTime: 30_000,
refetchOnWindowFocus: false,
})
}
export function useUpdateAgentSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateAgentSettings,
onSuccess: (data) => {
queryClient.setQueryData(agentSettingsKeys.all, data)
},
})
}
export function invalidateAgentSettings(queryClient: QueryClient): Promise<void> {
return queryClient.invalidateQueries({ queryKey: agentSettingsKeys.all })
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Browser-side session auth.
*
* The backend injects a random session token into index.html as
* `window.__MAGUI_TOKEN__`. We read it here and attach it to every
* outgoing API request and WebSocket handshake. When the token is
* absent (Vite dev server serving its own HTML) the backend accepts
* requests based on the dev-origin bypass instead.
*/
declare global {
interface Window {
__MAGUI_TOKEN__?: string
}
}
/** Keep in sync with `WS_PROTOCOL_TAG` in `backend/web/auth.py`. */
export const WS_PROTOCOL_TAG = 'magui.auth.bearer'
export function getSessionToken(): string {
return window.__MAGUI_TOKEN__ ?? ''
}
/** Bearer header for fetch. Empty when there is no injected token (dev). */
export function getAuthHeaders(): Record<string, string> {
const token = getSessionToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
/**
* Open a WebSocket with the token carried in `Sec-WebSocket-Protocol`
* (the only client-controlled handshake header in the browser). When
* no token is present we open an unauth'd socket and rely on the
* backend's dev-origin bypass.
*/
export function createAuthenticatedWebSocket(url: string): WebSocket {
const token = getSessionToken()
return token ? new WebSocket(url, [WS_PROTOCOL_TAG, token]) : new WebSocket(url)
}
+199
View File
@@ -0,0 +1,199 @@
/**
* API Client
*
* Base HTTP client for communicating with the backend API.
* Handles the standard response wrapper format: { status: boolean, data: T, message?: string }
*/
import { API_BASE_URL } from '@/lib/constants'
import { useBackendHealthStore } from '@/stores/backendHealthStore'
import { getAuthHeaders } from './auth'
// =============================================================================
// URL Helpers
// =============================================================================
/**
* Get the WebSocket base URL
* WebSocket requires full URL with protocol (ws:// or wss://)
*/
export function getWsBaseUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}/api`
}
// =============================================================================
// Types
// =============================================================================
/**
* Standard API response wrapper from backend
*/
export interface ApiResponse<T> {
status: boolean
data: T
message?: string
}
/**
* Custom error class for API errors
*/
export class ApiError extends Error {
statusCode?: number
response?: unknown
constructor(message: string, statusCode?: number, response?: unknown) {
super(message)
this.name = 'ApiError'
this.statusCode = statusCode
this.response = response
}
}
// =============================================================================
// API Client
// =============================================================================
/**
* Format an error response body into a user-readable message.
*
* Handles:
* - String `message` or `detail` fields (FastAPI HTTPException style)
* - Array `detail` (FastAPI Pydantic 422 — list of `{loc, msg, type, ...}`)
*
* Returns `null` when no recognizable error field is present.
*/
function formatErrorMessage(body: unknown): string | null {
if (!body || typeof body !== 'object') return null
const obj = body as Record<string, unknown>
if (typeof obj.message === 'string') return obj.message
const { detail } = obj
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
// Pydantic validation errors
const parts = detail
.map((e) => {
if (e && typeof e === 'object') {
const item = e as Record<string, unknown>
const loc = Array.isArray(item.loc) ? item.loc.filter((x) => x !== 'body').join('.') : ''
const msg = typeof item.msg === 'string' ? item.msg : JSON.stringify(item)
return loc ? `${loc}: ${msg}` : msg
}
return String(e)
})
.filter(Boolean)
if (parts.length > 0) return parts.join('; ')
}
return null
}
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: unknown
}
/**
* Make an API request and handle the response wrapper
*
* @param endpoint - API endpoint (without /api prefix)
* @param options - Fetch options
* @returns Unwrapped data from the response
* @throws ApiError if request fails or status is false
*/
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...getAuthHeaders(),
...options.headers,
}
const config: RequestInit = {
...options,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
}
let response: Response
try {
response = await fetch(url, config)
} catch (error) {
// Skip health updates for intentional cancellations (AbortController,
// React Query unmount cleanup) — they don't say anything about backend
// reachability and would otherwise flip the global banner spuriously.
if (error instanceof DOMException && error.name === 'AbortError') {
throw error
}
// Network/CORS/offline — backend unreachable.
useBackendHealthStore.getState().setReachable(false)
throw new ApiError(error instanceof Error ? error.message : 'Network error', undefined, error)
}
// 5xx → backend down; anything else (incl. 4xx) → backend responding.
if (response.status >= 500) {
useBackendHealthStore.getState().setReachable(false)
} else {
useBackendHealthStore.getState().setReachable(true)
}
// Handle HTTP errors (4xx, 5xx)
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorData = await response.json()
errorMessage = formatErrorMessage(errorData) ?? errorMessage
} catch {
// Response body is not JSON, use default message
}
throw new ApiError(errorMessage, response.status)
}
// Parse JSON response
let data: ApiResponse<T>
try {
data = await response.json()
} catch {
throw new ApiError('Invalid JSON response', response.status)
}
// Handle API-level errors (status: false in wrapper)
if (!data.status) {
throw new ApiError(data.message || 'API request failed', response.status, data)
}
return data.data
}
/**
* API client with typed methods
*/
export const apiClient = {
/**
* GET request
*/
get<T>(endpoint: string, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<T> {
return request<T>(endpoint, { ...options, method: 'GET' })
},
/**
* POST request
*/
post<T>(endpoint: string, body?: unknown, options?: Omit<RequestOptions, 'method'>): Promise<T> {
return request<T>(endpoint, { ...options, method: 'POST', body })
},
/**
* PUT request
*/
put<T>(endpoint: string, body?: unknown, options?: Omit<RequestOptions, 'method'>): Promise<T> {
return request<T>(endpoint, { ...options, method: 'PUT', body })
},
/**
* DELETE request
*/
delete<T>(endpoint: string, options?: Omit<RequestOptions, 'method' | 'body'>): Promise<T> {
return request<T>(endpoint, { ...options, method: 'DELETE' })
},
}
+59
View File
@@ -0,0 +1,59 @@
/**
* File Upload API
*
* Upload files to a run's working directory via REST API.
* Uses raw fetch (not apiClient) because file upload requires multipart/form-data.
*/
import { API_BASE_URL } from '@/lib/constants'
import { getAuthHeaders } from './auth'
// =============================================================================
// Types
// =============================================================================
/** Single file info returned from upload endpoint */
export interface UploadedFileInfo {
name: string
size: number
path: string
relative_path: string
}
/** Upload response from backend */
export interface UploadResponse {
status: boolean
message: string
files: UploadedFileInfo[]
}
// =============================================================================
// Upload Function
// =============================================================================
/**
* Upload files to a run's working directory.
*
* @param runId - Run ID to associate files with
* @param files - Array of File objects from browser file input
* @returns Upload response with file paths
* @throws Error if upload fails
*/
export async function uploadFiles(runId: string, files: File[]): Promise<UploadResponse> {
const formData = new FormData()
for (const file of files) {
formData.append('files', file)
}
const res = await fetch(`${API_BASE_URL}/runs/${runId}/upload`, {
method: 'POST',
body: formData, // browser auto-sets Content-Type with boundary
headers: getAuthHeaders(),
})
if (!res.ok) {
throw new Error(`File upload failed: ${res.status} ${res.statusText}`)
}
return res.json()
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Filesystem API
*
* Client for the server-side filesystem browsing API.
* Used by FolderBrowserDialog to let users browse and select folders.
*/
import { apiClient } from './client'
// =============================================================================
// Types
// =============================================================================
export interface DirectoryEntry {
name: string
type: 'file' | 'directory'
size?: number
modified: string
}
export interface DirectoryListing {
path: string
parent: string | null
entries: DirectoryEntry[]
}
export interface FilesystemRoots {
home: string
}
// =============================================================================
// API Functions
// =============================================================================
/**
* Get the user's home directory path.
*/
export function getRoots(): Promise<FilesystemRoots> {
return apiClient.get<FilesystemRoots>('/filesystem/roots')
}
/**
* List contents of a directory
*/
export function listDirectory(path: string, showHidden = false): Promise<DirectoryListing> {
const params = new URLSearchParams({ path })
if (showHidden) params.set('show_hidden', 'true')
return apiClient.get<DirectoryListing>(`/filesystem/list?${params}`)
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Backend health endpoint.
*
* Used by `useBackendHealthPolling`. We don't care about the response
* shape — the `request()` wrapper in `client.ts` updates the health
* store as a side effect, so a successful call is enough.
*/
import { apiClient } from './client'
export function pingBackend(): Promise<unknown> {
return apiClient.get<unknown>('/health')
}
+48
View File
@@ -0,0 +1,48 @@
/**
* API Module Exports
*/
export { apiClient, ApiError } from './client'
export type { ApiResponse } from './client'
export {
getOnboardingStatus,
getOnboardingEndpoints,
verifyEndpoints,
setAgentMode,
completeOnboarding,
resetOnboarding,
useCurrentAgentMode,
invalidateOnboardingEndpoints,
} from './onboarding'
export type {
AgentMode,
OnboardingStatus,
ModelEndpointsResponse,
ModelClientConfig,
ModelInfoConfig,
ModelVerifyResponse,
ModelEndpointVerification,
ModelEndpointInput,
VerifyEndpointsPayload,
} from './onboarding'
export {
getAgentSettings,
updateAgentSettings,
useAgentSettings,
useUpdateAgentSettings,
invalidateAgentSettings,
} from './agentSettings'
export type {
AgentSettings,
OrchestratorAgentSettings,
WebSurferAgentSettings,
} from './agentSettings'
export {
sessionKeys,
useSessionList,
useSessionsWithStatus,
useUpdateSession,
useDeleteSession,
} from './sessions'
+174
View File
@@ -0,0 +1,174 @@
/**
* Onboarding API
*
* Client functions for the /api/onboarding endpoints.
*/
import { useQuery, type QueryClient } from '@tanstack/react-query'
import { apiClient } from './client'
// =============================================================================
// Constants
// =============================================================================
/**
* Sentinel value the backend returns instead of real API keys.
* When sent back to the backend, it means "keep the existing DB value".
*/
export const API_KEY_MASKED = '__MASKED__'
/**
* Deployment mode controlling which agents are active.
* Mirrors `AgentMode` in `magentic_ui_config.py`.
*/
export type AgentMode = 'all' | 'omniagent_only' | 'websurfer_only'
// =============================================================================
// Types (mirror backend Pydantic models)
// =============================================================================
export interface ModelInfoConfig {
vision: boolean
function_calling: boolean
json_output: boolean
family: string
structured_output: boolean
multiple_system_messages: boolean
}
export interface ModelClientConfig {
provider: string
config: {
model: string
api_key: string
base_url: string
max_retries: number
model_info: ModelInfoConfig
}
}
export interface OnboardingStatus {
onboarding_completed: boolean
}
export interface ModelEndpointsResponse {
orchestrator: ModelClientConfig | null
web_surfer: ModelClientConfig | null
agent_mode: AgentMode
}
export interface ModelEndpointVerification {
success: boolean
error?: string
}
export interface ModelVerifyResponse {
/** Present when the verify request included `orchestrator`. */
orchestrator?: ModelEndpointVerification
/** Present when the verify request included `web_surfer`. */
web_surfer?: ModelEndpointVerification
}
export interface ModelEndpointInput {
base_url: string
model: string
api_key: string
}
export interface VerifyEndpointsPayload {
/** Required when `agent_mode` includes the orchestrator. */
orchestrator?: ModelEndpointInput
/** Required when `agent_mode` includes the web surfer. */
web_surfer?: ModelEndpointInput
/** Defaults to 'all' on the backend. */
agent_mode?: AgentMode
}
// =============================================================================
// API Functions
// =============================================================================
/**
* Check if onboarding is completed.
* Lightweight — reads only the Settings.onboarding_completed column.
*/
export function getOnboardingStatus(): Promise<OnboardingStatus> {
return apiClient.get<OnboardingStatus>('/onboarding/status')
}
/**
* Get saved model endpoints (masked api_keys).
* Used by settings page.
*/
export function getOnboardingEndpoints(): Promise<ModelEndpointsResponse> {
return apiClient.get<ModelEndpointsResponse>('/onboarding/endpoints')
}
/**
* Verify model endpoints and save them to DB.
* Only sends user-provided fields; backend fills role defaults.
*
* Caller must include the role(s) required by `agent_mode`. The inactive
* role may be omitted; its previously saved config is left untouched.
*/
export function verifyEndpoints(payload: VerifyEndpointsPayload): Promise<ModelVerifyResponse> {
return apiClient.post<ModelVerifyResponse>('/onboarding/verify', payload)
}
/**
* Update only the active `agent_mode` — no model verification.
*
* The backend rejects with HTTP 400 if the new mode requires a model that
* has no saved config yet.
*/
export function setAgentMode(mode: AgentMode): Promise<{ agent_mode: AgentMode }> {
return apiClient.post<{ agent_mode: AgentMode }>('/onboarding/agent-mode', {
agent_mode: mode,
})
}
/**
* Mark onboarding as completed.
* Requires both endpoints to be verified.
*/
export function completeOnboarding(): Promise<void> {
return apiClient.post('/onboarding/complete')
}
/**
* Reset onboarding config (clear model endpoints and completed flag).
*/
export function resetOnboarding(): Promise<void> {
return apiClient.post('/onboarding/reset')
}
// =============================================================================
// Query Hooks
// =============================================================================
const onboardingKeys = {
all: ['onboarding'] as const,
endpoints: () => [...onboardingKeys.all, 'endpoints'] as const,
}
/**
* The user's currently-saved `agent_mode` from settings.
*
* Cached indefinitely (`gcTime: Infinity` so the data survives even with no
* observers). Invalidate via `invalidateOnboardingEndpoints` after mutations.
*/
export function useCurrentAgentMode(): { agentMode: AgentMode | null; isLoading: boolean } {
const query = useQuery({
queryKey: onboardingKeys.endpoints(),
queryFn: getOnboardingEndpoints,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
select: (data) => data.agent_mode ?? null,
})
return { agentMode: query.data ?? null, isLoading: query.isLoading }
}
/** Invalidate the cached onboarding endpoints (and therefore `agent_mode`). */
export function invalidateOnboardingEndpoints(queryClient: QueryClient): Promise<void> {
return queryClient.invalidateQueries({ queryKey: onboardingKeys.endpoints() })
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Session API
*
* TanStack Query hooks for session management.
*/
import { useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from './client'
import { DEFAULT_USER_ID } from '@/lib/constants'
import type { Session, SessionListItem, SessionRuns, UISession } from '@/types'
import { serverStatusToSessionStatus } from '@/types'
// =============================================================================
// Query Keys
// =============================================================================
export const sessionKeys = {
all: ['sessions'] as const,
lists: () => [...sessionKeys.all, 'list'] as const,
list: (userId: string) => [...sessionKeys.lists(), userId] as const,
details: () => [...sessionKeys.all, 'detail'] as const,
detail: (id: number) => [...sessionKeys.details(), id] as const,
runs: (sessionId: number) => [...sessionKeys.detail(sessionId), 'runs'] as const,
}
// =============================================================================
// API Functions
// =============================================================================
/**
* Fetch all sessions for a user (with latest run status)
*/
async function fetchSessions(userId: string): Promise<SessionListItem[]> {
return apiClient.get<SessionListItem[]>(`/sessions/?user_id=${encodeURIComponent(userId)}`)
}
/**
* Create a new session
*/
export async function createSession(data: Partial<Session>): Promise<Session> {
return apiClient.post<Session>('/sessions/', data)
}
/**
* Update a session
*/
async function updateSession(
sessionId: number,
data: Partial<Session>,
userId: string
): Promise<Session> {
return apiClient.put<Session>(`/sessions/${sessionId}?user_id=${encodeURIComponent(userId)}`, {
...data,
id: sessionId,
user_id: userId,
})
}
/**
* Delete a session
*/
async function deleteSession(sessionId: number, userId: string): Promise<void> {
return apiClient.delete(`/sessions/${sessionId}?user_id=${encodeURIComponent(userId)}`)
}
/**
* Fetch runs for a session
*/
export async function fetchSessionRuns(sessionId: number, userId: string): Promise<SessionRuns> {
return apiClient.get<SessionRuns>(
`/sessions/${sessionId}/runs?user_id=${encodeURIComponent(userId)}`
)
}
// =============================================================================
// Query Hooks
// =============================================================================
/**
* Hook to fetch the run for a session (1 session = 1 run)
* Returns the run directly, or undefined if not loaded yet
*/
export function useSessionRun(sessionId: number | undefined, userId: string = DEFAULT_USER_ID) {
const query = useQuery({
queryKey: sessionKeys.runs(sessionId!),
queryFn: () => fetchSessionRuns(sessionId!, userId),
enabled: sessionId !== undefined,
// 1 session = 1 run (never changes), status updates via WebSocket
staleTime: Infinity,
refetchOnWindowFocus: false,
select: (data) => data.runs[0] ?? null,
})
return query
}
/**
* Hook to fetch all sessions with their latest run status
* Returns SessionListItem[] directly from backend (no N+1 queries)
*/
export function useSessionsWithStatus(userId: string = DEFAULT_USER_ID) {
return useQuery({
queryKey: sessionKeys.list(userId),
queryFn: () => fetchSessions(userId),
})
}
/**
* Hook to fetch all sessions formatted for UI display (Sidebar, Dashboard)
* Status comes from query cache, and can be updated directly by WebSocket manager
*/
export function useSessionList(userId: string = DEFAULT_USER_ID): {
sessions: UISession[]
isLoading: boolean
error: Error | null
} {
const { data, isLoading, error } = useSessionsWithStatus(userId)
const sessions: UISession[] = useMemo(() => {
if (!data) return []
// Sort by latest activity (run.updated_at), fallback to session.created_at.
// ISO-8601 strings sort lexically === chronologically. The backend already
// orders by the same key; this re-sort handles optimistic updates and WS
// status bumps in the cache.
return [...data]
.filter((item) => item.latest_run !== null)
.sort((a, b) => {
const at = a.latest_run?.updated_at ?? a.created_at ?? ''
const bt = b.latest_run?.updated_at ?? b.created_at ?? ''
return bt.localeCompare(at)
})
.map((item) => ({
id: item.session_id,
title: item.name || `Session ${item.session_id}`,
status: serverStatusToSessionStatus(item.latest_run!.status),
runId: String(item.latest_run!.run_id),
updatedAt: item.latest_run!.updated_at ?? item.created_at,
}))
}, [data])
return { sessions, isLoading, error: error as Error | null }
}
// =============================================================================
// Mutation Hooks
// =============================================================================
/**
* Hook to update a session
*/
export function useUpdateSession(userId: string = DEFAULT_USER_ID) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId, data }: { sessionId: number; data: Partial<Session> }) =>
updateSession(sessionId, data, userId),
onSuccess: (updatedSession) => {
// Update the specific session in cache
queryClient.setQueryData(sessionKeys.detail(updatedSession.id), updatedSession)
// Invalidate list to refetch
queryClient.invalidateQueries({ queryKey: sessionKeys.lists() })
},
})
}
/**
* Hook to delete a session
*/
export function useDeleteSession(userId: string = DEFAULT_USER_ID) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (sessionId: number) => deleteSession(sessionId, userId),
onSuccess: (_, sessionId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: sessionKeys.detail(sessionId) })
// Invalidate list to refetch
queryClient.invalidateQueries({ queryKey: sessionKeys.lists() })
},
})
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Trusted Folders API
*
* Client for the trusted folders CRUD API.
* Each folder is an independent DB row — no read-modify-write conflicts.
*/
import { apiClient } from './client'
import { DEFAULT_USER_ID } from '@/lib/constants'
// =============================================================================
// Types
// =============================================================================
export interface TrustedFolder {
id: number
name: string
path: string
}
// =============================================================================
// API Functions
// =============================================================================
/**
* List all trusted folders for the current user
*/
export function listTrustedFolders(): Promise<TrustedFolder[]> {
return apiClient.get<TrustedFolder[]>(
`/trusted-folders/?user_id=${encodeURIComponent(DEFAULT_USER_ID)}`
)
}
/**
* Add a folder to the trusted list
*/
export function addTrustedFolder(name: string, path: string): Promise<TrustedFolder> {
return apiClient.post<TrustedFolder>(
`/trusted-folders/?user_id=${encodeURIComponent(DEFAULT_USER_ID)}`,
{
name,
path,
}
)
}
/**
* Remove a trusted folder by ID
*/
export function removeTrustedFolder(id: number): Promise<void> {
return apiClient.delete(`/trusted-folders/${id}?user_id=${encodeURIComponent(DEFAULT_USER_ID)}`)
}
@@ -0,0 +1,40 @@
/**
* Action Description Component
*
* Displays CUA action thoughts and result inside the expanded browser view.
*
* Layout responsibilities live here:
* - Container has a minimum height (no shrink jitter); grows when content expands
* - Bottom-anchored so the text stays right above the toolbar; expanded content
* grows upward.
*
* Per-block interaction (click-to-expand, tooltip hint, etc.) lives in
* `ExpandableActionText`, which is shared with `DescriptionOverlay`.
*/
import { ExpandableActionText } from './ExpandableActionText'
// =============================================================================
// Types
// =============================================================================
interface ActionDescriptionProps {
thoughts?: string
actionResult?: string
}
// =============================================================================
// Component
// =============================================================================
export function ActionDescription({ thoughts, actionResult }: ActionDescriptionProps) {
return (
<div className="flex min-h-[68px] flex-col justify-end">
<ExpandableActionText
thoughts={thoughts}
actionResult={actionResult}
textClassName="text-foreground"
/>
</div>
)
}
@@ -0,0 +1,174 @@
/**
* BrowserEmbed Component
*
* Embedded browser view within message list.
* Uses fixed 16:10 aspect ratio (matching Docker VM screen: 1440x900).
* Supports screenshot playback with prev/next/Live controls.
*
* Playback state is read directly from chatStore via useSessionPlayback hook,
* eliminating prop drilling through ChatView → MessageList.
*
* Layout (top → bottom):
* Toolbar (title + expand button)
* Viewer area (VNC + screenshot overlay + DescriptionOverlay)
* Bottom bar: LIVE toggle + Control (live) or ProgressBar + steps (history)
*/
import { useState, useCallback } from 'react'
import { BrowserViewer } from './BrowserViewer'
import { BrowserHeader } from './BrowserHeader'
import { DescriptionOverlay } from './DescriptionOverlay'
import { PlaybackControlsRow } from './PlaybackControlsRow'
import { usePlaybackControls, getBrowserTitle } from './usePlaybackControls'
import { useSessionPlayback } from '@/hooks'
import type { BrowserViewMode } from '@/types'
// =============================================================================
// Types
// =============================================================================
interface BrowserEmbedProps {
novncUrl: string | null
/** Per-slot RFB password from the backend; required for the noVNC handshake. */
novncPassword: string | null
/** Session ID — used to read playback state from chatStore */
sessionId: number
/** Whether this session is selected/visible - disconnects VNC when hidden to save bandwidth */
isSessionSelected?: boolean
/** Called when mode changes via toolbar buttons */
onModeChange?: (mode: BrowserViewMode) => void
/** Called when Control button is clicked (switches to expanded view for takeover) */
onControlClick?: () => void
}
// VM screen aspect ratio (Docker config: 1440x900 = 16:10)
const ASPECT_RATIO = 16 / 10
// =============================================================================
// Component
// =============================================================================
export function BrowserEmbed({
novncUrl,
novncPassword,
sessionId,
isSessionSelected = true,
onModeChange,
onControlClick,
}: BrowserEmbedProps) {
// Read playback state directly from store (eliminates prop drilling)
const {
screenshotActions,
liveActionInfo,
playbackIsLive,
playbackIndex,
followLatest,
onPlaybackIndexChange,
onSetLive,
} = useSessionPlayback(sessionId)
// Track VNC connection status - used to hide Control button when disconnected
const [isVncConnected, setIsVncConnected] = useState(false)
const handleVncConnect = useCallback(() => {
setIsVncConnected(true)
}, [])
const handleVncDisconnect = useCallback(() => {
setIsVncConnected(false)
}, [])
// Shared playback logic
const playbackControls = usePlaybackControls({
screenshotActions,
isLive: playbackIsLive,
currentIndex: playbackIndex,
onIndexChange: onPlaybackIndexChange,
isVncConnected,
followLatest,
liveActionInfo,
})
const {
total,
hasHistory,
displayIsLive,
validCurrentIndex,
currentScreenshotUrl,
canStepBack,
canStepForward,
handleStepBack,
handleStepForward,
nextTooltip,
thoughts,
actionResult,
hasContent,
} = playbackControls
// Toolbar title
const title = getBrowserTitle(isVncConnected, hasHistory, playbackIsLive)
// Show bottom controls when VNC connected or has screenshot history
const showBottomControls = isVncConnected || hasHistory
return (
// Container with fixed width, height adapts to aspect ratio + fixed bottom section
<div className="border-border bg-card flex w-[480px] max-w-full flex-col overflow-hidden rounded-xl border shadow-sm">
{/* Toolbar with centered title */}
<BrowserHeader mode="embedded" onModeChange={onModeChange ?? (() => {})} title={title} />
{/* VNC Viewer with screenshot overlay + description overlay */}
<div className="relative w-full" style={{ aspectRatio: ASPECT_RATIO }}>
{/* VNC: only mount when URL + password are available. The
password is required for the RFB handshake; without it
react-vnc would prompt the user. */}
{isSessionSelected && novncUrl && novncPassword && (
<BrowserViewer
url={novncUrl}
password={novncPassword}
viewOnly
className="size-full"
onConnect={handleVncConnect}
onDisconnect={handleVncDisconnect}
/>
)}
{/* Screenshot overlay: shown when not in live VNC mode */}
{!displayIsLive && currentScreenshotUrl && (
<img
src={currentScreenshotUrl}
alt={`Screenshot ${validCurrentIndex + 1}`}
className="bg-card absolute inset-0 size-full object-contain"
/>
)}
{/* Description overlay: show when VNC connected or viewing screenshots */}
{(isVncConnected || !displayIsLive) && (
<DescriptionOverlay
thoughts={thoughts}
actionResult={actionResult}
hasContent={hasContent}
/>
)}
</div>
{/* Bottom bar: LIVE toggle + Control (live) or ProgressBar + steps (history) */}
{showBottomControls && (
<PlaybackControlsRow
compact
className="px-3 py-2.5"
isVncConnected={isVncConnected}
onControlClick={onControlClick}
displayIsLive={displayIsLive}
onSetLive={onSetLive}
total={total}
validCurrentIndex={validCurrentIndex}
onIndexChange={onPlaybackIndexChange}
canStepBack={canStepBack}
canStepForward={canStepForward}
onStepBack={handleStepBack}
onStepForward={handleStepForward}
nextTooltip={nextTooltip}
/>
)}
</div>
)
}
@@ -0,0 +1,199 @@
/**
* BrowserExpanded Component
*
* Side-by-side browser view container (right side of chat).
*
* Playback state is read directly from chatStore via useSessionPlayback hook,
* eliminating prop drilling.
*
* Layout (top → bottom):
* Toolbar (title + action buttons)
* Viewer area (VNC + screenshot overlay)
* Bottom section:
* - Normal: ActionDescription + LIVE toggle + Control/ProgressBar
* - Takeover: TakeoverNotice (with Release Control button)
*
* Modes:
* - Live: Shows VNC stream with pending action info
* - Playback: Shows screenshot with completed action info
* - Takeover: User is controlling, shows TakeoverNotice instead of playback controls
*/
import { useState, useCallback, useRef } from 'react'
import { BrowserViewer } from './BrowserViewer'
import { BrowserHeader } from './BrowserHeader'
import { ActionDescription } from './ActionDescription'
import { PlaybackControlsRow } from './PlaybackControlsRow'
import { TakeoverNotice } from './TakeoverNotice'
import { usePlaybackControls, getBrowserTitle } from './usePlaybackControls'
import { useSessionPlayback } from '@/hooks'
import type { BrowserViewMode, ControlState } from '@/types'
// =============================================================================
// Types
// =============================================================================
interface BrowserExpandedProps {
/** noVNC WebSocket URL for browser streaming */
novncUrl: string | null
/** Per-slot RFB password for the noVNC handshake. */
novncPassword: string | null
/** Session ID — used to read playback state from chatStore */
sessionId: number
/** Current view mode */
mode: BrowserViewMode
/** Called when mode changes via toolbar buttons */
onModeChange: (mode: BrowserViewMode) => void
/** Whether this session is selected/visible - disconnects VNC when hidden */
isSessionSelected?: boolean
/** Current browser control state */
controlState?: ControlState
/** Called when Control button is clicked */
onControlClick?: () => void
/** Called when VNC connection is lost (parent should reset takeover state) */
onVncDisconnect?: () => void
}
// =============================================================================
// Component
// =============================================================================
export function BrowserExpanded({
novncUrl,
novncPassword,
sessionId,
mode,
onModeChange,
isSessionSelected = true,
controlState = 'agent',
onControlClick,
onVncDisconnect,
}: BrowserExpandedProps) {
// Read playback state directly from store (eliminates prop drilling)
const {
screenshotActions,
liveActionInfo,
playbackIsLive,
playbackIndex,
followLatest,
onPlaybackIndexChange,
onSetLive,
} = useSessionPlayback(sessionId)
// Track VNC connection status.
// - Controls Take Control button visibility (hidden until first connect)
// - On disconnect: resets takeover state via onVncDisconnect callback
const [isVncConnected, setIsVncConnected] = useState(false)
// Track whether VNC has ever connected successfully.
// Used to distinguish "initial connection failure" from "real network drop".
// VNC may fire disconnect during its initial connection attempt (WebSocket fails
// then retries) — we must NOT reset takeover state for those transient disconnects.
const hasEverConnectedRef = useRef(false)
const handleVncConnect = useCallback(() => {
hasEverConnectedRef.current = true
setIsVncConnected(true)
}, [])
const handleVncDisconnect = useCallback(() => {
setIsVncConnected(false)
// Only notify parent (reset takeover state) if VNC was previously connected.
// Initial connection failures should not reset controlState.
if (hasEverConnectedRef.current) {
onVncDisconnect?.()
}
}, [onVncDisconnect])
// Shared playback logic (eliminates duplicated inline calculations)
const {
total,
hasHistory,
displayIsLive,
validCurrentIndex,
currentScreenshotUrl,
canStepBack,
canStepForward,
handleStepBack,
handleStepForward,
nextTooltip,
thoughts,
actionResult,
} = usePlaybackControls({
screenshotActions,
isLive: playbackIsLive,
currentIndex: playbackIndex,
onIndexChange: onPlaybackIndexChange,
isVncConnected: isVncConnected,
followLatest,
liveActionInfo,
})
// Key to reset ActionDescription expansion state when content changes
const contentKey = `${thoughts ?? ''}-${actionResult ?? ''}`
// User has or is requesting control
const isUserControl = controlState === 'user' || controlState === 'user-pending'
// Toolbar title
const title = getBrowserTitle(isVncConnected, hasHistory, playbackIsLive, isUserControl)
return (
<div className="bg-card border-border flex h-full flex-col overflow-hidden rounded-xl border shadow-sm">
{/* Toolbar */}
<BrowserHeader mode={mode} onModeChange={onModeChange} title={title} />
{/* Main content: VNC Viewer (always mounted) + Screenshot overlay */}
<div className="relative flex-1 overflow-hidden">
{/* VNC: only mount when URL + password are available. */}
{isSessionSelected && novncUrl && novncPassword && (
<BrowserViewer
url={novncUrl}
password={novncPassword}
viewOnly={controlState !== 'user'}
className="size-full"
onConnect={handleVncConnect}
onDisconnect={handleVncDisconnect}
/>
)}
{/* Screenshot overlay: shown when not in live VNC mode */}
{!isUserControl && !displayIsLive && currentScreenshotUrl && (
<img
src={currentScreenshotUrl}
alt={`Screenshot ${validCurrentIndex + 1}`}
className="bg-card absolute inset-0 size-full object-contain"
/>
)}
</div>
{/* Bottom section: TakeoverNotice when controlling, playback controls otherwise */}
{/* During takeover + VNC connecting: hide both (no misleading playback bar) */}
{/* When not takeover: hide controls while VNC connecting in live mode (prevents flash) */}
{isUserControl ? (
isVncConnected ? (
<TakeoverNotice controlState={controlState} onControlClick={onControlClick} />
) : null
) : isVncConnected || hasHistory ? (
<div className="flex flex-col gap-3 px-4 pt-4 pb-3">
{/* Action description (expandable) */}
<ActionDescription key={contentKey} thoughts={thoughts} actionResult={actionResult} />
{/* Controls row: LIVE toggle + Control (live) or ProgressBar + steps (history) */}
<PlaybackControlsRow
isVncConnected={isVncConnected}
onControlClick={onControlClick}
displayIsLive={displayIsLive}
onSetLive={onSetLive}
total={total}
validCurrentIndex={validCurrentIndex}
onIndexChange={onPlaybackIndexChange}
canStepBack={canStepBack}
canStepForward={canStepForward}
onStepBack={handleStepBack}
onStepForward={handleStepForward}
nextTooltip={nextTooltip}
/>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,107 @@
/**
* BrowserHeader Component
*
* Toolbar with Globe icon + centered title and action buttons.
* Aligned with FileViewHeader layout pattern.
*
* Button layout by mode:
* - embedded: [Expand]
* - expanded: [Maximize] [Close]
* - maximized: [Side-by-side] [Close]
*/
import { Globe, Maximize, Columns2, X, Expand } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useResponsiveLayout } from '@/hooks'
import type { BrowserViewMode } from '@/types'
// =============================================================================
// Types
// =============================================================================
interface BrowserHeaderProps {
/** Current view mode */
mode: BrowserViewMode
/** Called when mode changes via toolbar buttons */
onModeChange: (mode: BrowserViewMode) => void
/** Centered title text (e.g., "Browser (Live)", "Browser (History)") */
title?: string
}
// =============================================================================
// Component
// =============================================================================
export function BrowserHeader({ mode, onModeChange, title }: BrowserHeaderProps) {
const isExpanded = mode !== 'embedded'
const isMaximized = mode === 'maximized'
const { allowSideBySide } = useResponsiveLayout()
return (
<div className="relative flex items-center justify-end gap-2 p-2">
{/* Centered title with Globe icon */}
{title && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center gap-1">
<Globe className="text-foreground size-4 shrink-0" />
<span className="text-base leading-6 font-bold">{title}</span>
</div>
)}
{/* Right side action buttons */}
{isExpanded ? (
<>
{/* Maximize / Side-by-side toggle — hidden on narrow screens in maximized mode */}
{(!isMaximized || allowSideBySide) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
onClick={() => onModeChange(isMaximized ? 'expanded' : 'maximized')}
aria-label={isMaximized ? 'Side-by-side view' : 'Maximize view'}
>
{isMaximized ? (
<Columns2 className="size-3.5" />
) : (
<Maximize className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isMaximized ? 'Side-by-side view' : 'Maximize view'}</TooltipContent>
</Tooltip>
)}
{/* Close button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
onClick={() => onModeChange('embedded')}
aria-label="Close"
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Close</TooltipContent>
</Tooltip>
</>
) : (
/* Embedded: single expand button */
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
onClick={() => onModeChange('expanded')}
aria-label="Expand"
>
<Expand className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand</TooltipContent>
</Tooltip>
)}
</div>
)
}
@@ -0,0 +1,220 @@
/**
* BrowserViewer Component
*
* Wraps react-vnc VncScreen for browser viewing.
* Handles connection status, clipboard sync, and displays appropriate
* placeholder when disconnected.
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import { VncScreen, type VncScreenHandle } from 'react-vnc'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
type VncConnectionStatus = 'disconnected' | 'connecting' | 'connected'
// Connection timeout in milliseconds
const CONNECTION_TIMEOUT_MS = 10000
interface BrowserViewerProps {
url: string
/** Per-slot RFB password used during the noVNC handshake. */
password: string
viewOnly?: boolean
className?: string
/** Called when connection is established */
onConnect?: () => void
onDisconnect?: () => void
}
// =============================================================================
// Component
// =============================================================================
export function BrowserViewer({
url,
password,
viewOnly = true,
className,
onConnect,
onDisconnect,
}: BrowserViewerProps) {
// Start in 'connecting' state since VncScreen begins connecting on mount
const [status, setStatus] = useState<VncConnectionStatus>('connecting')
const vncRef = useRef<VncScreenHandle>(null)
// Track connection timeout timer for cleanup
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Clear connection timeout timer
const clearConnectionTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
// Reset to connecting state when URL changes (render-time state adjustment)
const [prevUrl, setPrevUrl] = useState(url)
if (prevUrl !== url) {
setPrevUrl(url)
setStatus('connecting')
}
// Connection timeout - show error if not connected within timeout
useEffect(() => {
timeoutRef.current = setTimeout(() => {
setStatus((current) => {
if (current === 'connecting') {
console.debug('[BrowserViewer] Connection timeout')
return 'disconnected'
}
return current
})
}, CONNECTION_TIMEOUT_MS)
return clearConnectionTimeout
}, [url, clearConnectionTimeout])
// Track container ref for resize detection
const containerRef = useRef<HTMLDivElement>(null)
// Trigger VNC scale update when container size changes (e.g., sidebar toggle)
// noVNC's internal ResizeObserver watches its own canvas, not our container.
// We use the public scaleViewport property toggle to force a recalculation.
useEffect(() => {
const container = containerRef.current
const rfb = vncRef.current?.rfb
if (!container || !rfb) return
const resizeObserver = new ResizeObserver(() => {
// Toggle scaleViewport to trigger noVNC's internal _updateScale()
// This is the public API way to force a scale recalculation
if (rfb.scaleViewport) {
rfb.scaleViewport = false
rfb.scaleViewport = true
}
})
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
}, [status]) // Re-attach when status changes to 'connected' (rfb becomes available)
// Update RFB viewOnly when prop changes
// react-vnc may not automatically update this on prop change
// Note: Modifying rfb properties is intentional - this is noVNC's API
useEffect(() => {
const rfb = vncRef.current?.rfb
if (rfb && rfb.viewOnly !== viewOnly) {
console.debug('[BrowserViewer] Updating viewOnly:', viewOnly)
// eslint-disable-next-line react-hooks/immutability -- noVNC API requires property mutation
rfb.viewOnly = viewOnly
}
// When taking control, move focus to the VNC canvas. The React re-renders
// triggered by the controlState transition often park focus on <body>,
// and noVNC's keyboard listener is attached to the canvas — so without
// this the user has to click the canvas first before typing reaches the
// VM. Only steal focus when it is already on <body>: if the user is
// typing into another input (e.g. the chat textarea), leave it alone.
if (!viewOnly && document.activeElement === document.body) {
vncRef.current?.focus()
}
}, [viewOnly])
// Clipboard sync: VM → User
// When the VM clipboard changes, write to the user's clipboard
const handleClipboard = useCallback((e: { detail: { text: string } }) => {
navigator.clipboard.writeText(e.detail.text).catch(() => {
// Clipboard write can fail if page is not focused or permission denied
})
}, [])
// Clipboard sync: User → VM
// Sync host clipboard to VM on canvas focus (covers keyboard paste since
// user must click into VNC first) and on right-click context menu paste.
useEffect(() => {
if (viewOnly || status !== 'connected') return
const container = containerRef.current
const canvas = container?.querySelector('canvas')
if (!canvas) return
// Read host clipboard and send to VM
const syncClipboardToVM = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) {
vncRef.current?.clipboardPaste(text)
}
} catch {
// Permission denied or page not focused
}
}
// Right-click menu paste (scoped to container, not document)
const handlePaste = (e: Event) => {
const clipboardEvent = e as ClipboardEvent
const text = clipboardEvent.clipboardData?.getData('text/plain')
if (text) {
vncRef.current?.clipboardPaste(text)
}
}
canvas.addEventListener('focus', syncClipboardToVM)
container?.addEventListener('paste', handlePaste)
return () => {
canvas.removeEventListener('focus', syncClipboardToVM)
container?.removeEventListener('paste', handlePaste)
}
}, [viewOnly, status])
const handleConnect = useCallback(() => {
console.debug('[BrowserViewer] Connected to VNC')
clearConnectionTimeout()
setStatus('connected')
onConnect?.()
}, [onConnect, clearConnectionTimeout])
const handleDisconnect = useCallback(() => {
console.debug('[BrowserViewer] Disconnected from VNC')
setStatus('disconnected')
onDisconnect?.()
}, [onDisconnect])
const handleSecurityFailure = useCallback(() => {
console.debug('[BrowserViewer] Security failure')
setStatus('disconnected')
}, [])
return (
<div ref={containerRef} className={cn('bg-card relative overflow-hidden', className)}>
<VncScreen
ref={vncRef}
url={url}
viewOnly={viewOnly}
scaleViewport
showDotCursor={false}
background="var(--card)"
rfbOptions={{ credentials: { password } }}
style={{
width: '100%',
height: '100%',
}}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onSecurityFailure={handleSecurityFailure}
onClipboard={handleClipboard}
/>
{/* Status Overlay */}
{status !== 'connected' && (
<div className="bg-card absolute inset-0 flex items-center justify-center">
<span className="text-muted-foreground text-sm">
{status === 'connecting' ? 'Connecting...' : 'Browser not available'}
</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,57 @@
/**
* DescriptionOverlay Component
*
* Semi-transparent floating overlay inside the Embedded browser viewer area.
* Shows CUA action thoughts and result text over the browser/screenshot.
*
* Truncated content is click-to-expand with a tooltip hint, mirroring the
* behavior in `ActionDescription` (expanded view) — both share
* `ExpandableActionText`.
*/
import { cn } from '@/lib/utils'
import { ExpandableActionText } from './ExpandableActionText'
// =============================================================================
// Types
// =============================================================================
interface DescriptionOverlayProps {
/** Current action planned thought */
thoughts?: string
/** Current action result text */
actionResult?: string
/** Whether there's any action description content */
hasContent: boolean
}
// =============================================================================
// Component
// =============================================================================
export function DescriptionOverlay({
thoughts,
actionResult,
hasContent,
}: DescriptionOverlayProps) {
if (!hasContent) return null
return (
<div
className={cn(
// Position: bottom of viewer area with 8px margin
'absolute inset-x-2 bottom-2',
// Appearance: frosted glass with accent for contrast (neutral-200/800)
'bg-accent/75 rounded-xl p-2 backdrop-blur-md',
// Prevent click-through to VNC canvas
'pointer-events-auto'
)}
>
<ExpandableActionText
thoughts={thoughts}
actionResult={actionResult}
textClassName="text-card-foreground"
/>
</div>
)
}
@@ -0,0 +1,151 @@
/**
* ExpandableActionText
*
* The "thoughts + actionResult" text block shown inside browser views.
* Treats the two lines as a single interactive unit:
* - thoughts: 2 lines max when collapsed (line-clamp-2)
* - actionResult: 1 line max when collapsed (truncate)
* - If either line is truncated, the whole block becomes clickable and shows a
* "Click to expand" hover hint
* - Click anywhere → both expand together; click again → both collapse
*
* Truncation is detected via ResizeObserver, so the affordance auto-updates
* as the surrounding container resizes.
*
* Used by both `ActionDescription` (expanded view) and `DescriptionOverlay`
* (embedded overlay).
*/
import { useEffect, useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
// =============================================================================
// Helpers
// =============================================================================
/**
* Detects whether a block-level element's content is visually truncated by CSS
* (line-clamp or text-overflow). Re-checks on element resize and whenever
* `text` changes (e.g. when switching to a different screenshot).
*
* Pass `enabled: false` to pause the ResizeObserver (e.g. while expanded). The
* last measured value is preserved so the caller stays interactive on the same
* render that toggles back to collapsed.
*/
function useIsTruncated(
ref: React.RefObject<HTMLElement | null>,
enabled: boolean,
text: string | undefined
) {
const [truncated, setTruncated] = useState(false)
useEffect(() => {
if (!enabled) return
const el = ref.current
if (!el) return
const check = () => {
const overflow = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth
setTruncated(overflow)
}
const observer = new ResizeObserver(check)
observer.observe(el)
// `check()` runs synchronously here so a `text` change immediately
// overwrites any stale value from the previous render.
check()
return () => observer.disconnect()
}, [ref, enabled, text])
// When text becomes empty there is nothing to truncate. Returning `false &&`
// here keeps the caller's `isInteractive` accurate without triggering an
// extra effect-driven setState.
return text ? truncated : false
}
// =============================================================================
// Component
// =============================================================================
interface ExpandableActionTextProps {
thoughts?: string
actionResult?: string
/** Extra class names applied to each line (e.g. text color). */
textClassName?: string
}
export function ExpandableActionText({
thoughts,
actionResult,
textClassName,
}: ExpandableActionTextProps) {
const thoughtsRef = useRef<HTMLParagraphElement>(null)
const actionRef = useRef<HTMLParagraphElement>(null)
const [isExpanded, setIsExpanded] = useState(false)
// Only detect truncation while collapsed; once expanded, the block stays
// clickable so the user can collapse it again.
const isThoughtsTruncated = useIsTruncated(thoughtsRef, !isExpanded, thoughts)
const isActionTruncated = useIsTruncated(actionRef, !isExpanded, actionResult)
const isInteractive = isThoughtsTruncated || isActionTruncated || isExpanded
if (!thoughts && !actionResult) return null
const toggle = () => setIsExpanded((v) => !v)
// Single Tooltip wraps the whole block so any hover/click on either line
// shows the same hint and toggles the same state. JSX tree stays stable
// (Tooltip + TooltipTrigger + inner div + <p> refs all constant) so the
// ResizeObserver-based detection keeps working across re-renders.
return (
<Tooltip>
<TooltipTrigger asChild>
<div
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
aria-expanded={isInteractive ? isExpanded : undefined}
onClick={isInteractive ? toggle : undefined}
onKeyDown={
isInteractive
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle()
}
}
: undefined
}
className={cn(
isInteractive &&
'focus-visible:ring-ring cursor-pointer rounded-sm focus-visible:ring-2 focus-visible:outline-none'
)}
>
{thoughts && (
<p
ref={thoughtsRef}
className={cn('text-sm leading-5', !isExpanded && 'line-clamp-2', textClassName)}
>
{thoughts}
</p>
)}
{actionResult && (
<p
ref={actionRef}
className={cn(
'text-sm leading-5 font-bold',
!isExpanded && 'truncate',
textClassName
)}
>
{actionResult}
</p>
)}
</div>
</TooltipTrigger>
{isInteractive && (
<TooltipContent>{isExpanded ? 'Click to collapse' : 'Click to expand'}</TooltipContent>
)}
</Tooltip>
)
}
@@ -0,0 +1,109 @@
/**
* PlaybackButtons Component
*
* Step back / Step forward button pair used in history mode.
* The Live toggle is now handled by a Switch in PlaybackControlsRow.
*/
import type { ReactNode } from 'react'
import { StepBack, StepForward } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
interface PlaybackButtonsProps {
/** Use smaller buttons (h-8/size-8) for compact layouts like BrowserEmbed */
compact?: boolean
/** Whether Prev button can be clicked */
canStepBack: boolean
/** Whether Next button can be clicked */
canStepForward: boolean
/** Handler for Prev button */
onStepBack: () => void
/** Handler for Next button */
onStepForward: () => void
/** Tooltip text for Next button */
nextTooltip: string
}
// =============================================================================
// Helpers
// =============================================================================
/** Icon button with conditional tooltip — tooltip hidden when disabled */
function StepButton({
compact,
enabled,
onClick,
icon,
label,
tooltip,
}: {
compact?: boolean
enabled: boolean
onClick: () => void
icon: ReactNode
label: string
tooltip: string
}) {
const btn = (
<Button
variant="secondary"
size="icon"
className={cn(compact ? 'size-8' : 'size-9')}
disabled={!enabled}
onClick={enabled ? onClick : undefined}
aria-label={label}
>
{icon}
</Button>
)
if (!enabled) return btn
return (
<Tooltip>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
}
// =============================================================================
// Component
// =============================================================================
export function PlaybackButtons({
compact,
canStepBack,
canStepForward,
onStepBack,
onStepForward,
nextTooltip,
}: PlaybackButtonsProps) {
return (
<div className="flex items-center gap-2">
<StepButton
compact={compact}
enabled={canStepBack}
onClick={onStepBack}
icon={<StepBack className="size-4" />}
label="Previous action"
tooltip="Previous action"
/>
<StepButton
compact={compact}
enabled={canStepForward}
onClick={onStepForward}
icon={<StepForward className="size-4" />}
label="Next action"
tooltip={nextTooltip}
/>
</div>
)
}
@@ -0,0 +1,175 @@
/**
* PlaybackControlsRow Component
*
* Unified controls row used by both BrowserEmbed and BrowserExpanded.
*
* Layout depends on mode:
* - Live (VNC connected): [LIVE toggle (ON)] ............. [Control button]
* - History: [LIVE toggle (OFF)] | [ProgressBar] [StepBack] [StepForward]
* - VNC only (no history): [LIVE toggle (disabled, ON)] ... [Control button]
* - History only (no VNC): [LIVE toggle (disabled, OFF)] | [ProgressBar] [StepBack] [StepForward]
*
* The LIVE toggle is always visible when this component renders. It is disabled
* when there is nothing to toggle between (no VNC, or no screenshot history).
* Parent components gate rendering on isVncConnected || hasHistory.
*/
import { MousePointer2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ProgressBar } from './ProgressBar'
import { PlaybackButtons } from './PlaybackButtons'
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
interface PlaybackControlsRowProps {
/** Additional classes (e.g. padding) */
className?: string
/** Use smaller buttons (h-8) for compact layouts like BrowserEmbed */
compact?: boolean
// -- VNC state --
/** Whether VNC is connected (determines if LIVE toggle is interactive) */
isVncConnected: boolean
// -- Control button (live mode only) --
/** Called when Control button is clicked; omit to hide the button entirely */
onControlClick?: () => void
// -- Playback state --
/** Whether display is in live mode (pre-computed by usePlaybackControls) */
displayIsLive: boolean
/** Set live mode on/off (matches Switch onCheckedChange signature) */
onSetLive: (isLive: boolean) => void
// -- ProgressBar (history mode only) --
/** Total number of screenshots */
total: number
/** Current screenshot index (clamped) */
validCurrentIndex: number
/** Called when user clicks/drags to a screenshot index */
onIndexChange: (index: number) => void
// -- PlaybackButtons (history mode only) --
/** Whether Prev button can be clicked */
canStepBack: boolean
/** Whether Next button can be clicked */
canStepForward: boolean
/** Handler for Prev button */
onStepBack: () => void
/** Handler for Next button */
onStepForward: () => void
/** Tooltip text for Next button */
nextTooltip: string
}
// =============================================================================
// Component
// =============================================================================
export function PlaybackControlsRow({
className,
compact,
isVncConnected,
onControlClick,
displayIsLive,
onSetLive,
total,
validCurrentIndex,
onIndexChange,
canStepBack,
canStepForward,
onStepBack,
onStepForward,
nextTooltip,
}: PlaybackControlsRowProps) {
// Toggle is interactive only when VNC is connected and there's history to switch between
const canToggle = isVncConnected && total > 0
return (
<div className={cn('flex items-center gap-3', className)}>
{/* LIVE toggle — always visible, disabled when not interactive */}
<div className="flex shrink-0 flex-col items-center gap-0.5">
<span
className={cn(
'text-[10px] leading-4 font-bold tracking-[0.2em]',
canToggle ? 'text-secondary-foreground' : 'text-muted-foreground'
)}
>
LIVE
</span>
<Tooltip>
<TooltipTrigger asChild>
<Switch
checked={displayIsLive}
onCheckedChange={onSetLive}
disabled={!canToggle}
aria-label="Toggle live view"
/>
</TooltipTrigger>
<TooltipContent>
{!canToggle
? displayIsLive
? 'Live view (no history yet)'
: 'No live connection'
: displayIsLive
? 'Switch to history view'
: 'Switch to live view'}
</TooltipContent>
</Tooltip>
</div>
{/* Live mode: Control button on far right */}
{displayIsLive && (
<>
{/* Spacer to push Control to the right */}
<div className="flex-1" />
{/* Control button */}
{onControlClick && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
className={cn('shrink-0 gap-1.5 px-3 text-sm', compact && 'h-8')}
onClick={onControlClick}
>
<MousePointer2 className="size-3.5" />
Control
</Button>
</TooltipTrigger>
<TooltipContent>Take control of the browser</TooltipContent>
</Tooltip>
)}
</>
)}
{/* History mode: separator + progress bar + step buttons */}
{!displayIsLive && (
<>
{/* Vertical separator between toggle and progress bar */}
<div className="bg-border w-px shrink-0 self-stretch" />
<ProgressBar
total={total}
currentIndex={validCurrentIndex}
onIndexChange={onIndexChange}
/>
<PlaybackButtons
compact={compact}
canStepBack={canStepBack}
canStepForward={canStepForward}
onStepBack={onStepBack}
onStepForward={onStepForward}
nextTooltip={nextTooltip}
/>
</>
)}
</div>
)
}
@@ -0,0 +1,185 @@
/**
* ProgressBar Component
*
* Draggable progress bar for screenshot playback.
*
* Progress: 0% = first screenshot, 100% = last screenshot.
* Only rendered in history mode (not live).
*/
import { useRef, useCallback, useEffect, type KeyboardEvent } from 'react'
import { cn } from '@/lib/utils'
// Half the playhead diameter (size-4 = 16px → radius = 8px).
// The playhead's center moves within [PLAYHEAD_R, 100% PLAYHEAD_R]
// so its circle never extends beyond the track edges.
const PLAYHEAD_R = 8
// =============================================================================
// Types
// =============================================================================
interface ProgressBarProps {
/** Total number of screenshots */
total: number
/** Current screenshot index (clamped) */
currentIndex: number
/** Called when user clicks/drags to a screenshot index */
onIndexChange: (index: number) => void
}
// =============================================================================
// Component
// =============================================================================
export function ProgressBar({ total, currentIndex, onIndexChange }: ProgressBarProps) {
const trackRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false)
const cleanupDrag = useRef<(() => void) | null>(null)
// Clean up document-level drag listeners on unmount
useEffect(() => {
return () => cleanupDrag.current?.()
}, [])
// Calculate playhead position:
// - Single screenshot → 100%
// - Multiple screenshots: first = 0%, last = 100%
const getProgress = (): number => {
if (total <= 1) return 100
return (currentIndex / (total - 1)) * 100
}
const progress = getProgress()
// Convert click/drag position to screenshot index.
const getIndexFromPosition = useCallback(
(percentage: number): number => {
if (total <= 1) return 0
const rawIndex = (percentage / 100) * (total - 1)
return Math.max(0, Math.min(total - 1, Math.round(rawIndex)))
},
[total]
)
const handlePositionChange = useCallback(
(clientX: number) => {
if (!trackRef.current || total === 0) return
const rect = trackRef.current.getBoundingClientRect()
// Map cursor position to the playhead's movable range
const x = clientX - rect.left - PLAYHEAD_R
const effectiveWidth = rect.width - PLAYHEAD_R * 2
const percentage = Math.max(0, Math.min(100, (x / effectiveWidth) * 100))
onIndexChange(getIndexFromPosition(percentage))
},
[onIndexChange, total, getIndexFromPosition]
)
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (isDragging.current || total === 0) return
handlePositionChange(e.clientX)
}
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (total === 0) return
isDragging.current = true
handlePositionChange(e.clientX)
const handleMouseMove = (moveEvent: MouseEvent) => {
handlePositionChange(moveEvent.clientX)
}
const handleMouseUp = () => {
isDragging.current = false
cleanupDrag.current = null
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
cleanupDrag.current = handleMouseUp
}
const isEmpty = total === 0
const isInteractive = !isEmpty
// Keyboard navigation for ARIA slider role
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (!isInteractive) return
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
e.preventDefault()
onIndexChange(Math.max(0, currentIndex - 1))
break
case 'ArrowRight':
case 'ArrowUp':
e.preventDefault()
onIndexChange(Math.min(total - 1, currentIndex + 1))
break
case 'Home':
e.preventDefault()
onIndexChange(0)
break
case 'End':
e.preventDefault()
onIndexChange(total - 1)
break
}
},
[isInteractive, currentIndex, total, onIndexChange]
)
return (
<div
ref={trackRef}
className={cn(
'relative flex flex-1 items-center py-[3px]',
isInteractive
? 'focus-visible:ring-ring cursor-pointer rounded focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none'
: 'cursor-default opacity-50'
)}
onClick={isInteractive ? handleTrackClick : undefined}
onMouseDown={isInteractive ? handleMouseDown : undefined}
onKeyDown={isInteractive ? handleKeyDown : undefined}
role="slider"
aria-label="Screenshot progress"
aria-valuemin={1}
aria-valuemax={total || 1}
aria-valuenow={isEmpty ? 1 : currentIndex + 1}
aria-disabled={!isInteractive}
tabIndex={isInteractive ? 0 : -1}
>
{/* Track — full width; fill tracks playhead center position */}
<div className="bg-accent-2 h-1.5 w-full overflow-hidden rounded-full">
{/* Progress fill — width matches playhead center so they stay aligned */}
<div
className={cn(
'h-full rounded-full transition-[width] duration-100',
isEmpty ? 'bg-muted-foreground/30' : 'bg-primary'
)}
style={{
width: `calc(${PLAYHEAD_R}px + (100% - ${PLAYHEAD_R * 2}px) * ${progress / 100})`,
}}
/>
</div>
{/* Playhead — moves within [PLAYHEAD_R, 100% PLAYHEAD_R] so it never overflows */}
{!isEmpty && (
<div
className={cn(
'absolute top-1/2 size-4 rounded-full',
'bg-background border-primary border-2 shadow-sm',
'transition-[left] duration-100'
)}
style={{
left: `calc(${PLAYHEAD_R}px + (100% - ${PLAYHEAD_R * 2}px) * ${progress / 100})`,
transform: 'translate(-50%, -50%)',
}}
/>
)}
</div>
)
}
@@ -0,0 +1,51 @@
import { MousePointer2Off } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { ControlState } from '@/types'
/**
* TakeoverNotice Component
*
* Displays a notice when user is in takeover mode (controlling the browser).
* - 'user-pending': Shows animated stripe with "Waiting for agent..." message
* - 'user': Shows solid background with control message + Release Control button
*
* Only used in expanded/maximized view.
*/
interface TakeoverNoticeProps {
/** Current control state (only 'user-pending' or 'user' should be passed) */
controlState: ControlState
/** Called when Release Control button is clicked */
onControlClick?: () => void
}
export function TakeoverNotice({ controlState, onControlClick }: TakeoverNoticeProps) {
const isPending = controlState === 'user-pending'
return (
<div
className={cn(
'flex w-full items-center gap-3 px-4 py-3',
// min-h: py-3(12)*2 + 2-line text(~40px) = 64px to keep both states same height
'min-h-16',
isPending ? 'animate-stripe' : 'bg-primary/20'
)}
>
<p className="text-foreground flex min-h-9 flex-1 items-center text-sm">
{isPending
? 'Waiting for the agent to stop...'
: "You are in control of the browser. The agent is paused and won't observe these actions."}
</p>
{!isPending && onControlClick && (
<Button
variant="destructive"
className="shrink-0 gap-1.5 px-3 text-sm"
onClick={onControlClick}
>
<MousePointer2Off className="size-3.5" />
Release Control
</Button>
)}
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Browser Components
*
* Components for real-time browser viewing via VNC.
*
* View modes:
* - Embedded: Shows in chat message list (BrowserEmbed)
* - Expanded: Side-by-side with chat (BrowserExpanded)
* - Maximized: Full screen, chat hidden (BrowserExpanded with mode='maximized')
*/
export { BrowserEmbed } from './BrowserEmbed'
export { BrowserExpanded } from './BrowserExpanded'
@@ -0,0 +1,159 @@
/**
* usePlaybackControls Hook
*
* Shared playback logic for browser components (BrowserEmbed, BrowserExpanded).
* Calculates derived state from screenshot actions + playback position.
*/
import { useMemo, useCallback } from 'react'
import type { LatestCuaAction } from '@/types'
import type { CuaAction } from '@/components/chat/messages'
// =============================================================================
// Types
// =============================================================================
interface UsePlaybackControlsOptions {
/** CUA actions with screenshots (completed actions only) */
screenshotActions: CuaAction[]
/** Whether currently showing live VNC stream */
isLive: boolean
/** Current screenshot index (when in playback mode) */
currentIndex: number
/** Called when index changes */
onIndexChange: (index: number) => void
/** Whether VNC is currently connected */
isVncConnected: boolean
/** Whether auto-advancing to latest screenshot */
followLatest: boolean
/** Action info for live mode */
liveActionInfo?: LatestCuaAction
}
interface UsePlaybackControlsResult {
/** Total number of screenshot actions */
total: number
/** Whether there are any screenshots to play back */
hasHistory: boolean
/** Whether display is in live mode (user wants live AND VNC connected) */
displayIsLive: boolean
/** Current index clamped to valid range */
validCurrentIndex: number
/** Screenshot URL for current playback position */
currentScreenshotUrl: string | undefined
/** Whether step-back button is enabled */
canStepBack: boolean
/** Whether step-forward button is enabled */
canStepForward: boolean
/** Step back handler */
handleStepBack: () => void
/** Step forward handler */
handleStepForward: () => void
/** Tooltip for step-forward button */
nextTooltip: string
/** Current thoughts text (from playback or live action) */
thoughts: string | undefined
/** Current action result text (from playback or live action) */
actionResult: string | undefined
/** Whether there's any content to display in description */
hasContent: boolean
}
// =============================================================================
// Hook
// =============================================================================
export function usePlaybackControls({
screenshotActions,
isLive,
currentIndex,
onIndexChange,
isVncConnected,
followLatest,
liveActionInfo,
}: UsePlaybackControlsOptions): UsePlaybackControlsResult {
const total = screenshotActions.length
const hasHistory = total > 0
// Display mode: live only when user wants live AND VNC is actually connected.
// This controls whether VNC stream or screenshot overlay is shown.
const displayIsLive = isLive && isVncConnected
// Clamp index to valid range.
// When followLatest is true, always show the latest screenshot.
const validCurrentIndex =
hasHistory && followLatest ? total - 1 : Math.max(0, Math.min(currentIndex, total - 1))
// Current screenshot action (playback mode)
const currentAction = hasHistory ? screenshotActions[validCurrentIndex] : undefined
// Screenshot URL for current action
const currentScreenshotUrl = currentAction?.screenshotUrl
// Step controls — disabled in live mode
const canStepBack = hasHistory && !displayIsLive && validCurrentIndex > 0
const canStepForward = hasHistory && !displayIsLive && validCurrentIndex < total - 1
const handleStepBack = useCallback(() => {
if (canStepBack) {
onIndexChange(validCurrentIndex - 1)
}
}, [canStepBack, validCurrentIndex, onIndexChange])
const handleStepForward = useCallback(() => {
if (canStepForward) {
onIndexChange(validCurrentIndex + 1)
}
}, [canStepForward, validCurrentIndex, onIndexChange])
// Tooltips
const nextTooltip = displayIsLive
? 'Already on latest'
: validCurrentIndex >= total - 1
? 'Last action'
: 'Next action'
// Action description content: use live info when live, playback action otherwise
const { thoughts, actionResult, hasContent } = useMemo(() => {
if (displayIsLive) {
const t = liveActionInfo?.thoughts
const a = liveActionInfo?.actionResult
return { thoughts: t, actionResult: a, hasContent: !!(t || a) }
}
const t = currentAction?.toolArgs?.thoughts
const a = currentAction?.actionResult
return { thoughts: t, actionResult: a, hasContent: !!(t || a) }
}, [displayIsLive, liveActionInfo, currentAction])
return {
total,
hasHistory,
displayIsLive,
validCurrentIndex,
currentScreenshotUrl,
canStepBack,
canStepForward,
handleStepBack,
handleStepForward,
nextTooltip,
thoughts,
actionResult,
hasContent,
}
}
/**
* Generate browser title based on connection, playback, and control state.
*/
export function getBrowserTitle(
isVncConnected: boolean,
hasHistory: boolean,
isLive: boolean,
isUserControl?: boolean
): string {
if (isUserControl) return 'Browser (User Control)'
if (!isVncConnected && !hasHistory) return 'Browser'
// VNC disconnected + screenshots = always show history (e.g., completed session)
if (!isVncConnected && hasHistory) return 'Browser (History)'
return isLive ? 'Browser (Live)' : 'Browser (History)'
}
+437
View File
@@ -0,0 +1,437 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { FileChip } from '@/components/common'
import { FolderMountDialog } from './FolderMountDialog'
import { FolderBrowserDialog } from './FolderBrowserDialog'
import { Send, CircleStop, CirclePlay, File, FolderClosed, ChevronDown, X } from 'lucide-react'
import type { SessionStatus, FileAttachment } from '@/types'
import { getInputDraft, setInputDraft, clearInputDraft } from '@/lib/inputDrafts'
import { useFileAttachments } from '@/hooks/useFileAttachments'
import { useFolderMount } from '@/hooks/useFolderMount'
interface ChatInputProps {
/** Session ID — used to persist input drafts per session */
sessionId?: number
onSend?: (message: string, files?: FileAttachment[]) => void
onStop?: () => void
disabled?: boolean
sessionStatus?: SessionStatus
isStopping?: boolean
isSending?: boolean
/** Show the file upload button. */
showAttachments?: boolean
/** Show the folder mount button (start-task only). */
showFolderMount?: boolean
/** Whether user is controlling (or waiting to control) the browser */
isControlling?: boolean
/** Whether user needs to describe their browser actions (persists after releasing control) */
pendingTakeoverFeedback?: boolean
/** Pre-loaded file attachments (e.g. from sample tasks) */
initialAttachments?: FileAttachment[]
}
// Placeholder text based on session state
const PLACEHOLDER_DEFAULT = 'Type your message here.'
const PLACEHOLDER_ACTIVE = 'Send a message to steer the agent, or use Stop to end the task.'
const PLACEHOLDER_PAUSED = 'Type your message here, or click Continue to resume the task.'
const PLACEHOLDER_TAKEOVER = 'Briefly describe what you changed in the browser.'
// Shared button styles
const BUTTON_TEXT_CLASS = 'text-sm tracking-wide'
const ICON_CLASS = 'size-3.5'
/**
* Chat input component with auto-growing textarea and toolbar.
* File attachment logic is extracted to useFileAttachments hook.
*/
export function ChatInput({
sessionId,
onSend,
onStop,
disabled = false,
sessionStatus,
isStopping = false,
isSending = false,
showAttachments = false,
showFolderMount = false,
isControlling = false,
pendingTakeoverFeedback = false,
initialAttachments,
}: ChatInputProps) {
const [value, setValue] = useState(() => (sessionId != null ? getInputDraft(sessionId) : ''))
const textareaRef = useRef<HTMLTextAreaElement>(null)
// File attachment state & handlers (extracted hook)
const {
attachments,
clearAttachments,
removeAttachment,
isDragOver,
fileInputRef,
handleFileInputChange,
handlePaste,
handleDragOver,
handleDragLeave,
handleDrop,
} = useFileAttachments(initialAttachments)
// Folder mounting state & handlers (extracted hook using server-side file browser)
const {
mountedFolder,
folderDialogOpen,
setFolderDialogOpen,
pendingFolderName,
folderBrowserOpen,
setFolderBrowserOpen,
handleSelectFolder,
handleBrowserSelect,
handleFolderAllow,
handleFolderAlwaysAllow,
handleFolderCancel,
handleDeselectFolder,
} = useFolderMount(sessionId)
// =========================================================================
// Input Draft Persistence
// =========================================================================
const prevSessionIdRef = useRef(sessionId)
useEffect(() => {
if (prevSessionIdRef.current === sessionId) return
if (prevSessionIdRef.current != null && value) {
setInputDraft(prevSessionIdRef.current, value)
}
setValue(sessionId != null ? getInputDraft(sessionId) : '')
prevSessionIdRef.current = sessionId
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// Ref for unmount cleanup — updated synchronously in handleValueChange
const valueRef = useRef(value)
const handleValueChange = useCallback(
(newValue: string) => {
setValue(newValue)
valueRef.current = newValue
if (sessionId != null) {
setInputDraft(sessionId, newValue)
}
},
[sessionId]
)
// Save draft on unmount
useEffect(() => {
return () => {
const current = prevSessionIdRef.current
if (current != null && valueRef.current) {
setInputDraft(current, valueRef.current)
}
}
}, [])
// =========================================================================
// Textarea Auto-resize
// =========================================================================
const recalculateHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 192)}px`
}
}, [])
useEffect(() => {
recalculateHeight()
}, [value, recalculateHeight])
useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
const resizeObserver = new ResizeObserver(() => recalculateHeight())
resizeObserver.observe(textarea)
return () => resizeObserver.disconnect()
}, [recalculateHeight])
// =========================================================================
// Session State Derived Values
// =========================================================================
// Suppress Stop/Continue buttons whenever the user is in (or just exited)
// takeover — those buttons would conflict with the takeover/feedback flow.
// Note: this is independent of the placeholder, which is driven by
// `isControlling` alone (the post-release guidance lives in ChatInputBanner).
const suppressTaskControls = isControlling || pendingTakeoverFeedback
const showStopButton = sessionStatus === 'active' && !suppressTaskControls
const showContinueButton = sessionStatus === 'paused' && !suppressTaskControls
// Use the takeover placeholder only while the user is actively controlling
// the browser. After release, ChatInputBanner above the input already shows
// the same guidance, so fall through to the default placeholder to avoid
// duplicating the message inside the textarea.
const placeholder = isControlling
? PLACEHOLDER_TAKEOVER
: showStopButton
? PLACEHOLDER_ACTIVE
: showContinueButton
? PLACEHOLDER_PAUSED
: PLACEHOLDER_DEFAULT
// =========================================================================
// Submit Handlers
// =========================================================================
const handleSubmit = () => {
if ((value.trim() || attachments.length > 0) && onSend) {
onSend(value.trim(), attachments.length > 0 ? attachments : undefined)
setValue('')
valueRef.current = ''
clearAttachments()
if (sessionId != null) clearInputDraft(sessionId)
}
}
const handleContinue = () => {
if (onSend) {
onSend('Continue.', attachments.length > 0 ? attachments : undefined)
setValue('')
valueRef.current = ''
clearAttachments()
if (sessionId != null) clearInputDraft(sessionId)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const nativeEvent = e.nativeEvent as KeyboardEvent & { isComposing?: boolean }
if (e.key === 'Enter' && !e.shiftKey && !nativeEvent.isComposing) {
e.preventDefault()
handleSubmit()
}
}
// =========================================================================
// Render
// =========================================================================
return (
<div
className={cn(
'border-border bg-background rounded-xl border shadow-sm',
showAttachments &&
isDragOver &&
'border-primary shadow-[0_0_0_3px_var(--card-selected-ring)]'
)}
onDragOver={showAttachments ? handleDragOver : undefined}
onDragLeave={showAttachments ? handleDragLeave : undefined}
onDrop={showAttachments ? handleDrop : undefined}
>
{/* Textarea */}
<div className="px-5 py-3">
<label htmlFor="chat-input" className="sr-only">
Message input
</label>
<textarea
id="chat-input"
ref={textareaRef}
value={value}
onChange={(e) => handleValueChange(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={showAttachments ? handlePaste : undefined}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="text-foreground placeholder:text-muted-foreground max-h-48 min-h-6 w-full resize-none bg-transparent text-base leading-6 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{/* File attachments (below textarea, above toolbar) */}
{showAttachments && attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pb-2">
{attachments.map((attachment) => (
<FileChip
key={attachment.id}
name={attachment.name}
context="input"
status={attachment.status}
onRemove={() => removeAttachment(attachment.id)}
/>
))}
</div>
)}
{/* Hidden file input */}
{showAttachments && (
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInputChange}
aria-hidden="true"
tabIndex={-1}
/>
)}
{/* Toolbar */}
<div
className={cn(
'flex items-center px-3 pt-1.5 pb-3',
showAttachments || showFolderMount ? 'justify-between' : 'justify-end'
)}
>
{/* Left: Upload + Folder buttons */}
{(showAttachments || showFolderMount) && (
<div className="flex items-center gap-2">
{showAttachments && (
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
aria-label="Upload file"
>
<File className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>Upload File</span>
</Button>
)}
{showFolderMount &&
(mountedFolder ? (
// Selected state: folder name + chevron + purple ring
<Tooltip>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="secondary"
className="border-primary border shadow-[0_0_0_3px_var(--card-selected-ring)]"
disabled={disabled}
>
<FolderClosed className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>{mountedFolder.name}</span>
<ChevronDown className={ICON_CLASS} aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleSelectFolder}>
<FolderClosed className="size-4" />
Select another folder
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDeselectFolder}>
<X className="size-4" />
Deselect folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent>Work in {mountedFolder.name} folder</TooltipContent>
</Tooltip>
) : (
// Unselected state: "Work in Folder" button
<Button
type="button"
variant="secondary"
onClick={handleSelectFolder}
disabled={disabled}
aria-label="Work in folder"
>
<FolderClosed className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>Work in Folder</span>
</Button>
))}
</div>
)}
{/* Right: Action buttons */}
<div>
{showStopButton ? (
// Mid-run steer: empty input → Stop, has text → Send.
value.trim() ? (
<Button
type="button"
onClick={handleSubmit}
disabled={disabled || isSending}
aria-label="Send message"
>
<Send className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>{isSending ? 'Sending...' : 'Send'}</span>
</Button>
) : (
<Button
type="button"
variant="destructive"
onClick={onStop}
disabled={isStopping}
aria-label="Stop task"
>
<CircleStop className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>{isStopping ? 'Stopping...' : 'Stop'}</span>
</Button>
)
) : showContinueButton ? (
value.trim() ? (
<Button
type="button"
onClick={handleSubmit}
disabled={disabled || isSending}
aria-label="Send message"
>
<Send className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>{isSending ? 'Sending...' : 'Send'}</span>
</Button>
) : (
<Button
type="button"
onClick={handleContinue}
disabled={disabled || isSending}
aria-label="Continue task"
>
<CirclePlay className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>
{isSending ? 'Continuing...' : 'Continue'}
</span>
</Button>
)
) : (
<Button
type="button"
onClick={handleSubmit}
disabled={disabled || (!value.trim() && attachments.length === 0) || isSending}
aria-label="Send message"
>
<Send className={ICON_CLASS} aria-hidden="true" />
<span className={BUTTON_TEXT_CLASS}>{isSending ? 'Sending...' : 'Send'}</span>
</Button>
)}
</div>
</div>
{/* Folder mount confirmation dialog */}
<FolderMountDialog
open={folderDialogOpen}
onOpenChange={setFolderDialogOpen}
folderName={pendingFolderName}
onAllow={handleFolderAllow}
onAlwaysAllow={handleFolderAlwaysAllow}
onCancel={handleFolderCancel}
/>
{/* Folder browser dialog (browser mode only) */}
<FolderBrowserDialog
open={folderBrowserOpen}
onOpenChange={setFolderBrowserOpen}
onSelect={handleBrowserSelect}
/>
</div>
)
}
@@ -0,0 +1,33 @@
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Markdown } from '@/components/common'
interface ChatInputBannerProps {
/** Instructional text displayed in the banner. Supports Markdown (e.g. **bold**). */
message: string
/** Called when the banner is dismissed */
onDismiss: () => void
}
/**
* Inline banner displayed directly above the chat input to provide context
* or guidance for the next user message — for example after pre-filling a
* sample task prompt, or after the user releases browser control and needs
* to describe what they did. Styled to match TakeoverNotice (bg-primary/20).
*/
export function ChatInputBanner({ message, onDismiss }: ChatInputBannerProps) {
return (
<div className="bg-primary/20 mb-2 flex w-full items-center gap-3 rounded-lg py-2.5 pr-2.5 pl-4">
<Markdown className="text-foreground flex-1 text-sm">{message}</Markdown>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0"
onClick={onDismiss}
aria-label="Dismiss"
>
<X className="size-4" />
</Button>
</div>
)
}
+824
View File
@@ -0,0 +1,824 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { cn } from '@/lib/utils'
import { ChatInput } from './ChatInput'
import { clearInputDraft, setInputDraft } from '@/lib/inputDrafts'
import { MessageList } from './MessageList'
import { SessionStatusIndicator, ReasoningPlaceholder } from './messages'
import { SampleTaskCards } from '@/components/common'
import { ChatInputBanner } from './ChatInputBanner'
import { shouldUseInputResponse } from './chatViewUtils'
import {
useScrollRestoration,
useScrollToImportantBackgroundMessage,
useAutoScrollToBottom,
useWebSocketManager,
} from '@/hooks'
import type { SampleTask } from '@/lib/sampleTasks'
import { consumePendingSampleTask } from '@/lib/sampleTasks'
import {
useSessionMessages,
useSessionStatus,
useChatStore,
useNotificationStore,
useNovncUrl,
useNovncPassword,
useMountedFolder,
useSessionAgentMode,
useIsCuaActive,
useUIStore,
useBackendHealthStore,
} from '@/stores'
import {
useSessionRun,
useUpdateSession,
createSession,
fetchSessionRuns,
sessionKeys,
} from '@/api/sessions'
import { useCurrentAgentMode } from '@/api/onboarding'
import type {
BrowserViewMode,
SessionListItem,
FileAttachment,
FileInfo,
FolderInfo,
UploadedFileRef,
} from '@/types'
import { ACTIVE_SESSION_STATUSES } from '@/types'
import type { CuaAction } from './messages'
import { isDraftSession, DEFAULT_USER_ID } from '@/lib/constants'
import { uploadFiles } from '@/api/files'
import { createFileAttachment } from '@/lib/fileUtils'
import type { Message } from '@/types/api'
// Negative IDs for optimistic messages (range: -1_000_000 and below)
// Separated from WS message IDs (range: -1 to -999_999) to avoid collision
let optimisticMsgId = -1_000_000
// Message shown above the chat input after the user releases browser control,
// reminding them that the agent needs feedback before continuing.
const TAKEOVER_FEEDBACK_BANNER_MESSAGE =
'Tell the agent what you changed in the browser, what to do next, or just type **Continue**.'
interface ChatViewProps {
sessionId?: number
sessionTitle?: string
className?: string
/** Whether this chat view is currently active/visible (for scroll, notifications, VNC) */
isActive?: boolean
/** Current browser view mode - determines how browser is displayed */
browserViewMode?: BrowserViewMode
/** Called when mode changes via browser toolbar buttons */
onBrowserModeChange?: (mode: BrowserViewMode) => void
/** Called when a CUA action is clicked (for Browser screenshot sync) */
onCuaActionClick?: (action: CuaAction) => void
/** ID of the currently highlighted CUA action (from Browser playback) */
highlightedActionId?: string | null
/** Called when user clicks Control button in browser toolbar */
onControlClick?: () => void
/** Called when user clicks a file chip to preview */
onFilePreview?: (file: FileInfo) => void
}
/**
* Chat view component displaying conversation messages and input.
* Layout: scrollable message area + fixed input at bottom.
* - Preserves scroll position when switching between sessions
* - Auto-scrolls to bottom when new messages arrive (if user is at bottom)
* - Shows embedded browser when browserViewMode is 'embedded'
*/
export function ChatView({
sessionId,
sessionTitle,
className,
isActive = true,
browserViewMode = 'embedded',
onBrowserModeChange,
onCuaActionClick,
highlightedActionId,
onControlClick,
onFilePreview,
}: ChatViewProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
// Get messages and status from chat store
const messages = useSessionMessages(sessionId)
const sessionStatus = useSessionStatus(sessionId)
const novncUrl = useNovncUrl(sessionId)
const novncPassword = useNovncPassword(sessionId)
// Block Send when disconnected. Banner explains why.
const backendReachable = useBackendHealthStore((s) => s.reachable)
// Scroll restoration for switching between sessions
const { scrollRef, handleItemRef } = useScrollRestoration({
key: sessionId ? String(sessionId) : undefined,
isActive,
})
// On session re-entry, jump to a final-answer / input-request / error
// that arrived while the view was in the background, instead of restoring
// the stale saved scroll position.
useScrollToImportantBackgroundMessage({
sessionId,
isActive,
messages,
scrollRef,
})
// Check if any screenshots exist (enables browser embed without VNC)
const hasScreenshots = useMemo(() => messages.some((m) => m.kind === 'screenshot'), [messages])
// Loading state for past sessions (skip for draft sessions)
const { isLoading: isLoadingMessages } = useSessionRun(
isDraftSession(sessionId) ? undefined : sessionId
)
// Get runId from store (set by useEnsureSessionData in SessionPage)
const runId = useChatStore((s) => (sessionId ? s.getSessionState(sessionId).runId : null))
// Auto-scroll to bottom when new messages arrive (shares scrollRef with useScrollRestoration)
useAutoScrollToBottom({
scrollRef,
itemCount: messages.length,
isActive,
})
// Scroll to last CUA group when switching back to embedded mode
// (browser embed is at the bottom of messages, likely off-screen)
const prevBrowserModeRef = useRef(browserViewMode)
useEffect(() => {
const prev = prevBrowserModeRef.current
prevBrowserModeRef.current = browserViewMode
let rafId: number | undefined
if (browserViewMode === 'embedded' && prev !== 'embedded' && scrollRef.current) {
// Use requestAnimationFrame to wait for DOM update after mode switch
rafId = requestAnimationFrame(() => {
const container = scrollRef.current
if (!container) return
// Find last CUA group and scroll it to the top of the viewport
const cuaGroups = container.querySelectorAll('[data-cua-group]')
const lastGroup = cuaGroups[cuaGroups.length - 1] as HTMLElement | undefined
if (lastGroup) {
lastGroup.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
// Fallback: scroll to bottom if no CUA groups
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
}
})
}
return () => {
if (rafId !== undefined) cancelAnimationFrame(rafId)
}
}, [browserViewMode, scrollRef])
// Get WebSocket manager functions
const { sendStart, sendInputResponse, sendStop } = useWebSocketManager()
// Get mounted folder for this session (to pass as mount_dirs in start message)
const mountedFolder = useMountedFolder(sessionId)
// Hide upload + folder-mount when Fara is the active agent. Fall back to the
// user's saved agent_mode for draft sessions; while it's still loading, treat
// as Fara to avoid flashing the buttons to a Fara-only user.
const sessionAgentMode = useSessionAgentMode(sessionId)
const { agentMode: currentAgentMode, isLoading: isCurrentAgentModeLoading } =
useCurrentAgentMode()
const effectiveAgentMode = sessionAgentMode ?? currentAgentMode
const isFaraOnly =
effectiveAgentMode === 'websurfer_only' ||
(sessionAgentMode == null && isCurrentAgentModeLoading)
// Gate the in-flight CUA check on a still-running session so an orphaned
// tool_call (e.g. session stopped mid-delegation) doesn't permanently hide
// the follow-up upload UI.
const isSessionRunning =
sessionStatus !== undefined && ACTIVE_SESSION_STATUSES.includes(sessionStatus)
const isCuaActive = useIsCuaActive(sessionId) && isSessionRunning
// Draft session management
const clearDraftSession = useUIStore((s) => s.clearDraftSession)
const initSession = useChatStore((s) => s.initSession)
const addMessage = useChatStore((s) => s.addMessage)
const setMountedFolder = useChatStore((s) => s.setMountedFolder)
const updateOptimisticFileStatus = useChatStore((s) => s.updateOptimisticFileStatus)
// Get pending action for optimistic UI (e.g., showing "Stopping..." state)
const pendingAction = useChatStore((s) =>
sessionId ? s.getSessionState(sessionId).pendingAction : null
)
const setPendingAction = useChatStore((s) => s.setPendingAction)
// Get inputRequest state - indicates backend is waiting for user input
const hasInputRequest = useChatStore((s) =>
sessionId ? s.getSessionState(sessionId).inputRequest !== null : false
)
// Approvals are decisions, not messages: backend ignores any
// attachments on an approval response.
const isPendingApproval = useChatStore((s) =>
sessionId ? s.getSessionState(sessionId).inputRequest?.input_type === 'approval' : false
)
// Get takeover state
const controlState = useChatStore((s) =>
sessionId ? s.getSessionState(sessionId).controlState : ('agent' as const)
)
const setControlState = useChatStore((s) => s.setControlState)
const pendingTakeoverFeedback = useChatStore((s) =>
sessionId ? s.getSessionState(sessionId).pendingTakeoverFeedback : false
)
const setPendingTakeoverFeedback = useChatStore((s) => s.setPendingTakeoverFeedback)
// Transient agent activity: ``calling_model`` shows "Waiting for model…"
// in the status indicator; ``generating`` shows a shimmer placeholder card.
// Cleared by the store when the next persistent message arrives.
const agentActivity = useChatStore((s) =>
sessionId ? s.getSessionState(sessionId).agentActivity : null
)
// Timeout for pending actions - clear if stuck for too long (e.g., backend crashed)
// Timeout: clear stuck pending actions (e.g., backend crashed during stop/pause/send).
// Skip draft sessions — draft 'sending' can take arbitrarily long (file uploads)
// and is cleaned up by createDraftSession's clearSession on next New Session.
const PENDING_ACTION_TIMEOUT = 10000 // 10 seconds
useEffect(() => {
if (!pendingAction || !sessionId) return
if (isDraftSession(sessionId)) return // draft cleanup handled by createDraftSession
const elapsed = Date.now() - pendingAction.timestamp
const remaining = PENDING_ACTION_TIMEOUT - elapsed
const clearPending = () => {
console.warn('[ChatView] Pending action timed out, clearing:', pendingAction.type)
setPendingAction(sessionId, null)
// If takeover was pending (Take Control while active), reset controlState too
// Otherwise it stays stuck at 'user-pending' with a disabled button forever
if (pendingAction.type === 'pausing' && controlState === 'user-pending') {
setControlState(sessionId, 'agent')
}
}
if (remaining <= 0) {
clearPending()
return
}
const timeoutId = setTimeout(clearPending, remaining)
return () => clearTimeout(timeoutId)
}, [pendingAction, sessionId, setPendingAction, controlState, setControlState])
// Session update mutation for auto-renaming
const updateSession = useUpdateSession()
const updateSessionName = useNotificationStore((s) => s.updateSessionName)
const removeSessionNotifications = useNotificationStore((s) => s.removeSessionNotifications)
// Clear notifications when viewing this session
useEffect(() => {
if (sessionId && isActive) {
removeSessionNotifications(sessionId)
}
}, [sessionId, isActive, removeSessionNotifications])
/**
* Build and inject an optimistic user message into the chat store.
* Shows immediately in the UI before backend confirms.
*/
const injectOptimisticUserMessage = useCallback(
(
targetSessionId: number,
targetRunId: string,
message: string,
files?: FileAttachment[],
folder?: FolderInfo
) => {
const attachedFiles: FileInfo[] | undefined =
files && files.length > 0
? files.map((f) => ({
name: f.name,
url: '',
path: '',
extension: f.name.split('.').pop() || '',
file_type: f.mimeType,
action: 'created' as const,
timestamp: Date.now() / 1000,
uploadStatus: 'uploading' as const,
}))
: undefined
const optimisticMsg: Message = {
id: --optimisticMsgId,
session_id: targetSessionId,
run_id: targetRunId,
config: {
source: 'user',
content: message,
metadata: {
_optimistic: true,
...(attachedFiles ? { attached_files: JSON.stringify(attachedFiles) } : {}),
...(folder ? { mounted_folder: folder } : {}),
},
},
created_at: new Date().toISOString(),
}
addMessage(targetSessionId, optimisticMsg)
},
[addMessage]
)
/** Upload pending attachments for a run and return WS file refs. */
const uploadAttachmentsForRun = useCallback(
async (
targetSessionId: number,
targetRunId: string,
files: FileAttachment[] | undefined,
logContext: string
): Promise<UploadedFileRef[] | undefined> => {
if (!files || files.length === 0) return undefined
try {
const uploadResult = await uploadFiles(
targetRunId,
files.map((f) => f.file)
)
updateOptimisticFileStatus(targetSessionId, 'uploaded')
return uploadResult.files.map((f) => ({
name: f.name,
path: f.relative_path,
uploaded: true,
}))
} catch (err) {
console.error(`File upload failed (${logContext}):`, err)
updateOptimisticFileStatus(targetSessionId, 'error')
return undefined
}
},
[updateOptimisticFileStatus]
)
/**
* Promote a draft session to a real backend session and send the first message.
* Returns early from handleSend — caller should not continue after calling this.
*/
const promoteDraftAndSend = useCallback(
async (message: string, files?: FileAttachment[]) => {
setPendingAction(sessionId!, { type: 'sending', timestamp: Date.now() })
// Capture mount dirs before draft is cleared
const mountDirs = mountedFolder ? [mountedFolder.path] : undefined
// Track whether a real session was created, so catch/early-return can clean up properly
let createdSessionId: number | null = null
try {
// Create session with message as name
const sessionName = message.slice(0, 100).trim() || 'New Session'
const session = await createSession({ name: sessionName, user_id: DEFAULT_USER_ID })
createdSessionId = session.id
// Fetch the auto-created run to get runId
const runsData = await fetchSessionRuns(session.id, DEFAULT_USER_ID)
const run = runsData.runs[0]
if (!run) {
console.error('No run found for newly created session')
// Clean up draft state and navigate to the created session
// (session exists on backend, just has no run — user can retry from there)
setPendingAction(sessionId!, null)
clearInputDraft(sessionId!)
clearDraftSession()
navigate(`/sessions/${session.id}`, { replace: true })
return
}
// Initialize chatStore with real session
initSession(session.id, run.id, run.status)
// Optimistically inject user message immediately
injectOptimisticUserMessage(session.id, run.id, message, files, mountedFolder ?? undefined)
// Set pendingAction on real session (draft keeps its pending until navigate)
setPendingAction(session.id, { type: 'sending', timestamp: Date.now() })
// Inject new session into query cache for instant sidebar update.
// `latest_run.updated_at` is set to now so the session sorts to the top
// (matches the backend's COALESCE(updated_at, created_at) DESC ordering).
queryClient.setQueryData(
sessionKeys.list(DEFAULT_USER_ID),
(old: SessionListItem[] | undefined) => {
const now = new Date().toISOString()
const newItem: SessionListItem = {
session_id: session.id,
name: sessionName,
created_at: now,
latest_run: { run_id: Number(run.id), status: run.status, updated_at: now },
}
return old ? [newItem, ...old] : [newItem]
}
)
// Invalidate in background so cache stays accurate
queryClient.invalidateQueries({ queryKey: sessionKeys.lists() })
// Upload files if any (now we have a real runId)
const fileRefs = await uploadAttachmentsForRun(session.id, run.id, files, 'draft promotion')
// Send the task via WebSocket (before navigate so cleanup runs in mounted context)
const taskJson = { content: message }
const success = await sendStart(
run.id,
session.id,
JSON.stringify(taskJson),
fileRefs,
mountDirs
)
if (!success) {
console.warn('Failed to send message after promoting draft')
setPendingAction(session.id, null)
}
// Migrate mounted folder from draft to real session
if (mountedFolder) {
setMountedFolder(session.id, mountedFolder)
}
// Clear draft state and navigate to real session
// Don't clear draft pendingAction here — it keeps isSending=true until navigate
// prevents upload button flash. Draft ChatView becomes invisible after navigate.
clearInputDraft(sessionId!)
clearDraftSession()
navigate(`/sessions/${session.id}`, { replace: true })
} catch (error) {
console.error('Failed to promote draft session:', error)
// TODO: Show toast/notification to user when session creation fails
// Clean up pendingAction on draft
setPendingAction(sessionId!, null)
// Clean up pendingAction on real session if it was created and had pending set
if (createdSessionId !== null) {
setPendingAction(createdSessionId, null)
// Session was created on backend — clean up draft and navigate there
// so user doesn't get stuck on a broken draft
clearInputDraft(sessionId!)
clearDraftSession()
navigate(`/sessions/${createdSessionId}`, { replace: true })
}
}
},
[
sessionId,
mountedFolder,
setPendingAction,
clearDraftSession,
navigate,
initSession,
injectOptimisticUserMessage,
setMountedFolder,
uploadAttachmentsForRun,
sendStart,
queryClient,
]
)
const handleSend = useCallback(
async (message: string, files?: FileAttachment[]) => {
// Draft session: promote to real session first
if (isDraftSession(sessionId)) {
return promoteDraftAndSend(message, files)
}
// Need runId and sessionId to send messages
if (!runId || !sessionId) {
console.warn('Cannot send message: no active run')
return
}
// Set pending action for optimistic UI feedback (prevents double-send)
setPendingAction(sessionId, { type: 'sending', timestamp: Date.now() })
// Auto-rename session if it has default name ("New Session ...")
// Only rename on first user message (when no messages in store)
if (sessionTitle?.startsWith('New Session') && messages.length === 0) {
const newName = message.slice(0, 100).trim()
if (newName) {
updateSession.mutate(
{ sessionId, data: { name: newName } },
{
onSuccess: () => {
updateSessionName(sessionId, newName)
},
}
)
}
}
// Decide whether to send as input_response (existing InputRequest,
// takeover feedback, or mid-run steer/inbox queue) or start a new task.
// See `shouldUseInputResponse` JSDoc for the full routing matrix.
const useInputResponseRoute = shouldUseInputResponse({
hasInputRequest,
controlState,
sessionStatus,
})
let success: boolean
if (useInputResponseRoute) {
// input_response messages do not carry folder mounting context.
injectOptimisticUserMessage(sessionId, runId, message, files)
const fileRefs = await uploadAttachmentsForRun(sessionId, runId, files, 'input response')
success = await sendInputResponse(runId, sessionId, message, fileRefs)
if (success) {
if (controlState === 'user') {
setControlState(sessionId, 'agent')
}
if (pendingTakeoverFeedback) {
setPendingTakeoverFeedback(sessionId, false)
}
}
} else {
// start messages can include mounted folder context.
injectOptimisticUserMessage(sessionId, runId, message, files, mountedFolder ?? undefined)
// Start a new task, optionally with uploaded files
const fileRefs = await uploadAttachmentsForRun(sessionId, runId, files, 'start task')
const taskJson = { content: message }
const mountDirs = mountedFolder ? [mountedFolder.path] : undefined
success = await sendStart(runId, sessionId, JSON.stringify(taskJson), fileRefs, mountDirs)
if (success && pendingTakeoverFeedback) {
setPendingTakeoverFeedback(sessionId, false)
}
}
if (!success) {
console.warn('Failed to send message, clearing pending action')
setPendingAction(sessionId, null)
}
},
[
sessionId,
runId,
sessionTitle,
messages.length,
mountedFolder,
hasInputRequest,
sessionStatus,
controlState,
pendingTakeoverFeedback,
setPendingAction,
setControlState,
setPendingTakeoverFeedback,
uploadAttachmentsForRun,
promoteDraftAndSend,
injectOptimisticUserMessage,
sendStart,
sendInputResponse,
updateSession,
updateSessionName,
]
)
// Handle stop button click
const handleStop = async () => {
if (!runId || !sessionId) {
console.warn('Cannot stop: no active run')
return
}
// Set pending action for optimistic UI feedback
setPendingAction(sessionId, { type: 'stopping', timestamp: Date.now() })
// Send stop message via WebSocket
const success = await sendStop(runId, sessionId)
// If send failed (e.g., connection error), clear pending action immediately
if (!success) {
console.warn('Failed to send stop, clearing pending action')
setPendingAction(sessionId, null)
}
}
// Determine if stop is in progress
const isStopping = pendingAction?.type === 'stopping'
// Determine if message send is in progress
const isSending = pendingAction?.type === 'sending'
// Track whether MessageList wants to hide the session status indicator
const [hideStatusIndicator, setHideStatusIndicator] = useState(false)
const handleHideStatusIndicator = useCallback((v: boolean) => setHideStatusIndicator(v), [])
// Sample task banner — shown when user selects a sample task to edit before sending
const [sampleTaskBanner, setSampleTaskBanner] = useState<string | null>(null)
// Key to force ChatInput remount when sample task is selected (so it reads the new draft)
const [inputKey, setInputKey] = useState(0)
// Pre-loaded file attachments for sample tasks
const [sampleAttachments, setSampleAttachments] = useState<FileAttachment[]>([])
const handleSampleTaskSelect = useCallback(
async (task: SampleTask) => {
if (sessionId == null) return
setInputDraft(sessionId, task.prompt)
setSampleTaskBanner(task.ctaText)
// Fetch sample files if the task has any
let attachments: FileAttachment[] = []
if (task.files && task.files.length > 0) {
try {
const fetched = await Promise.all(
task.files.map(async (f) => {
const res = await fetch(f.publicPath)
if (!res.ok) {
throw new Error(`Failed to fetch ${f.name}: HTTP ${res.status}`)
}
const blob = await res.blob()
const file = new File([blob], f.name, { type: blob.type || 'text/plain' })
return createFileAttachment(file)
})
)
attachments = fetched
} catch {
// TODO: show user-visible notification that sample files failed to load
console.warn('Failed to fetch sample task files')
}
}
setSampleAttachments(attachments)
setInputKey((k) => k + 1)
},
[sessionId]
)
// On mount: check for a pending sample task (set by SampleTasksPage before navigation)
useEffect(() => {
const pending = consumePendingSampleTask()
if (pending) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time mount initialization
void handleSampleTaskSelect(pending).catch((err) => {
console.error('Failed to load pending sample task:', err)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Clear banner when message is sent
const handleSendWithBannerClear = useCallback(
async (message: string, files?: FileAttachment[]) => {
setSampleTaskBanner(null)
return handleSend(message, files)
},
[handleSend]
)
return (
<div className={cn('bg-background flex h-full w-full flex-col', className)}>
{/* Messages area - scrollable */}
<div className="relative flex-1">
<div
ref={scrollRef}
className={cn(
'absolute inset-0 overflow-y-auto',
// In expanded mode, add right margin so scrollbar doesn't touch browser panel
browserViewMode === 'expanded' && 'mr-1'
)}
>
<div className="mx-auto flex min-h-full w-full max-w-[960px] flex-col p-6">
{sessionId ? (
messages.length > 0 ? (
<>
<MessageList
messages={messages}
sessionId={sessionId}
onItemRef={handleItemRef}
novncUrl={novncUrl}
novncPassword={novncPassword}
hasScreenshots={hasScreenshots}
sessionStatus={sessionStatus}
isSessionSelected={isActive}
browserViewMode={browserViewMode}
onBrowserModeChange={onBrowserModeChange}
onCuaActionClick={onCuaActionClick}
highlightedActionId={highlightedActionId}
onControlClick={onControlClick}
onHideStatusIndicator={handleHideStatusIndicator}
onFilePreview={onFilePreview}
/>
{/* Shimmer placeholder while the model is generating,
replaced by the real ReasoningMessage on completion. */}
{agentActivity === 'generating' && !isSending && (
<div className="mt-6">
<ReasoningPlaceholder />
</div>
)}
{/* Status indicator — hidden when redundant, while sending,
or while generating (the placeholder card covers that). */}
{!hideStatusIndicator && !isSending && agentActivity !== 'generating' && (
<div className="mt-6">
<SessionStatusIndicator
status={sessionStatus}
labelOverride={
agentActivity === 'model_slow'
? 'Model is slow to respond, still waiting…'
: agentActivity === 'calling_model'
? 'Waiting for model…'
: undefined
}
/>
</div>
)}
</>
) : isLoadingMessages ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-lg">Loading messages...</p>
</div>
) : isSending ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-lg">Starting session...</p>
</div>
) : isDraftSession(sessionId) ? (
// Draft (newly created, not yet persisted) sessions show sample task
// cards as an onboarding affordance. Real sessions with no messages
// (rare — e.g. failed runs) fall through to a neutral empty state
// below; we never want sample prompts to leak into a real session
// (see issue #582).
<div className="flex flex-1 flex-col items-center justify-center">
<div className="w-full max-w-[520px]">
<SampleTaskCards
title="Type a message to get started, or try a sample task:"
onTaskSelect={handleSampleTaskSelect}
/>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-lg">Send a message to start the task</p>
</div>
)
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-lg">
Select a session, or click the New Session button
</p>
</div>
)}
</div>
</div>
</div>
{/* Divider line */}
<div className="bg-border h-px w-full" />
{/* Input area - fixed at bottom */}
<div className="flex w-full justify-center px-6 pt-4 pb-3">
<div className="w-full max-w-[960px]">
{sampleTaskBanner ? (
<ChatInputBanner
message={sampleTaskBanner}
onDismiss={() => setSampleTaskBanner(null)}
/>
) : pendingTakeoverFeedback && controlState === 'agent' && sessionId != null ? (
<ChatInputBanner
message={TAKEOVER_FEEDBACK_BANNER_MESSAGE}
onDismiss={() => setPendingTakeoverFeedback(sessionId, false)}
/>
) : null}
<ChatInput
key={inputKey}
sessionId={sessionId}
onSend={handleSendWithBannerClear}
onStop={handleStop}
disabled={!sessionId || !backendReachable}
sessionStatus={sessionStatus}
isStopping={isStopping}
isSending={isSending}
showAttachments={
(messages.length === 0 ||
(shouldUseInputResponse({ hasInputRequest, controlState, sessionStatus }) &&
!isPendingApproval) ||
sessionStatus === 'completed' ||
sessionStatus === 'stopped' ||
sessionStatus === 'error') &&
!isLoadingMessages &&
!isSending &&
!isFaraOnly &&
!isCuaActive
}
showFolderMount={
messages.length === 0 &&
!isLoadingMessages &&
!isSending &&
!hasInputRequest &&
!isFaraOnly &&
!isCuaActive
}
isControlling={controlState === 'user'}
pendingTakeoverFeedback={pendingTakeoverFeedback}
initialAttachments={sampleAttachments}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,214 @@
/**
* Folder Browser Dialog
*
* A server-side folder browser that lets users navigate directories
* and select a folder. Works in any browser.
* The backend provides directory listings via /api/filesystem/*.
* Browsing is restricted to the user's home directory and below.
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Folder, ChevronRight, Home, Loader2, TriangleAlert, X, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { getRoots, listDirectory } from '@/api/filesystem'
import type { DirectoryEntry } from '@/api/filesystem'
interface FolderBrowserDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (name: string, path: string) => void
}
export function FolderBrowserDialog({ open, onOpenChange, onSelect }: FolderBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [entries, setEntries] = useState<DirectoryEntry[]>([])
const [homePath, setHomePath] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestIdRef = useRef(0)
const navigateTo = useCallback((path: string) => {
const id = ++requestIdRef.current
setLoading(true)
setError(null)
setCurrentPath(path)
setEntries([])
listDirectory(path)
.then((data) => {
if (requestIdRef.current !== id) return // stale response
setCurrentPath(data.path)
setEntries(data.entries)
})
.catch((err) => {
if (requestIdRef.current !== id) return
setError(err.message || 'Failed to list directory')
})
.finally(() => {
if (requestIdRef.current !== id) return
setLoading(false)
})
}, [])
// Always start from home when dialog opens
useEffect(() => {
if (!open) return
if (homePath) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- re-fetch on dialog open
navigateTo(homePath)
return
}
getRoots()
.then((data) => {
setHomePath(data.home)
navigateTo(data.home)
})
.catch((err) => {
setError(err.message || 'Failed to load')
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleEntryClick = useCallback(
(entry: DirectoryEntry) => {
if (entry.type === 'directory' && currentPath) {
const newPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`
navigateTo(newPath)
}
},
[currentPath, navigateTo]
)
const handleSelect = useCallback(() => {
if (!currentPath) return
const name = currentPath.split('/').pop() || currentPath
onSelect(name, currentPath)
onOpenChange(false)
}, [currentPath, onSelect, onOpenChange])
// Breadcrumb: show home as a single root, then sub-segments below it
const normalizedHome = homePath && homePath !== '/' ? homePath.replace(/\/+$/, '') : homePath
const isWithinHome =
!!normalizedHome &&
!!currentPath &&
(currentPath === normalizedHome || currentPath.startsWith(`${normalizedHome}/`))
const subPath = isWithinHome && normalizedHome ? currentPath.slice(normalizedHome.length) : null
const breadcrumbs = subPath
? subPath
.split('/')
.filter(Boolean)
.map((segment, i, arr) => ({
name: segment,
path: normalizedHome + '/' + arr.slice(0, i + 1).join('/'),
}))
: []
const isAtHome = currentPath === homePath
const directories = entries.filter((e) => e.type === 'directory')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[min(520px,80vh)] max-w-2xl flex-col">
<DialogHeader>
<DialogTitle>Select a Folder</DialogTitle>
<DialogDescription>Choose a folder for the agent to work in.</DialogDescription>
</DialogHeader>
{/* Breadcrumb: Home as root, then sub-folders */}
<div className="text-muted-foreground flex scrollbar-none items-center gap-0.5 overflow-x-auto pb-1 text-sm">
{homePath && (
<button
onClick={() => navigateTo(homePath)}
className={cn(
'flex shrink-0 items-center gap-1.5 transition-colors',
isAtHome && breadcrumbs.length === 0
? 'text-foreground font-medium'
: 'hover:text-foreground'
)}
>
<Home className="size-3.5" />
{homePath}
</button>
)}
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex shrink-0 items-center gap-0.5">
<ChevronRight className="size-3 shrink-0" />
<button
onClick={() => navigateTo(crumb.path)}
className={cn(
'shrink-0 transition-colors',
i === breadcrumbs.length - 1
? 'text-foreground font-medium'
: 'hover:text-foreground'
)}
>
{crumb.name}
</button>
</span>
))}
</div>
{/* Error alert */}
{error && (
<Alert variant="destructive" className="shrink-0 [&>svg+div]:-translate-y-px">
<TriangleAlert className="size-4" />
<AlertDescription className="text-sm">{error}</AlertDescription>
</Alert>
)}
{/* Directory listing */}
<div className="border-border min-h-0 flex-1 overflow-y-auto rounded-lg border">
{loading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm">Loading...</span>
</div>
) : directories.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center">
<span className="text-sm">No subfolders</span>
</div>
) : (
<ul role="listbox" className="p-1">
{directories.map((entry) => (
<li
key={entry.name}
role="option"
aria-selected={false}
tabIndex={0}
onClick={() => handleEntryClick(entry)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleEntryClick(entry)
}}
className="hover:bg-accent flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm"
>
<Folder className="text-primary size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<ChevronRight className="text-muted-foreground size-3.5 shrink-0" />
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<X className="size-4" />
Cancel
</Button>
<Button onClick={handleSelect} disabled={!currentPath}>
<Check className="size-4" />
Select This Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,82 @@
/**
* Folder Mount Confirmation Dialog
*
* Prompts the user to authorize MagenticLite to access files in a selected folder.
* Offers Cancel / Allow (with "Always Allow" dropdown option).
*/
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { X, ChevronDown, Check } from 'lucide-react'
interface FolderMountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
folderName: string
onAllow: () => void
onAlwaysAllow: () => void
onCancel: () => void
}
export function FolderMountDialog({
open,
onOpenChange,
folderName,
onAllow,
onAlwaysAllow,
onCancel,
}: FolderMountDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Allow MagenticLite to access files in &ldquo;{folderName}&rdquo;?
</DialogTitle>
<DialogDescription>
MagenticLite can read, edit, and delete files. Be mindful of sharing sensitive
information.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="secondary" onClick={onCancel}>
<X className="size-4" />
Cancel
</Button>
{/* Split button: Allow + dropdown for Always Allow */}
<div className="flex gap-px">
<Button onClick={onAllow} className="rounded-r-none" autoFocus>
<Check className="size-4" />
Allow
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-l-none pr-2.5 pl-2">
<ChevronDown className="h-4 w-4" />
<span className="sr-only">More allow options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onAlwaysAllow}>
Always allow access to this folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,202 @@
/**
* Chat message list component
*
* Handles message rendering including merging related messages:
* - code-execution + tool-result → single CodeExecutionMessage
* - Consecutive CUA actions → grouped CuaMessage with BrowserEmbed
*
* Performance optimization:
* - Render items (pairing/grouping) are memoized and only recomputed when message count changes
* - Individual message rendering is handled by memoized MessageRenderer
*/
import { useMemo, useEffect } from 'react'
import { Globe } from 'lucide-react'
import { MessageRenderer } from './MessageRenderer'
import { CuaMessage, type CuaAction, CollapsibleHeader } from './messages'
import { BrowserEmbed } from '@/components/browser'
import { type ParsedMessage, type BrowserViewMode, type SessionStatus } from '@/types'
import type { FileInfo } from '@/types'
import {
computeRenderItems,
insertTimestampSeparators,
shouldHideStatusIndicator,
} from './messageListUtils'
import { formatChatSeparator } from '@/lib/timeFormat'
// =============================================================================
// Types
// =============================================================================
interface MessageListProps {
messages: ParsedMessage[]
sessionId: number
/** Callback to register item element refs for scroll tracking */
onItemRef?: (id: string, element: HTMLDivElement | null) => void
/** noVNC WebSocket URL for browser embed (if available) */
novncUrl?: string | null
/** Per-slot RFB password for the noVNC handshake. */
novncPassword?: string | null
/** Whether screenshot actions exist (enables browser embed without VNC) */
hasScreenshots?: boolean
/** Current session status - used for CUA message tense */
sessionStatus?: SessionStatus
/** Whether this session is selected/visible - disconnects VNC when hidden */
isSessionSelected?: boolean
/** Current browser view mode - determines how browser is displayed */
browserViewMode?: BrowserViewMode
/** Called when mode changes via browser toolbar buttons */
onBrowserModeChange?: (mode: BrowserViewMode) => void
/** Called when a CUA action is clicked (for Browser screenshot sync) */
onCuaActionClick?: (action: CuaAction) => void
/** ID of the currently highlighted CUA action (from Browser playback) */
highlightedActionId?: string | null
/** Called when user clicks the Take Control button in browser toolbar */
onControlClick?: () => void
/** Notifies parent when session status indicator should be hidden */
onHideStatusIndicator?: (hidden: boolean) => void
/** Called when user clicks a file chip to preview */
onFilePreview?: (file: FileInfo) => void
}
// Re-export CuaAction type for consumers
export type { CuaAction }
// =============================================================================
// Component
// =============================================================================
/** Renders a list of chat messages with CUA grouping and browser embed */
export function MessageList({
messages,
sessionId,
onItemRef,
novncUrl,
novncPassword,
hasScreenshots = false,
sessionStatus,
isSessionSelected = true,
browserViewMode = 'embedded',
onBrowserModeChange,
onCuaActionClick,
highlightedActionId,
onControlClick,
onHideStatusIndicator,
onFilePreview,
}: MessageListProps) {
// Compute render items - re-runs when message count, novncUrl, or sessionId changes
// Internal messages (browser_address, debugging) are always filtered out and logged to console instead.
// Then layer in inline timestamp separators (independent post-processing pass).
const renderItems = useMemo(
() => insertTimestampSeparators(computeRenderItems(messages, novncUrl, hasScreenshots)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[messages, novncUrl, hasScreenshots, sessionId]
)
// Hide session status indicator when last message already conveys the status
const hideStatusIndicator = useMemo(
() => shouldHideStatusIndicator(renderItems, sessionStatus),
[renderItems, sessionStatus]
)
// Notify parent when status indicator visibility changes
useEffect(() => {
onHideStatusIndicator?.(hideStatusIndicator)
}, [hideStatusIndicator, onHideStatusIndicator])
// Stable ref callback factory - creates callbacks that persist across renders
// Uses closure-based cache instead of ref to avoid ref access during render
// eslint-disable-next-line react-hooks/preserve-manual-memoization -- closure-based cache requires manual memoization
const getRefCallback = useMemo(() => {
if (!onItemRef) return () => undefined
const cache = new Map<string, (el: HTMLDivElement | null) => void>()
return (id: string) => {
let callback = cache.get(id)
if (!callback) {
callback = (el: HTMLDivElement | null) => onItemRef(id, el)
cache.set(id, callback)
}
return callback
}
}, [onItemRef])
return (
<div role="log" aria-label="Conversation" aria-live="polite" className="flex flex-col gap-6">
{renderItems.map((item) => {
switch (item.kind) {
case 'message':
return (
<MessageRenderer
key={item.message.id}
ref={getRefCallback(item.message.id)}
message={item.message}
sessionId={sessionId}
codeResultContent={item.codeResultContent}
toolResultContent={item.toolResultContent}
onFilePreview={onFilePreview}
/>
)
case 'cua-group':
return (
<div key={item.groupId} data-cua-group={item.groupId} className="flex w-full">
<div className="min-w-0 pr-16">
<CuaMessage
groupId={item.groupId}
sessionId={sessionId}
actions={item.actions}
canUseProgressiveTense={item.canUseProgressiveTense}
sessionStatus={sessionStatus}
hasLiveBrowser={!!novncUrl}
onActionClick={onCuaActionClick}
highlightedActionId={highlightedActionId}
/>
</div>
</div>
)
case 'cua-placeholder':
// Show a non-interactive header as placeholder before first CUA action arrives
return (
<div key={item.groupId} className="flex w-full">
<div className="min-w-0 pr-16">
<CollapsibleHeader
icon={<Globe className="size-4" />}
label={sessionStatus === 'active' ? 'Using web browser' : 'Used web browser'}
isExpanded={false}
disabled
isActive={sessionStatus === 'active'}
/>
</div>
</div>
)
case 'browser-embed':
// Only show embedded browser in embedded mode
// In expanded/maximized modes, browser is shown in SessionView
if (browserViewMode !== 'embedded') return null
return (
<BrowserEmbed
key={item.groupId}
novncUrl={novncUrl ?? null}
novncPassword={novncPassword ?? null}
sessionId={sessionId}
isSessionSelected={isSessionSelected}
onModeChange={onBrowserModeChange}
onControlClick={onControlClick}
/>
)
case 'timestamp':
return (
<div key={item.id} className="flex w-full justify-center">
<p className="text-muted-foreground text-sm leading-[21px] font-bold tracking-[0.07px]">
{formatChatSeparator(item.iso)}
</p>
</div>
)
}
})}
</div>
)
}
@@ -0,0 +1,274 @@
/**
* Message Renderer
*
* Central component for rendering individual message types.
* Uses ParsedMessage with discriminated union by 'kind'.
*
* Handles: user, text, summary, error, code-execution, tool-result, orchestrator-tool,
* cua-non-browser, reasoning, system-status, input-request, final-answer
*
* Does NOT handle (processed elsewhere):
* - cua-browser, screenshot: grouped into CuaMessage in messageListUtils
* - browser-address, internal: filtered out in messageListUtils
*/
import { memo } from 'react'
import { Markdown } from '@/components/common'
import { FileChip, FolderChip } from '@/components/common'
import {
CodeExecutionMessage,
OrchestratorToolMessage,
ToolResultMessage,
FinalAnswerMessage,
ReasoningMessage,
SystemStatusMessage,
InputRequestMessage,
ErrorMessage,
FileMessage,
MemorizedFactMessage,
} from './messages'
import { type ParsedMessage } from '@/types'
import { isPreviewable, openFileOrDownload, triggerFileDownload } from '@/lib/fileUtils'
import type { FileInfo } from '@/types'
// =============================================================================
// Props
// =============================================================================
export interface MessageRendererProps {
/** Parsed message with kind discriminant */
message: ParsedMessage
/** Session ID for state tracking */
sessionId: number
/** Reference for scroll tracking */
ref?: React.Ref<HTMLDivElement>
/** Code result content (for merging code-execution with tool-result) */
codeResultContent?: string
/** Tool result content (for merging orchestrator-tool with tool-result) */
toolResultContent?: string
/** Called when user clicks a file chip to preview */
onFilePreview?: (file: FileInfo) => void
}
// =============================================================================
// Main Component
// =============================================================================
/**
* Renders a message based on its kind.
* Handles: user, text, code execution, final answer, summary, error, etc.
*
* Wrapped in React.memo for performance optimization with large message lists.
* Since old messages' object references don't change (store uses shallow copy),
* memo can skip re-rendering unchanged messages when new messages arrive.
*/
export const MessageRenderer = memo(function MessageRenderer({
message,
sessionId,
ref,
codeResultContent,
toolResultContent,
onFilePreview,
}: MessageRendererProps) {
// User messages always use UserMessageBubble
if (message.kind === 'user') {
return (
<div ref={ref} className="flex w-full flex-col items-end gap-2">
<div className="flex w-full justify-end pl-16">
<div className="bg-secondary rounded-lg px-4 py-3 shadow-sm">
<div className="text-secondary-foreground text-base leading-6">
<TextContent content={message.content} />
</div>
</div>
</div>
{/* File/folder chips below user message (right-aligned, tight spacing) */}
{(!!message.mountedFolder ||
(message.attachedFiles && message.attachedFiles.length > 0)) && (
<div className="flex w-full flex-wrap justify-end gap-2">
{message.attachedFiles?.map((file, i) => (
<FileChip
key={`${file.name}-${i}`}
name={file.name}
extension={file.extension}
context="chat"
tooltip={isPreviewable(file.name) ? 'Preview' : 'Download'}
{...(file.uploadStatus ? { status: file.uploadStatus } : {})}
onClick={() => openFileOrDownload(file, onFilePreview)}
onDownload={() => triggerFileDownload(file)}
/>
))}
{message.mountedFolder && (
<FolderChip name={message.mountedFolder.name} path={message.mountedFolder.path} />
)}
</div>
)}
</div>
)
}
// Assistant messages - render based on kind
const content = renderMessageContent(
message,
sessionId,
codeResultContent,
toolResultContent,
onFilePreview
)
// Don't render container if content is null/empty (e.g., non-browser-tool with empty thoughts)
if (!content) return null
return <AssistantMessageContainer ref={ref}>{content}</AssistantMessageContainer>
})
// =============================================================================
// Content Rendering by Kind
// =============================================================================
function renderMessageContent(
message: ParsedMessage,
sessionId: number,
codeResultContent?: string,
toolResultContent?: string,
onFilePreview?: (file: FileInfo) => void
): React.ReactNode {
switch (message.kind) {
case 'code-execution':
return (
<CodeExecutionMessage
groupId={message.id}
sessionId={sessionId}
codeContent={message.code}
language={message.language}
resultContent={codeResultContent}
/>
)
case 'tool-result':
// Standalone tool result (not paired with code-execution)
return (
<ToolResultMessage
groupId={message.id}
sessionId={sessionId}
toolName={message.toolName}
resultContent={message.result}
/>
)
case 'final-answer':
return <FinalAnswerMessage content={message.content} />
case 'system-status':
if (!message.content) return null
return <SystemStatusMessage status={message.status} content={message.content} />
case 'input-request':
// Approval cards render even without content (tool/reason metadata suffice)
if (!message.content && message.inputType !== 'approval') return null
return (
<InputRequestMessage
content={message.content}
inputType={message.inputType}
sessionId={sessionId}
messageId={message.id}
tool={message.tool}
toolArgs={message.toolArgs}
category={message.category}
reason={message.reason}
/>
)
case 'summary':
case 'text':
if (!message.content) return null
return <TextContent content={message.content} />
case 'error':
return <ErrorMessage content={message.content} />
case 'reasoning':
return (
<ReasoningMessage
groupId={message.id}
sessionId={sessionId}
content={message.content}
thinkingSeconds={message.thinkingSeconds}
/>
)
case 'cua-non-browser':
// pause_and_memorize_fact: collapsible header "Memorized a fact" with the fact in the body.
if (message.tool === 'pause_and_memorize_fact') {
if (!message.toolArgs.fact) return null
return (
<MemorizedFactMessage
groupId={message.id}
sessionId={sessionId}
fact={message.toolArgs.fact}
/>
)
}
// read_page_answer_question / run_command are folded into the CUA group.
// Other non-browser tools (e.g., terminate) display their thoughts here.
if (!message.toolArgs.thoughts) return null
return <TextContent content={message.toolArgs.thoughts} />
case 'orchestrator-tool':
return (
<OrchestratorToolMessage
tool={message.tool}
toolArgs={message.toolArgs}
resultContent={toolResultContent}
groupId={message.id}
sessionId={sessionId}
approvalStatus={message.approvalStatus}
/>
)
case 'file':
return (
<FileMessage
files={message.files}
summary={message.summary}
uploadedFiles={message.uploadedFiles}
onFilePreview={onFilePreview}
/>
)
// Internal messages (internal, browser-address) are filtered in messageListUtils
// Screenshot and cua-browser are handled in CUA groups
default:
return null
}
}
// =============================================================================
// Content Components
// =============================================================================
/** Text/Markdown content - returns null for empty content */
function TextContent({ content }: { content: string }) {
if (!content) return null
return <Markdown>{content}</Markdown>
}
// =============================================================================
// Layout Containers
// =============================================================================
interface ContainerProps {
children: React.ReactNode
ref?: React.Ref<HTMLDivElement>
}
/** Assistant message container - left-aligned, flat style */
function AssistantMessageContainer({ children, ref }: ContainerProps) {
return (
<div ref={ref} className="flex w-full">
<div className="min-w-0 pr-16">
<div className="text-foreground text-base leading-6">{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
/**
* ChatView utility functions
*
* Pure functions extracted from ChatView.tsx for testing.
*/
import type { ControlState, SessionStatus } from '@/types'
/**
* Decide whether a user-submitted chat message should be sent as an
* `input_response` (vs. starting a new task with `start`).
*
* Routes to `input_response` when:
* - Backend has a pending InputRequest (text prompt, approval, or takeover pause).
* - User is in browser takeover mode (`controlState === 'user'`) and is now
* providing post-takeover feedback that resolves the takeover InputRequest.
* - Agent is actively running or paused — the backend's mid-run inbox queues
* the message and the agent drains it at the next checkpoint (PR #614).
*
* Otherwise (`created` / `completed` / `stopped` / `error`), the message
* starts a new task via `start`.
*/
export function shouldUseInputResponse(args: {
hasInputRequest: boolean
controlState: ControlState
sessionStatus: SessionStatus
}): boolean {
const { hasInputRequest, controlState, sessionStatus } = args
return (
hasInputRequest ||
controlState === 'user' ||
sessionStatus === 'active' ||
sessionStatus === 'paused'
)
}
+1
View File
@@ -0,0 +1 @@
export { ChatView } from './ChatView'
@@ -0,0 +1,597 @@
/**
* Message list utility functions
*
* Pure functions for message pairing and grouping logic,
* extracted from MessageList.tsx for testing.
*/
import {
type ParsedMessage,
type SessionStatus,
isParsedInternalMessage,
isCuaBrowserMessage,
} from '@/types'
import { exceedsChatGap } from '@/lib/timeFormat'
import { type CuaAction, type ToolArgs } from './messages'
// =============================================================================
// Types - Render Items
// =============================================================================
/** Result from collectScreenshotActions */
export interface ScreenshotActionsResult {
/** CUA actions that have screenshots (for progress bar / playback) */
completedActions: CuaAction[]
/** The most recent action that doesn't have a screenshot yet (for live display) */
pendingAction: CuaAction | null
/** Whether there are non-CUA messages after the last CUA action (hides live action info) */
hasMessageAfterLastCua: boolean
}
/** Union type for all renderable items */
export type RenderItem =
| {
kind: 'message'
message: ParsedMessage
codeResultContent?: string
toolResultContent?: string
}
| {
kind: 'cua-group'
actions: CuaAction[]
groupId: string
canUseProgressiveTense: boolean
/** ISO timestamp of the first action in the group; used for chat timestamp separators. */
firstTimestamp?: string
}
| { kind: 'cua-placeholder'; groupId: string }
| { kind: 'browser-embed'; groupId: string }
| {
/** Inline timestamp divider rendered between messages when the gap exceeds `CHAT_TIMESTAMP_GAP_MS`. */
kind: 'timestamp'
id: string
iso: string
}
// =============================================================================
// Message Pairing Logic
//
// Two functions process messages with overlapping logic:
// - collectScreenshotActions: lightweight, only extracts screenshot actions (for BrowserExpanded)
// - computeRenderItems: full processing with grouping/merging (for MessageList)
//
// Trade-off: Some duplicate traversal, but keeps data flow simple.
// Merging them would require passing screenshotActions through multiple component layers.
// =============================================================================
/**
* Collect CUA actions with screenshots for BrowserExpanded playback bar.
* @param messages - Parsed messages to process
*/
export function collectScreenshotActions(messages: ParsedMessage[]): ScreenshotActionsResult {
const filteredMessages = messages.filter((msg) => !isParsedInternalMessage(msg))
const completedActions: CuaAction[] = []
let pendingAction: CuaAction | null = null
let lastCuaIndex = -1 // Track last CUA action (browser-tool or screenshot) index
for (let i = 0; i < filteredMessages.length; i++) {
const msg = filteredMessages[i]
// Browser tool message: create pending action
if (isCuaBrowserMessage(msg)) {
// If there was a pending action without screenshot, discard it
pendingAction = {
messageId: msg.id,
rawContent: msg.toolArgs.thoughts ?? '',
tool: msg.tool,
toolArgs: msg.toolArgs as ToolArgs,
isComplete: false,
}
lastCuaIndex = i
continue
}
// Screenshot message: associate with pending action
if (msg.kind === 'screenshot' && pendingAction) {
if (msg.imageUrl) {
pendingAction.screenshotUrl = msg.imageUrl
pendingAction.isComplete = true
}
if (msg.actionResult) {
pendingAction.actionResult = msg.actionResult
}
// Only add if we have a screenshot URL
if (pendingAction.screenshotUrl) {
completedActions.push(pendingAction)
}
pendingAction = null
lastCuaIndex = i
}
}
// Check if there are non-internal messages after the last CUA action
const hasMessageAfterLastCua = lastCuaIndex >= 0 && lastCuaIndex < filteredMessages.length - 1
return { completedActions, pendingAction, hasMessageAfterLastCua }
}
/**
* Check if two adjacent messages should be merged.
* Returns true if current is code-execution and next is tool-result with execute_code tool.
*/
export function shouldMerge(current: ParsedMessage, next: ParsedMessage): boolean {
if (current.kind !== 'code-execution') return false
return next.kind === 'tool-result' && next.toolName === 'execute_code'
}
/**
* Check if an orchestrator-tool message should merge with the next tool-result.
* Only merges when adjacent — delegate_cua results typically arrive after CUA messages,
* so they won't merge and will render as standalone ToolResultMessage.
*/
export function shouldMergeOrchestratorTool(current: ParsedMessage, next: ParsedMessage): boolean {
if (current.kind !== 'orchestrator-tool') return false
if (next.kind !== 'tool-result') return false
return !!current.toolCallId && current.toolCallId === next.toolCallId
}
/**
* Compute render items for MessageList, with CUA grouping and browser embed.
* @param messages - Parsed messages to process
* @param novncUrl - noVNC WebSocket URL (enables live VNC if provided)
* @param hasScreenshots - Whether screenshot actions exist (enables screenshot-only browser embed)
*/
export function computeRenderItems(
messages: ParsedMessage[],
novncUrl: string | null | undefined,
hasScreenshots = false
): RenderItem[] {
// Always filter out internal messages (they're logged to console instead)
// Also filter out approved approval-type input-request messages
const approvedInputRequestIds = new Set<string>()
let lastApprovalRequestId: string | null = null
for (const msg of messages) {
if (msg.kind === 'input-request' && (msg as { inputType?: string }).inputType === 'approval') {
lastApprovalRequestId = msg.id
} else if (
msg.kind === 'approval-response' &&
(msg as { decision: string }).decision === 'approve' &&
lastApprovalRequestId
) {
approvedInputRequestIds.add(lastApprovalRequestId)
lastApprovalRequestId = null
} else if (msg.kind !== 'system-status') {
lastApprovalRequestId = null
}
}
// Hide settled "Continue" continuation cards (and their bracketing
// awaiting_input / active system-status frames) so consecutive CUA
// actions stay in one browser group across the continuation. Stop
// cards remain visible.
const settledContinuationIds = new Set<string>()
let lastAwaitingInputId: string | null = null
let pendingContinuationRequestId: string | null = null
let pendingContinuationAwaitingInputId: string | null = null
let collectNextActiveFrame = false
for (const msg of messages) {
if (msg.kind === 'system-status') {
const status = (msg as { status?: string }).status
if (collectNextActiveFrame && status === 'active') {
settledContinuationIds.add(msg.id)
collectNextActiveFrame = false
continue
}
lastAwaitingInputId = status === 'awaiting_input' ? msg.id : null
continue
}
if (
msg.kind === 'input-request' &&
(msg as { inputType?: string }).inputType === 'continuation'
) {
pendingContinuationRequestId = msg.id
pendingContinuationAwaitingInputId = lastAwaitingInputId
lastAwaitingInputId = null
collectNextActiveFrame = false
continue
}
if (msg.kind === 'continuation-response' && pendingContinuationRequestId) {
if (msg.decision === 'continue') {
settledContinuationIds.add(pendingContinuationRequestId)
if (pendingContinuationAwaitingInputId) {
settledContinuationIds.add(pendingContinuationAwaitingInputId)
}
collectNextActiveFrame = true
}
pendingContinuationRequestId = null
pendingContinuationAwaitingInputId = null
lastAwaitingInputId = null
continue
}
lastAwaitingInputId = null
pendingContinuationRequestId = null
pendingContinuationAwaitingInputId = null
collectNextActiveFrame = false
}
const filteredMessages = messages.filter(
(msg) =>
!isParsedInternalMessage(msg) &&
!approvedInputRequestIds.has(msg.id) &&
!settledContinuationIds.has(msg.id)
)
const items: RenderItem[] = []
let currentCuaGroup: CuaAction[] = []
let cuaGroupStartId: string | null = null
/** Timestamp of the first message that started the current CUA group. */
let cuaGroupStartTimestamp: string | null = null
/** Track items index of the last CUA group for browser embed placement */
let lastCuaGroupItemIndex = -1
/** Track seen file URLs to deduplicate file messages (show each file only once) */
const seenFileUrls = new Set<string>()
/**
* Map tool_call_id → tool name from orchestrator-tool messages so standalone
* tool-results (e.g., delegate_cua, when CUA messages prevent merging) can
* recover their tool name from the originating tool_call rather than falling
* back to a generic "Running tool" label.
*/
const toolCallIdToName = new Map<string, string>()
for (const msg of filteredMessages) {
if (msg.kind === 'orchestrator-tool' && msg.toolCallId) {
toolCallIdToName.set(msg.toolCallId, msg.tool)
}
}
const flushCuaGroup = () => {
if (currentCuaGroup.length > 0) {
lastCuaGroupItemIndex = items.length
items.push({
kind: 'cua-group',
actions: [...currentCuaGroup],
groupId: `cua-${cuaGroupStartId}`,
canUseProgressiveTense: false, // Will be updated after all items are processed
firstTimestamp: cuaGroupStartTimestamp ?? undefined,
})
currentCuaGroup = []
cuaGroupStartId = null
cuaGroupStartTimestamp = null
}
}
for (let i = 0; i < filteredMessages.length; i++) {
const current = filteredMessages[i]
const next = filteredMessages[i + 1]
// Handle CUA actions (browser tools, plus read_page_answer_question / run_command).
// pause_and_memorize_fact, terminate, and stop render standalone outside the group.
const isInlineNonBrowser =
current.kind === 'cua-non-browser' &&
(current.tool === 'read_page_answer_question' || current.tool === 'run_command')
if (isCuaBrowserMessage(current) || isInlineNonBrowser) {
// Mark previous action as complete when a new action arrives
if (currentCuaGroup.length > 0) {
currentCuaGroup[currentCuaGroup.length - 1].isComplete = true
} else {
cuaGroupStartId = current.id
cuaGroupStartTimestamp = current.timestamp
}
currentCuaGroup.push({
messageId: current.id,
rawContent: current.toolArgs.thoughts ?? '',
tool: current.tool,
toolArgs: current.toolArgs as ToolArgs,
// Inline non-browser actions never produce a screenshot — mark complete so they don't block tense
isComplete: isInlineNonBrowser,
})
continue
}
// Non-CUA message (including screenshot): mark last action as complete
if (currentCuaGroup.length > 0) {
currentCuaGroup[currentCuaGroup.length - 1].isComplete = true
// If it's a screenshot, associate its URL and actionResult with the previous action
if (current.kind === 'screenshot') {
if (current.imageUrl) {
currentCuaGroup[currentCuaGroup.length - 1].screenshotUrl = current.imageUrl
}
// actionResult is the completed action description (past tense) from screenshot message
if (current.actionResult) {
currentCuaGroup[currentCuaGroup.length - 1].actionResult = current.actionResult
}
}
// Errors during a CUA flow: fold in as inline action rows so they don't
// break the visual grouping with a separate styled error block.
// Carry the last real action's tool info so consecutive retry errors
// still show "Error Visiting ..." instead of just "Error".
if (current.kind === 'error' && current.content) {
const failed = [...currentCuaGroup].reverse().find((a) => a.tool)
currentCuaGroup.push({
messageId: current.id,
rawContent: current.content,
tool: failed?.tool,
toolArgs: failed?.toolArgs,
isComplete: true,
errorContent: current.content,
})
continue
}
}
// Screenshot is part of CUA group, don't flush
if (current.kind === 'screenshot') {
continue
}
// Other non-CUA messages end the CUA group
flushCuaGroup()
// Handle orchestrator-tool messages (OmniAgent tool_call)
// Always render. If adjacent tool-result matches, merge into one item.
if (current.kind === 'orchestrator-tool') {
if (next && shouldMergeOrchestratorTool(current, next)) {
const resultContent = next.kind === 'tool-result' ? next.result : ''
items.push({
kind: 'message',
message: current,
toolResultContent: resultContent,
})
i++ // Skip the tool-result message
} else {
items.push({ kind: 'message', message: current })
}
continue
}
// Handle code execution merging
if (next && shouldMerge(current, next)) {
// Get result content from tool-result message
const resultContent = next.kind === 'tool-result' ? next.result : ''
items.push({
kind: 'message',
message: current,
codeResultContent: resultContent,
})
i++ // Skip the tool-result message
continue
}
// Standalone tool result (e.g., non-adjacent OmniAgent results like delegate_cua)
// Don't skip — let MessageRenderer display it
if (current.kind === 'tool-result') {
// If the parser couldn't extract a tool name from the content, recover it
// from the matching orchestrator-tool via tool_call_id.
let resolved = current
if (!current.toolName && current.toolCallId) {
const lookupName = toolCallIdToName.get(current.toolCallId)
if (lookupName) {
resolved = { ...current, toolName: lookupName }
}
}
items.push({ kind: 'message', message: resolved })
continue
}
// File messages: deduplicate by URL — only show each file the first time
// it appears. The end-of-run summary (`message.summary === true`) is
// exempt from this dedup so its "Files the agent created or modified"
// list is shown beneath the final answer even though every file has
// been seen earlier.
if (current.kind === 'file') {
if (current.summary) {
items.push({ kind: 'message', message: current })
continue
}
const newFiles = current.files.filter((f) => !seenFileUrls.has(f.url))
newFiles.forEach((f) => seenFileUrls.add(f.url))
if (newFiles.length > 0) {
// Create a new message with only unseen files
const deduped =
newFiles.length === current.files.length ? current : { ...current, files: newFiles }
items.push({ kind: 'message', message: deduped })
}
// Skip if all files were already seen
continue
}
// Regular message
items.push({ kind: 'message', message: current })
}
// Flush any remaining CUA group
flushCuaGroup()
// Remove delegate_cua placeholder once CUA groups exist (CUA group replaces it)
if (lastCuaGroupItemIndex >= 0) {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]
if (
item.kind === 'message' &&
item.message.kind === 'orchestrator-tool' &&
item.message.tool === 'delegate_cua' &&
item.toolResultContent === undefined
) {
items.splice(i, 1)
if (i <= lastCuaGroupItemIndex) {
lastCuaGroupItemIndex--
}
}
}
}
// Update canUseProgressiveTense for the last CUA group (regardless of browser embed)
let lastCuaGroupIndex = -1
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].kind === 'cua-group') {
lastCuaGroupIndex = i
break
}
}
if (lastCuaGroupIndex >= 0) {
const lastCuaGroup = items[lastCuaGroupIndex]
if (lastCuaGroup.kind === 'cua-group') {
// Only allow progressive tense if there are no message items after it
const hasMessageAfter = items.slice(lastCuaGroupIndex + 1).some((it) => it.kind === 'message')
lastCuaGroup.canUseProgressiveTense = !hasMessageAfter
}
}
if (novncUrl || hasScreenshots) {
// Stable groupId prevents VNC reconnection when CUA groups change.
const groupId = 'browser-embed'
if (lastCuaGroupItemIndex >= 0) {
items.splice(lastCuaGroupItemIndex + 1, 0, {
kind: 'browser-embed',
groupId,
})
} else {
const lastItem = items[items.length - 1]
const hasDelegateCuaPlaceholder =
lastItem?.kind === 'message' &&
lastItem.message.kind === 'orchestrator-tool' &&
lastItem.message.tool === 'delegate_cua'
if (hasDelegateCuaPlaceholder) {
// delegate_cua already signals browsing intent; embed under it.
items.push({
kind: 'browser-embed',
groupId,
})
} else if (hasScreenshots) {
items.push({
kind: 'cua-placeholder',
groupId: 'cua-placeholder',
})
items.push({
kind: 'browser-embed',
groupId,
})
}
// A live browser with no CUA action yet (websurfer_only at launch):
// defer the embed until the first tool call forms a cua-group, so the
// bottom status indicator stays visible.
}
}
return items
}
/**
* Pick the representative ISO timestamp for a render item, used to decide
* whether a timestamp separator is needed before it.
*
* - `message` → the message's own timestamp.
* - `cua-group` → the first action's source-message timestamp (the group is
* atomic and never split internally).
* - `cua-placeholder` / `browser-embed` / `timestamp` → no anchor; these never
* trigger a separator on their own.
*/
function timestampOfItem(item: RenderItem): string | undefined {
if (item.kind === 'message') return item.message.timestamp
if (item.kind === 'cua-group') return item.firstTimestamp
return undefined
}
/**
* Insert inline timestamp separators between render items when the gap exceeds
* `CHAT_TIMESTAMP_GAP_MS`. Always inserts a leading separator above the first
* item that has a timestamp, so every chat opens with a time anchor.
*
* Operates at the RenderItem layer so CUA groups stay atomic.
*/
export function insertTimestampSeparators(items: RenderItem[]): RenderItem[] {
const out: RenderItem[] = []
let lastIso: string | undefined
let leadingInserted = false
for (let i = 0; i < items.length; i++) {
const item = items[i]
const iso = timestampOfItem(item)
if (iso) {
if (!leadingInserted) {
// Always anchor the first timestamped item with a leading separator.
out.push({ kind: 'timestamp', id: `ts-lead-${iso}`, iso })
leadingInserted = true
} else if (exceedsChatGap(lastIso, iso)) {
out.push({ kind: 'timestamp', id: `ts-${i}-${iso}`, iso })
}
lastIso = iso
}
out.push(item)
}
return out
}
/**
* Check if the session status indicator should be hidden.
*
* Hidden when the last message already conveys the same information:
* - Active: shimmer headers show activity
* - Completed: last message is final-answer
* - Awaiting input: last message is input-request with content
* - Error: last message is error/system-status(error) with content
*/
export function shouldHideStatusIndicator(
renderItems: RenderItem[],
sessionStatus: SessionStatus | undefined
): boolean {
if (sessionStatus === 'active') {
// These indicators are always near the tail — check from the end, skip
// structural items that are not real messages (browser-embed, timestamp).
for (let i = renderItems.length - 1; i >= 0; i--) {
const item = renderItems[i]
if (item.kind === 'browser-embed' || item.kind === 'timestamp') continue
return (
item.kind === 'cua-placeholder' ||
(item.kind === 'cua-group' && item.canUseProgressiveTense) ||
(item.kind === 'message' &&
((item.message.kind === 'orchestrator-tool' && item.toolResultContent === undefined) ||
(item.message.kind === 'code-execution' && item.codeResultContent === undefined)))
)
}
return false
}
// Find last message item for terminal-state checks
let lastMsg: ParsedMessage | undefined
for (let i = renderItems.length - 1; i >= 0; i--) {
const item = renderItems[i]
if (item.kind === 'message') {
lastMsg = item.message
break
}
}
if (!lastMsg) return false
if (sessionStatus === 'completed') {
return (
lastMsg.kind === 'final-answer' ||
(lastMsg.kind === 'system-status' && lastMsg.status === 'complete' && !!lastMsg.content)
)
}
if (sessionStatus === 'awaiting-input') {
return (
lastMsg.kind === 'input-request' && (!!lastMsg.content || lastMsg.inputType === 'approval')
)
}
if (sessionStatus === 'error') {
return (
(lastMsg.kind === 'error' ||
(lastMsg.kind === 'system-status' && lastMsg.status === 'error')) &&
!!lastMsg.content
)
}
return false
}
@@ -0,0 +1,77 @@
/**
* Code Block Component
*
* Displays code with syntax highlighting using Prism.js.
* Theme-aware: adapts to light/dark mode via CSS in index.css.
*
* Visual style (rounded-xl, border, shadow-sm) must stay consistent
* with PreBlock and Markdown's <pre> (MarkdownPre).
*/
import { useMemo } from 'react'
import { useTheme, useWrapState } from '@/hooks'
import { WrapToggleButton } from '@/components/common'
import { MAX_CONTENT_BLOCK_HEIGHT } from '@/lib/constants'
import { highlightCode } from '@/lib/prismConfig'
export interface CodeBlockProps {
/** Code content to display */
children: string
/** Language for syntax highlighting (e.g., 'json', 'python', 'bash') */
language?: string
/** Whether to wrap long lines (default: false, use horizontal scroll) */
wrap?: boolean
/** Maximum height before scrolling (default: 500px) */
maxHeight?: number
}
/**
* Renders code content with syntax highlighting.
* Falls back to plain text if language is not recognized.
*/
export function CodeBlock({
children,
language,
wrap,
maxHeight = MAX_CONTENT_BLOCK_HEIGHT,
}: CodeBlockProps) {
const theme = useTheme()
const [localWrap, setLocalWrap] = useWrapState(wrap)
const highlightedHtml = useMemo(() => highlightCode(children, language), [children, language])
// Whitespace styles based on wrap state
const preStyle = {
margin: 0,
...(localWrap && { whiteSpace: 'pre-wrap' as const, wordBreak: 'break-word' as const }),
}
return (
<div className="group/code relative">
<WrapToggleButton
wrap={localWrap}
onToggle={() => setLocalWrap((w) => !w)}
groupName="code"
/>
<div
className="code-editor-wrapper border-border overflow-auto rounded-xl border shadow-sm"
data-theme={theme}
style={{ maxHeight }}
>
{highlightedHtml ? (
<pre className="p-4" style={preStyle}>
<code
className="font-mono text-xs leading-none"
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
</pre>
) : (
// Fallback: plain monospace text
<pre className="p-4" style={preStyle}>
<code className="font-mono text-xs leading-none">{children}</code>
</pre>
)}
</div>
</div>
)
}
@@ -0,0 +1,65 @@
/**
* Code Execution Message Component
*
* Displays code execution requests (code_to_execute) with optional results (tool_result).
* Code and language are pre-parsed by lib/messages/parser.ts.
*
* Display:
* - Without result: "Executing code" (in progress)
* - With result: "Executed code" (completed)
*
* TODO: Investigate whether this component and the code-execution / codeResultContent
* merge logic (in messageListUtils.shouldMerge) are still triggered by OmniAgent.
*/
import { Code } from 'lucide-react'
import { Markdown } from '@/components/common'
import { CodeBlock } from './CodeBlock'
import { CollapsibleHeader } from './CollapsibleHeader'
import { useCollapsibleGroup } from '@/hooks'
export interface CodeExecutionMessageProps {
groupId: string
sessionId: number
/** Clean code (fence wrapper already stripped by parser) */
codeContent: string
/** Language from code fence (e.g. 'python') */
language: string
/** Clean result text (prefix already stripped by parser) */
resultContent?: string
}
/**
* Renders a code execution message showing code and optional result.
* - Without result: "Executing code" (in progress)
* - With result: "Executed code" (completed)
*/
export function CodeExecutionMessage({
groupId,
sessionId,
codeContent,
language,
resultContent,
}: CodeExecutionMessageProps) {
const { isExpanded, toggle } = useCollapsibleGroup(sessionId, groupId, 'toolCall')
const displayName = resultContent ? 'Executed code' : 'Executing code'
return (
<div className="flex w-full flex-col">
<CollapsibleHeader
icon={<Code className="size-4 shrink-0" />}
label={displayName}
isExpanded={isExpanded}
onToggle={toggle}
isActive={resultContent === undefined}
/>
{isExpanded && (
<div className="flex flex-col gap-4 pt-4 pl-6 text-sm leading-5">
{codeContent && <CodeBlock language={language}>{codeContent}</CodeBlock>}
{resultContent && <Markdown>{resultContent}</Markdown>}
</div>
)}
</div>
)
}
@@ -0,0 +1,90 @@
/**
* Collapsible Header Component
*
* Reusable header button for collapsible message sections.
* Used by: CodeExecutionMessage and future tool call displays.
*/
import { ChevronDown, ChevronUp } from 'lucide-react'
const SHIMMER_CYCLE_MS = 3000
const SHIMMER_PHASE_OFFSET_MS = Date.now() % SHIMMER_CYCLE_MS
interface CollapsibleHeaderBaseProps {
icon: React.ReactNode
label: string
isExpanded: boolean
/** When true, label uses shimmer effect to indicate in-progress state */
isActive?: boolean
}
/** Interactive header — onToggle is required */
interface CollapsibleHeaderInteractiveProps extends CollapsibleHeaderBaseProps {
disabled?: false
onToggle: () => void
}
/** Non-interactive header — onToggle is not needed */
interface CollapsibleHeaderDisabledProps extends CollapsibleHeaderBaseProps {
disabled: true
onToggle?: never
}
export type CollapsibleHeaderProps =
| CollapsibleHeaderInteractiveProps
| CollapsibleHeaderDisabledProps
/**
* Renders a clickable header with icon, label, and expand/collapse chevron.
* Follows consistent styling across all collapsible message types.
* When disabled, renders as a non-interactive placeholder (invisible chevron for layout).
*/
export function CollapsibleHeader({
icon,
label,
isExpanded,
onToggle,
disabled = false,
isActive = false,
}: CollapsibleHeaderProps) {
const shimmerDelay = `-${SHIMMER_PHASE_OFFSET_MS}ms`
const labelClass = isActive
? 'text-sm leading-5 font-bold shimmer-text'
: 'text-sm leading-5 font-bold'
const labelStyle = isActive
? ({ animationDelay: shimmerDelay } as React.CSSProperties)
: undefined
// Disabled state: render as static div without chevron
if (disabled) {
return (
<div className="text-foreground flex items-center gap-2">
{icon}
<span className={labelClass} style={labelStyle}>
{label}
</span>
<ChevronDown className="size-4 shrink-0 opacity-0" aria-hidden="true" />
</div>
)
}
return (
<button
type="button"
onClick={onToggle}
className="text-foreground flex cursor-pointer items-center gap-2"
>
{icon}
<span className={labelClass} style={labelStyle}>
{label}
</span>
{isExpanded ? (
<ChevronUp className="size-4 shrink-0" />
) : (
<ChevronDown className="size-4 shrink-0" />
)}
</button>
)
}
@@ -0,0 +1,374 @@
/**
* CUA (Computer Use Agent) Message Component
*
* Displays a group of web browser actions in a collapsible section.
* Used for: Visiting URLs, Scrolling, Typing, Clicking, etc.
*
* Tense handling:
* - Progressive tense (Visiting, Typing): only the LAST action in the LAST group when sessionStatus === 'active'
* - Past tense (Visited, Typed): all other actions
* - Header: progressive if any action uses progressive tense
*/
import { useState } from 'react'
import { Globe } from 'lucide-react'
import { CollapsibleHeader } from './CollapsibleHeader'
import { useCollapsibleGroup } from '@/hooks'
import { type SessionStatus } from '@/types'
import { getCuaToolLabel } from '@/lib/messages'
import { cn } from '@/lib/utils'
import { useUIStore } from '@/stores/uiStore'
// =============================================================================
// Types
// =============================================================================
/** Tool arguments from metadata.tool_args */
export interface ToolArgs {
action?: string
url?: string
query?: string
text?: string
coordinate?: [number, number]
pixels?: number
keys?: string[]
time?: number
press_enter?: boolean
delete_existing_text?: boolean
thoughts?: string
/** pause_and_memorize_fact: the memorized fact text */
fact?: string
/** read_page_answer_question / ask_user_question */
question?: string
/** run_command */
command?: string
[key: string]: unknown
}
export interface CuaAction {
/** Source message ID (for Chat ↔ Browser linkage) */
messageId: string
/** Raw action text (fallback display) */
rawContent: string
/** Tool name from metadata.tool (e.g., 'visit_url', 'type', 'click') */
tool?: string
/** Structured arguments from metadata.tool_args */
toolArgs?: ToolArgs
/** Whether this action has completed (a follow-up message arrived after the tool call).
* Controls tense: incomplete → progressive ("Visiting"), complete → past ("Visited"). */
isComplete?: boolean
/** Screenshot URL captured after this action completed (for progress bar/carousel) */
screenshotUrl?: string
/** Description of the completed action (past tense) from screenshot message. */
actionResult?: string
/** Error message text — when set, this row renders as an inline error within the group */
errorContent?: string
}
interface CuaMessageProps {
actions: CuaAction[]
/** Unique ID for this CUA group (for expand state tracking) */
groupId: string
/** Session ID (for expand state tracking) */
sessionId: number
/** Whether progressive tense can be used (true if no messages after this group) */
canUseProgressiveTense?: boolean
/** Current session status - used to determine tense */
sessionStatus?: SessionStatus
/** Whether a live noVNC stream is available — when true, the latest-thoughts
subtitle is hidden (the user can already see the action in the live browser). */
hasLiveBrowser?: boolean
/** Called when an action item is clicked (for Browser screenshot sync) */
onActionClick?: (action: CuaAction) => void
/** ID of the currently highlighted action (from Browser playback) */
highlightedActionId?: string | null
}
// =============================================================================
// Constants & Helpers
// =============================================================================
// Header labels: [progressive, past]
const HEADER_LABELS: [string, string] = ['Using web browser', 'Used web browser']
/**
* Extract detail string from tool args for display.
* Returns simplified URL, search query, typed text, scroll direction, keys, or coordinates.
*
* Note: Some parsed fields are not displayed (kept for potential future use):
* - action: redundant with metadata.tool
* - press_enter, delete_existing_text: type action modifiers
* - thoughts: stored in rawContent, used as fallback display
*/
function getToolDetail(tool: string | undefined, toolArgs: ToolArgs | undefined): string | null {
if (!tool || !toolArgs) return null
const toolLower = tool.toLowerCase()
switch (toolLower) {
case 'visit_url':
if (toolArgs.url) {
try {
const { hostname } = new URL(toolArgs.url)
const display = hostname.replace(/^www\./, '')
return display.length > 40 ? display.slice(0, 40) + '...' : display
} catch {
// Invalid URL - fallback to truncation
return toolArgs.url.length > 40 ? toolArgs.url.slice(0, 40) + '...' : toolArgs.url
}
}
break
case 'web_search':
if (toolArgs.query) {
const query = toolArgs.query
return query.length > 40 ? query.slice(0, 40) + '...' : query
}
break
case 'type':
case 'input_text':
if (toolArgs.text) {
const text = toolArgs.text
return text.length > 40 ? text.slice(0, 40) + '...' : text
}
break
case 'click':
case 'left_click':
case 'double_click':
case 'right_click':
case 'triple_click':
if (toolArgs.coordinate) {
const [x, y] = toolArgs.coordinate
return `(${x}, ${y})`
}
break
case 'left_click_drag':
if (toolArgs.coordinate) {
const [x, y] = toolArgs.coordinate
return `to (${x}, ${y})`
}
break
case 'scroll':
// Negative pixels = scroll down, positive = scroll up
if (toolArgs.pixels !== undefined) {
return toolArgs.pixels < 0 ? 'down' : 'up'
}
break
case 'hscroll':
// Negative pixels = scroll left, positive = scroll right
if (toolArgs.pixels !== undefined) {
return toolArgs.pixels < 0 ? 'left' : 'right'
}
break
case 'key':
case 'keypress':
// Keys are pressed together as a combination, not in sequence
if (toolArgs.keys && Array.isArray(toolArgs.keys)) {
const keysStr = toolArgs.keys.join('+')
return keysStr.length > 40 ? keysStr.slice(0, 40) + '...' : keysStr
}
break
case 'mouse_move':
case 'hover':
if (toolArgs.coordinate) {
const [x, y] = toolArgs.coordinate
return `to (${x}, ${y})`
}
break
case 'history_back':
return null
case 'wait':
case 'sleep':
if (toolArgs.time !== undefined) {
return `${toolArgs.time}s`
}
break
case 'pause_and_memorize_fact':
if (toolArgs.fact) {
return toolArgs.fact
}
break
case 'read_page_answer_question':
if (toolArgs.question) {
const q = toolArgs.question
return q.length > 60 ? q.slice(0, 60) + '...' : q
}
break
case 'run_command':
if (toolArgs.command) {
const c = toolArgs.command
return c.length > 60 ? c.slice(0, 60) + '...' : c
}
break
}
return null
}
// =============================================================================
// Component
// =============================================================================
export function CuaMessage({
actions,
groupId,
sessionId,
canUseProgressiveTense = false,
sessionStatus,
hasLiveBrowser = false,
onActionClick,
highlightedActionId,
}: CuaMessageProps) {
// Collapsible state management
const { isExpanded, toggle } = useCollapsibleGroup(sessionId, groupId, 'toolCall')
const showBrowserActionDetails = useUIStore((s) => s.showBrowserActionDetails)
// Use progressive tense only when allowed AND session is active
const useProgressiveTense = canUseProgressiveTense && sessionStatus === 'active'
// Header label: progressive if using progressive tense
const headerLabel = useProgressiveTense ? HEADER_LABELS[0] : HEADER_LABELS[1]
// Latest action's reasoning (thoughts) — surfaced under the header so users
// see what the agent is doing without expanding the group.
// Hidden when: live browser is active, group is expanded (thoughts shown inline),
// or there are newer messages after this group (thoughts are stale).
const latestThoughts = actions[actions.length - 1]?.toolArgs?.thoughts?.trim() || null
const showLatestThoughts =
latestThoughts && !hasLiveBrowser && !isExpanded && canUseProgressiveTense
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<CollapsibleHeader
icon={<Globe className="size-4" />}
label={headerLabel}
isExpanded={isExpanded}
onToggle={toggle}
isActive={useProgressiveTense}
/>
{showLatestThoughts && (
<div className="text-muted-foreground ml-6 text-sm">{latestThoughts}</div>
)}
</div>
{isExpanded && (
// Vertical line + indented content layout
<div className="border-border ml-2 flex flex-col gap-0.5 border-l pl-2">
{actions.map((action, index) => {
const isHighlighted = highlightedActionId === action.messageId
if (action.errorContent) {
return (
<CuaErrorRow
key={`${action.messageId}-${showBrowserActionDetails}`}
errorContent={action.errorContent}
failedTool={action.tool}
failedToolArgs={action.toolArgs}
defaultExpanded={showBrowserActionDetails}
isHighlighted={isHighlighted}
/>
)
}
// Use progressive tense if:
// 1. This action has no screenshot yet (action still in progress)
// 2. AND session is active
// 3. AND this is the last action AND canUseProgressiveTense is true
const isLastAction = index === actions.length - 1
const actionInProgress = !action.isComplete
const actionUseProgressiveTense =
actionInProgress && useProgressiveTense && isLastAction
const label = getCuaToolLabel(action.tool, actionUseProgressiveTense)
const detail = getToolDetail(action.tool, action.toolArgs)
const thoughts = action.toolArgs?.thoughts?.trim() || null
// Only clickable if action has a screenshot URL (can be shown in browser playback)
const isClickable = action.screenshotUrl && onActionClick
return (
<div
key={action.messageId}
className={cn(isClickable && 'cursor-pointer')}
onClick={isClickable ? () => onActionClick(action) : undefined}
>
<div
className={cn(
'text-foreground w-fit rounded-md px-2 text-sm break-words',
isHighlighted && 'bg-accent'
)}
>
<span className="font-bold">{label}</span>
{detail && <span className="ml-1.5">{detail}</span>}
</div>
{(showBrowserActionDetails ||
(isLastAction && actionInProgress && !hasLiveBrowser)) &&
thoughts && (
<div className="text-muted-foreground px-2 pb-1 text-xs break-words whitespace-pre-line">
{thoughts}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// =============================================================================
// CuaErrorRow
// =============================================================================
interface CuaErrorRowProps {
errorContent: string
failedTool: string | undefined
failedToolArgs: ToolArgs | undefined
defaultExpanded: boolean
isHighlighted: boolean
}
/**
* Error row inside a CUA group. Shows which action failed (action
* label + detail). Click toggles the full error text below.
* Defaults to expanded/collapsed based on the `showBrowserActionDetails` toggle;
* click overrides for this individual row.
*/
function CuaErrorRow({
errorContent,
failedTool,
failedToolArgs,
defaultExpanded,
isHighlighted,
}: CuaErrorRowProps) {
const [override, setOverride] = useState<boolean | null>(null)
const isExpanded = override ?? defaultExpanded
const failedLabel = failedTool ? getCuaToolLabel(failedTool, true) : null
const failedDetail = getToolDetail(failedTool, failedToolArgs)
return (
<div className="cursor-pointer" onClick={() => setOverride(!isExpanded)}>
<div
className={cn(
'text-status-error w-fit rounded-md px-2 text-sm break-words',
isHighlighted && 'bg-accent'
)}
>
<span className="font-bold">Error</span>
{(failedLabel || failedDetail) && (
<span className="ml-1.5">
{failedLabel}
{failedDetail && ` ${failedDetail}`}
</span>
)}
</div>
{isExpanded && (
<div className="text-status-error px-2 pb-1 text-xs break-words whitespace-pre-line">
{errorContent}
</div>
)}
</div>
)
}
@@ -0,0 +1,40 @@
/**
* Error Message
*
* Displays agent runtime error messages (metadata.type: 'error').
* Examples: tool call failures, page not initialized, etc.
*
* Note: Run status errors (system message with status: 'error') are displayed
* by SystemStatusMessage, not this component.
*/
import { Markdown } from '@/components/common'
import { HeaderedMessage } from './HeaderedMessage'
// =============================================================================
// Types
// =============================================================================
export interface ErrorMessageProps {
/** Error message content */
content: string
}
// =============================================================================
// Component
// =============================================================================
/**
* Renders error message with red "Error" header.
* Returns null when content is empty (hides entire message).
*/
export function ErrorMessage({ content }: ErrorMessageProps) {
// Hide entire message when backend sends empty content
if (!content) return null
return (
<HeaderedMessage header="Error" headerClassName="text-status-error">
<Markdown>{content}</Markdown>
</HeaderedMessage>
)
}
@@ -0,0 +1,138 @@
/**
* File Message Component
*
* Displays agent-generated or edited files as bare chips in the chat view.
* No collapsible header — just inline chips in the message flow.
*
* When `summary` is true, this is the end-of-run aggregated list shown
* beneath the final answer. It renders two sections so users can see the
* full input/output picture at a glance:
*
* - "Files you uploaded" (from `uploadedFiles`, if any)
* - "Files the agent created or modified" (from `files`, if any)
*/
import { FileChip } from '@/components/common'
import type { FileInfo } from '@/types'
import { isPreviewable, triggerFileDownload, openFileOrDownload } from '@/lib/fileUtils'
// =============================================================================
// Props
// =============================================================================
export interface FileMessageProps {
/** Files generated or edited by the agent */
files: FileInfo[]
/** Called when user clicks a previewable file chip */
onFilePreview?: (file: FileInfo) => void
/**
* When true, this is the end-of-run aggregated file list. Renders the
* chips under section headers ("Files you uploaded" / "Files the agent
* created or modified") so users get a complete overview after the
* final answer.
*/
summary?: boolean
/**
* Files the user uploaded for this run. Only meaningful when `summary`
* is true; rendered above the agent-generated files for overview.
*/
uploadedFiles?: FileInfo[]
}
// =============================================================================
// Internal helpers
// =============================================================================
function FileChipList({
files,
onFilePreview,
}: {
files: FileInfo[]
onFilePreview?: (file: FileInfo) => void
}) {
return (
<div className="flex flex-wrap gap-2">
{files.map((file, i) => (
<FileChip
key={`${file.name}-${i}`}
name={file.name}
extension={file.extension}
context="chat"
tooltip={isPreviewable(file.name) ? 'Preview' : 'Download'}
onClick={() => openFileOrDownload(file, onFilePreview)}
onDownload={() => triggerFileDownload(file)}
/>
))}
</div>
)
}
function FileSection({
label,
files,
onFilePreview,
}: {
label: string
files: FileInfo[]
onFilePreview?: (file: FileInfo) => void
}) {
return (
<div className="flex flex-col gap-2">
<div className="text-muted-foreground text-sm font-medium">{label}</div>
<FileChipList files={files} onFilePreview={onFilePreview} />
</div>
)
}
// =============================================================================
// Component
// =============================================================================
/**
* Renders bare file chips inline in the message list.
* Chips open preview for previewable files, or trigger download for others.
*
* For the end-of-run summary, chips are rendered under section headers
* for uploaded and generated files.
*/
export function FileMessage({
files,
onFilePreview,
summary = false,
uploadedFiles,
}: FileMessageProps) {
if (summary) {
const hasUploaded = !!uploadedFiles && uploadedFiles.length > 0
const hasGenerated = files.length > 0
// Pull the summary up so it visually belongs to the preceding final
// answer instead of looking like an unrelated next message. Standard
// message gap is gap-6 (24px); -mt-3 (12px) leaves a gentle separation
// (~12px) without breaking the message-as-its-own-bubble model.
// Sections inside the summary use the same 12px rhythm via gap-3.
return (
<div className="-mt-3 flex flex-col gap-3">
{hasUploaded && (
<FileSection
label="Files you uploaded"
files={uploadedFiles!}
onFilePreview={onFilePreview}
/>
)}
{hasGenerated && (
<FileSection
label="Files the agent created or modified"
files={files}
onFilePreview={onFilePreview}
/>
)}
</div>
)
}
// Non-summary chips sit tight against the previous message
return (
<div className="-mt-4">
<FileChipList files={files} onFilePreview={onFilePreview} />
</div>
)
}
@@ -0,0 +1,60 @@
/**
* Final Answer Message
*
* Displays the agent's final answer with a "Final Answer" headline
* and formatted content below.
*/
import { Markdown } from '@/components/common'
import { HeaderedMessage } from './HeaderedMessage'
// =============================================================================
// Types
// =============================================================================
export interface FinalAnswerMessageProps {
/** Pre-extracted content from ParsedFinalAnswerMessage */
content: string
}
// =============================================================================
// Constants
// =============================================================================
/** Prefix to strip from content (backend includes this in the message) */
const FINAL_ANSWER_PREFIX = 'Final Answer:'
// =============================================================================
// Component
// =============================================================================
/**
* Renders final answer with styled headline and body content.
*
* Backend format: "Final Answer: <actual content>"
* UI format:
* Final Answer (headline)
* <actual content> (body)
*/
export function FinalAnswerMessage({ content }: FinalAnswerMessageProps) {
const bodyContent = extractBodyContent(content)
return (
<HeaderedMessage header="Final Answer" headerClassName="text-status-completed">
<Markdown>{bodyContent}</Markdown>
</HeaderedMessage>
)
}
// =============================================================================
// Helpers
// =============================================================================
/**
* Extract the body content by removing the "Final Answer: " prefix if present.
*/
function extractBodyContent(content: string): string {
// Remove "Final Answer: " prefix if present (case-insensitive, with optional whitespace)
const prefixRegex = new RegExp(`^${FINAL_ANSWER_PREFIX}\\s*`, 'i')
return content.replace(prefixRegex, '').trim()
}
@@ -0,0 +1,37 @@
/**
* Headered Message
*
* Base component for messages with a styled header and body content.
* Used by FinalAnswerMessage, InputRequestMessage, and SystemStatusMessage.
*/
import { cn } from '@/lib/utils'
// =============================================================================
// Types
// =============================================================================
export interface HeaderedMessageProps {
/** Header text */
header: string
/** Optional class name for header styling (e.g., color) */
headerClassName?: string
/** Body content */
children: React.ReactNode
}
// =============================================================================
// Component
// =============================================================================
/**
* Renders a message with a styled header and body content.
*/
export function HeaderedMessage({ header, headerClassName, children }: HeaderedMessageProps) {
return (
<div className="flex flex-col">
<h3 className={cn('mb-2 text-lg leading-7 font-bold', headerClassName)}>{header}</h3>
<div className="text-base leading-6">{children}</div>
</div>
)
}
@@ -0,0 +1,319 @@
/**
* Renders the agent's input request as one of three card variants:
* `text_input`, `approval`, or `continuation`.
*/
import { useCallback, useMemo, useState } from 'react'
import { Check, ChevronDown, X } from 'lucide-react'
import { Markdown } from '@/components/common'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useWebSocketManager } from '@/hooks'
import { useChatStore } from '@/stores/chatStore'
import { HeaderedMessage } from './HeaderedMessage'
// =============================================================================
// Types
// =============================================================================
export interface InputRequestMessageProps {
/** Message content from agent */
content: string
/** Input type: 'text_input' or 'approval' */
inputType?: string
/** Session ID for sending responses */
sessionId?: number
/** Unique message ID for persisting approval decision */
messageId?: string
/** Approval-specific fields */
tool?: string
toolArgs?: Record<string, unknown>
category?: string
reason?: string
}
// =============================================================================
// Component
// =============================================================================
/**
* Renders input request message.
* Returns null when content is empty (hides entire message).
*/
export function InputRequestMessage({
content,
inputType,
sessionId,
messageId,
tool,
toolArgs,
reason,
}: InputRequestMessageProps) {
// Approval card renders even without content (tool/reason are sufficient)
if (inputType === 'approval' && sessionId != null) {
return (
<ApprovalCard
sessionId={sessionId}
messageId={messageId}
tool={tool}
toolArgs={toolArgs}
reason={reason}
/>
)
}
// Continuation card: Continue / Stop buttons + the agent's prompt text
if (inputType === 'continuation' && sessionId != null) {
return <ContinuationCard sessionId={sessionId} messageId={messageId} content={content} />
}
// Hide text input message when backend sends empty content
if (!content) return null
// Text input request
return (
<HeaderedMessage header="Input Request" headerClassName="text-status-waiting-input">
<Markdown>{content}</Markdown>
</HeaderedMessage>
)
}
// =============================================================================
// Approval Card
// =============================================================================
interface ApprovalCardProps {
sessionId: number
messageId?: string
tool?: string
toolArgs?: Record<string, unknown>
reason?: string
}
function ApprovalCard({ sessionId, messageId, tool, toolArgs, reason }: ApprovalCardProps) {
const [responded, setResponded] = useState<'approve' | 'deny' | null>(null)
const { sendApprovalResponse } = useWebSocketManager()
const getSessionState = useChatStore((s) => s.getSessionState)
const setAutoApprove = useChatStore((s) => s.setAutoApprove)
const sessionState = getSessionState(sessionId)
const runId = sessionState.runId ?? ''
// Check messages for a persisted approval_response after this card
const persistedDecision = useMemo(() => {
if (!messageId) return null
const messages = sessionState.messages
const myIndex = messages.findIndex((m) => m.id === messageId)
if (myIndex === -1) return null
// Look at subsequent messages for an approval-response
for (let i = myIndex + 1; i < messages.length; i++) {
const m = messages[i]
if (m.kind === 'approval-response') {
return m.decision
}
// Skip system-status messages (they sit between request and response)
if (m.kind !== 'system-status') break
}
return null
}, [messageId, sessionState.messages])
const decision = responded ?? persistedDecision
const handleResponse = useCallback(
async (d: 'approve' | 'deny') => {
if (decision || !runId) return
const success = await sendApprovalResponse(runId, sessionId, d, 'user')
if (success) {
setResponded(d)
}
},
[decision, runId, sessionId, sendApprovalResponse]
)
const handleAutoApproveAndRespond = useCallback(
async (scope: { scope: 'all' } | { scope: 'tool'; tool: string }) => {
if (decision || !runId) return
setAutoApprove(sessionId, scope)
const success = await sendApprovalResponse(runId, sessionId, 'approve', 'user')
if (success) {
setResponded('approve')
}
},
[decision, runId, sessionId, sendApprovalResponse, setAutoApprove]
)
// Approved: hide card entirely
if (decision === 'approve') return null
// Format the command for display
const commandDisplay =
tool === 'bash'
? String(toolArgs?.command ?? '')
: tool
? `${tool}(${Object.entries(toolArgs ?? {})
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(', ')})`
: ''
return (
<div className="border-border bg-muted rounded-xl border p-4 shadow-sm">
{/* Title */}
<div className="text-status-waiting-input mb-3 text-lg leading-7 font-bold">
Approval Required
</div>
{/* Description */}
<p className="text-foreground-secondary mb-2 text-sm">
{tool === 'bash'
? 'The following bash command needs your approval before it can be executed:'
: 'The following action needs your approval before it can be executed:'}
</p>
{/* Command */}
{commandDisplay && (
<div className="border-border bg-background text-foreground-alt mb-3 rounded-md border p-2.5 font-mono text-xs leading-relaxed">
{commandDisplay}
</div>
)}
{/* Reason + hint */}
<p className="text-foreground-tertiary mb-3 text-xs">
{reason && (
<>
<span className="font-bold">Reason:</span> {reason}
<br />
</>
)}
You may approve, deny, or send a message with an alternative suggestion.
</p>
{/* Action / Result */}
{decision === 'deny' ? (
<div className="text-destructive text-sm font-medium">
{tool === 'bash' ? 'You denied the command.' : 'You denied the action.'}
</div>
) : decision === 'alternative' ? (
<div className="text-muted-foreground text-sm font-medium">
You suggested an alternative.
</div>
) : (
<div className="flex justify-end gap-2">
<Button variant="destructive" onClick={() => handleResponse('deny')}>
<X className="h-3.5 w-3.5" />
Deny
</Button>
{/* Split button: Approve + dropdown for auto-approve options */}
<div className="flex gap-px">
<Button
variant="default"
className="rounded-r-none"
onClick={() => handleResponse('approve')}
>
<Check className="h-3.5 w-3.5" />
Approve
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="rounded-l-none pr-2.5 pl-2">
<ChevronDown className="h-4 w-4" />
<span className="sr-only">Auto-approve options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{tool && (
<DropdownMenuItem
onClick={() => handleAutoApproveAndRespond({ scope: 'tool', tool })}
>
Always approve <span className="font-bold">{tool}</span> in this session
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleAutoApproveAndRespond({ scope: 'all' })}>
Always approve all tools in this session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</div>
)
}
// =============================================================================
// Continuation Card
// =============================================================================
interface ContinuationCardProps {
sessionId: number
messageId?: string
content: string
}
/** Continue/Stop card shown when an agent hits its per-batch `max_rounds` cap. */
function ContinuationCard({ sessionId, messageId, content }: ContinuationCardProps) {
const [responded, setResponded] = useState<'continue' | 'stop' | null>(null)
const { sendContinuationResponse } = useWebSocketManager()
const getSessionState = useChatStore((s) => s.getSessionState)
const sessionState = getSessionState(sessionId)
const runId = sessionState.runId ?? ''
const persistedDecision = useMemo<'continue' | 'stop' | null>(() => {
if (!messageId) return null
const messages = sessionState.messages
const myIndex = messages.findIndex((m) => m.id === messageId)
if (myIndex === -1) return null
for (let i = myIndex + 1; i < messages.length; i++) {
const m = messages[i]
if (m.kind === 'continuation-response') {
return m.decision
}
if (m.kind !== 'system-status') break
}
return null
}, [messageId, sessionState.messages])
const decision = responded ?? persistedDecision
const handleResponse = useCallback(
async (choice: 'continue' | 'stop') => {
if (decision || !runId) return
const success = await sendContinuationResponse(runId, sessionId, choice)
if (success) setResponded(choice)
},
[decision, runId, sessionId, sendContinuationResponse]
)
// Continue: hide entirely (matches Approve behavior).
if (decision === 'continue') return null
return (
<div className="border-border bg-muted rounded-xl border p-4 shadow-sm">
<div className="text-status-waiting-input mb-3 text-lg leading-7 font-bold">Keep going?</div>
{content && (
<div className="text-foreground-secondary mb-3 text-sm">
<Markdown>{content}</Markdown>
</div>
)}
{/* Action / Result */}
{decision === 'stop' ? (
<div className="text-destructive text-sm font-medium">You stopped here.</div>
) : (
<div className="flex justify-end gap-2">
<Button variant="destructive" onClick={() => handleResponse('stop')}>
<X className="h-3.5 w-3.5" />
Stop
</Button>
<Button variant="default" onClick={() => handleResponse('continue')}>
<Check className="h-3.5 w-3.5" />
Continue
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,36 @@
/**
* Memorized Fact Message Component
*
* Renders a `pause_and_memorize_fact` action as a collapsible section,
* mirroring the "Used web browser" header pattern. Header reads
* "Memorized a fact"; expanded body shows the fact text.
*/
import { NotebookPen } from 'lucide-react'
import { CollapsibleHeader } from './CollapsibleHeader'
import { useCollapsibleGroup } from '@/hooks'
export interface MemorizedFactMessageProps {
groupId: string
sessionId: number
fact: string
}
export function MemorizedFactMessage({ groupId, sessionId, fact }: MemorizedFactMessageProps) {
const { isExpanded, toggle } = useCollapsibleGroup(sessionId, groupId, 'toolCall')
return (
<div className="flex w-full flex-col">
<CollapsibleHeader
icon={<NotebookPen className="size-4 shrink-0" />}
label="Memorized a fact"
isExpanded={isExpanded}
onToggle={toggle}
/>
{isExpanded && fact && (
<div className="text-foreground pt-2 pl-6 text-sm break-words">{fact}</div>
)}
</div>
)
}
@@ -0,0 +1,142 @@
/**
* Orchestrator Tool Message Component
*
* Displays OmniAgent tool_call messages with collapsible args and optional result.
* Always rendered in the message list (not just as a shimmer placeholder).
*
* Two states:
* - Active (no resultContent): shimmer header with progressive tense, expandable args
* - Complete (with resultContent): past tense header, expandable args + "Result" section
*/
import { CollapsibleHeader } from './CollapsibleHeader'
import { PreBlock } from './PreBlock'
import { useCollapsibleGroup } from '@/hooks'
import { useSessionStatus } from '@/stores/chatStore'
import { getOrchestratorToolLabel, getOrchestratorToolIcon } from '@/lib/messages/constants'
// =============================================================================
// Tool Args Display
// =============================================================================
/** Check if a string value contains multiple lines */
function isMultiLine(value: unknown): boolean {
return typeof value === 'string' && value.includes('\n')
}
function ToolArgsDisplay({ toolArgs }: { toolArgs: Record<string, unknown> }) {
const entries = Object.entries(toolArgs)
if (entries.length === 0) return null
return (
<div className="space-y-1">
{entries.map(([key, value]) => {
const strValue = String(value)
if (isMultiLine(value)) {
return (
<div key={key}>
<div className="text-muted-foreground mb-2 font-semibold">{key}:</div>
<PreBlock>{strValue}</PreBlock>
</div>
)
}
return (
<div key={key} className="flex gap-1.5">
<span className="text-muted-foreground shrink-0 font-semibold">{key}:</span>
<code className="bg-muted min-w-0 rounded px-1 py-0.5 font-mono text-xs wrap-break-word">
{strValue}
</code>
</div>
)
})}
</div>
)
}
// =============================================================================
// Component
// =============================================================================
export interface OrchestratorToolMessageProps {
tool: string
toolArgs: Record<string, unknown>
/** When provided, the tool has completed and this is the result content */
resultContent?: string
groupId: string
sessionId: number
/** How this tool call was approved: 'user' | 'auto_session' | 'auto_policy' | undefined */
approvalStatus?: string
}
/**
* Renders an orchestrator tool call with collapsible args and optional result.
* - Active: shimmer header (progressive tense), expandable args
* - Complete: static header (past tense), expandable args + result section
*/
export function OrchestratorToolMessage({
tool,
toolArgs,
resultContent,
groupId,
sessionId,
approvalStatus,
}: OrchestratorToolMessageProps) {
// A tool call with no result is "active" only while the run is still
// going; once it terminates (error/stopped/complete), an unfinished call
// can never complete, so stop the shimmer instead of spinning forever.
const sessionStatus = useSessionStatus(sessionId)
const isComplete = resultContent !== undefined
const isActive = !isComplete && sessionStatus === 'active'
const label = getOrchestratorToolLabel(tool, isActive)
const icon = getOrchestratorToolIcon(tool)
const { isExpanded, toggle } = useCollapsibleGroup(sessionId, groupId, 'toolCall')
// delegate_cua is a placeholder header — don't show its args (task param)
const displayArgs = tool === 'delegate_cua' ? {} : toolArgs
const hasContent = Object.keys(displayArgs).length > 0 || isComplete
return (
<div className="flex w-full flex-col">
{hasContent ? (
<CollapsibleHeader
icon={icon}
label={label}
isExpanded={isExpanded}
onToggle={toggle}
isActive={isActive}
/>
) : (
<CollapsibleHeader
icon={icon}
label={label}
isExpanded={false}
disabled
isActive={isActive}
/>
)}
{isExpanded && hasContent && (
<div className="space-y-3 pt-3 pl-6 text-sm leading-5">
<ToolArgsDisplay toolArgs={displayArgs} />
{approvalStatus && (
<div className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
{approvalStatus === 'user' && '✓ Approved by user'}
{approvalStatus === 'auto_session' && '✓ Auto-approved for the session'}
{approvalStatus === 'auto_policy' && '✓ Auto-approved by policy'}
{approvalStatus === 'auto_safe' && '✓ Safe action; no approval needed'}
</div>
)}
{isComplete && resultContent && (
<>
<div className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
Result
</div>
<PreBlock>{resultContent}</PreBlock>
</>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,44 @@
/**
* PreBlock — shared content block component
*
* Used by OrchestratorToolMessage and ToolResultMessage for displaying
* multi-line text (args, results) with:
* - Monospace font, scrollable, max-height constrained
* - Hover-to-show wrap toggle button (shadcn Button + Tooltip)
*
* Visual style (rounded-xl, p-4, border, shadow-sm) must stay consistent
* with CodeBlock and Markdown's <pre> (MarkdownPre).
*/
import { cn } from '@/lib/utils'
import { WrapToggleButton } from '@/components/common'
import { MAX_CONTENT_BLOCK_HEIGHT } from '@/lib/constants'
import { useWrapState } from '@/hooks'
// =============================================================================
// PreBlock
// =============================================================================
/**
* Scrollable pre block for multi-line string values.
* Rounded rectangle with muted background, monospace font, max-height scroll.
* Includes a hover-visible wrap toggle button (rounded, icon-only).
*/
export function PreBlock({ children }: { children: string }) {
const [wrap, setWrap] = useWrapState()
return (
<div className="group/pre relative">
<WrapToggleButton wrap={wrap} onToggle={() => setWrap((w) => !w)} />
<pre
className={cn(
'bg-muted border-border overflow-auto rounded-xl border p-4 font-mono text-xs leading-relaxed shadow-sm',
wrap && 'wrap-break-word whitespace-pre-wrap'
)}
style={{ maxHeight: MAX_CONTENT_BLOCK_HEIGHT }}
>
{children}
</pre>
</div>
)
}
@@ -0,0 +1,57 @@
/**
* Reasoning Message Component
*
* Displays the agent's internal thought process in a collapsible section.
* Header shows "Thought for Xs" (with duration) or "Thought" (without).
* Defaults to collapsed, controlled by verbose mode.
*/
import { Brain } from 'lucide-react'
import { Markdown } from '@/components/common'
import { CollapsibleHeader } from './CollapsibleHeader'
import { useCollapsibleGroup } from '@/hooks'
// =============================================================================
// Component
// =============================================================================
export interface ReasoningMessageProps {
groupId: string
sessionId: number
content: string
/** Seconds the agent spent thinking, or null if unknown */
thinkingSeconds: number | null
}
/**
* Renders agent reasoning in a collapsible section.
* Shows "Thought for Xs" header with Brain icon.
*/
export function ReasoningMessage({
groupId,
sessionId,
content,
thinkingSeconds,
}: ReasoningMessageProps) {
const { isExpanded, toggle } = useCollapsibleGroup(sessionId, groupId, 'reasoning')
const label =
thinkingSeconds != null && thinkingSeconds > 0 ? `Thought for ${thinkingSeconds}s` : 'Thought'
return (
<div className="flex w-full flex-col">
<CollapsibleHeader
icon={<Brain className="size-4 shrink-0" />}
label={label}
isExpanded={isExpanded}
onToggle={toggle}
/>
{isExpanded && content && (
<div className="pt-4 pl-6 text-sm leading-5">
<Markdown>{content}</Markdown>
</div>
)}
</div>
)
}
@@ -0,0 +1,26 @@
/**
* Reasoning Placeholder
*
* Disabled (non-interactive) reasoning card with a shimmer "Thinking…"
* label. Rendered in the chat list while the model is streaming back
* its reply but hasn't finished, so the user has a visible signal that
* something is happening. Replaced by the real ReasoningMessage when
* the LLM call completes and the reasoning content arrives.
*/
import { Brain } from 'lucide-react'
import { CollapsibleHeader } from './CollapsibleHeader'
export function ReasoningPlaceholder() {
return (
<div className="flex w-full flex-col">
<CollapsibleHeader
icon={<Brain className="size-4 shrink-0" />}
label="Thinking…"
isExpanded={false}
disabled
isActive
/>
</div>
)
}
@@ -0,0 +1,41 @@
/**
* Session Status Indicator
*
* Displays session status at the bottom of the chat view.
* Shows icon + status text matching Figma design.
*
* Note: This is a status indicator, not a chat message type.
* Rendered directly by ChatView, not through MessageRenderer.
*/
import { cn } from '@/lib/utils'
import { SESSION_STATUS_UI_CONFIG } from '@/lib/constants'
import type { SessionStatus } from '@/types'
export interface SessionStatusIndicatorProps {
status: SessionStatus
/**
* Optional override for the label text. Used by the transient
* ``agent_state`` signal so we can swap "In progress" for
* "Waiting for model\u2026" without changing the underlying status.
* The icon and color stay tied to ``status`` so the visual stack
* (spinner + active color) is preserved.
*/
labelOverride?: string
}
/**
* Status indicator shown at the bottom of chat view.
*/
export function SessionStatusIndicator({ status, labelOverride }: SessionStatusIndicatorProps) {
const config = SESSION_STATUS_UI_CONFIG[status]
const StatusIcon = config.icon
const label = labelOverride ?? config.chatLabel
return (
<div className="flex items-center gap-1">
<StatusIcon className={cn('size-4 shrink-0', config.colorClass, config.iconClassName)} />
<p className={cn('truncate text-sm leading-[21px] font-bold', config.colorClass)}>{label}</p>
</div>
)
}
@@ -0,0 +1,58 @@
/**
* System Status Message
*
* Displays system status messages (complete, error, stopped, paused, awaiting_input, etc.)
* with a colored header based on status type.
* Uses HeaderedMessage for consistent styling.
*/
import { Markdown } from '@/components/common'
import type { ServerRunStatus } from '@/types/api'
import { HeaderedMessage } from './HeaderedMessage'
// =============================================================================
// Types
// =============================================================================
export interface SystemStatusMessageProps {
/** Status type determines header text and color */
status: ServerRunStatus
/** Message content to display below header */
content: string
}
// =============================================================================
// Constants
// =============================================================================
/** Header configuration for each status type */
const STATUS_CONFIG: Record<ServerRunStatus, { label: string; className: string }> = {
created: { label: 'Created', className: 'text-muted-foreground' },
active: { label: 'Active', className: 'text-status-active' },
complete: { label: 'Complete', className: 'text-status-completed' },
error: { label: 'Error', className: 'text-status-error' },
stopped: { label: 'Stopped', className: 'text-status-stopped' },
paused: { label: 'Paused', className: 'text-status-waiting-input' },
awaiting_input: { label: 'Awaiting Input', className: 'text-status-waiting-input' },
}
// =============================================================================
// Component
// =============================================================================
/**
* Renders system status message with colored header and body content.
* Returns null when content is empty (hides entire message).
*/
export function SystemStatusMessage({ status, content }: SystemStatusMessageProps) {
// Hide entire message when backend sends empty content
if (!content) return null
const { label, className } = STATUS_CONFIG[status]
return (
<HeaderedMessage header={label} headerClassName={className}>
<Markdown>{content}</Markdown>
</HeaderedMessage>
)
}
@@ -0,0 +1,57 @@
/**
* Tool Result Message Component
*
* Displays standalone tool results from OmniAgent (not merged with orchestrator-tool).
* This is used for non-adjacent tool results, e.g., delegate_cua results that arrive
* after CUA browser messages in between.
*
* Uses centralized labels and icons from constants.ts.
*/
import { Markdown } from '@/components/common'
import { CollapsibleHeader } from './CollapsibleHeader'
import { PreBlock } from './PreBlock'
import { useCollapsibleGroup } from '@/hooks'
import { getOrchestratorToolLabel, getOrchestratorToolIcon } from '@/lib/messages/constants'
// =============================================================================
// Component
// =============================================================================
export interface ToolResultMessageProps {
groupId: string
sessionId: number
toolName: string | undefined
resultContent: string
}
/**
* Renders a standalone tool result with collapsible header.
* The header label changes based on the tool that produced the result.
*/
export function ToolResultMessage({
groupId,
sessionId,
toolName,
resultContent,
}: ToolResultMessageProps) {
const { isExpanded, toggle } = useCollapsibleGroup(sessionId, groupId, 'toolCall')
const label = getOrchestratorToolLabel(toolName, false)
const icon = getOrchestratorToolIcon(toolName)
return (
<div className="flex w-full flex-col">
<CollapsibleHeader icon={icon} label={label} isExpanded={isExpanded} onToggle={toggle} />
{isExpanded && resultContent && (
<div className="pt-3 pl-6 text-sm leading-5">
{toolName === 'delegate_cua' ? (
<Markdown>{resultContent}</Markdown>
) : (
<PreBlock>{resultContent}</PreBlock>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,25 @@
/**
* Message Component Exports
*
* Specialized message display components for different content types.
*/
export { CodeBlock, type CodeBlockProps } from './CodeBlock'
export { CodeExecutionMessage, type CodeExecutionMessageProps } from './CodeExecutionMessage'
export { CollapsibleHeader, type CollapsibleHeaderProps } from './CollapsibleHeader'
export { CuaMessage, type CuaAction, type ToolArgs } from './CuaMessage'
export { ErrorMessage, type ErrorMessageProps } from './ErrorMessage'
export { FileMessage, type FileMessageProps } from './FileMessage'
export { FinalAnswerMessage, type FinalAnswerMessageProps } from './FinalAnswerMessage'
export { HeaderedMessage, type HeaderedMessageProps } from './HeaderedMessage'
export { InputRequestMessage, type InputRequestMessageProps } from './InputRequestMessage'
export { MemorizedFactMessage, type MemorizedFactMessageProps } from './MemorizedFactMessage'
export {
OrchestratorToolMessage,
type OrchestratorToolMessageProps,
} from './OrchestratorToolMessage'
export { ReasoningMessage, type ReasoningMessageProps } from './ReasoningMessage'
export { ReasoningPlaceholder } from './ReasoningPlaceholder'
export { SessionStatusIndicator, type SessionStatusIndicatorProps } from './SessionStatusIndicator'
export { SystemStatusMessage, type SystemStatusMessageProps } from './SystemStatusMessage'
export { ToolResultMessage, type ToolResultMessageProps } from './ToolResultMessage'
@@ -0,0 +1,30 @@
/**
* ConnectionStatusBanner — shown below the page header when the backend
* is unreachable. Mounted on each page that has a Header (and mirrored
* inside SettingsDialog since dialog overlays dim it).
*/
import { CircleAlert } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useConnectionIssue } from '@/hooks'
import { CONNECTION_STATUS_COPY } from '@/lib/connectionStatus'
export function ConnectionStatusBanner() {
const issue = useConnectionIssue()
if (!issue) return null
return (
<div
role="alert"
className={cn(
'bg-destructive-subtle border-destructive-border/40 text-destructive',
'flex w-full shrink-0 items-center gap-2 border-y px-4 py-2 text-sm'
)}
>
<CircleAlert className="size-4 shrink-0" />
<p>
<span className="font-bold">{CONNECTION_STATUS_COPY.label}</span>{' '}
{CONNECTION_STATUS_COPY.message}
</p>
</div>
)
}
@@ -0,0 +1,59 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react'
interface ErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
}
interface ErrorBoundaryState {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error to console in development
console.error('ErrorBoundary caught an error:', error, errorInfo)
// TODO: Send to error reporting service in production
}
handleReset = () => {
this.setState({ hasError: false, error: undefined })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-8 text-center">
<AlertTriangle className="text-destructive size-12" />
<div className="space-y-2">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-muted-foreground text-sm">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
</div>
<Button onClick={this.handleReset} variant="outline">
Try again
</Button>
</div>
)
}
return this.props.children
}
}
+261
View File
@@ -0,0 +1,261 @@
/**
* File Attachment Chip
*
* Compact chip displaying a file name with icon. Used in two contexts:
* - PromptInput: shows delete button on hover (removes attachment)
* - ChatView: shows download button on hover, click opens preview or downloads
*
* States:
* - pending/uploaded: file icon + name
* - uploading: loader-circle (spinning) + name
* - error: red border + tooltip "Upload failed"
*/
import { createElement } from 'react'
import { File, LoaderCircle, X, Download } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { FileAttachmentStatus } from '@/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getFileExtension, getFileIcon } from '@/lib/fileUtils'
// =============================================================================
// Props
// =============================================================================
export interface FileChipProps {
/** File name to display */
name: string
/** Chip context determines hover behavior */
context: 'input' | 'chat'
/** Upload status (used in both contexts for chat file chips with upload tracking) */
status?: FileAttachmentStatus
/**
* File extension (without dot). When provided, the leading icon adapts to
* the file type (image / spreadsheet / code / etc.). When omitted, the
* extension is derived from `name` and finally falls back to a generic
* file icon for unknown types.
*/
extension?: string
/** Called when remove button is clicked (input context) */
onRemove?: () => void
/** Called when download button is clicked (chat context) */
onDownload?: () => void
/** Called when chip body is clicked (chat context → preview) */
onClick?: () => void
/** Optional tooltip shown on hover (e.g. 'Click to preview' or 'Click to download') */
tooltip?: string
}
// =============================================================================
// Component
// =============================================================================
/**
* Renders a file attachment chip with context-dependent hover behavior.
*/
export function FileChip({
name,
context,
status = 'uploaded',
extension,
onRemove,
onDownload,
onClick,
tooltip,
}: FileChipProps) {
const isError = status === 'error'
const isClickable = context === 'chat' && status === 'uploaded' && !!onClick
const chip = (
<div
className={cn(
'group/chip flex h-8 items-center gap-1.5 overflow-hidden rounded-lg border pr-1.5',
isError ? 'border-destructive' : 'border-border-5',
isClickable && 'cursor-pointer'
)}
onClick={isClickable ? onClick : undefined}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={
isClickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick?.()
}
}
: undefined
}
>
{/* Icon area — context-dependent hover behavior */}
<ChipIconArea
context={context}
status={status}
name={name}
extension={extension}
onRemove={onRemove}
onDownload={onDownload}
/>
{/* File name */}
<span
className={cn(
'truncate text-sm leading-5 font-medium',
isError ? 'text-destructive' : 'text-foreground'
)}
>
{name}
</span>
</div>
)
// Error state: wrap in tooltip
if (isError) {
return (
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent>Upload failed</TooltipContent>
</Tooltip>
)
}
// Clickable chip with tooltip
if (tooltip && isClickable) {
return (
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
}
return chip
}
// =============================================================================
// Icon Area Sub-component
// =============================================================================
interface ChipIconAreaProps {
context: 'input' | 'chat'
status: FileAttachmentStatus
name: string
extension?: string
onRemove?: () => void
onDownload?: () => void
}
/**
* Renders a lucide icon picked by file extension. The icon constant returned
* by `getFileIcon` is one of the stable lucide exports (e.g. `FileSpreadsheet`)
* — not a freshly created component — but lint can't prove that statically.
* Using `createElement` (instead of `<Icon />`) communicates this explicitly
* and avoids a `react-hooks/static-components` warning while keeping a single
* call site for the extension → icon mapping.
*/
function FileExtensionIcon({ extension, className }: { extension: string; className?: string }) {
return createElement(getFileIcon(extension), { className })
}
/**
* Left icon area of the chip.
* - Uploading: spinning loader
* - Error: file icon (red, no interaction)
* - Input hover: X button (remove)
* - Chat uploaded hover: download button
*/
function ChipIconArea({
context,
status,
name,
extension,
onRemove,
onDownload,
}: ChipIconAreaProps) {
const iconSize = 'size-4'
const ext = extension ?? getFileExtension(name)
// Uploading state — show spinner
if (status === 'uploading') {
return (
<div className="flex h-full w-[26px] shrink-0 items-center justify-center">
<LoaderCircle className={cn(iconSize, 'text-muted-foreground animate-spin')} />
</div>
)
}
// Error state — static file icon, no interaction
if (status === 'error') {
return (
<div className="flex h-full w-[26px] shrink-0 items-center justify-center">
<File className={cn(iconSize, 'text-destructive')} />
</div>
)
}
// Input context — show X on hover
if (context === 'input') {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-full w-[26px] shrink-0 items-center justify-center',
'group-hover/chip:bg-secondary'
)}
onClick={(e) => {
e.stopPropagation()
onRemove?.()
}}
aria-label={`Remove ${name}`}
>
<FileExtensionIcon
extension={ext}
className={cn(iconSize, 'text-muted-foreground group-hover/chip:hidden')}
/>
<X className={cn(iconSize, 'text-muted-foreground hidden group-hover/chip:block')} />
</button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
)
}
// Chat context — show download on hover (only when uploaded)
if (status !== 'uploaded') {
return (
<div className="flex h-full w-[26px] shrink-0 items-center justify-center">
<FileExtensionIcon extension={ext} className={cn(iconSize, 'text-muted-foreground')} />
</div>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex h-full w-[26px] shrink-0 items-center justify-center',
'group-hover/chip:bg-secondary'
)}
onClick={(e) => {
e.stopPropagation()
onDownload?.()
}}
aria-label={`Download ${name}`}
>
<FileExtensionIcon
extension={ext}
className={cn(iconSize, 'text-muted-foreground group-hover/chip:hidden')}
/>
<Download
className={cn(iconSize, 'text-muted-foreground hidden group-hover/chip:block')}
/>
</button>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,31 @@
import { FolderClosed } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export interface FolderChipProps {
/** Folder name to display */
name: string
/** Full folder path shown on hover */
path: string
}
export function FolderChip({ name, path }: FolderChipProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="group/chip border-border-5 flex h-8 cursor-default items-center gap-1.5 overflow-hidden rounded-lg border pr-1.5 text-left"
aria-label={`${name}: ${path}`}
>
<div className="flex h-full w-[26px] shrink-0 items-center justify-center">
<FolderClosed className="text-muted-foreground size-4" />
</div>
<span className="text-foreground truncate text-sm leading-5 font-medium">{name}</span>
</button>
</TooltipTrigger>
<TooltipContent>
<span>{path}</span>
</TooltipContent>
</Tooltip>
)
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Markdown Renderer
*
* Configurable Markdown component with custom styling for chat messages.
* Uses react-markdown with remark-gfm for GitHub Flavored Markdown support.
*
* Future extensions:
* - Code syntax highlighting (Shiki)
* - Copy button for code blocks
* - Language labels for code blocks
* - Collapsible sections
*/
import { useRef, type ComponentProps } from 'react'
import { cn } from '@/lib/utils'
import { WrapToggleButton } from './WrapToggleButton'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { MAX_CONTENT_BLOCK_HEIGHT } from '@/lib/constants'
import { useWrapState } from '@/hooks'
/** Table cell that shows a tooltip with full text when content is truncated (computed on hover). */
function TruncatedTd(props: ComponentProps<'td'>) {
const ref = useRef<HTMLTableCellElement>(null)
const handleMouseEnter = () => {
const el = ref.current
if (el) {
el.title = el.scrollWidth > el.clientWidth ? (el.textContent ?? '') : ''
}
}
return (
<td
ref={ref}
onMouseEnter={handleMouseEnter}
className="max-w-xs truncate px-4 py-2"
{...props}
/>
)
}
/**
* Pre block with hover-visible wrap toggle button (same UX as PreBlock).
* Visual style (rounded-xl, p-4, border, shadow-sm) must stay consistent
* with PreBlock and CodeBlock.
*/
function MarkdownPre({ children, ...props }: ComponentProps<'pre'>) {
const [wrap, setWrap] = useWrapState()
return (
<div className="group/pre relative mb-3 last:mb-0">
<WrapToggleButton wrap={wrap} onToggle={() => setWrap((w) => !w)} />
<pre
className={cn(
'bg-muted border-border overflow-auto rounded-xl border p-4 shadow-sm',
wrap && 'wrap-break-word whitespace-pre-wrap'
)}
style={{ maxHeight: MAX_CONTENT_BLOCK_HEIGHT }}
{...props}
>
{children}
</pre>
</div>
)
}
/**
* Custom component mappings for Markdown elements.
* Styled for chat context with compact spacing.
*/
const markdownComponents: Components = {
// Links - open in new tab, same color as text with underline
a: ({ href, children, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
{...props}
>
{children}
</a>
),
// Paragraphs - compact margins for chat
p: ({ children, ...props }) => (
<p className="mb-3 last:mb-0" {...props}>
{children}
</p>
),
// Lists
ul: ({ children, ...props }) => (
<ul className="mb-3 list-disc pl-6 last:mb-0" {...props}>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol className="mb-3 list-decimal pl-6 last:mb-0" {...props}>
{children}
</ol>
),
li: ({ children, ...props }) => (
<li className="mb-1" {...props}>
{children}
</li>
),
// Code - basic styling, can enhance with Shiki later
code: ({ className, children, ...props }) => {
// className is "language-xxx" for code blocks, undefined for inline
const isInline = !className
return isInline ? (
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-sm" {...props}>
{children}
</code>
) : (
<code className={cn('font-mono text-sm', className)} {...props}>
{children}
</code>
)
},
pre: MarkdownPre,
// Blockquotes
blockquote: ({ children, ...props }) => (
<blockquote className="border-border mb-3 border-l-4 pl-4 italic last:mb-0" {...props}>
{children}
</blockquote>
),
// Headings - sized for chat context (smaller than page headings)
h1: ({ children, ...props }) => (
<h1 className="mb-3 text-xl font-bold last:mb-0" {...props}>
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2 className="mb-3 text-lg font-bold last:mb-0" {...props}>
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3 className="mb-2 text-base font-bold last:mb-0" {...props}>
{children}
</h3>
),
// Tables - card-style with rounded corners and borders
table: ({ children, ...props }) => (
<div
className="border-border mb-3 w-fit overflow-auto rounded-xl border shadow-sm last:mb-0"
style={{ maxHeight: MAX_CONTENT_BLOCK_HEIGHT }}
>
<table className="border-collapse text-sm" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-muted sticky top-0 z-10" {...props}>
{children}
</thead>
),
tbody: ({ children, ...props }) => <tbody {...props}>{children}</tbody>,
th: ({ children, ...props }) => (
<th className="px-4 pt-4 pb-3 text-left text-xs font-bold" {...props}>
{children}
</th>
),
td: TruncatedTd,
tr: ({ children, ...props }) => (
<tr className="last:[&>td]:pb-4" {...props}>
{children}
</tr>
),
}
export interface MarkdownProps {
/** Markdown content to render */
children: string
/** Additional CSS classes */
className?: string
}
/**
* Renders Markdown content with custom styling.
*
* @example
* ```tsx
* <Markdown>**Hello** world!</Markdown>
* ```
*/
export function Markdown({ children, className }: MarkdownProps) {
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
</div>
)
}
@@ -0,0 +1,189 @@
import { type ReactNode } from 'react'
import { type ModelEndpointConfig } from '@/stores/onboardingStore'
import { TriangleAlert } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { cn } from '@/lib/utils'
import { API_KEY_MASKED } from '@/api/onboarding'
interface ModelConfigCardProps {
/** Display name shown as card title */
name: string
/** Current field values */
config: ModelEndpointConfig
/** Field change handler */
onChange: (config: Partial<ModelEndpointConfig>) => void
/** Visual state */
state?: 'default' | 'filled' | 'error'
/** Whether inputs are disabled */
disabled?: boolean
/** Custom placeholders per field */
placeholders?: Partial<Record<keyof ModelEndpointConfig, string>>
/**
* When provided, renders a checkbox in the card header. The card body is
* rendered grayed-out and inputs disabled when `enabled` is false.
*/
enabled?: boolean
/** Called when the header checkbox is toggled. Required if `enabled` is set. */
onEnabledChange?: (enabled: boolean) => void
/** Description text rendered under the title */
description?: ReactNode
/** Optional row rendered below the description (e.g. recommended model link) */
recommendation?: ReactNode
}
const DEFAULT_PLACEHOLDERS: Record<keyof ModelEndpointConfig, string> = {
endpointUrl: 'e.g. http://localhost:6000/v1',
modelName: 'e.g. magenticbrain-v1',
apiKey: '',
}
const FIELDS: { key: keyof ModelEndpointConfig; label: string }[] = [
{ key: 'endpointUrl', label: 'Endpoint URL' },
{ key: 'modelName', label: 'Model Name' },
{ key: 'apiKey', label: 'API Key (optional)' },
]
export function ModelConfigCard({
name,
config,
onChange,
state = 'default',
disabled = false,
placeholders,
enabled,
onEnabledChange,
description,
recommendation,
}: ModelConfigCardProps) {
const isMasked = config.apiKey === API_KEY_MASKED
const idPrefix = name.toLowerCase().replace(/\s+/g, '-')
const hasCheckbox = enabled !== undefined
// When the card supports a checkbox and is unchecked, force-disable inputs.
const inputsDisabled = disabled || (hasCheckbox && !enabled)
const dimmed = hasCheckbox && !enabled
return (
<Card
className={cn(
'flex flex-col gap-3 p-4',
state === 'error' && 'border-destructive shadow-[0_0_0_3px_var(--card-error-ring)]'
)}
>
<div className={cn('flex flex-col gap-1', dimmed && 'opacity-50')}>
{/* Title row. When the card has a checkbox, wrap it in a <label> so
clicking the title also toggles the checkbox. */}
{hasCheckbox ? (
<label
htmlFor={`${idPrefix}-enabled`}
className={cn(
'flex items-center gap-2 self-start',
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
)}
>
<Checkbox
id={`${idPrefix}-enabled`}
checked={enabled}
onCheckedChange={(v) => onEnabledChange?.(v === true)}
disabled={disabled}
className="size-5"
aria-label={`Enable ${name}`}
/>
<h4 className="text-foreground text-base font-bold">{name}</h4>
</label>
) : (
<h4 className="text-foreground text-base font-bold">{name}</h4>
)}
{(description || recommendation) && (
// Reserve a minimum height so adjacent cards line up at the form
// fields below — sized for ~2 lines of description plus one line of
// recommendation.
<div className="flex min-h-[75px] flex-col gap-1">
{description && (
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
)}
{recommendation && <div className="text-xs">{recommendation}</div>}
</div>
)}
</div>
<div className={cn('flex flex-col gap-2', dimmed && 'opacity-50')}>
{FIELDS.map((field) => {
const isApiKeyField = field.key === 'apiKey'
const showMasked = isApiKeyField && isMasked
const inputId = `${idPrefix}-${field.key}`
return (
<div key={field.key} className="flex flex-col gap-1">
<label htmlFor={inputId} className="text-muted-foreground text-xs">
{field.label}
</label>
<Input
id={inputId}
value={showMasked ? '••••••••' : config[field.key]}
onChange={(e) => onChange({ [field.key]: e.target.value })}
onFocus={showMasked ? () => onChange({ apiKey: '' }) : undefined}
readOnly={showMasked}
placeholder={placeholders?.[field.key] ?? DEFAULT_PLACEHOLDERS[field.key]}
disabled={inputsDisabled}
className={cn(
'bg-background h-8 text-sm shadow-none',
showMasked && 'cursor-pointer'
)}
/>
</div>
)
})}
</div>
</Card>
)
}
/**
* Shared alert for verification errors.
* Renders each error line with the model name in bold.
*/
export function VerificationAlert({ errors }: { errors: string[] }) {
if (errors.length === 0) return null
return (
<Alert variant="destructive">
<TriangleAlert className="size-4" />
<AlertTitle>Verification Failed</AlertTitle>
<AlertDescription className="text-sm">
{errors.map((err, i) => {
const colonIdx = err.indexOf(': ')
if (colonIdx === -1) return <div key={i}>{err}</div>
return (
<div key={i}>
<span className="font-semibold">{err.slice(0, colonIdx)}</span>
{err.slice(colonIdx)}
</div>
)
})}
</AlertDescription>
</Alert>
)
}
/**
* Recommended-model row shown inside each ModelConfigCard's recommendation slot.
* Wraps the model name in an external link.
*/
export function RecommendedModel({ name, url }: { name: string; url: string }) {
return (
<span className="text-muted-foreground">
<span className="text-foreground font-bold">Recommended model:</span>{' '}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-2 hover:underline"
>
{name}
</a>
</span>
)
}
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import { Card } from '@/components/ui/card'
import { SAMPLE_TASKS } from '@/lib/sampleTasks'
import type { SampleTask } from '@/lib/sampleTasks'
interface SampleTaskCardsProps {
/** Title shown above the task cards. Pass `null` to hide. */
title?: ReactNode
/** Called when user clicks a task card's CTA button */
onTaskSelect: (task: SampleTask) => void
}
/**
* Displays sample task cards. Used in SampleTasksPage
* and the new-session empty state.
*/
export function SampleTaskCards({
title = 'Try a sample task:',
onTaskSelect,
}: SampleTaskCardsProps) {
return (
<div className="flex flex-col gap-4">
{title !== null && <span className="text-foreground text-sm font-bold">{title}</span>}
{SAMPLE_TASKS.map((task) => (
<Card
key={task.id}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onTaskSelect(task)
}
}}
className="hover:bg-card-hover focus-visible:ring-ring flex cursor-pointer items-center gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
onClick={() => onTaskSelect(task)}
aria-label={`${task.label}`}
>
<task.icon className="text-primary size-5 shrink-0" />
<span className="text-foreground flex-1 text-[13px] leading-snug">{task.label}</span>
</Card>
))}
</div>
)
}
@@ -0,0 +1,51 @@
/**
* WrapToggleButton
*
* Hover-visible wrap toggle button for code/pre blocks.
* Shared by PreBlock, CodeBlock, and Markdown's <pre>.
*
* Must be placed inside a container with `group/pre` or `group/code` class
* and `relative` positioning.
*/
import { WrapText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
interface WrapToggleButtonProps {
wrap: boolean
onToggle: () => void
/** Tailwind group name for hover visibility (default: 'pre') */
groupName?: string
}
export function WrapToggleButton({ wrap, onToggle, groupName = 'pre' }: WrapToggleButtonProps) {
const hoverClass =
groupName === 'pre'
? 'opacity-0 group-hover/pre:opacity-100'
: 'opacity-0 group-hover/code:opacity-100'
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onToggle}
className={cn(
'absolute top-3 right-3 z-10 size-8 transition-colors',
hoverClass,
wrap
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
aria-label={wrap ? 'Disable line wrap' : 'Enable line wrap'}
>
<WrapText />
</Button>
</TooltipTrigger>
<TooltipContent side="left">{wrap ? 'Disable line wrap' : 'Enable line wrap'}</TooltipContent>
</Tooltip>
)
}
+8
View File
@@ -0,0 +1,8 @@
export { ErrorBoundary } from './ErrorBoundary'
export { ConnectionStatusBanner } from './ConnectionStatusBanner'
export { FileChip, type FileChipProps } from './FileChip'
export { FolderChip, type FolderChipProps } from './FolderChip'
export { Markdown } from './Markdown'
export { ModelConfigCard, VerificationAlert, RecommendedModel } from './ModelConfigCard'
export { SampleTaskCards } from './SampleTaskCards'
export { WrapToggleButton } from './WrapToggleButton'

Some files were not shown because too many files have changed in this diff Show More