Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Profiles tab in the Web UI: browse, search, create (from template with live
preview, or from scratch via a schema-driven form), edit, clone, and delete
agent profiles over the profile management APIs, with validate-before-save
surfacing bounded findings and the truncation-marker contract (#510)
- MiniMax Code (`mcode`) provider with per-terminal authentication and profile
isolation, model and MCP configuration, multi-turn TUI orchestration,
supervisor/worker E2E coverage, and provider documentation (#624)
Expand Down
2 changes: 1 addition & 1 deletion docs/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Then open the same URLs (localhost:5173 or localhost:9889) in your local browser

## Features

Manage sessions, spawn agents, create scheduled flows, configure agent directories, and interact with live terminals — all from the browser. Includes live status badges, an inbox for agent-to-agent messaging, output viewer, and provider auto-detection.
Manage sessions, spawn agents, browse and author agent profiles (create from templates or from scratch, edit, clone, delete — with server-side validation before every write), create scheduled flows, configure agent directories, and interact with live terminals — all from the browser. Includes live status badges, an inbox for agent-to-agent messaging, output viewer, and provider auto-detection.

## Related

Expand Down
22 changes: 19 additions & 3 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ In development mode, Vite proxies API requests (`/sessions`, `/terminals`, `/age

The main dashboard showing all active sessions with their terminals. Provides session creation (provider + agent profile selection), session deletion, and real-time terminal status via polling. Clicking a terminal opens it in the Terminal View.

### Profiles (`ProfilesPanel.tsx`)

Master-detail browser for agent profiles over the profile management APIs. One catalog fetch on mount (no polling) plus a 300 ms-debounced, server-ranked search rendered in server order. The detail pane shows source, provider, model, tags, capabilities, and a `duplicated_in` shadowing warning. Local-store profiles support Edit (raw source via `/source`, placeholders unresolved), Clone, and Delete (type-to-confirm); read-only sources offer "Clone to customise". Creation runs through `ProfileCreateModal`; every write is validated first, with error findings blocking and warnings allowed through.

### Agents (`AgentPanel.tsx`)

Lists all discovered agent profiles from all configured directories (built-in, local store, provider-specific, custom). Shows profile name, description, and source label. Supports launching agents directly with provider and working directory selection.
Expand Down Expand Up @@ -99,8 +103,11 @@ Full PTY terminal access via WebSocket (`/terminals/{id}/ws`). Uses xterm.js for
| `InboxPanel.tsx` | Displays agent-to-agent messages queued in a terminal's inbox |
| `OutputViewer.tsx` | Extracts and displays the last assistant response from a terminal |
| `StatusBadge.tsx` | Color-coded terminal status indicator (idle, processing, completed, error) |
| `ConfirmModal.tsx` | Reusable confirmation dialog for destructive actions |
| `CustomSelect.tsx` | Styled dropdown select component |
| `ConfirmModal.tsx` | Reusable confirmation dialog for destructive actions; optional type-to-confirm gate (`confirmationText`) |
| `CustomSelect.tsx` | Styled dropdown select component; menu portaled to `document.body` (never clipped by modal scroll containers, flips upward when short on space) |
| `ProfileCreateModal.tsx` | Schema-driven profile creation: from-template (debounced live preview) and from-scratch (form generated from the profile JSON-Schema) |
| `ProfileEditorModal.tsx` | Raw source editor for edit (PUT, name fixed) and clone (POST under a new name) |
| `ValidationFindings.tsx` | Renders bounded validation findings with severity bullets and the omission-marker contract |
| `ErrorBoundary.tsx` | React error boundary with fallback UI |

## Project Structure
Expand All @@ -115,6 +122,10 @@ web/
│ ├── index.css # Tailwind CSS imports
│ ├── components/
│ │ ├── DashboardHome.tsx
│ │ ├── ProfilesPanel.tsx
│ │ ├── ProfileCreateModal.tsx
│ │ ├── ProfileEditorModal.tsx
│ │ ├── ValidationFindings.tsx
│ │ ├── AgentPanel.tsx
│ │ ├── FlowsPanel.tsx
│ │ ├── SettingsPanel.tsx
Expand All @@ -129,7 +140,12 @@ web/
│ ├── setup.ts
│ ├── api.test.ts
│ ├── store.test.ts
│ └── components.test.tsx
│ ├── components.test.tsx
│ ├── profiles-panel.test.tsx
│ ├── profile-create-modal.test.tsx
│ ├── profile-editor.test.tsx
│ ├── validation-findings.test.tsx
│ └── custom-select.test.tsx
├── vite.config.ts
├── tailwind.config.js
├── tsconfig.json
Expand Down
13 changes: 10 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import { DashboardHome } from './components/DashboardHome'
import { AgentPanel } from './components/AgentPanel'
import { FlowsPanel } from './components/FlowsPanel'
import { MemoryPanel } from './components/MemoryPanel'
import { ProfilesPanel } from './components/ProfilesPanel'
import { SettingsPanel } from './components/SettingsPanel'
import { WorkflowsPanel } from './components/WorkflowsPanel'
import { CaoMark } from './components/CaoMark'
import { Bot, Home, Clock, Settings, Brain, Workflow, CheckCircle, XCircle, Info, Wifi, WifiOff } from 'lucide-react'
import { Bot, Home, Clock, Settings, Brain, Workflow, CheckCircle, XCircle, Info, Wifi, WifiOff, Package } from 'lucide-react'

type TabKey = 'home' | 'agents' | 'flows' | 'settings' | 'memory' | 'workflows'
type TabKey = 'home' | 'profiles' | 'agents' | 'flows' | 'settings' | 'memory' | 'workflows'

// Workflows + Memory appended last so Alt+N numbering of existing tabs never shifts
// Profiles sits between Home and Agents (#510): browsing/authoring profiles
// precedes launching agents, and AgentPanel stays the launch picker. This was
// a one-time Alt+N renumbering of the tabs after it; Workflows + Memory remain
// appended last (Memory is conditional, so keeping it last stops the numbering
// of the always-visible tabs shifting with the memory backend's status).
const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [
{ key: 'home', label: 'Home', icon: <Home size={16} /> },
{ key: 'profiles', label: 'Profiles', icon: <Package size={16} /> },
{ key: 'agents', label: 'Agents', icon: <Bot size={16} /> },
{ key: 'flows', label: 'Flows', icon: <Clock size={16} /> },
{ key: 'settings', label: 'Settings', icon: <Settings size={16} /> },
Expand Down Expand Up @@ -143,6 +149,7 @@ export default function App() {
<ErrorBoundary>
<Suspense fallback={<div className="text-gray-500 text-sm py-12 text-center">Loading...</div>}>
{tab === 'home' && <DashboardHome onNavigate={(t) => setTab(t as TabKey)} />}
{tab === 'profiles' && <ProfilesPanel />}
{tab === 'agents' && <AgentPanel />}
{tab === 'flows' && <FlowsPanel />}
{tab === 'settings' && <SettingsPanel />}
Expand Down
127 changes: 127 additions & 0 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,77 @@ export interface AgentProfileInfo {
duplicated_in?: string[]
}

/**
* One row from `GET /agents/profiles/search`. The backend contract
* (services/profile_search.py RESULT_FIELDS) is metadata-only: the profile
* prompt body is never returned. `score` = `coverage` + a BM25 tie-break
* fraction below 1, so descending score always agrees with the server's
* result order — the client must preserve that order, never re-sort.
*/
export interface ProfileSearchResult {
name: string
description: string
capabilities: string[]
tags: string[]
role: string
source: AgentProfileSource
coverage: number
score: number
}

/**
* Parsed profile from `GET /agents/profiles/{name}`. This response is
* *resolved* (env-var placeholders substituted) — fine for display, but an
* editor must load `GET /agents/profiles/{name}/source` instead so a save
* never persists resolved secrets. Only the fields the detail pane renders
* are declared; the endpoint returns the full model with nulls excluded.
*/
export interface AgentProfileDetail {
name: string
description: string
provider?: string
model?: string
role?: string
tags?: string[]
capabilities?: string[]
}

/** One scaffold template from `GET /agents/profiles/templates`. `name` is `category/name`. */
export interface TemplateSummary {
name: string
description: string
}

/**
* One finding from the profile validator, shared by
* `POST /agents/profiles/validate` and the write routes' `warnings`.
*/
export interface ProfileValidationMessage {
severity: 'error' | 'warning'
message: string
path?: string | null
}

export interface ProfileValidationResponse {
valid: boolean
messages: ProfileValidationMessage[]
}

/**
* Outcome of a profile create or replace. `warnings` carries advisory
* findings that did not block the write; error findings reject with 400
* (detail shape `{message, errors}`) and never reach here.
*/
export interface ProfileWriteResponse {
name: string
warnings: ProfileValidationMessage[]
}

export interface TemplatePreview {
template: string
content: string
}

export interface AgentDirsSettings {
agent_dirs: Record<string, string>
extra_dirs: string[]
Expand Down Expand Up @@ -394,6 +465,62 @@ export interface RunSummaryRow {
export const api = {
// Agent Profiles & Providers
listProfiles: () => fetchJSON<AgentProfileInfo[]>('/agents/profiles'),
// Server-ranked search. Result order is the relevance ranking — render as-is.
searchProfiles: (q: string, limit?: number) =>
fetchJSON<ProfileSearchResult[]>(`/agents/profiles/search?q=${encodeURIComponent(q)}${limit ? `&limit=${limit}` : ''}`),
getProfile: (name: string) => fetchJSON<AgentProfileDetail>(`/agents/profiles/${encodeURIComponent(name)}`),
// Profile authoring (issue #510).
getProfileSchema: () => fetchJSON<Record<string, any>>('/agents/profiles/schema'),
listProfileTemplates: () => fetchJSON<TemplateSummary[]>('/agents/profiles/templates'),
// The template identifier is `category/name` and travels as two path
// segments — the backend route is declared as
// `/templates/{category}/{name}/schema` — so the slash must NOT be encoded.
getTemplateSchema: (template: string) =>
fetchJSON<Record<string, any>>(`/agents/profiles/templates/${template.split('/').map(encodeURIComponent).join('/')}/schema`),
previewTemplate: (template: string, config: Record<string, unknown>) =>
fetchJSON<TemplatePreview>('/agents/profiles/templates/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template, config }),
// Authoring calls run the full validator server-side; the 10s
// default turns a slow round-trip into a phantom 'Validation failed'.
timeoutMs: 30000
}),
validateProfile: (content: string) =>
fetchJSON<ProfileValidationResponse>('/agents/profiles/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
// Authoring calls run the full validator server-side; the 10s
// default turns a slow round-trip into a phantom 'Validation failed'.
timeoutMs: 30000
}),
createProfile: (name: string, content: string) =>
fetchJSON<ProfileWriteResponse>('/agents/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, content }),
// Authoring calls run the full validator server-side; the 10s
// default turns a slow round-trip into a phantom 'Validation failed'.
timeoutMs: 30000
}),
// The authoring read: returns the document exactly as stored, with env-var
// placeholders intact. An editor MUST read from here — the parsed
// GET /agents/profiles/{name} is resolved, and round-tripping it through a
// write would persist resolved secrets into a plaintext profile.
getProfileSource: (name: string) =>
fetchJSON<{ name: string; content: string }>(`/agents/profiles/${encodeURIComponent(name)}/source`),
replaceProfile: (name: string, content: string) =>
fetchJSON<ProfileWriteResponse>(`/agents/profiles/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
// Authoring calls run the full validator server-side; the 10s
// default turns a slow round-trip into a phantom 'Validation failed'.
timeoutMs: 30000
}),
deleteProfile: (name: string) =>
fetchJSON<void>(`/agents/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' }),
listProviders: () => fetchJSON<ProviderInfo[]>('/agents/providers'),

// Settings
Expand Down
40 changes: 36 additions & 4 deletions web/src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Loader2, X } from 'lucide-react'

interface ConfirmModalProps {
Expand All @@ -10,6 +10,13 @@ interface ConfirmModalProps {
cancelLabel?: string
variant?: 'danger' | 'warning'
loading?: boolean
/**
* Type-to-confirm gate. When set, an input is shown and the confirm button
* stays disabled until the user types this exact string (e.g. the name of
* the thing being deleted). Optional and additive: callers that omit it get
* the original confirm-on-click behavior.
*/
confirmationText?: string
onConfirm: () => void
onCancel: () => void
}
Expand All @@ -23,14 +30,20 @@ export function ConfirmModal({
cancelLabel = 'Cancel',
variant = 'danger',
loading = false,
confirmationText,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const cancelRef = useRef<HTMLButtonElement>(null)
const [typed, setTyped] = useState('')

useEffect(() => {
if (open) cancelRef.current?.focus()
}, [open])
if (open) {
cancelRef.current?.focus()
// Reset per open so a previous confirmation never carries over.
setTyped('')
}
}, [open, confirmationText])

useEffect(() => {
if (!open) return
Expand Down Expand Up @@ -80,6 +93,25 @@ export function ConfirmModal({
</div>
)}

{/* Type-to-confirm gate */}
{confirmationText !== undefined && (
<div className="mx-6 mb-4">
<label htmlFor="confirm-typed" className="block text-xs text-gray-400 mb-1.5">
Type <span className="font-mono text-gray-200 select-all">{confirmationText}</span> to confirm:
</label>
<input
id="confirm-typed"
aria-label="Confirmation text"
type="text"
autoComplete="off"
spellCheck={false}
value={typed}
onChange={e => setTyped(e.target.value)}
className="w-full px-3 py-2 bg-gray-950 border border-gray-700 rounded-lg text-sm text-gray-200 font-mono placeholder-gray-600 focus:outline-none focus:border-red-600"
/>
</div>
)}

{/* Actions */}
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-gray-800/30 border-t border-gray-700/30">
<button
Expand All @@ -92,7 +124,7 @@ export function ConfirmModal({
</button>
<button
onClick={onConfirm}
disabled={loading}
disabled={loading || (confirmationText !== undefined && typed !== confirmationText)}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-all focus:outline-none focus:ring-2 disabled:opacity-60 flex items-center gap-2 ${colors.btn}`}
>
{loading && <Loader2 size={14} className="animate-spin" />}
Expand Down
Loading
Loading