Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
140 changes: 140 additions & 0 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,82 @@ 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 TemplateConfigValidation {
valid: boolean
errors: string[]
}

export interface TemplatePreview {
template: string
content: string
}

export interface AgentDirsSettings {
agent_dirs: Record<string, string>
extra_dirs: string[]
Expand Down Expand Up @@ -394,6 +470,70 @@ 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). 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.
getProfileSchema: () => fetchJSON<Record<string, any>>('/agents/profiles/schema'),
listProfileTemplates: () => fetchJSON<TemplateSummary[]>('/agents/profiles/templates'),
getTemplateSchema: (template: string) =>
fetchJSON<Record<string, any>>(`/agents/profiles/templates/${template.split('/').map(encodeURIComponent).join('/')}/schema`),
validateTemplateConfig: (template: string, config: Record<string, unknown>) =>
fetchJSON<TemplateConfigValidation>('/agents/profiles/templates/validate', {
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
}),
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
75 changes: 65 additions & 10 deletions web/src/components/CustomSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'

export interface SelectOption {
Expand All @@ -15,27 +16,68 @@ interface CustomSelectProps {
options: SelectOption[]
placeholder?: string
className?: string
/** Accessible name for the trigger button (schema-driven forms label by field name). */
ariaLabel?: string
/** Validation-error styling: thick red boundary on the trigger. Additive; default unchanged. */
invalid?: boolean
}

export function CustomSelect({ value, onChange, options, placeholder = 'Select...', className = '' }: CustomSelectProps) {
/** Menu height cap; also used to decide when to flip the menu upward. */
const MENU_MAX_H = 256

export function CustomSelect({ value, onChange, options, placeholder = 'Select...', className = '', ariaLabel, invalid = false }: CustomSelectProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// Fixed-position coordinates for the portaled menu, derived from the trigger.
const [pos, setPos] = useState<{ left: number; width: number; top?: number; bottom?: number }>({ left: 0, width: 0, top: 0 })

// The menu is rendered in a portal at document.body with position:fixed so
// it can never be clipped by an ancestor scroll container — the modal
// bodies (Create profile, Create flow) are overflow-y-auto, and an
// in-flow absolute menu gets cut off at the container edge. When there is
// not enough room below the trigger, the menu flips upward.
useLayoutEffect(() => {
if (!open || !ref.current) return
const rect = ref.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const flipUp = spaceBelow < MENU_MAX_H + 8 && rect.top > spaceBelow
setPos(flipUp
? { left: rect.left, width: rect.width, bottom: window.innerHeight - rect.top + 4 }
: { left: rect.left, width: rect.width, top: rect.bottom + 4 })
}, [open])

useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
const t = e.target as Node
// The menu lives in a portal, so it is NOT inside `ref` — check both.
if (ref.current?.contains(t) || menuRef.current?.contains(t)) return
setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])

useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
// A fixed-position menu does not follow its trigger when an ancestor
// scrolls; close instead of drifting. Scrolls inside the menu itself
// (its own overflow list) are fine.
const onScroll = (e: Event) => {
if (menuRef.current?.contains(e.target as Node)) return
setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
}
}, [open])

const selected = options.find(o => o.value === value)
Expand All @@ -56,17 +98,29 @@ export function CustomSelect({ value, onChange, options, placeholder = 'Select..
<div ref={ref} className={`relative ${className}`}>
<button
type="button"
aria-label={ariaLabel}
aria-expanded={open}
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between bg-gray-900 border border-gray-700 text-sm rounded-lg px-3 py-2.5 focus:border-emerald-500 focus:outline-none transition-colors hover:border-gray-600"
className={`w-full flex items-center justify-between bg-gray-900 text-sm rounded-lg px-3 py-2.5 focus:outline-none transition-colors ${
invalid
? 'border-2 !border-red-500 ring-2 ring-red-500/30'
: 'border border-gray-700 focus:border-emerald-500 hover:border-gray-600'
}`}
>
<span className={selected ? 'text-gray-200' : 'text-gray-500'}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown size={14} className={`text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>

{open && (
<div className="absolute z-50 mt-1 w-full bg-gray-900 border border-gray-700 rounded-lg shadow-xl shadow-black/30 max-h-64 overflow-y-auto">
{open && createPortal(
<div
ref={menuRef}
role="listbox"
data-testid="custom-select-menu"
style={{ position: 'fixed', left: pos.left, width: pos.width, top: pos.top, bottom: pos.bottom, maxHeight: MENU_MAX_H }}
className="z-[80] bg-gray-900 border border-gray-700 rounded-lg shadow-xl shadow-black/30 overflow-y-auto"
>
{groups.map((group, gi) => (
<div key={gi}>
{group.label && (
Expand Down Expand Up @@ -107,7 +161,8 @@ export function CustomSelect({ value, onChange, options, placeholder = 'Select..
{options.length === 0 && (
<div className="px-3 py-4 text-sm text-gray-500 text-center">No options available</div>
)}
</div>
</div>,
document.body,
)}
</div>
)
Expand Down
Loading
Loading