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
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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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}}"
|
||||
@@ -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
@@ -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/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"vitest.explorer"
|
||||
]
|
||||
}
|
||||
Vendored
+33
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`microsoft/magentic-ui`
|
||||
- 原始仓库:https://github.com/microsoft/magentic-ui
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+41
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 24–48 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 ~15–20 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 $3–4 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 ~15–20 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) |
|
||||
@@ -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 **30–90 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
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
*.min.js
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+6701
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 “{folderName}”?
|
||||
</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'
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user