Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ 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)
- Oh My Pi (`omp`) provider with additive native configuration, profile MCP extension wiring, lifecycle detection, and supervisor/worker orchestration support (#559)
- Add the official xAI Grok Build CLI as the `grok_cli` provider, including
isolated per-terminal MCP configuration, native hard tool restrictions,
multi-turn TUI support, orchestration e2e coverage, and provider docs.
- async run submit, discovery, and live event following (#505) (#525)

- rewrite the cao tui front door in Rust (#547)
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