e.stopPropagation()}
- className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-2xl max-h-[85vh] flex flex-col mx-4"
- >
+
e.stopPropagation()} className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-2xl max-h-[85vh] flex flex-col mx-4">
{/* Header */}
{t('teamSettings', 'Team Settings')}
-
+
{/* Scrollable body */}
-
- {/* ── Section 1: Team Info ── */}
+ {/* Team Info */}
{t('settings.teamInfo', 'Team Info')}
- setName(e.target.value)}
- className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent/50"
- />
+ setValue('name', e.target.value, { shouldValidate: true })} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent/50" />
-
-
-
{t('status', 'Status')}
-
{team?.status || 'active'}
-
-
-
{t('leadAgent', 'Lead agent')}
-
- {leadMember?.emoji && {leadMember.emoji}}
- {leadMember?.display_name || leadMember?.agent_key || '—'}
-
-
+
{t('status', 'Status')}{team?.status || 'active'}
+
{t('leadAgent', 'Lead agent')}{leadMember?.emoji && {leadMember.emoji}}{leadMember?.display_name || leadMember?.agent_key || '—'}
- {/* ── Section 2: Members ── */}
-
-
-
{t('members', 'Members')} ({members.length})
-
-
+ {/* Members */}
+ setShowAdd(!showAdd)}
+ onAddAgentChange={setAddAgent}
+ onAddMember={handleAddMember}
+ onRemoveMember={handleRemoveMember}
+ />
- {showAdd && (
-
-
-
-
-
-
- )}
-
-
- {sorted.map((m) => (
-
- {m.emoji ? (
- {m.emoji}
- ) : (
-
- )}
- {m.display_name || m.agent_key || m.agent_id.slice(0, 8)}
-
- {m.role}
-
- {m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
-
- )}
-
- ))}
- {members.length === 0 && (
-
{t('settings.noMembers', 'No members')}
- )}
-
-
-
- {/* ── Section 3: Notifications ── */}
+ {/* Notifications */}
{t('settings.notifications', 'Notifications')}
{NOTIFY_KEYS.map((key) => (
{t(`settings.notify_${key}`, key)}
-
))}
-
- {/* Notification mode */}
{t('settings.notifyMode', 'Notification mode')}
{(['direct', 'leader'] as const).map((mode) => (
-
setNotifyMode(mode)}
- className={`text-left rounded-lg border p-3 transition-colors cursor-pointer ${
- notifyMode === mode
- ? 'border-accent bg-accent/5'
- : 'border-border hover:border-accent/30'
- }`}
- >
+ setValue('notifyMode', mode)}
+ className={`text-left rounded-lg border p-3 transition-colors cursor-pointer ${notifyMode === mode ? 'border-accent bg-accent/5' : 'border-border hover:border-accent/30'}`}>
{t(`settings.notifyMode_${mode}`, mode)}
{t(`settings.notifyMode_${mode}_desc`)}
))}
- {notifyMode === 'leader' && (
-
{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}
- )}
+ {notifyMode === 'leader' &&
{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}
}
{/* Footer */}
-
- {saving && }
+
+ {isSubmitting && }
{t('settings.save', 'Save')}
diff --git a/ui/desktop/frontend/src/components/teams/task-detail-meta.tsx b/ui/desktop/frontend/src/components/teams/task-detail-meta.tsx
new file mode 100644
index 00000000..43e04a07
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/task-detail-meta.tsx
@@ -0,0 +1,197 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { MarkdownRenderer } from '../chat/MarkdownRenderer'
+import { IconChevronDown, IconDocument, IconCheckCircle } from '../common/Icons'
+import { getApiClient } from '../../lib/api'
+import { STATUS_BADGE, PRIORITY_BADGE, isTaskLocked, TERMINAL_STATUSES } from '../../types/team'
+import type { TeamTaskData, TeamMemberData, TeamTaskAttachment } from '../../types/team'
+
+/** Human-readable file size */
+export function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+/** Metadata label + value pair */
+export function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ )
+}
+
+/** Collapsible bordered section */
+export function CollapsibleSection({ title, icon, defaultOpen = true, children }: {
+ title: string; icon: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode
+}) {
+ const [open, setOpen] = useState(defaultOpen)
+ return (
+
+
setOpen((v) => !v)}
+ className="flex w-full items-center gap-2 px-4 py-3 text-sm font-medium text-text-muted hover:text-text-primary transition-colors cursor-pointer"
+ >
+ {icon}
+ {title}
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+interface TaskDetailBodyProps {
+ task: TeamTaskData
+ members: TeamMemberData[]
+ attachments: TeamTaskAttachment[]
+}
+
+export function TaskDetailBody({ task, members, attachments }: TaskDetailBodyProps) {
+ const { t } = useTranslation('teams')
+
+ const prio = PRIORITY_BADGE[task.priority] ?? PRIORITY_BADGE[3]
+ const statusCls = STATUS_BADGE[task.status] ?? ''
+ const locked = isTaskLocked(task)
+ const isTerminal = TERMINAL_STATUSES.has(task.status)
+ const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
+
+ return (
+ <>
+ {/* Progress bar */}
+ {task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (() => {
+ const pct = Math.min(100, Math.max(0, task.progress_percent))
+ return (
+
+
+ {t('progress', 'Progress')}
+ {pct}%
+
+
+ {task.progress_step &&
{task.progress_step}
}
+
+ )
+ })()}
+
+ {/* Status badges row */}
+
+ {task.identifier && (
+
+ {task.identifier}
+
+ )}
+
+ {task.status.replace(/_/g, ' ')}
+
+ {locked && (
+
+ Running
+
+ )}
+
+
+ {/* Metadata grid */}
+
+
+
+ {prio.label}
+
+
+
+ {member ? (
+
+ {member.emoji && {member.emoji}}
+ {member.display_name || member.agent_key}
+
+ ) : task.owner_agent_key || '—'}
+
+ {task.task_type && task.task_type !== 'general' && (
+
+ {task.task_type}
+
+ )}
+ {task.created_at && (
+ {new Date(task.created_at).toLocaleString()}
+ )}
+ {task.updated_at && (
+ {new Date(task.updated_at).toLocaleString()}
+ )}
+
+
+ {/* Blocked by */}
+ {task.blocked_by && task.blocked_by.length > 0 && (
+
+
{t('blockedBy', 'Blocked by')}
+
+ {task.blocked_by.map((id) => (
+
+ {id.slice(0, 8)}
+
+ ))}
+
+
+ )}
+
+ {/* Description */}
+ {task.description && (
+
}>
+
+
+
+
+ )}
+
+ {/* Result */}
+ {task.result && (
+
}>
+
+
+
+
+ )}
+
+ {/* Attachments */}
+ {attachments.length > 0 && (
+
}>
+
+ {attachments.map((a) => {
+ const fileName = a.path?.split('/').pop() || 'file'
+ const baseUrl = getApiClient()?.getBaseUrl() || ''
+ const fullUrl = a.download_url?.startsWith('http') ? a.download_url : `${baseUrl}${a.download_url}`
+ return (
+
+ )
+ })}
+
+
+ )}
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/team-member-list.tsx b/ui/desktop/frontend/src/components/teams/team-member-list.tsx
new file mode 100644
index 00000000..f0dbafeb
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/team-member-list.tsx
@@ -0,0 +1,80 @@
+import { useTranslation } from 'react-i18next'
+import { Combobox } from '../common/Combobox'
+import { IconClose, IconPlus, IconUser, IconSpinner } from '../common/Icons'
+import type { TeamMemberData } from '../../types/team'
+
+const ROLE_COLORS: Record
= {
+ lead: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
+ reviewer: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
+ member: 'bg-surface-tertiary text-text-muted',
+}
+
+interface TeamMemberListProps {
+ members: TeamMemberData[]
+ sorted: TeamMemberData[]
+ availableAgents: { value: string; label: string }[]
+ showAdd: boolean
+ addAgent: string
+ adding: boolean
+ removing: string | null
+ onToggleAdd: () => void
+ onAddAgentChange: (id: string) => void
+ onAddMember: () => void
+ onRemoveMember: (agentId: string) => void
+}
+
+export function TeamMemberList({
+ members, sorted, availableAgents,
+ showAdd, addAgent, adding, removing,
+ onToggleAdd, onAddAgentChange, onAddMember, onRemoveMember,
+}: TeamMemberListProps) {
+ const { t } = useTranslation('teams')
+
+ return (
+
+
+
{t('members', 'Members')} ({members.length})
+
+ {t('settings.addMember', 'Add member')}
+
+
+
+ {showAdd && (
+
+
+
+
+
+ {adding ? '...' : t('settings.add', 'Add')}
+
+
+ )}
+
+
+ {sorted.map((m) => (
+
+ {m.emoji ? {m.emoji} : }
+ {m.display_name || m.agent_key || m.agent_id.slice(0, 8)}
+ {m.role}
+ {m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
+ onRemoveMember(m.agent_id)}
+ disabled={removing === m.agent_id}
+ className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-error cursor-pointer transition-opacity disabled:opacity-50"
+ >
+ {removing === m.agent_id ? : }
+
+ )}
+
+ ))}
+ {members.length === 0 && (
+
{t('settings.noMembers', 'No members')}
+ )}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/teams/team-task-list-view.tsx b/ui/desktop/frontend/src/components/teams/team-task-list-view.tsx
new file mode 100644
index 00000000..5b5b8552
--- /dev/null
+++ b/ui/desktop/frontend/src/components/teams/team-task-list-view.tsx
@@ -0,0 +1,94 @@
+import { useTranslation } from 'react-i18next'
+import { TERMINAL_STATUSES } from '../../types/team'
+import type { TeamTaskData, TeamMemberData } from '../../types/team'
+
+interface TeamTaskListViewProps {
+ tasks: TeamTaskData[]
+ members: TeamMemberData[]
+ loading: boolean
+ selected: Set
+ onSelectChange: (next: Set) => void
+ onTaskClick: (task: TeamTaskData) => void
+ onBulkDelete: () => void
+}
+
+export function TeamTaskListView({
+ tasks, members, loading, selected, onSelectChange, onTaskClick, onBulkDelete,
+}: TeamTaskListViewProps) {
+ const { t } = useTranslation('teams')
+
+ return (
+
+ {selected.size > 0 && (
+
+ {selected.size} selected
+
+ {t('deleteSelected', 'Delete selected')}
+
+
+ )}
+
+ {tasks.length === 0 && !loading && (
+
{t('noTasks', 'No tasks yet')}
+ )}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/tools/MediaProviderChainForm.tsx b/ui/desktop/frontend/src/components/tools/MediaProviderChainForm.tsx
similarity index 53%
rename from ui/desktop/frontend/src/components/settings/tools/MediaProviderChainForm.tsx
rename to ui/desktop/frontend/src/components/tools/MediaProviderChainForm.tsx
index 6bf2d265..71664c8d 100644
--- a/ui/desktop/frontend/src/components/settings/tools/MediaProviderChainForm.tsx
+++ b/ui/desktop/frontend/src/components/tools/MediaProviderChainForm.tsx
@@ -12,25 +12,12 @@ import {
import {
SortableContext,
verticalListSortingStrategy,
- useSortable,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
-import { CSS } from '@dnd-kit/utilities'
-import { Switch } from '../../common/Switch'
-import { Combobox } from '../../common/Combobox'
-import { getApiClient } from '../../../lib/api'
-import type { BuiltinToolData } from '../../../types/builtin-tool'
-
-interface ProviderEntry {
- id: string
- provider: string
- model: string
- enabled: boolean
- timeout: number
- max_retries: number
- params?: Record
-}
+import { getApiClient } from '../../lib/api'
+import { SortableProviderCard, type ProviderEntry } from './sortable-provider-card'
+import type { BuiltinToolData } from '../../types/builtin-tool'
interface ModelInfo {
id: string
@@ -43,124 +30,6 @@ interface MediaProviderChainFormProps {
onClose: () => void
}
-/* ─── Sortable Card ─── */
-
-function SortableProviderCard({
- entry,
- index,
- providerOptions,
- modelOptions,
- modelLoading,
- onUpdate,
- onRemove,
- onProviderChange,
-}: {
- entry: ProviderEntry
- index: number
- providerOptions: { value: string; label: string }[]
- modelOptions: { value: string; label: string }[]
- modelLoading: boolean
- onUpdate: (id: string, updates: Partial) => void
- onRemove: (id: string) => void
- onProviderChange: (id: string, providerName: string) => void
-}) {
- const { t } = useTranslation(['tools', 'common'])
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.id })
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1,
- }
-
- return (
-
- {/* Header */}
-
-
- {/* Drag handle */}
-
-
-
- #{index + 1}
- {entry.provider || t('builtin.mediaChain.newProvider')}
- {entry.model && / {entry.model}}
-
-
-
onUpdate(entry.id, { enabled: v })} />
- onRemove(entry.id)} className="p-1 text-text-muted hover:text-error transition-colors">
-
-
-
-
-
- {/* Provider + Model */}
-
-
-
- onProviderChange(entry.id, v)}
- options={providerOptions}
- placeholder={t('builtin.mediaChain.selectProvider')}
- allowCustom
- />
-
-
-
- onUpdate(entry.id, { model: v })}
- options={modelOptions}
- placeholder={t('builtin.mediaChain.selectModel')}
- loading={modelLoading}
- allowCustom
- />
-
-
-
- {/* Timeout + Retries */}
-
-
- )
-}
-
-/* ─── Main Form ─── */
-
export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderChainFormProps) {
const { t } = useTranslation(['tools', 'common'])
const [chain, setChain] = useState([])
@@ -169,11 +38,8 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
const [dbProviders, setDbProviders] = useState<{ id: string; name: string }[]>([])
const [modelsByProvider, setModelsByProvider] = useState>({})
-
- // Track which providers we've already started loading models for
const loadingRef = useRef>(new Set())
- // Parse initial chain entries with stable IDs
useEffect(() => {
const settings = tool.settings as { providers?: Omit[] }
const entries = (settings?.providers ?? []).map((p) => ({
@@ -188,7 +54,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
setSaving(false)
loadingRef.current.clear()
- // Fetch providers list
getApiClient().get<{ providers: { id: string; name: string; display_name?: string }[] | null }>('/v1/providers')
.then((res) => {
setDbProviders((res.providers ?? []).map((p) => ({ id: p.id, name: p.name })))
@@ -215,7 +80,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
const res = await getApiClient().get<{ models?: ModelInfo[] }>(`/v1/providers/${prov.id}/models`)
setModelsByProvider((prev) => ({ ...prev, [providerName]: res.models ?? [] }))
} catch {
- // Mark as loaded with empty list so we don't retry — user can still type manually
setModelsByProvider((prev) => ({ ...prev, [providerName]: [] }))
}
}
@@ -234,21 +98,13 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
}, [])
function addEntry() {
- setChain((prev) => [...prev, {
- id: crypto.randomUUID(),
- provider: '',
- model: '',
- enabled: true,
- timeout: 120,
- max_retries: 2,
- }])
+ setChain((prev) => [...prev, { id: crypto.randomUUID(), provider: '', model: '', enabled: true, timeout: 120, max_retries: 2 }])
}
async function handleSave() {
setSaving(true)
setError('')
try {
- // Strip internal `id` field before saving
const serialized = chain.map(({ id: _id, ...rest }) => rest)
await onSave(tool.name, { providers: serialized })
onClose()
@@ -259,7 +115,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
}
}
- // Drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -277,8 +132,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
}, [])
const providerOptions = dbProviders.map((p) => ({ value: p.name, label: p.name }))
-
- // Disable save if any entry is missing provider or model
const hasIncomplete = chain.some((e) => !e.provider || !e.model)
return (
diff --git a/ui/desktop/frontend/src/components/settings/tools/ToolList.tsx b/ui/desktop/frontend/src/components/tools/ToolList.tsx
similarity index 97%
rename from ui/desktop/frontend/src/components/settings/tools/ToolList.tsx
rename to ui/desktop/frontend/src/components/tools/ToolList.tsx
index 472886ab..103e6d62 100644
--- a/ui/desktop/frontend/src/components/settings/tools/ToolList.tsx
+++ b/ui/desktop/frontend/src/components/tools/ToolList.tsx
@@ -1,10 +1,10 @@
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
-import { useBuiltinTools } from '../../../hooks/use-builtin-tools'
-import { Switch } from '../../common/Switch'
-import { RefreshButton } from '../../common/RefreshButton'
+import { useBuiltinTools } from '../../hooks/use-builtin-tools'
+import { Switch } from '../common/Switch'
+import { RefreshButton } from '../common/RefreshButton'
import { ToolSettingsDialog } from './ToolSettingsDialog'
-import type { BuiltinToolData } from '../../../types/builtin-tool'
+import type { BuiltinToolData } from '../../types/builtin-tool'
const CATEGORY_ORDER = [
'filesystem', 'runtime', 'web', 'memory', 'media', 'browser',
diff --git a/ui/desktop/frontend/src/components/tools/ToolSettingsDialog.tsx b/ui/desktop/frontend/src/components/tools/ToolSettingsDialog.tsx
new file mode 100644
index 00000000..250db670
--- /dev/null
+++ b/ui/desktop/frontend/src/components/tools/ToolSettingsDialog.tsx
@@ -0,0 +1,53 @@
+import { useTranslation } from 'react-i18next'
+import { MediaProviderChainForm } from './MediaProviderChainForm'
+import { ExtractorChainForm } from './extractor-chain-form'
+import { JsonSettingsForm } from './json-settings-form'
+import type { BuiltinToolData } from '../../types/builtin-tool'
+
+const MEDIA_TOOLS = new Set([
+ 'read_image', 'read_document', 'read_audio', 'read_video',
+ 'create_image', 'create_video', 'create_audio',
+])
+
+interface ToolSettingsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ tool: BuiltinToolData
+ onSave: (name: string, settings: Record) => Promise
+}
+
+export function ToolSettingsDialog({ open, onOpenChange, tool, onSave }: ToolSettingsDialogProps) {
+ const { t } = useTranslation(['tools', 'common'])
+ if (!open) return null
+
+ const handleClose = () => onOpenChange(false)
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
{t('builtin.settingsDialog.title', { name: tool.display_name })}
+
{tool.name}
+
+
+
+
+
+
+ {/* Route to specialized form */}
+ {tool.name === 'web_fetch' ? (
+
+ ) : MEDIA_TOOLS.has(tool.name) ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/tools/extractor-chain-form.tsx b/ui/desktop/frontend/src/components/tools/extractor-chain-form.tsx
new file mode 100644
index 00000000..3ef811c6
--- /dev/null
+++ b/ui/desktop/frontend/src/components/tools/extractor-chain-form.tsx
@@ -0,0 +1,121 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Switch } from '../common/Switch'
+import type { BuiltinToolData } from '../../types/builtin-tool'
+
+interface ExtractorEntry {
+ name: string
+ enabled: boolean
+ base_url?: string
+ timeout?: number
+ max_retries?: number
+}
+
+const EXTRACTOR_DISPLAY: Record = {
+ defuddle: 'Defuddle',
+ 'html-to-markdown': 'HTML to Markdown',
+}
+
+interface ExtractorChainFormProps {
+ tool: BuiltinToolData
+ onSave: (name: string, settings: Record) => Promise
+ onClose: () => void
+}
+
+export function ExtractorChainForm({ tool, onSave, onClose }: ExtractorChainFormProps) {
+ const { t } = useTranslation(['tools', 'common'])
+ const [extractors, setExtractors] = useState([])
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ const settings = tool.settings as { extractors?: ExtractorEntry[] }
+ setExtractors(settings?.extractors ?? [])
+ setError('')
+ setSaving(false)
+ }, [tool])
+
+ function updateExtractor(index: number, updates: Partial) {
+ setExtractors((prev) => prev.map((e, i) => i === index ? { ...e, ...updates } : e))
+ }
+
+ async function handleSave() {
+ setSaving(true)
+ setError('')
+ try {
+ await onSave(tool.name, { extractors })
+ onClose()
+ } catch (err) {
+ setError((err as Error).message || 'Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+ <>
+
+ {extractors.map((ext, i) => (
+
+
+
+ #{i + 1}
+ {EXTRACTOR_DISPLAY[ext.name] ?? ext.name}
+
+
updateExtractor(i, { enabled: v })} />
+
+
+ {ext.name === 'defuddle' && (
+ <>
+
+
+ updateExtractor(i, { base_url: e.target.value })}
+ placeholder="https://fetch.goclaw.sh/"
+ className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+
+
+ updateExtractor(i, { max_retries: Math.max(0, Number(e.target.value)) })}
+ className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
+ >
+ )}
+
+ {(ext.name === 'defuddle' || (ext.timeout && ext.timeout > 0)) && (
+
+
+
updateExtractor(i, { timeout: Math.max(0, Number(e.target.value)) })}
+ className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
+ />
+
0 = {t('common:default')}
+
+ )}
+
+ ))}
+ {extractors.length === 0 && (
+
{t('builtin.mediaChain.noProviders')}
+ )}
+
+
+ {error && }
+
+
+ {t('builtin.settingsDialog.cancel')}
+
+
+ {saving ? t('builtin.settingsDialog.saving') : t('builtin.settingsDialog.save')}
+
+
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/tools/json-settings-form.tsx b/ui/desktop/frontend/src/components/tools/json-settings-form.tsx
new file mode 100644
index 00000000..f343dc9c
--- /dev/null
+++ b/ui/desktop/frontend/src/components/tools/json-settings-form.tsx
@@ -0,0 +1,84 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { BuiltinToolData } from '../../types/builtin-tool'
+
+interface JsonSettingsFormProps {
+ tool: BuiltinToolData
+ onSave: (name: string, settings: Record) => Promise
+ onClose: () => void
+}
+
+export function JsonSettingsForm({ tool, onSave, onClose }: JsonSettingsFormProps) {
+ const { t } = useTranslation(['tools', 'common'])
+ const [value, setValue] = useState('')
+ const [error, setError] = useState('')
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ setValue(JSON.stringify(tool.settings, null, 2))
+ setError('')
+ setSaving(false)
+ }, [tool])
+
+ function validate(json: string): Record | null {
+ try {
+ const parsed = JSON.parse(json)
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ setError(t('builtin.jsonDialog.invalidJson'))
+ return null
+ }
+ setError('')
+ return parsed as Record
+ } catch {
+ setError(t('builtin.jsonDialog.invalidJson'))
+ return null
+ }
+ }
+
+ function handleFormat() {
+ const parsed = validate(value)
+ if (parsed) setValue(JSON.stringify(parsed, null, 2))
+ }
+
+ async function handleSave() {
+ const parsed = validate(value)
+ if (!parsed) return
+ setSaving(true)
+ try {
+ await onSave(tool.name, parsed)
+ onClose()
+ } catch (err) {
+ setError((err as Error).message || t('builtin.jsonDialog.invalidJson'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {t('builtin.jsonDialog.cancel')}
+
+
+ {saving ? t('builtin.jsonDialog.saving') : t('builtin.jsonDialog.save')}
+
+
+ >
+ )
+}
diff --git a/ui/desktop/frontend/src/components/tools/sortable-provider-card.tsx b/ui/desktop/frontend/src/components/tools/sortable-provider-card.tsx
new file mode 100644
index 00000000..d9f5cc31
--- /dev/null
+++ b/ui/desktop/frontend/src/components/tools/sortable-provider-card.tsx
@@ -0,0 +1,98 @@
+import { useTranslation } from 'react-i18next'
+import { useSortable } from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { Switch } from '../common/Switch'
+import { Combobox } from '../common/Combobox'
+
+export interface ProviderEntry {
+ id: string
+ provider: string
+ model: string
+ enabled: boolean
+ timeout: number
+ max_retries: number
+ params?: Record
+}
+
+interface SortableProviderCardProps {
+ entry: ProviderEntry
+ index: number
+ providerOptions: { value: string; label: string }[]
+ modelOptions: { value: string; label: string }[]
+ modelLoading: boolean
+ onUpdate: (id: string, updates: Partial) => void
+ onRemove: (id: string) => void
+ onProviderChange: (id: string, providerName: string) => void
+}
+
+export function SortableProviderCard({
+ entry, index, providerOptions, modelOptions, modelLoading,
+ onUpdate, onRemove, onProviderChange,
+}: SortableProviderCardProps) {
+ const { t } = useTranslation(['tools', 'common'])
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.id })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ #{index + 1}
+ {entry.provider || t('builtin.mediaChain.newProvider')}
+ {entry.model && / {entry.model}}
+
+
+
onUpdate(entry.id, { enabled: v })} />
+ onRemove(entry.id)} className="p-1 text-text-muted hover:text-error transition-colors">
+
+
+
+
+
+ {/* Provider + Model */}
+
+
+
+ onProviderChange(entry.id, v)} options={providerOptions} placeholder={t('builtin.mediaChain.selectProvider')} allowCustom />
+
+
+
+ onUpdate(entry.id, { model: v })} options={modelOptions} placeholder={t('builtin.mediaChain.selectModel')} loading={modelLoading} allowCustom />
+
+
+
+ {/* Timeout + Retries */}
+
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/settings/traces/TraceDetailDialog.tsx b/ui/desktop/frontend/src/components/traces/TraceDetailDialog.tsx
similarity index 55%
rename from ui/desktop/frontend/src/components/settings/traces/TraceDetailDialog.tsx
rename to ui/desktop/frontend/src/components/traces/TraceDetailDialog.tsx
index 621f1f46..01d9db2c 100644
--- a/ui/desktop/frontend/src/components/settings/traces/TraceDetailDialog.tsx
+++ b/ui/desktop/frontend/src/components/traces/TraceDetailDialog.tsx
@@ -1,190 +1,18 @@
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
-import { toast } from '../../../stores/toast-store'
-import { fetchTraceDetail } from '../../../hooks/use-traces'
-import { getApiClient, isApiClientReady } from '../../../lib/api'
-import { DownloadURL } from '../../../../wailsjs/go/main/App'
-import type { TraceData, SpanData } from '../../../types/trace'
+import { toast } from '../../stores/toast-store'
+import { fetchTraceDetail } from '../../hooks/use-traces'
+import { getApiClient, isApiClientReady } from '../../lib/api'
+import { DownloadURL } from '../../../wailsjs/go/main/App'
+import type { TraceData, SpanData } from '../../types/trace'
+import { formatDuration, formatTokens, statusClass } from './trace-detail-formatters'
+import { buildSpanTree, SpanRow } from './trace-span-tree'
interface Props {
traceId: string
onClose: () => void
}
-import { MarkdownRenderer } from '../../chat/MarkdownRenderer'
-
-function TraceContentPreview({ text }: { text?: string }) {
- if (!text) return null
- const trimmed = text.trim()
- // Auto-format JSON into a markdown code block
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
- try {
- const formatted = JSON.stringify(JSON.parse(trimmed), null, 2)
- return (
-
-
-
- )
- } catch { /* not valid JSON, fall through */ }
- }
- // Render as markdown (handles code blocks, headings, lists, etc.)
- return (
-
-
-
- )
-}
-
-function formatDuration(ms: number | undefined | null, startTime?: string, endTime?: string): string {
- // Fallback: compute from start/end times if ms is missing
- if (ms == null || isNaN(ms) || ms === 0) {
- if (startTime && endTime) {
- const computed = new Date(endTime).getTime() - new Date(startTime).getTime()
- if (!isNaN(computed) && computed > 0) ms = computed
- else return '—'
- } else {
- return '—'
- }
- }
- if (ms < 1000) return `${ms}ms`
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
- const min = Math.floor(ms / 60000)
- const sec = Math.round((ms % 60000) / 1000)
- return `${min}m ${sec}s`
-}
-
-function formatTokens(count: number | null | undefined): string {
- if (count == null) return '0'
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
- return count.toString()
-}
-
-function statusClass(status: string): string {
- const s = status.toLowerCase()
- if (s === 'completed' || s === 'ok' || s === 'success') {
- return 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400'
- }
- if (s === 'error' || s === 'failed') {
- return 'bg-red-500/15 text-red-700 border-red-500/25 dark:text-red-400'
- }
- return 'bg-blue-500/15 text-blue-700 border-blue-500/25 dark:text-blue-400'
-}
-
-function spanTypeIcon(spanType: string): string {
- switch (spanType) {
- case 'llm_call': return '🤖'
- case 'tool_call': return '🔧'
- case 'agent': return '👤'
- case 'embedding': return '📊'
- default: return '📌'
- }
-}
-
-// Build span tree from flat list using parent_span_id
-interface SpanNode { span: SpanData; children: SpanNode[]; depth: number }
-
-function buildSpanTree(spans: SpanData[]): SpanNode[] {
- const byId = new Map()
- const roots: SpanNode[] = []
- for (const span of spans) {
- byId.set(span.id, { span, children: [], depth: 0 })
- }
- for (const span of spans) {
- const node = byId.get(span.id)!
- const parentId = span.parent_span_id
- if (parentId && byId.has(parentId)) {
- const parent = byId.get(parentId)!
- node.depth = parent.depth + 1
- parent.children.push(node)
- } else {
- roots.push(node)
- }
- }
- // Flatten tree in DFS order
- const flat: SpanNode[] = []
- function walk(nodes: SpanNode[]) {
- for (const n of nodes) { flat.push(n); walk(n.children) }
- }
- walk(roots)
- return flat
-}
-
-function SpanRow({ node, expanded, onToggle }: { node: SpanNode; expanded: boolean; onToggle: () => void }) {
- const { t } = useTranslation('traces')
- const { span, depth } = node
- const hasTokens = (span.input_tokens ?? 0) > 0 || (span.output_tokens ?? 0) > 0
- const subtitle = span.span_type === 'llm_call'
- ? [span.model, span.provider].filter(Boolean).join(' / ')
- : span.span_type === 'tool_call' ? span.tool_name : undefined
- const cacheRead = (span.metadata?.cache_read_tokens as number) ?? 0
- const cacheCreate = (span.metadata?.cache_creation_tokens as number) ?? 0
- const thinkingTokens = (span.metadata?.thinking_tokens as number) ?? 0
- // Expandable only if there's content to show
- const hasExpandContent = !!span.input_preview || !!span.output_preview || !!span.error || cacheCreate > 0
-
- return (
-
- {/* Row */}
-
- {/* Toggle arrow — only visible if expandable */}
-
- {spanTypeIcon(span.span_type)}
- {span.name}
- {subtitle && {subtitle}}
-
- {hasTokens && (
-
- {formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
- {cacheRead > 0 && ({formatTokens(cacheRead)} cached)}
- {thinkingTokens > 0 && ({formatTokens(thinkingTokens)} thinking)}
-
- )}
- {formatDuration(span.duration_ms, span.start_time, span.end_time)}
-
- {span.status}
-
-
-
-
- {/* Expanded details — only input/output/error, no duplicate metadata */}
- {expanded && hasExpandContent && (
-
- {/* Cache breakdown (only shows extra info not in collapsed row) */}
- {cacheCreate > 0 && (
-
- +{formatTokens(cacheCreate)} cache write
-
- )}
- {span.error &&
{span.error}
}
- {span.input_preview && (
-
-
{t('detail.input')}
-
-
- )}
- {span.output_preview && (
-
-
{t('detail.output')}
-
-
- )}
-
- )}
-
- )
-}
-
export function TraceDetailDialog({ traceId, onClose }: Props) {
const { t } = useTranslation('traces')
const [trace, setTrace] = useState(null)
@@ -263,7 +91,6 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
{trace && (
<>
- {/* Copy trace ID */}
)}
- {/* Export trace */}
{t('detail.input')}
- {inputOpen &&
}
+ {inputOpen && (
+
+
{trace.input_preview}
+
+ )}
)}
{trace.output_preview && (
@@ -365,7 +195,11 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
{t('detail.output')}
- {outputOpen &&
}
+ {outputOpen && (
+
+
{trace.output_preview}
+
+ )}