refactor(desktop): react-arch audit — schemas, RHF forms, file splits, services, store cleanup, dir restructure

- Phase 1: add zod, react-hook-form, @hookform/resolvers; create 8 Zod schemas
- Phase 2: migrate 6 form dialogs from useState to useForm+zodResolver
- Phase 3: split 24 files >200L into ~40 focused modules (0 files >300L remaining)
- Phase 4: create services/ layer (8 service files), eliminate direct ws.call in components
- Phase 5: split chat-store into chat-message-store + chat-activity-store
- Phase 6: flatten settings/ directory — move 9 feature subdirs to top-level components/
This commit is contained in:
viettranx committed 2026-04-04 07:47:02 +07:00
1 parent 4d9c286aae
commit 6be88f4178
123 files changed
+4717 -4346

No files matched your search

+3
View File
@@ -12,17 +12,20 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"i18next": "^26.0.1",
"immer": "^11.1.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.1",
"react-i18next": "^17.0.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
+39
View File
@@ -17,6 +17,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.4))
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -35,6 +38,9 @@ importers:
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
react-hook-form:
specifier: ^7.72.1
version: 7.72.1(react@19.2.4)
react-i18next:
specifier: ^17.0.1
version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
@@ -50,6 +56,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
zod:
specifier: ^4.3.6
version: 4.3.6
zustand:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
@@ -116,6 +125,11 @@ packages:
'@emnapi/wasi-threads@1.2.0':
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
'@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
peerDependencies:
react-hook-form: ^7.55.0
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -242,6 +256,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
@@ -812,6 +829,12 @@ packages:
peerDependencies:
react: ^19.2.4
react-hook-form@7.72.1:
resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.1:
resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==}
peerDependencies:
@@ -987,6 +1010,9 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zustand@5.0.12:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
@@ -1053,6 +1079,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.4))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.72.1(react@19.2.4)
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1135,6 +1166,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {}
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.2.2':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -1844,6 +1877,10 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-hook-form@7.72.1(react@19.2.4):
dependencies:
react: 19.2.4
react-i18next@17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
@@ -2054,6 +2091,8 @@ snapshots:
void-elements@3.1.0: {}
zod@4.3.6: {}
zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.2.14
+4 -2
View File
@@ -8,7 +8,8 @@ import { wails } from './lib/wails'
import { initWsClient } from './lib/ws'
import { initApiClient } from './lib/api'
import { useSessionStore } from './stores/session-store'
import { useChatStore } from './stores/chat-store'
import { useChatMessageStore } from './stores/chat-message-store'
import { useChatActivityStore } from './stores/chat-activity-store'
import { ErrorBoundary } from './components/common/ErrorBoundary'
import { Toaster } from './components/common/Toaster'
import { SplashScreen } from './components/common/SplashScreen'
@@ -27,7 +28,8 @@ function AppReady() {
e.preventDefault()
// "New Chat" — clear session + chat directly (avoids duplicate useSessions instance)
useSessionStore.getState().setActiveSession(null)
useChatStore.getState().clear()
useChatMessageStore.getState().clear()
useChatActivityStore.getState().clear()
}
if (mod && e.key === ',') { e.preventDefault(); openSettings() }
if (e.key === 'Escape' && activeView === 'settings') { closeSettings() }
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import type { AgentData } from '../../../types/agent'
import type { AgentData } from '../../types/agent'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -6,8 +6,8 @@ import { EvolutionSection } from './EvolutionSection'
import { AgentSkillsSection } from './AgentSkillsSection'
import { AgentMcpSection } from './AgentMcpSection'
import { AgentFilesTab } from './AgentFilesTab'
import { ConfirmDialog } from '../../common/ConfirmDialog'
import type { AgentData } from '../../../types/agent'
import { ConfirmDialog } from '../common/ConfirmDialog'
import type { AgentData } from '../../types/agent'
type DetailTab = 'overview' | 'files'
@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { getWsClient } from '../../../lib/ws'
import { getApiClient } from '../../../lib/api'
import type { BootstrapFile } from '../../../types/agent'
import { agentService } from '../../services/agent-service'
import { RegenerateDialog } from './agent-regenerate-dialog'
import type { BootstrapFile } from '../../types/agent'
/** Files hidden from desktop UI — USER.md is per-user (managed by bootstrap), HEARTBEAT.md needs cron config */
const HIDDEN_FILES = ['MEMORY.json', 'BOOTSTRAP.md', 'USER.md', 'USER_PREDEFINED.md', 'HEARTBEAT.md']
@@ -24,16 +24,12 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
const [dirty, setDirty] = useState(false)
const [regenerateOpen, setRegenerateOpen] = useState(false)
// Load file list
useEffect(() => {
const ws = getWsClient()
setLoading(true)
ws.call('agents.files.list', { agentId: agentKey })
.then((res: unknown) => {
const r = res as { files?: BootstrapFile[] }
const fileList = (r?.files ?? []).filter((f) => !HIDDEN_FILES.includes(f.name))
agentService.listFiles(agentKey)
.then((res) => {
const fileList = (res?.files ?? []).filter((f) => !HIDDEN_FILES.includes(f.name))
setFiles(fileList)
// Auto-select first file
if (fileList.length > 0 && !selectedFile) {
setSelectedFile(fileList[0].name)
}
@@ -42,12 +38,10 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
.finally(() => setLoading(false))
}, [agentKey, agentType, selectedFile])
// Load file content
const loadFile = useCallback(async (name: string) => {
setFileLoading(true)
try {
const ws = getWsClient()
const res = (await ws.call('agents.files.get', { agentId: agentKey, name })) as { file?: BootstrapFile }
const res = await agentService.getFile(agentKey, name)
setContent(res?.file?.content ?? '')
setDirty(false)
} catch {
@@ -65,8 +59,7 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
if (!selectedFile) return
setSaving(true)
try {
const ws = getWsClient()
await ws.call('agents.files.set', { agentId: agentKey, name: selectedFile, content })
await agentService.setFile(agentKey, selectedFile, content)
setDirty(false)
} catch (err) {
console.error('Failed to save file:', err)
@@ -77,8 +70,7 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
const handleRegenerate = async (prompt: string) => {
try {
await getApiClient().post(`/v1/agents/${agentId}/regenerate`, { prompt })
// Reload current file after regeneration
await agentService.regenerate(agentId, prompt)
if (selectedFile) {
setTimeout(() => loadFile(selectedFile), 2000)
}
@@ -176,78 +168,11 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
</div>
</div>
{/* Edit with AI dialog */}
{regenerateOpen && (
<RegenerateDialog
open={regenerateOpen}
onOpenChange={setRegenerateOpen}
onRegenerate={handleRegenerate}
/>
)}
</div>
)
}
/** Dialog for "Edit with AI" — sends prompt to regenerate endpoint */
function RegenerateDialog({ open, onOpenChange, onRegenerate }: {
open: boolean
onOpenChange: (open: boolean) => void
onRegenerate: (prompt: string) => Promise<void>
}) {
const { t } = useTranslation('agents')
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
if (!prompt.trim()) return
setLoading(true)
try {
await onRegenerate(prompt.trim())
onOpenChange(false)
setPrompt('')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<svg className="h-4 w-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
</svg>
Edit with AI
</h3>
<p className="text-xs text-text-muted">
Describe how you want to modify the agent's personality, knowledge, or behavior. The AI will regenerate the context files accordingly.
</p>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Make the agent more formal and add expertise in data analysis..."
rows={4}
autoFocus
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
<div className="flex justify-end gap-2">
<button onClick={() => onOpenChange(false)} disabled={loading} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!prompt.trim() || loading}
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
</svg>
{loading ? t('fileEditor.sending') : t('fileEditor.regenerate')}
</button>
</div>
</div>
<RegenerateDialog
open={regenerateOpen}
onOpenChange={setRegenerateOpen}
onRegenerate={handleRegenerate}
/>
</div>
)
}
@@ -0,0 +1,194 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { useProviders } from '../../hooks/use-providers'
import { getApiClient } from '../../lib/api'
import { slugify } from '../../lib/slug'
import { AgentPresetSelector } from './agent-preset-selector'
import { AgentIdentityFields } from './agent-identity-fields'
import { agentFormSchema, type AgentFormData } from '../../schemas/agent.schema'
import type { AgentData, AgentInput } from '../../types/agent'
interface AgentFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
agent?: AgentData | null
onSubmit: (input: AgentInput) => Promise<AgentData | void>
}
export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFormDialogProps) {
const isEditing = !!agent
const { t } = useTranslation(['agents', 'desktop', 'common'])
const { providers } = useProviders()
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<AgentFormData>({
resolver: zodResolver(agentFormSchema),
mode: 'onChange',
})
// UI-only state (not form data)
const [models, setModels] = useState<string[]>([])
const [modelsLoading, setModelsLoading] = useState(false)
const [verifyResult, setVerifyResult] = useState<{ valid: boolean; error?: string } | null>(null)
const [verifying, setVerifying] = useState(false)
const [submitError, setSubmitError] = useState('')
const [selectedPresetKey, setSelectedPresetKey] = useState('')
// Reset form when dialog opens
useEffect(() => {
if (!open) return
reset({
displayName: agent?.display_name ?? '',
emoji: (agent?.other_config?.emoji as string) ?? '🦊',
agentKey: agent?.agent_key ?? '',
providerName: agent?.provider ?? '',
model: agent?.model ?? '',
description: (agent?.other_config?.description as string) ?? '',
isDefault: agent?.is_default ?? false,
})
setSubmitError('')
setSelectedPresetKey('')
setVerifyResult(isEditing ? { valid: true } : null)
setModels([])
}, [open, agent, isEditing, reset])
const providerName = watch('providerName')
const model = watch('model')
const displayName = watch('displayName')
// Derive agentKey from displayName/preset when creating
useEffect(() => {
if (isEditing) return
const derived = selectedPresetKey || slugify(displayName) || 'agent'
setValue('agentKey', derived)
}, [displayName, selectedPresetKey, isEditing, setValue])
const selectedProvider = useMemo(() => providers.find((p) => p.name === providerName), [providers, providerName])
// Load models when provider changes
const loadModels = useCallback(async (providerId: string) => {
setModelsLoading(true)
try {
const res = await getApiClient().get<{ models: Array<{ id: string }> }>(`/v1/providers/${providerId}/models`)
setModels((res.models ?? []).map((m) => m.id))
} catch { setModels([]) } finally { setModelsLoading(false) }
}, [])
useEffect(() => { if (selectedProvider?.id) loadModels(selectedProvider.id) }, [selectedProvider?.id, loadModels])
useEffect(() => { if (!isEditing) setVerifyResult(null) }, [providerName, model, isEditing])
const providerOptions = useMemo(() => providers.filter((p) => p.enabled).map((p) => ({ value: p.name, label: p.display_name || p.name })), [providers])
const modelOptions = useMemo(() => models.map((m) => ({ value: m, label: m })), [models])
const handleVerify = async () => {
if (!selectedProvider?.id || !model.trim()) return
setVerifying(true)
try {
const res = await getApiClient().post<{ valid: boolean; error?: string }>(`/v1/providers/${selectedProvider.id}/verify`, { model: model.trim() })
setVerifyResult({ valid: res.valid, error: res.error })
} catch (err) {
setVerifyResult({ valid: false, error: err instanceof Error ? err.message : 'Verification failed' })
} finally { setVerifying(false) }
}
const onValid = async (data: AgentFormData) => {
setSubmitError('')
try {
const otherConfig: Record<string, unknown> = {}
if (data.description?.trim()) otherConfig.description = data.description.trim()
if (data.emoji?.trim()) otherConfig.emoji = data.emoji.trim()
await onSubmit({
agent_key: data.agentKey,
display_name: data.displayName.trim() || undefined,
provider: data.providerName,
model: data.model.trim(),
agent_type: isEditing ? agent!.agent_type : 'predefined',
is_default: data.isDefault || undefined,
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
})
onOpenChange(false)
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Failed to save agent')
}
}
if (!open) return null
const canCreate = isEditing
? !!providerName && !!model.trim()
: !!displayName.trim() && !!providerName && !!model.trim() && verifyResult?.valid && !!watch('description')?.trim()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-3xl w-full mx-4 overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
<h3 className="text-sm font-semibold text-text-primary">
{isEditing ? t('agents:detail.tabs.agent') + ' — ' + t('common:edit') : t('agents:create.title')}
</h3>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Body — 2 columns */}
<div className="flex-1 overflow-y-auto overscroll-contain p-5">
<div className="grid grid-cols-2 gap-5">
{/* Left column — identity & model */}
<AgentIdentityFields
isEditing={isEditing}
register={register}
watch={watch}
setValue={setValue}
errors={errors}
providerOptions={providerOptions}
modelOptions={modelOptions}
modelsLoading={modelsLoading}
verifyResult={verifyResult}
onAgentKeyChange={(val) => { setSelectedPresetKey(''); setValue('agentKey', val, { shouldValidate: true }) }}
/>
{/* Right column — personality */}
<div className="space-y-1.5 flex flex-col">
<label className="text-xs font-medium text-text-secondary">{t('agents:detail.personality')}</label>
{!isEditing && (
<AgentPresetSelector
currentDescription={watch('description') ?? ''}
onSelect={({ description, emoji, displayName, agentKey }) => {
setValue('description', description)
setValue('emoji', emoji)
setValue('displayName', displayName)
setSelectedPresetKey(agentKey)
}}
/>
)}
<textarea {...register('description')} placeholder={t('agents:create.descriptionPlaceholder')} className="flex-1 min-h-[200px] w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y" />
{errors.description && <p className="text-xs text-error">{errors.description.message}</p>}
</div>
</div>
</div>
{submitError && <div className="px-5"><p className="text-xs text-error">{submitError}</p></div>}
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-5 py-4 shrink-0">
<div>{verifyResult && !verifyResult.valid && <span className="text-[11px] text-error">{verifyResult.error}</span>}</div>
<div className="flex items-center gap-2">
<button onClick={() => onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors">{t('agents:create.cancel')}</button>
{!isEditing && selectedProvider?.id && model.trim() && !verifyResult?.valid && (
<button onClick={handleVerify} disabled={verifying} className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 flex items-center gap-1.5">
{verifying ? (<><svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>{t('desktop:agent.verifying')}</>) : t('desktop:agent.verifyModel')}
</button>
)}
<button onClick={handleSubmit(onValid)} disabled={!canCreate || isSubmitting} className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5">
{isSubmitting ? '...' : isEditing ? t('common:save') : (<>{verifyResult?.valid && (<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>)}{t('desktop:agent.summon')}</>)}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -1,13 +1,13 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useAgentCrud } from '../../../hooks/use-agent-crud'
import { useAgentStore } from '../../../stores/agent-store'
import { useAgentCrud } from '../../hooks/use-agent-crud'
import { useAgentStore } from '../../stores/agent-store'
import { AgentCard } from './AgentCard'
import { AgentFormDialog } from './AgentFormDialog'
import { AgentDetailPanel } from './AgentDetailPanel'
import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
import { SummoningModal } from '../../onboarding/SummoningModal'
import type { AgentData, AgentInput } from '../../../types/agent'
import { ConfirmDeleteDialog } from '../common/ConfirmDeleteDialog'
import { SummoningModal } from '../onboarding/SummoningModal'
import type { AgentData, AgentInput } from '../../types/agent'
export function AgentList() {
const { t } = useTranslation(['agents', 'common'])
@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { getApiClient, isApiClientReady } from '../../../lib/api'
import { Switch } from '../../common/Switch'
import type { MCPServerData, MCPAgentGrant } from '../../../types/mcp'
import { getApiClient, isApiClientReady } from '../../lib/api'
import { Switch } from '../common/Switch'
import type { MCPServerData, MCPAgentGrant } from '../../types/mcp'
interface AgentMcpSectionProps {
agentId: string
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { getApiClient, isApiClientReady } from '../../../lib/api'
import { Switch } from '../../common/Switch'
import { getApiClient, isApiClientReady } from '../../lib/api'
import { Switch } from '../common/Switch'
interface SkillWithGrant {
id: string
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { Switch } from '../../common/Switch'
import { Switch } from '../common/Switch'
interface EvolutionSectionProps {
selfEvolve: boolean
@@ -1,4 +1,4 @@
import type { MemoryConfig } from '../../../types/agent'
import type { MemoryConfig } from '../../types/agent'
interface MemorySectionProps {
config: MemoryConfig | null | undefined
@@ -1,8 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import { useProviders } from '../../../hooks/use-providers'
import { getApiClient } from '../../../lib/api'
import { Combobox } from '../common/Combobox'
import { useProviders } from '../../hooks/use-providers'
import { getApiClient } from '../../lib/api'
interface ModelBudgetSectionProps {
provider: string
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Switch } from '../../common/Switch'
import { Combobox } from '../../common/Combobox'
import { Switch } from '../common/Switch'
import { Combobox } from '../common/Combobox'
interface PersonalitySectionProps {
emoji: string
@@ -0,0 +1,74 @@
import { useTranslation } from 'react-i18next'
import { Combobox } from '../common/Combobox'
import { Switch } from '../common/Switch'
import type { UseFormRegister, UseFormWatch, UseFormSetValue, FieldErrors } from 'react-hook-form'
import type { AgentFormData } from '../../schemas/agent.schema'
interface AgentIdentityFieldsProps {
isEditing: boolean
register: UseFormRegister<AgentFormData>
watch: UseFormWatch<AgentFormData>
setValue: UseFormSetValue<AgentFormData>
errors: FieldErrors<AgentFormData>
providerOptions: { value: string; label: string }[]
modelOptions: { value: string; label: string }[]
modelsLoading: boolean
verifyResult: { valid: boolean; error?: string } | null
onAgentKeyChange: (val: string) => void
}
export function AgentIdentityFields({
isEditing, register, watch, setValue, errors,
providerOptions, modelOptions, modelsLoading, verifyResult,
onAgentKeyChange,
}: AgentIdentityFieldsProps) {
const { t } = useTranslation(['agents', 'desktop', 'common'])
const agentKey = watch('agentKey')
const providerName = watch('providerName')
const model = watch('model')
return (
<div className="space-y-4">
{/* Display name + emoji */}
<div className="flex gap-2">
<div className="space-y-1 flex-1">
<label className="text-xs font-medium text-text-secondary">{t('agents:create.displayName').replace(' *', '')}</label>
<input {...register('displayName')} placeholder={t('agents:create.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
<div className="space-y-1 w-16">
<label className="text-xs font-medium text-text-secondary">{t('agents:identity.emoji')}</label>
<input value={watch('emoji') ?? ''} onChange={(e) => setValue('emoji', e.target.value.slice(0, 2))} maxLength={2} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary text-center focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
</div>
{/* Agent key (create only) */}
{!isEditing && (
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">Agent Key</label>
<input value={agentKey} onChange={(e) => onAgentKeyChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono focus:outline-none focus:ring-1 focus:ring-accent" />
{errors.agentKey && <p className="text-xs text-error">{errors.agentKey.message}</p>}
</div>
)}
{/* Provider */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('common:provider')}</label>
<Combobox value={providerName} onChange={(v) => setValue('providerName', v, { shouldValidate: true })} options={providerOptions} placeholder={t('agents:create.selectProvider')} />
</div>
{/* Model */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('common:model')}</label>
<Combobox value={model} onChange={(v) => setValue('model', v, { shouldValidate: true })} options={modelOptions} placeholder={modelsLoading ? t('agents:create.loadingModels') : t('agents:create.enterOrSelectModel')} allowCustom />
{verifyResult && !verifyResult.valid && <p className="text-xs text-error">{verifyResult.error || t('desktop:agent.verifyFailed')}</p>}
{verifyResult?.valid && !isEditing && <p className="text-xs text-success">{t('desktop:agent.verified')}</p>}
</div>
{/* Default toggle */}
<div className="flex items-center gap-2">
<Switch checked={watch('isDefault')} onCheckedChange={(v) => setValue('isDefault', v)} />
<span className="text-xs text-text-secondary">{t('agents:identity.defaultAgent')}</span>
</div>
</div>
)
}
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
const PRESET_KEYS = [
{ key: 'foxSpirit', emoji: '🦊', ns: 'agents', agentKey: 'little-fox' },
{ key: 'artisan', emoji: '🎨', ns: 'agents', agentKey: 'artisan' },
{ key: 'astrologer', emoji: '🔮', ns: 'agents', agentKey: 'mimi' },
{ key: 'researcher', emoji: '🔬', ns: 'desktop', agentKey: 'scholar' },
{ key: 'writer', emoji: '✍️', ns: 'desktop', agentKey: 'quill' },
{ key: 'coder', emoji: '👨‍💻', ns: 'desktop', agentKey: 'dev' },
]
interface AgentPresetSelectorProps {
currentDescription: string
onSelect: (preset: { description: string; emoji: string; displayName: string; agentKey: string }) => void
}
export function AgentPresetSelector({ currentDescription, onSelect }: AgentPresetSelectorProps) {
const { t } = useTranslation(['agents', 'desktop'])
return (
<div className="flex flex-wrap gap-1.5">
{PRESET_KEYS.map((p) => {
const label = t(`${p.ns}:presets.${p.key}.label`)
const prompt = t(`${p.ns}:presets.${p.key}.prompt`)
const nameMatch = prompt.match(/^Name:\s*([^.]+)/)
const presetDisplayName = nameMatch ? nameMatch[1].trim() : label.replace(/^\S+\s*/, '')
return (
<button
key={p.key}
type="button"
onClick={() => onSelect({ description: prompt, emoji: p.emoji, displayName: presetDisplayName, agentKey: p.agentKey })}
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
currentDescription === prompt
? 'border-accent bg-accent/10 text-accent font-medium'
: 'border-border text-text-secondary hover:bg-surface-tertiary hover:text-text-primary'
}`}
>
{label}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,73 @@
// Dialog for "Edit with AI" — sends prompt to regenerate agent context files endpoint.
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
interface RegenerateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onRegenerate: (prompt: string) => Promise<void>
}
export function RegenerateDialog({ open, onOpenChange, onRegenerate }: RegenerateDialogProps) {
const { t } = useTranslation('agents')
const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
if (!prompt.trim()) return
setLoading(true)
try {
await onRegenerate(prompt.trim())
onOpenChange(false)
setPrompt('')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<svg className="h-4 w-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
</svg>
Edit with AI
</h3>
<p className="text-xs text-text-muted">
Describe how you want to modify the agent's personality, knowledge, or behavior. The AI will regenerate the context files accordingly.
</p>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Make the agent more formal and add expertise in data analysis..."
rows={4}
autoFocus
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => onOpenChange(false)}
disabled={loading}
className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!prompt.trim() || loading}
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
</svg>
{loading ? t('fileEditor.sending') : t('fileEditor.regenerate')}
</button>
</div>
</div>
</div>
)
}
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { ChannelFields } from './channel-field-renderer'
import { configSchema, NETWORK_KEYS, LIMITS_KEYS, STREAMING_KEYS, BEHAVIOR_KEYS, ACCESS_KEYS } from './channel-schemas'
import type { ChannelInstanceData } from '../../../types/channel'
import type { ChannelInstanceData } from '../../types/channel'
interface ChannelAdvancedTabProps {
instance: ChannelInstanceData
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { Switch } from "../../common/Switch";
import { Switch } from "../common/Switch";
import type {
ChannelInstanceData,
ChannelStatus,
} from "../../../types/channel";
import { getChannelStatusDisplay } from "../../../utils/channel-status";
} from "../../types/channel";
import { getChannelStatusDisplay } from "../../utils/channel-status";
interface ChannelCardProps {
instance: ChannelInstanceData;
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChannelFields } from './channel-field-renderer'
import { credentialsSchema } from './channel-schemas'
import type { ChannelInstanceData } from '../../../types/channel'
import type { ChannelInstanceData } from '../../types/channel'
interface ChannelCredentialsTabProps {
instance: ChannelInstanceData
@@ -1,13 +1,13 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useChannelDetail } from "../../../hooks/use-channel-detail";
import { useAgentCrud } from "../../../hooks/use-agent-crud";
import { useChannelDetail } from "../../hooks/use-channel-detail";
import { useAgentCrud } from "../../hooks/use-agent-crud";
import { ChannelGeneralTab } from "./ChannelGeneralTab";
import { ChannelCredentialsTab } from "./ChannelCredentialsTab";
import { ChannelManagersTab } from "./ChannelManagersTab";
import { ChannelAdvancedTab } from "./ChannelAdvancedTab";
import type { ChannelStatus } from "../../../types/channel";
import { getChannelStatusDisplay } from "../../../utils/channel-status";
import type { ChannelStatus } from "../../types/channel";
import { getChannelStatusDisplay } from "../../utils/channel-status";
interface ChannelDetailPanelProps {
instanceId: string;
@@ -1,11 +1,14 @@
import { useState, useEffect, useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import { Switch } from '../../common/Switch'
import { Combobox } from '../common/Combobox'
import { Switch } from '../common/Switch'
import { ChannelFields } from './channel-field-renderer'
import { credentialsSchema } from './channel-schemas'
import type { ChannelInstanceInput } from '../../../types/channel'
import type { AgentData } from '../../../types/agent'
import { channelFormSchema, type ChannelFormData } from '../../schemas/channel.schema'
import type { ChannelInstanceInput } from '../../types/channel'
import type { AgentData } from '../../types/agent'
interface ChannelFormDialogProps {
open: boolean
@@ -19,30 +22,28 @@ interface ChannelFormDialogProps {
export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists, discordExists, onSubmit }: ChannelFormDialogProps) {
const { t } = useTranslation('channels')
const [displayName, setDisplayName] = useState('')
const [channelType, setChannelType] = useState('')
const [agentId, setAgentId] = useState('')
const [enabled, setEnabled] = useState(true)
const [credentials, setCredentials] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<ChannelFormData>({
resolver: zodResolver(channelFormSchema),
mode: 'onChange',
})
const channelType = watch('channelType')
const credentials = watch('credentials')
// Reset form when dialog opens
useEffect(() => {
if (!open) return
setDisplayName('')
setChannelType('')
setAgentId('')
setEnabled(true)
setCredentials({})
setError('')
// Auto-select the only available type
const available = []
if (!telegramExists) available.push('telegram')
if (!discordExists) available.push('discord')
if (available.length === 1) setChannelType(available[0])
}, [open, telegramExists, discordExists])
reset({
displayName: '',
channelType: available.length === 1 ? available[0] : '',
agentId: '',
enabled: true,
credentials: {},
})
}, [open, telegramExists, discordExists, reset])
const typeOptions = useMemo(() => {
const opts = []
@@ -59,31 +60,23 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
const credFields = channelType ? (credentialsSchema[channelType] ?? []) : []
const handleCredChange = (key: string, value: unknown) => {
setCredentials((prev) => ({ ...prev, [key]: String(value ?? '') }))
setValue('credentials', { ...credentials, [key]: String(value ?? '') })
}
const canCreate = !!channelType && !!agentId
&& credFields.filter((f) => f.required).every((f) => credentials[f.key]?.trim())
const canCreate = !!channelType && !!watch('agentId')
&& credFields.filter((f) => f.required).every((f) => credentials?.[f.key]?.trim())
const handleCreate = async () => {
setLoading(true)
setError('')
try {
await onSubmit({
name: channelType, // auto-slug: "telegram" or "discord"
displayName: displayName.trim(),
channelType,
agentId,
credentials,
config: {},
enabled,
})
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create channel')
} finally {
setLoading(false)
}
const onValid = async (data: ChannelFormData) => {
await onSubmit({
name: data.channelType,
displayName: data.displayName,
channelType: data.channelType,
agentId: data.agentId,
credentials: data.credentials,
config: {},
enabled: data.enabled,
})
onOpenChange(false)
}
if (!open) return null
@@ -106,7 +99,7 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
{/* Display Name */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.displayName')}</label>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={t('form.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
<input value={watch('displayName')} onChange={(e) => setValue('displayName', e.target.value)} placeholder={t('form.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
{/* Channel Type */}
@@ -119,23 +112,25 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
) : (
<Combobox
value={channelType}
onChange={(v) => { setChannelType(v); setCredentials({}) }}
onChange={(v) => { setValue('channelType', v, { shouldValidate: true }); setValue('credentials', {}) }}
options={typeOptions}
placeholder={t('form.selectType')}
allowCustom={false}
/>
)}
{errors.channelType && <p className="text-xs text-error">{errors.channelType.message}</p>}
</div>
{/* Agent */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.agent')} *</label>
<Combobox value={agentId} onChange={setAgentId} options={agentOptions} placeholder={t('form.selectAgent')} />
<Combobox value={watch('agentId')} onChange={(v) => setValue('agentId', v, { shouldValidate: true })} options={agentOptions} placeholder={t('form.selectAgent')} />
{errors.agentId && <p className="text-xs text-error">{errors.agentId.message}</p>}
</div>
{/* Enabled */}
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Switch checked={watch('enabled')} onCheckedChange={(v) => setValue('enabled', v)} />
<span className="text-xs text-text-secondary">{t('form.enabled')}</span>
</div>
@@ -143,19 +138,18 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
{credFields.length > 0 && (
<div className="space-y-2 border-t border-border pt-4">
<h4 className="text-xs font-semibold text-text-secondary">{t('form.credentials')}</h4>
<ChannelFields fields={credFields} values={credentials} onChange={handleCredChange} idPrefix="cf-cred" />
<ChannelFields fields={credFields} values={credentials ?? {}} onChange={handleCredChange} idPrefix="cf-cred" />
</div>
)}
</div>
{/* Footer */}
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4 shrink-0">
<button onClick={() => onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors cursor-pointer">
{t('form.cancel')}
</button>
<button onClick={handleCreate} disabled={!canCreate || loading} className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer">
{loading ? t('form.saving') : t('form.create')}
<button onClick={handleSubmit(onValid)} disabled={!canCreate || isSubmitting} className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer">
{isSubmitting ? t('form.saving') : t('form.create')}
</button>
</div>
</div>
@@ -1,11 +1,11 @@
import { useState, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Switch } from '../../common/Switch'
import { Combobox } from '../../common/Combobox'
import { Switch } from '../common/Switch'
import { Combobox } from '../common/Combobox'
import { ChannelFields } from './channel-field-renderer'
import { configSchema, ESSENTIAL_CONFIG_KEYS } from './channel-schemas'
import type { ChannelInstanceData } from '../../../types/channel'
import type { AgentData } from '../../../types/agent'
import type { ChannelInstanceData } from '../../types/channel'
import type { AgentData } from '../../types/agent'
interface ChannelGeneralTabProps {
instance: ChannelInstanceData
@@ -1,15 +1,15 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChannelCrud } from '../../../hooks/use-channel-crud'
import { useChannelStatus } from '../../../hooks/use-channel-status'
import { useAgentCrud } from '../../../hooks/use-agent-crud'
import { useChannelCrud } from '../../hooks/use-channel-crud'
import { useChannelStatus } from '../../hooks/use-channel-status'
import { useAgentCrud } from '../../hooks/use-agent-crud'
import { ChannelCard } from './ChannelCard'
import { ChannelFormDialog } from './ChannelFormDialog'
import { ChannelDetailPanel } from './ChannelDetailPanel'
import { PairedDevicesSection } from './PairedDevicesSection'
import { ConfirmDialog } from '../../common/ConfirmDialog'
import { RefreshButton } from '../../common/RefreshButton'
import type { ChannelInstanceData } from '../../../types/channel'
import { ConfirmDialog } from '../common/ConfirmDialog'
import { RefreshButton } from '../common/RefreshButton'
import type { ChannelInstanceData } from '../../types/channel'
export function ChannelList() {
const { t } = useTranslation('channels')
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import type { GroupManagerGroupInfo, GroupManagerData, ChannelContact } from '../../../types/channel'
import { Combobox } from '../common/Combobox'
import { ManagerGroupCard } from './manager-group-card'
import type { GroupManagerGroupInfo, GroupManagerData, ChannelContact } from '../../types/channel'
interface ChannelManagersTabProps {
listManagerGroups: () => Promise<GroupManagerGroupInfo[]>
@@ -11,10 +12,6 @@ interface ChannelManagersTabProps {
listContacts: (search: string) => Promise<ChannelContact[]>
}
function shortGroupId(id: string): string {
return id.match(/^group:[^:]+:(.+)$/)?.[1] ?? id
}
export function ChannelManagersTab({
listManagerGroups, listManagers, addManager, removeManager, listContacts,
}: ChannelManagersTabProps) {
@@ -31,11 +28,7 @@ export function ChannelManagersTab({
const [error, setError] = useState('')
const loadGroups = useCallback(async () => {
try {
setGroups(await listManagerGroups())
} catch {
setGroups([])
}
try { setGroups(await listManagerGroups()) } catch { setGroups([]) }
}, [listManagerGroups])
useEffect(() => { loadGroups() }, [loadGroups])
@@ -63,9 +56,7 @@ export function ChannelManagersTab({
value: c.sender_id,
label: c.display_name ? `${c.display_name} (${c.sender_id})` : c.sender_id,
})))
} catch {
setContactOptions([])
}
} catch { setContactOptions([]) }
}, [listContacts])
const handleInlineAdd = async (groupId: string) => {
@@ -110,7 +101,6 @@ export function ChannelManagersTab({
setNewGroupId('')
setNewUserId('')
await loadGroups()
// Auto-expand new group
setExpanded((prev) => ({ ...prev, [gid]: true }))
const data = await listManagers(gid)
setManagersMap((prev) => ({ ...prev, [gid]: data }))
@@ -136,77 +126,22 @@ export function ChannelManagersTab({
) : (
<div className="space-y-2">
{groups.map((g) => (
<div key={g.group_id} className="border border-border rounded-lg overflow-hidden">
<button
onClick={() => handleToggle(g.group_id)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-tertiary/30 transition-colors text-left cursor-pointer"
>
<span className="text-xs font-medium text-text-primary font-mono">{shortGroupId(g.group_id)}</span>
<div className="flex items-center gap-3">
<span className="rounded-full bg-surface-tertiary px-2 py-0.5 text-[11px] text-text-muted tabular-nums">
{g.writer_count === 1
? t('detail.managers.managersCount', { count: g.writer_count })
: t('detail.managers.managersCountPlural', { count: g.writer_count })}
</span>
<svg className={`w-3.5 h-3.5 text-text-muted transition-transform ${expanded[g.group_id] ? 'rotate-90' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}><path d="M9 18l6-6-6-6" /></svg>
</div>
</button>
{expanded[g.group_id] && (
<div className="border-t border-border bg-surface-tertiary/10 px-4 pb-3 pt-2 space-y-3">
{loadingMap[g.group_id] ? (
<p className="text-xs text-text-muted py-2">{t('detail.managers.loadingManagers')}</p>
) : (managersMap[g.group_id] ?? []).length === 0 ? (
<p className="text-xs text-text-muted py-2">{t('detail.managers.noManagers')}</p>
) : (
<div className="rounded-md border border-border bg-surface-secondary overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.userId')}</th>
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.name')}</th>
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.username')}</th>
<th className="px-3 py-1.5 w-10" />
</tr>
</thead>
<tbody>
{(managersMap[g.group_id] ?? []).map((m) => (
<tr key={m.user_id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/20">
<td className="px-3 py-1.5 font-mono">{m.user_id}</td>
<td className="px-3 py-1.5 text-text-muted">{m.display_name || '—'}</td>
<td className="px-3 py-1.5 text-text-muted">{m.username ? `@${m.username}` : '—'}</td>
<td className="px-3 py-1.5 text-right">
<button onClick={() => handleRemove(g.group_id, m.user_id)} className="text-[11px] text-text-muted hover:text-error transition-colors cursor-pointer">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Inline add */}
<div className="flex gap-2 items-end">
<div className="flex-1">
<Combobox
value={inlineUserId[g.group_id] ?? ''}
onChange={(v) => { setInlineUserId((prev) => ({ ...prev, [g.group_id]: v })); handleContactSearch(v) }}
options={contactOptions}
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
/>
</div>
<button
onClick={() => handleInlineAdd(g.group_id)}
disabled={addingMap[g.group_id] || !inlineUserId[g.group_id]?.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
>
{t('detail.managers.addForm.add')}
</button>
</div>
</div>
)}
</div>
<ManagerGroupCard
key={g.group_id}
groupId={g.group_id}
writerCount={g.writer_count}
expanded={!!expanded[g.group_id]}
loading={!!loadingMap[g.group_id]}
managers={managersMap[g.group_id] ?? []}
adding={!!addingMap[g.group_id]}
inlineUserId={inlineUserId[g.group_id] ?? ''}
contactOptions={contactOptions}
onToggle={() => handleToggle(g.group_id)}
onInlineUserIdChange={(v) => setInlineUserId((prev) => ({ ...prev, [g.group_id]: v }))}
onContactSearch={handleContactSearch}
onInlineAdd={() => handleInlineAdd(g.group_id)}
onRemove={(userId) => handleRemove(g.group_id, userId)}
/>
))}
</div>
)}
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePairedDevices } from '../../../hooks/use-paired-devices'
import { ConfirmDialog } from '../../common/ConfirmDialog'
import { RefreshButton } from '../../common/RefreshButton'
import type { PendingPairing, PairedDevice } from '../../../types/channel'
import { usePairedDevices } from '../../hooks/use-paired-devices'
import { ConfirmDialog } from '../common/ConfirmDialog'
import { RefreshButton } from '../common/RefreshButton'
import type { PendingPairing, PairedDevice } from '../../types/channel'
function formatRelativeTime(ms: number): string {
const diff = Date.now() - ms
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Switch } from '../../common/Switch'
import { Combobox } from '../../common/Combobox'
import { Switch } from '../common/Switch'
import { Combobox } from '../common/Combobox'
import type { FieldDef } from './channel-schemas'
interface ChannelFieldsProps {
@@ -0,0 +1,117 @@
// Expandable group card showing managers list and inline add form for a single channel group.
import { useTranslation } from 'react-i18next'
import { Combobox } from '../common/Combobox'
import type { GroupManagerData } from '../../types/channel'
function shortGroupId(id: string): string {
return id.match(/^group:[^:]+:(.+)$/)?.[1] ?? id
}
interface ManagerGroupCardProps {
groupId: string
writerCount: number
expanded: boolean
loading: boolean
managers: GroupManagerData[]
adding: boolean
inlineUserId: string
contactOptions: { value: string; label: string }[]
onToggle: () => void
onInlineUserIdChange: (v: string) => void
onContactSearch: (v: string) => void
onInlineAdd: () => void
onRemove: (userId: string) => void
}
export function ManagerGroupCard({
groupId, writerCount, expanded, loading, managers, adding, inlineUserId,
contactOptions, onToggle, onInlineUserIdChange, onContactSearch, onInlineAdd, onRemove,
}: ManagerGroupCardProps) {
const { t } = useTranslation('channels')
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-tertiary/30 transition-colors text-left cursor-pointer"
>
<span className="text-xs font-medium text-text-primary font-mono">{shortGroupId(groupId)}</span>
<div className="flex items-center gap-3">
<span className="rounded-full bg-surface-tertiary px-2 py-0.5 text-[11px] text-text-muted tabular-nums">
{writerCount === 1
? t('detail.managers.managersCount', { count: writerCount })
: t('detail.managers.managersCountPlural', { count: writerCount })}
</span>
<svg
className={`w-3.5 h-3.5 text-text-muted transition-transform ${expanded ? 'rotate-90' : ''}`}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}
>
<path d="M9 18l6-6-6-6" />
</svg>
</div>
</button>
{expanded && (
<div className="border-t border-border bg-surface-tertiary/10 px-4 pb-3 pt-2 space-y-3">
{loading ? (
<p className="text-xs text-text-muted py-2">{t('detail.managers.loadingManagers')}</p>
) : managers.length === 0 ? (
<p className="text-xs text-text-muted py-2">{t('detail.managers.noManagers')}</p>
) : (
<div className="rounded-md border border-border bg-surface-secondary overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.userId')}</th>
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.name')}</th>
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.username')}</th>
<th className="px-3 py-1.5 w-10" />
</tr>
</thead>
<tbody>
{managers.map((m) => (
<tr key={m.user_id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/20">
<td className="px-3 py-1.5 font-mono">{m.user_id}</td>
<td className="px-3 py-1.5 text-text-muted">{m.display_name || '—'}</td>
<td className="px-3 py-1.5 text-text-muted">{m.username ? `@${m.username}` : '—'}</td>
<td className="px-3 py-1.5 text-right">
<button
onClick={() => onRemove(m.user_id)}
className="text-[11px] text-text-muted hover:text-error transition-colors cursor-pointer"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Inline add */}
<div className="flex gap-2 items-end">
<div className="flex-1">
<Combobox
value={inlineUserId}
onChange={(v) => { onInlineUserIdChange(v); onContactSearch(v) }}
options={contactOptions}
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
/>
</div>
<button
onClick={onInlineAdd}
disabled={adding || !inlineUserId.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
>
{t('detail.managers.addForm.add')}
</button>
</div>
</div>
)}
</div>
)
}
@@ -1,16 +1,12 @@
import { useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { MarkdownRenderer } from './MarkdownRenderer'
import { AuthImage, downloadFile } from './AuthImage'
import { downloadFile } from './AuthImage'
import { getApiClient, isApiClientReady } from '../../lib/api'
import { FilePreviewRenderer, isMarkdown, isCode, isText } from './file-preview-renderer'
// Strip ?ft= token and timestamps from filename for display
function cleanFilename(name: string): string {
// Remove query params
const base = name.split('?')[0]
// Remove timestamp suffix: "file.1774537056.md" → "file.md"
return base.replace(/\.\d{9,}(\.\w+)$/, '$1')
}
@@ -21,52 +17,11 @@ interface FilePreviewDialogProps {
onClose: () => void
}
function getLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
py: 'python', go: 'go', rs: 'rust', java: 'java',
c: 'c', cpp: 'cpp', h: 'c', rb: 'ruby', sh: 'bash',
yaml: 'yaml', yml: 'yaml', toml: 'toml', json: 'json',
xml: 'xml', html: 'html', css: 'css',
}
return map[ext] ?? 'text'
}
function isImage(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('image/')) return true
return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename)
}
function isVideo(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('video/')) return true
return /\.(mp4|webm|ogg|mov)$/i.test(filename)
}
function isAudio(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('audio/')) return true
return /\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(filename)
}
function isMarkdown(filename: string): boolean {
return /\.(md|markdown)$/i.test(filename)
}
function isCode(filename: string): boolean {
return /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml|json|xml|html|css)$/i.test(filename)
}
function isText(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('text/')) return true
return /\.(txt|log|csv|conf|ini|env)$/i.test(filename)
}
export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClose }: FilePreviewDialogProps) {
const { t } = useTranslation('common')
const [textContent, setTextContent] = useState<string | null>(null)
const [loadError, setLoadError] = useState(false)
// Normalize: if filename is a full URL/path, extract the basename
const filename = (rawFilename.includes('/') ? rawFilename.split('/').pop() : rawFilename)?.split('?')[0] ?? rawFilename
const needsTextFetch = isMarkdown(filename) || isCode(filename) || isText(filename, mimeType)
@@ -75,9 +30,6 @@ export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClos
let cancelled = false
setTextContent(null)
setLoadError(false)
// For URLs without ?ft= token, sign them first to get a valid ft= token.
// This avoids tenant header issues with direct Bearer auth fetch.
// Fetch with Bearer auth via ApiClient.fetchFile (handles base URL + auth header).
const doFetch = isApiClientReady()
? getApiClient().fetchFile(url)
: fetch(url)
@@ -103,81 +55,6 @@ export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClos
const displayName = cleanFilename(filename)
function renderContent() {
if (isImage(filename, mimeType)) {
return (
<AuthImage src={url} alt={displayName} className="max-w-full max-h-[70vh] object-contain mx-auto rounded-lg" />
)
}
if (isVideo(filename, mimeType)) {
return <video src={url} controls className="max-w-full rounded-lg" />
}
if (isAudio(filename, mimeType)) {
return <audio src={url} controls className="w-full" />
}
if (needsTextFetch) {
if (loadError) {
return (
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
<p className="text-sm">{t('errors.serverError', 'Failed to load file preview')}</p>
<button
onClick={() => downloadFile(url, displayName)}
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors cursor-pointer"
>
{t('download', 'Download')}
</button>
</div>
)
}
if (textContent === null) {
return <p className="text-text-muted text-sm p-4">{t('loading')}</p>
}
if (isMarkdown(filename)) {
// Resolve relative image/link paths in markdown against the file's directory URL
const baseDir = url.substring(0, url.lastIndexOf('/') + 1)
const resolved = textContent.replace(
/(!?\[.*?\])\((?!https?:\/\/|\/|#)(.*?)\)/g,
(_, prefix, relPath) => `${prefix}(${baseDir}${relPath})`,
)
return (
<div className="p-4 overflow-y-auto max-h-[70vh]">
<MarkdownRenderer content={resolved} />
</div>
)
}
if (isCode(filename)) {
return (
<div className="overflow-y-auto max-h-[70vh]">
<SyntaxHighlighter
language={getLanguage(filename)}
style={oneDark}
customStyle={{ margin: 0, fontSize: '13px', borderRadius: 0 }}
>
{textContent}
</SyntaxHighlighter>
</div>
)
}
// plain text / log / csv
return (
<pre className="p-4 text-xs font-mono text-text-primary overflow-auto max-h-[70vh] whitespace-pre-wrap break-words">
{textContent}
</pre>
)
}
return (
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
<p className="text-sm">{t('selectFileToView')}</p>
<button
onClick={() => downloadFile(url, displayName)}
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors"
>
{t('download')}
</button>
</div>
)
}
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -217,7 +94,15 @@ export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClos
{/* Content */}
<div className="overflow-hidden">
{renderContent()}
<FilePreviewRenderer
url={url}
filename={filename}
displayName={displayName}
mimeType={mimeType}
textContent={textContent}
loadError={loadError}
needsTextFetch={needsTextFetch}
/>
</div>
</div>
</div>
@@ -1,17 +1,10 @@
import { useState, useRef, useCallback, type KeyboardEvent, type DragEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useFileAttachments, extractFilePaths } from './use-file-attachments'
import { AttachmentPreviewStrip } from './attachment-preview-strip'
import type { AttachedFile } from './use-file-attachments'
/** A file queued for upload or a direct filesystem path (desktop). */
export interface AttachedFile {
id: string
/** Browser File object — present for drag/drop and file picker uploads. */
file?: File
/** Direct filesystem path — present for pasted paths (desktop only, skip HTTP upload). */
localPath?: string
name: string
/** Image thumbnail data URL (only for browser File images). */
preview?: string
}
export type { AttachedFile }
interface InputBarProps {
onSend: (text: string, files?: AttachedFile[]) => void
@@ -21,78 +14,26 @@ interface InputBarProps {
placeholder?: string
}
/** Human-readable file size. */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** Detect if text looks like one or more absolute file paths (one per line). */
const FILE_PATH_RE = /^(\/[\w.\-/ ]+(?:\.\w+)?|[A-Z]:\\[\w.\-\\ ]+(?:\.\w+)?)$/
function extractFilePaths(text: string): string[] {
return text.split('\n').map((l) => l.trim()).filter((l) => FILE_PATH_RE.test(l))
}
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: InputBarProps) {
const { t } = useTranslation('common')
const [text, setText] = useState('')
const [files, setFiles] = useState<AttachedFile[]>([])
const [dragging, setDragging] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const addFiles = useCallback((incoming: FileList | File[]) => {
const arr = Array.from(incoming)
const newFiles: AttachedFile[] = arr.map((f) => ({
id: crypto.randomUUID().slice(0, 8),
file: f,
name: f.name,
}))
// Generate image previews
for (const af of newFiles) {
if (af.file && IMAGE_TYPES.includes(af.file.type)) {
const fileObj = af.file
const reader = new FileReader()
reader.onload = () => {
setFiles((prev) => prev.map((f) => f.id === af.id ? { ...f, preview: reader.result as string } : f))
}
reader.readAsDataURL(fileObj)
}
}
setFiles((prev) => [...prev, ...newFiles])
}, [])
/** Add files by filesystem path (desktop paste). */
const addLocalPaths = useCallback((paths: string[]) => {
const newFiles: AttachedFile[] = paths.map((p) => ({
id: crypto.randomUUID().slice(0, 8),
localPath: p,
name: p.split('/').pop() || p.split('\\').pop() || p,
}))
setFiles((prev) => [...prev, ...newFiles])
}, [])
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id))
}, [])
const { files, addFiles, addLocalPaths, removeFile, clearFiles } = useFileAttachments()
const handleSend = useCallback(() => {
const hasContent = text.trim().length > 0 || files.length > 0
if (!hasContent || disabled) return
onSend(text.trim(), files.length > 0 ? files : undefined)
setText('')
setFiles([])
clearFiles()
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [text, files, disabled, onSend])
}, [text, files, disabled, onSend, clearFiles])
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -108,9 +49,7 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
}
// --- Paste: detect file paths ---
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// If clipboard has files (e.g. screenshot paste), handle as file upload
const items = e.clipboardData.items
const pastedFiles: File[] = []
for (let i = 0; i < items.length; i++) {
@@ -124,18 +63,14 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
addFiles(pastedFiles)
return
}
// Check if pasted text looks like file path(s)
const pasted = e.clipboardData.getData('text/plain')
const paths = extractFilePaths(pasted)
if (paths.length > 0) {
e.preventDefault()
addLocalPaths(paths)
}
// Otherwise, let default paste behavior handle it (normal text)
}, [addFiles, addLocalPaths])
// --- Drag & drop ---
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
dragCounter.current++
@@ -146,27 +81,19 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
dragCounter.current--
if (dragCounter.current === 0) setDragging(false)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
}
const handleDragOver = (e: DragEvent) => { e.preventDefault() }
const handleDrop = (e: DragEvent) => {
e.preventDefault()
dragCounter.current = 0
setDragging(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
const handleAttachClick = () => {
fileInputRef.current?.click()
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files)
}
const handleFileChange = () => {
const input = fileInputRef.current
if (input?.files && input.files.length > 0) {
addFiles(input.files)
input.value = '' // reset so same file can be re-selected
input.value = ''
}
}
@@ -181,62 +108,22 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
onDrop={handleDrop}
>
<div className="max-w-3xl mx-auto">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
{/* Drop overlay */}
{dragging && (
<div className="mb-2 rounded-xl border-2 border-dashed border-accent/50 bg-accent/5 py-4 text-center text-xs text-accent">
{t('dropFilesHere', 'Drop files here')}
</div>
)}
{/* Attached files preview */}
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{files.map((af) => (
<div
key={af.id}
className="group flex items-center gap-1.5 bg-surface-secondary border border-border rounded-lg px-2 py-1 text-xs max-w-[200px]"
>
{af.preview ? (
<img src={af.preview} alt="" className="w-5 h-5 rounded object-cover shrink-0" />
) : (
<svg className="w-3.5 h-3.5 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
<span className="truncate text-text-secondary">{af.name}</span>
{af.file && <span className="text-text-muted shrink-0">({formatSize(af.file.size)})</span>}
<button
onClick={() => removeFile(af.id)}
className="ml-auto shrink-0 text-text-muted hover:text-error transition-colors opacity-0 group-hover:opacity-100"
title={t('remove', 'Remove')}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
)}
<AttachmentPreviewStrip files={files} onRemove={removeFile} />
{/* Input container */}
<div className={[
'flex items-end gap-0 bg-surface-secondary rounded-2xl border transition-colors',
dragging ? 'border-accent/50' : 'border-border focus-within:border-accent/40',
].join(' ')}>
{/* Attach button */}
<button
onClick={handleAttachClick}
onClick={() => fileInputRef.current?.click()}
className="p-3 text-text-muted hover:text-text-secondary transition-colors shrink-0 cursor-pointer"
title={t('attachFile')}
disabled={disabled}
@@ -246,7 +133,6 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
</svg>
</button>
{/* Textarea */}
<textarea
ref={textareaRef}
value={text}
@@ -260,7 +146,6 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
style={{ maxHeight: 160 }}
/>
{/* Send / Stop button */}
<div className="p-2 shrink-0">
{isRunning ? (
<button
@@ -268,12 +153,10 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
className="w-10 h-10 flex items-center justify-center hover:opacity-90 transition-opacity"
title={t('stopGeneration')}
>
{/* Spinning border ring */}
<svg className="absolute w-8 h-8 animate-spin" viewBox="0 0 32 32" fill="none" style={{ animationDuration: '1.5s' }}>
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" className="text-error/20" />
<path d="M16 2 A14 14 0 0 1 30 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-error" />
</svg>
{/* Center stop icon */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="relative text-error">
<rect x="4" y="4" width="16" height="16" rx="3" />
</svg>
@@ -1,4 +1,4 @@
import type { Message } from '../../stores/chat-store'
import type { ChatMessage as Message } from '../../stores/chat-store'
import { formatTimestamp } from '../../lib/format'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { getWsClient } from '../../lib/ws'
import { teamService } from '../../services/team-service'
import type { TeamTaskData } from '../../types/team'
import { PRIORITY_BADGE } from '../../types/team'
@@ -55,8 +56,7 @@ export function TaskPanel({ sessionKey, onTaskClick }: TaskPanelProps) {
const fetchActiveTasks = useCallback(async () => {
if (!sessionKey) return
try {
const ws = getWsClient()
const res = await ws.call('teams.tasks.active-by-session', { sessionKey }) as { tasks: ActiveTaskResponse[] | null }
const res = await teamService.activeTasksBySession(sessionKey)
setTasks((res.tasks ?? []).map(normalizeActiveTask))
} catch { /* session may not have team tasks */ }
}, [sessionKey])
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import { formatSize, type AttachedFile } from './use-file-attachments'
interface AttachmentPreviewStripProps {
files: AttachedFile[]
onRemove: (id: string) => void
}
export function AttachmentPreviewStrip({ files, onRemove }: AttachmentPreviewStripProps) {
const { t } = useTranslation('common')
if (files.length === 0) return null
return (
<div className="flex flex-wrap gap-1.5 mb-2">
{files.map((af) => (
<div
key={af.id}
className="group flex items-center gap-1.5 bg-surface-secondary border border-border rounded-lg px-2 py-1 text-xs max-w-[200px]"
>
{af.preview ? (
<img src={af.preview} alt="" className="w-5 h-5 rounded object-cover shrink-0" />
) : (
<svg className="w-3.5 h-3.5 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
<span className="truncate text-text-secondary">{af.name}</span>
{af.file && <span className="text-text-muted shrink-0">({formatSize(af.file.size)})</span>}
<button
onClick={() => onRemove(af.id)}
className="ml-auto shrink-0 text-text-muted hover:text-error transition-colors opacity-0 group-hover:opacity-100"
title={t('remove', 'Remove')}
>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
)
}
@@ -0,0 +1,130 @@
import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { MarkdownRenderer } from './MarkdownRenderer'
import { AuthImage, downloadFile } from './AuthImage'
export function getLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
py: 'python', go: 'go', rs: 'rust', java: 'java',
c: 'c', cpp: 'cpp', h: 'c', rb: 'ruby', sh: 'bash',
yaml: 'yaml', yml: 'yaml', toml: 'toml', json: 'json',
xml: 'xml', html: 'html', css: 'css',
}
return map[ext] ?? 'text'
}
export function isImage(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('image/')) return true
return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename)
}
export function isVideo(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('video/')) return true
return /\.(mp4|webm|ogg|mov)$/i.test(filename)
}
export function isAudio(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('audio/')) return true
return /\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(filename)
}
export function isMarkdown(filename: string): boolean {
return /\.(md|markdown)$/i.test(filename)
}
export function isCode(filename: string): boolean {
return /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml|json|xml|html|css)$/i.test(filename)
}
export function isText(filename: string, mimeType?: string): boolean {
if (mimeType?.startsWith('text/')) return true
return /\.(txt|log|csv|conf|ini|env)$/i.test(filename)
}
interface FilePreviewRendererProps {
url: string
filename: string
displayName: string
mimeType?: string
textContent: string | null
loadError: boolean
needsTextFetch: boolean
}
export function FilePreviewRenderer({
url, filename, displayName, mimeType, textContent, loadError, needsTextFetch,
}: FilePreviewRendererProps) {
const { t } = useTranslation('common')
if (isImage(filename, mimeType)) {
return <AuthImage src={url} alt={displayName} className="max-w-full max-h-[70vh] object-contain mx-auto rounded-lg" />
}
if (isVideo(filename, mimeType)) {
return <video src={url} controls className="max-w-full rounded-lg" />
}
if (isAudio(filename, mimeType)) {
return <audio src={url} controls className="w-full" />
}
if (needsTextFetch) {
if (loadError) {
return (
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
<p className="text-sm">{t('errors.serverError', 'Failed to load file preview')}</p>
<button
onClick={() => downloadFile(url, displayName)}
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors cursor-pointer"
>
{t('download', 'Download')}
</button>
</div>
)
}
if (textContent === null) {
return <p className="text-text-muted text-sm p-4">{t('loading')}</p>
}
if (isMarkdown(filename)) {
const baseDir = url.substring(0, url.lastIndexOf('/') + 1)
const resolved = textContent.replace(
/(!?\[.*?\])\((?!https?:\/\/|\/|#)(.*?)\)/g,
(_, prefix, relPath) => `${prefix}(${baseDir}${relPath})`,
)
return (
<div className="p-4 overflow-y-auto max-h-[70vh]">
<MarkdownRenderer content={resolved} />
</div>
)
}
if (isCode(filename)) {
return (
<div className="overflow-y-auto max-h-[70vh]">
<SyntaxHighlighter
language={getLanguage(filename)}
style={oneDark}
customStyle={{ margin: 0, fontSize: '13px', borderRadius: 0 }}
>
{textContent}
</SyntaxHighlighter>
</div>
)
}
return (
<pre className="p-4 text-xs font-mono text-text-primary overflow-auto max-h-[70vh] whitespace-pre-wrap break-words">
{textContent}
</pre>
)
}
return (
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
<p className="text-sm">{t('selectFileToView')}</p>
<button
onClick={() => downloadFile(url, displayName)}
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors"
>
{t('download')}
</button>
</div>
)
}
@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react'
/** A file queued for upload or a direct filesystem path (desktop). */
export interface AttachedFile {
id: string
/** Browser File object — present for drag/drop and file picker uploads. */
file?: File
/** Direct filesystem path — present for pasted paths (desktop only, skip HTTP upload). */
localPath?: string
name: string
/** Image thumbnail data URL (only for browser File images). */
preview?: string
}
/** Human-readable file size. */
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** Detect if text looks like one or more absolute file paths (one per line). */
const FILE_PATH_RE = /^(\/[\w.\-/ ]+(?:\.\w+)?|[A-Z]:\\[\w.\-\\ ]+(?:\.\w+)?)$/
export function extractFilePaths(text: string): string[] {
return text.split('\n').map((l) => l.trim()).filter((l) => FILE_PATH_RE.test(l))
}
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
export function useFileAttachments() {
const [files, setFiles] = useState<AttachedFile[]>([])
const addFiles = useCallback((incoming: FileList | File[]) => {
const arr = Array.from(incoming)
const newFiles: AttachedFile[] = arr.map((f) => ({
id: crypto.randomUUID().slice(0, 8),
file: f,
name: f.name,
}))
// Generate image previews
for (const af of newFiles) {
if (af.file && IMAGE_TYPES.includes(af.file.type)) {
const fileObj = af.file
const reader = new FileReader()
reader.onload = () => {
setFiles((prev) => prev.map((f) => f.id === af.id ? { ...f, preview: reader.result as string } : f))
}
reader.readAsDataURL(fileObj)
}
}
setFiles((prev) => [...prev, ...newFiles])
}, [])
/** Add files by filesystem path (desktop paste). */
const addLocalPaths = useCallback((paths: string[]) => {
const newFiles: AttachedFile[] = paths.map((p) => ({
id: crypto.randomUUID().slice(0, 8),
localPath: p,
name: p.split('/').pop() || p.split('\\').pop() || p,
}))
setFiles((prev) => [...prev, ...newFiles])
}, [])
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id))
}, [])
const clearFiles = useCallback(() => setFiles([]), [])
return { files, addFiles, addLocalPaths, removeFile, clearFiles }
}
@@ -1,12 +1,13 @@
import { useState, useEffect } from 'react'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import { Switch } from '../../common/Switch'
import { useAgentCrud } from '../../../hooks/use-agent-crud'
import type { CronSchedule, CronPayload } from '../../../types/cron'
type ScheduleKind = 'every' | 'cron' | 'at'
type EveryUnit = 'seconds' | 'minutes' | 'hours'
import { Combobox } from '../common/Combobox'
import { Switch } from '../common/Switch'
import { useAgentCrud } from '../../hooks/use-agent-crud'
import { cronFormSchema, toEveryMs, type CronFormData, type ScheduleKind, type EveryUnit } from '../../schemas/cron.schema'
import { slugify } from '../../lib/slug'
import type { CronSchedule, CronPayload } from '../../types/cron'
interface CronFormDialogProps {
open: boolean
@@ -20,82 +21,47 @@ interface CronFormDialogProps {
}) => Promise<void>
}
function slugify(v: string): string {
return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
}
function toEveryMs(value: number, unit: EveryUnit): number {
if (unit === 'minutes') return value * 60 * 1000
if (unit === 'hours') return value * 3600 * 1000
return value * 1000
}
export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogProps) {
const { t } = useTranslation('cron')
const { agents } = useAgentCrud()
const [name, setName] = useState('')
const [agentId, setAgentId] = useState('')
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>('every')
const [everyValue, setEveryValue] = useState(5)
const [everyUnit, setEveryUnit] = useState<EveryUnit>('minutes')
const [cronExpr, setCronExpr] = useState('')
const [message, setMessage] = useState('')
const [deleteAfterRun, setDeleteAfterRun] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<CronFormData>({
resolver: zodResolver(cronFormSchema),
mode: 'onChange',
defaultValues: { name: '', agentId: '', scheduleKind: 'every', everyValue: 5, everyUnit: 'minutes', cronExpr: '', message: '', deleteAfterRun: false },
})
useEffect(() => {
if (!open) return
setName('')
setAgentId('')
setScheduleKind('every')
setEveryValue(5)
setEveryUnit('minutes')
setCronExpr('')
setMessage('')
setDeleteAfterRun(false)
setSaving(false)
setError('')
}, [open])
if (open) reset({ name: '', agentId: '', scheduleKind: 'every', everyValue: 5, everyUnit: 'minutes', cronExpr: '', message: '', deleteAfterRun: false })
}, [open, reset])
if (!open) return null
const agentOptions = agents.map((a) => ({
value: a.id,
label: a.display_name || a.agent_key,
}))
const scheduleKind = watch('scheduleKind')
const everyValue = watch('everyValue')
const everyUnit = watch('everyUnit')
const cronExpr = watch('cronExpr')
const canSubmit = name.trim() && message.trim() && !saving &&
(scheduleKind === 'at' || scheduleKind === 'every' ? everyValue > 0 : cronExpr.trim())
const agentOptions = agents.map((a) => ({ value: a.id, label: a.display_name || a.agent_key }))
const canSubmit = watch('name').trim() && watch('message').trim()
&& (scheduleKind === 'at' || scheduleKind === 'every' ? everyValue > 0 : cronExpr.trim())
function buildSchedule(): CronSchedule {
if (scheduleKind === 'every') {
return { kind: 'every', everyMs: toEveryMs(everyValue, everyUnit) }
}
if (scheduleKind === 'cron') {
return { kind: 'cron', expr: cronExpr.trim(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }
}
if (scheduleKind === 'every') return { kind: 'every', everyMs: toEveryMs(everyValue, everyUnit) }
if (scheduleKind === 'cron') return { kind: 'cron', expr: cronExpr.trim(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }
return { kind: 'at', atMs: Date.now() + 60_000 }
}
async function handleSubmit() {
setSaving(true)
setError('')
try {
await onSubmit({
name,
agentId: agentId || undefined,
schedule: buildSchedule(),
payload: { kind: 'message', message },
deleteAfterRun: scheduleKind === 'at' ? deleteAfterRun : undefined,
})
onOpenChange(false)
} catch (err) {
setError((err as Error).message || 'Failed to create')
} finally {
setSaving(false)
}
const onValid = async (data: CronFormData) => {
await onSubmit({
name: data.name,
agentId: data.agentId || undefined,
schedule: buildSchedule(),
payload: { kind: 'message', message: data.message },
deleteAfterRun: data.scheduleKind === 'at' ? data.deleteAfterRun : undefined,
})
onOpenChange(false)
}
const SCHEDULE_KINDS: ScheduleKind[] = ['every', 'cron', 'at']
@@ -125,24 +91,19 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('create.name')}</label>
<input
value={name}
onChange={(e) => setName(slugify(e.target.value))}
value={watch('name')}
onChange={(e) => setValue('name', slugify(e.target.value), { shouldValidate: true })}
placeholder={t('create.namePlaceholder')}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent font-mono"
/>
<p className="text-[11px] text-text-muted">{t('create.nameHint')}</p>
{errors.name && <p className="text-xs text-error">{errors.name.message}</p>}
</div>
{/* Agent */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('create.agentId')}</label>
<Combobox
value={agentId}
onChange={setAgentId}
options={agentOptions}
placeholder={t('create.agentIdPlaceholder')}
allowCustom={false}
/>
<Combobox value={watch('agentId')} onChange={(v) => setValue('agentId', v)} options={agentOptions} placeholder={t('create.agentIdPlaceholder')} allowCustom={false} />
</div>
{/* Schedule type */}
@@ -153,7 +114,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<button
key={kind}
type="button"
onClick={() => setScheduleKind(kind)}
onClick={() => setValue('scheduleKind', kind)}
className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${
scheduleKind === kind
? 'border-accent bg-accent/10 text-accent font-medium'
@@ -172,7 +133,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
type="number"
min={1}
value={everyValue}
onChange={(e) => setEveryValue(Math.max(1, Number(e.target.value)))}
onChange={(e) => setValue('everyValue', Math.max(1, Number(e.target.value)))}
className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
/>
<div className="flex gap-1">
@@ -180,7 +141,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
<button
key={u.value}
type="button"
onClick={() => setEveryUnit(u.value)}
onClick={() => setValue('everyUnit', u.value)}
className={`border rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
everyUnit === u.value
? 'border-accent bg-accent/10 text-accent font-medium'
@@ -198,8 +159,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
{scheduleKind === 'cron' && (
<div className="space-y-1 pt-1">
<input
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
{...register('cronExpr')}
placeholder="0 * * * *"
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
@@ -209,58 +169,40 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
</div>
)}
{/* Once */}
{scheduleKind === 'at' && (
<p className="text-xs text-text-muted pt-1">{t('create.onceDesc')}</p>
)}
{scheduleKind === 'at' && <p className="text-xs text-text-muted pt-1">{t('create.onceDesc')}</p>}
</div>
{/* Message */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('create.message')}</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
{...register('message')}
placeholder={t('create.messagePlaceholder')}
rows={3}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
{errors.message && <p className="text-xs text-error">{errors.message.message}</p>}
</div>
{/* Delete after run — only for 'at' */}
{/* Delete after run */}
{scheduleKind === 'at' && (
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<div>
<p className="text-xs font-medium text-text-primary">{t('detail.deleteAfterRun')}</p>
<p className="text-[11px] text-text-muted">{t('detail.deleteAfterRunDesc')}</p>
</div>
<Switch checked={deleteAfterRun} onCheckedChange={setDeleteAfterRun} />
<Switch checked={watch('deleteAfterRun')} onCheckedChange={(v) => setValue('deleteAfterRun', v)} />
</div>
)}
</div>
{error && (
<div className="px-5 pb-1">
<p className="text-xs text-error">{error}</p>
</div>
)}
{/* Footer */}
<div className="border-t border-border px-5 py-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
>
<button type="button" onClick={() => onOpenChange(false)} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('create.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors"
>
{saving ? t('create.creating') : t('create.create')}
<button type="button" onClick={handleSubmit(onValid)} disabled={!canSubmit || isSubmitting} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{isSubmitting ? t('create.creating') : t('create.create')}
</button>
</div>
</div>
@@ -0,0 +1,160 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useCron } from '../../hooks/use-cron'
import { useAgentCrud } from '../../hooks/use-agent-crud'
import { RefreshButton } from '../common/RefreshButton'
import { ConfirmDialog } from '../common/ConfirmDialog'
import { CronFormDialog } from './CronFormDialog'
import { CronRunsDialog } from './CronRunsDialog'
import { CronListItem } from './cron-list-item'
import type { CronJob } from '../../types/cron'
export function CronList() {
const { t } = useTranslation('cron')
const { jobs, loading, fetchJobs, createJob, deleteJob, toggleJob, runJob, fetchRuns } = useCron()
const { agents } = useAgentCrud()
const [formOpen, setFormOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null)
const [toggleTarget, setToggleTarget] = useState<CronJob | null>(null)
const [runsTarget, setRunsTarget] = useState<CronJob | null>(null)
const [runningIds, setRunningIds] = useState<Set<string>>(new Set())
function agentName(agentId: string): string {
if (!agentId) return t('defaultAgent')
return agents.find((a) => a.id === agentId)?.display_name
|| agents.find((a) => a.id === agentId)?.agent_key
|| agentId
}
async function handleRunNow(job: CronJob) {
setRunningIds((prev) => new Set(prev).add(job.id))
try {
await runJob(job.id)
} finally {
setRunningIds((prev) => { const s = new Set(prev); s.delete(job.id); return s })
}
}
async function handleToggleConfirm() {
if (!toggleTarget) return
await toggleJob(toggleTarget.id)
setToggleTarget(null)
}
async function handleDeleteConfirm() {
if (!deleteTarget) return
await deleteJob(deleteTarget.id)
setDeleteTarget(null)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
<p className="text-xs text-text-muted mt-0.5">{t('description')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setFormOpen(true)}
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover transition-colors flex items-center gap-1.5"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{t('newJob')}
</button>
<RefreshButton onRefresh={fetchJobs} />
</div>
</div>
{/* Loading skeleton */}
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
))}
</div>
) : jobs.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-12">
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.schedule')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.agent')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<CronListItem
key={job.id}
job={job}
agentName={agentName}
isRunning={runningIds.has(job.id)}
onRunNow={handleRunNow}
onToggle={(j) => setToggleTarget(j)}
onDelete={(j) => setDeleteTarget(j)}
onViewHistory={(j) => setRunsTarget(j)}
/>
))}
</tbody>
</table>
</div>
)}
<CronFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onSubmit={async (params) => { await createJob(params) }}
/>
{toggleTarget && (
<ConfirmDialog
open
onOpenChange={() => setToggleTarget(null)}
title={toggleTarget.enabled ? t('disable.title') : t('enable.title')}
description={toggleTarget.enabled
? t('disable.description', { name: toggleTarget.name })
: t('enable.description', { name: toggleTarget.name })
}
confirmLabel={toggleTarget.enabled ? t('disable.confirmLabel') : t('enable.confirmLabel')}
onConfirm={handleToggleConfirm}
/>
)}
{deleteTarget && (
<ConfirmDialog
open
onOpenChange={() => setDeleteTarget(null)}
title={t('delete.title')}
description={t('delete.description', { name: deleteTarget.name })}
confirmLabel={t('delete.confirmLabel')}
variant="destructive"
onConfirm={handleDeleteConfirm}
/>
)}
{runsTarget && (
<CronRunsDialog
open
onOpenChange={() => setRunsTarget(null)}
job={runsTarget}
onFetchRuns={fetchRuns}
/>
)}
</div>
)
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import type { CronJob, CronRunLog } from '../../../types/cron'
import type { CronJob, CronRunLog } from '../../types/cron'
interface CronRunsDialogProps {
open: boolean
@@ -0,0 +1,127 @@
// Individual cron job table row with actions: run history, run now, toggle, delete.
import { useTranslation } from 'react-i18next'
import { Switch } from '../common/Switch'
import type { CronJob, CronSchedule } from '../../types/cron'
export function formatSchedule(s: CronSchedule): string {
if (s.kind === 'every' && s.everyMs) {
const sec = s.everyMs / 1000
if (sec < 60) return `every ${sec}s`
if (sec < 3600) return `every ${Math.round(sec / 60)}m`
return `every ${Math.round(sec / 3600)}h`
}
if (s.kind === 'cron' && s.expr) return s.expr
if (s.kind === 'at' && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`
return '—'
}
export function statusBadgeClass(status?: string): string {
if (!status) return 'bg-surface-tertiary text-text-secondary border border-border'
if (status === 'ok' || status === 'success') {
return 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
}
if (status === 'error' || status === 'failed') {
return 'bg-red-500/15 text-red-700 border border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
}
if (status === 'running') {
return 'bg-blue-500/15 text-blue-700 border border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20 animate-pulse'
}
return 'bg-amber-500/15 text-amber-700 border border-amber-500/25 dark:text-amber-400 dark:bg-amber-500/10 dark:border-amber-500/20'
}
interface CronListItemProps {
job: CronJob
agentName: (agentId: string) => string
isRunning: boolean
onRunNow: (job: CronJob) => void
onToggle: (job: CronJob) => void
onDelete: (job: CronJob) => void
onViewHistory: (job: CronJob) => void
}
export function CronListItem({
job, agentName, isRunning, onRunNow, onToggle, onDelete, onViewHistory,
}: CronListItemProps) {
const { t } = useTranslation('cron')
return (
<tr className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<div className="min-w-0">
<span className="font-mono text-sm text-text-primary">{job.name}</span>
{job.state?.lastStatus && (
<span className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${statusBadgeClass(job.state.lastStatus)}`}>
{job.state.lastStatus}
</span>
)}
</div>
</div>
</td>
{/* Schedule */}
<td className="px-4 py-3 text-xs text-text-muted font-mono">
{formatSchedule(job.schedule)}
</td>
{/* Agent */}
<td className="px-4 py-3 text-xs text-text-muted">
{agentName(job.agentId)}
</td>
{/* Enabled badge */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
job.enabled
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
: 'bg-surface-tertiary text-text-secondary border border-border'
}`}>
{job.enabled ? t('detail.enabled') : t('detail.disabled')}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onViewHistory(job)}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
title={t('runHistory')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" /><path d="M12 7v5l4 2" />
</svg>
</button>
<button
onClick={() => onRunNow(job)}
disabled={isRunning}
className="p-1 text-text-muted hover:text-accent transition-colors disabled:opacity-50"
title={t('runNow')}
>
{isRunning ? (
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polygon points="6 3 20 12 6 21 6 3" />
</svg>
)}
</button>
<Switch checked={job.enabled} onCheckedChange={() => onToggle(job)} />
<button
onClick={() => onDelete(job)}
className="p-1 text-text-muted hover:text-error transition-colors"
title={t('delete.title')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)
}
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useUiStore } from '../../../stores/ui-store'
import { useAgentStore } from '../../../stores/agent-store'
import { getWsClient } from '../../../lib/ws'
import { teamService } from '../../../services/team-service'
import { TeamCreateDialog } from '../../teams/TeamCreateDialog'
import type { TeamData } from '../../../types/team'
@@ -18,9 +18,8 @@ export function SidebarTeams() {
const agents = useAgentStore((s) => s.agents)
useEffect(() => {
getWsClient().call('teams.list').then((res) => {
const r = res as { teams: TeamData[] | null }
setTeams(r.teams ?? [])
teamService.list().then((res) => {
setTeams(res.teams ?? [])
}).catch(() => {})
}, [])
@@ -0,0 +1,219 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Switch } from '../common/Switch'
import { McpTestResult } from './mcp-test-result'
import { McpTransportFields } from './mcp-transport-fields'
import { mcpFormSchema, type MCPFormData } from '../../schemas/mcp.schema'
import type { MCPServerData, MCPServerInput, MCPTestResult } from '../../types/mcp'
const TRANSPORTS = ['stdio', 'sse', 'streamable-http'] as const
function slugify(v: string): string {
return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
}
interface McpFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
server?: MCPServerData | null
onSubmit: (data: MCPServerInput) => Promise<unknown>
onTest: (data: { transport: string; command?: string; args?: string[]; url?: string; headers?: Record<string, string>; env?: Record<string, string> }) => Promise<MCPTestResult>
}
export function McpFormDialog({ open, onOpenChange, server, onSubmit, onTest }: McpFormDialogProps) {
const { t } = useTranslation(['mcp', 'common'])
const isEdit = Boolean(server)
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<MCPFormData>({
resolver: zodResolver(mcpFormSchema),
mode: 'onChange',
})
// Test connection state (UI-only, not form data)
const [testState, setTestState] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
const [testResult, setTestResult] = useState<MCPTestResult | null>(null)
useEffect(() => {
if (!open) return
if (server) {
reset({
name: server.name,
displayName: server.display_name || '',
transport: server.transport,
command: server.command || '',
args: Array.isArray(server.args) ? server.args.join(' ') : '',
url: server.url || '',
headers: server.headers ?? {},
env: server.env ?? {},
toolPrefix: server.tool_prefix?.replace(/^mcp_/, '') ?? '',
timeoutSec: server.timeout_sec || 30,
enabled: server.enabled,
})
} else {
reset({ name: '', displayName: '', transport: 'stdio', command: '', args: '', url: '', headers: {}, env: {}, toolPrefix: '', timeoutSec: 30, enabled: true })
}
setTestState('idle')
setTestResult(null)
}, [open, server, reset])
if (!open) return null
const transport = watch('transport')
const headers = watch('headers')
const env = watch('env')
const name = watch('name')
function buildInput(data: MCPFormData): MCPServerInput {
const input: MCPServerInput = { name: data.name, display_name: data.displayName || undefined, transport: data.transport, timeout_sec: data.timeoutSec, enabled: data.enabled }
if (data.transport === 'stdio') {
input.command = data.command
input.args = data.args.trim() ? data.args.trim().split(/\s+/) : undefined
} else {
input.url = data.url
if (Object.keys(data.headers).length > 0) input.headers = data.headers
}
if (Object.keys(data.env).length > 0) input.env = data.env
if (data.toolPrefix.trim()) input.tool_prefix = data.toolPrefix.trim()
return input
}
const onValid = async (data: MCPFormData) => {
await onSubmit(buildInput(data))
onOpenChange(false)
}
async function handleTest() {
setTestState('testing')
setTestResult(null)
try {
const data: Parameters<typeof onTest>[0] = { transport }
if (transport === 'stdio') {
data.command = watch('command')
const a = watch('args').trim()
if (a) data.args = a.split(/\s+/)
} else {
data.url = watch('url')
if (Object.keys(headers).length > 0) data.headers = headers
}
if (Object.keys(env).length > 0) data.env = env
const result = await onTest(data)
setTestResult(result)
setTestState(result.success ? 'success' : 'error')
} catch (err) {
setTestResult({ success: false, error: (err as Error).message })
setTestState('error')
}
}
const canSubmit = watch('name').trim() && (transport === 'stdio' ? watch('command').trim() : watch('url').trim())
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
<div className="relative w-full max-w-lg bg-surface-secondary rounded-xl border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<span className="text-sm font-semibold text-text-primary">{isEdit ? t('form.editTitle') : t('form.createTitle')}</span>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Form */}
<div className="max-h-[70vh] overflow-y-auto p-5 space-y-4">
{/* Name */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.name')}</label>
<input
value={name}
onChange={(e) => setValue('name', slugify(e.target.value), { shouldValidate: true })}
disabled={isEdit}
placeholder="my-mcp-server"
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
/>
<p className="text-[11px] text-text-muted">{t('form.nameHint')}</p>
{errors.name && <p className="text-xs text-error">{errors.name.message}</p>}
</div>
{/* Display Name */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.displayName')}</label>
<input {...register('displayName')} placeholder={t('form.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
{/* Transport */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.transport')}</label>
<div className="grid grid-cols-3 gap-2">
{TRANSPORTS.map((tr) => (
<button key={tr} type="button" onClick={() => setValue('transport', tr)}
className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${transport === tr ? 'border-accent bg-accent/10 text-accent font-medium' : 'border-border text-text-secondary hover:bg-surface-tertiary/30'}`}
>{tr.toUpperCase()}</button>
))}
</div>
</div>
<McpTransportFields
transport={transport}
headers={headers}
env={env}
register={register}
setValue={setValue}
/>
{/* Tool Prefix */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.toolPrefix')}</label>
<div className="flex items-stretch">
<span className="inline-flex items-center bg-surface-tertiary/70 border border-r-0 border-border rounded-l-lg px-2.5 text-base md:text-sm text-text-muted select-none">mcp_</span>
<input
value={watch('toolPrefix')}
onChange={(e) => setValue('toolPrefix', e.target.value.replace(/[^a-z0-9_]/gi, '_').toLowerCase())}
placeholder={name.replace(/-/g, '_') || 'auto'}
className="flex-1 min-w-0 bg-surface-tertiary border border-border rounded-r-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<p className="text-[11px] text-text-muted">{t('form.toolPrefixHint')}</p>
</div>
{/* Timeout */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.timeout')}</label>
<input type="number" min={1} {...register('timeoutSec', { valueAsNumber: true })} className="w-24 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
{/* Enabled */}
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<span className="text-xs font-medium text-text-primary">{t('form.enabled')}</span>
<Switch checked={watch('enabled')} onCheckedChange={(v) => setValue('enabled', v)} />
</div>
</div>
{/* Footer */}
<div className="border-t border-border px-5 py-4 space-y-2">
<McpTestResult state={testState} result={testResult} />
<div className="flex items-center justify-between">
<button type="button" onClick={handleTest} disabled={isSubmitting || testState === 'testing'} className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 shrink-0">
{testState === 'testing' ? (
<span className="flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
{t('form.testing')}
</span>
) : t('form.testConnection')}
</button>
<div className="flex items-center gap-2">
<button type="button" onClick={() => onOpenChange(false)} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">{t('form.cancel')}</button>
<button type="button" onClick={handleSubmit(onValid)} disabled={!canSubmit || isSubmitting} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{isSubmitting ? t('form.saving') : isEdit ? t('form.update') : t('form.create')}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,8 +1,8 @@
import { useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import type { MCPServerData, MCPAgentGrant } from '../../../types/mcp'
import type { AgentData } from '../../../types/agent'
import { Combobox } from '../common/Combobox'
import type { MCPServerData, MCPAgentGrant } from '../../types/mcp'
import type { AgentData } from '../../types/agent'
interface McpGrantsDialogProps {
open: boolean
@@ -0,0 +1,158 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMcpServers, MAX_MCP_LITE } from '../../hooks/use-mcp-servers'
import { useAgentCrud } from '../../hooks/use-agent-crud'
import { McpFormDialog } from './McpFormDialog'
import { McpGrantsDialog } from './McpGrantsDialog'
import { McpToolsDialog } from './McpToolsDialog'
import { ConfirmDeleteDialog } from '../common/ConfirmDeleteDialog'
import { RefreshButton } from '../common/RefreshButton'
import { McpServerRow } from './mcp-server-row'
import type { MCPServerData } from '../../types/mcp'
export function McpServerList() {
const { t } = useTranslation(['mcp', 'common'])
const {
servers, loading, atLimit,
fetchServers, createServer, updateServer, deleteServer,
testConnection, reconnectServer, listServerTools,
listGrants, grantAgent, revokeAgent,
} = useMcpServers()
const { agents } = useAgentCrud()
const [formOpen, setFormOpen] = useState(false)
const [editServer, setEditServer] = useState<MCPServerData | null>(null)
const [deleteTarget, setDeleteTarget] = useState<MCPServerData | null>(null)
const [grantsServer, setGrantsServer] = useState<MCPServerData | null>(null)
const [toolsServer, setToolsServer] = useState<MCPServerData | null>(null)
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
function openCreate() { setEditServer(null); setFormOpen(true) }
function openEdit(s: MCPServerData) { setEditServer(s); setFormOpen(true) }
async function handleReconnect(id: string) {
setReconnectingId(id)
try { await reconnectServer(id) } finally { setReconnectingId(null) }
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
<p className="text-xs text-text-muted mt-0.5">{t('description')} (max {MAX_MCP_LITE})</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={openCreate}
disabled={atLimit}
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{t('addServer')}
</button>
<RefreshButton onRefresh={fetchServers} />
</div>
</div>
{atLimit && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
{t('noMatchTitle')} ({MAX_MCP_LITE}). {t('noMatchDescription')}
</p>
)}
{/* Loading skeleton */}
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
))}
</div>
) : servers.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-12">
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
</svg>
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.transport')}</th>
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.tools')}</th>
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.agents')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{servers.map((s) => (
<McpServerRow
key={s.id}
server={s}
reconnectingId={reconnectingId}
onReconnect={handleReconnect}
onEdit={openEdit}
onDelete={(srv) => setDeleteTarget(srv)}
onViewTools={(srv) => setToolsServer(srv)}
onManageGrants={(srv) => setGrantsServer(srv)}
/>
))}
</tbody>
</table>
</div>
)}
<McpFormDialog
open={formOpen}
onOpenChange={setFormOpen}
server={editServer}
onSubmit={async (data) => {
if (editServer) await updateServer(editServer.id, data)
else await createServer(data)
}}
onTest={testConnection}
/>
{deleteTarget && (
<ConfirmDeleteDialog
open
onOpenChange={() => setDeleteTarget(null)}
title="Delete MCP Server"
description={`This will permanently delete the server "${deleteTarget.display_name || deleteTarget.name}" and all its agent grants.`}
confirmValue={deleteTarget.name}
onConfirm={async () => { await deleteServer(deleteTarget.id); setDeleteTarget(null) }}
/>
)}
{grantsServer && (
<McpGrantsDialog
open
onOpenChange={() => setGrantsServer(null)}
server={grantsServer}
agents={agents}
onLoadGrants={listGrants}
onGrant={grantAgent}
onRevoke={revokeAgent}
/>
)}
{toolsServer && (
<McpToolsDialog
open
onOpenChange={() => setToolsServer(null)}
server={toolsServer}
onLoadTools={listServerTools}
/>
)}
</div>
)
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { MCPServerData, MCPToolInfo } from '../../../types/mcp'
import type { MCPServerData, MCPToolInfo } from '../../types/mcp'
interface McpToolsDialogProps {
open: boolean
@@ -0,0 +1,112 @@
// MCP server table row with status badge and action buttons.
import { useTranslation } from 'react-i18next'
import type { MCPServerData } from '../../types/mcp'
interface McpServerRowProps {
server: MCPServerData
reconnectingId: string | null
onReconnect: (id: string) => void
onEdit: (server: MCPServerData) => void
onDelete: (server: MCPServerData) => void
onViewTools: (server: MCPServerData) => void
onManageGrants: (server: MCPServerData) => void
}
export function McpServerRow({
server: s, reconnectingId, onReconnect, onEdit, onDelete, onViewTools, onManageGrants,
}: McpServerRowProps) {
const { t } = useTranslation(['mcp', 'common'])
return (
<tr className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
</svg>
<div className="min-w-0">
<div className="text-sm font-medium text-text-primary truncate">{s.display_name || s.name}</div>
<div className="font-mono text-[11px] text-text-muted bg-surface-tertiary/50 px-1.5 py-0.5 rounded inline-block mt-0.5">
mcp_{s.name}
</div>
</div>
</div>
</td>
{/* Transport */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
s.transport === 'sse'
? 'bg-surface-tertiary text-text-secondary'
: 'border border-border text-text-muted'
}`}>
{s.transport.toUpperCase()}
</span>
</td>
{/* Tools */}
<td className="px-4 py-3 text-center">
<button
onClick={() => onViewTools(s)}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
title={t('viewTools')}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
</button>
</td>
{/* Agents */}
<td className="px-4 py-3 text-center">
<button
onClick={() => onManageGrants(s)}
className="inline-flex items-center gap-1 text-sm text-text-primary hover:text-accent transition-colors"
title={t('manageGrants')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{s.agent_count ?? 0}
</button>
</td>
{/* Enabled */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
s.enabled
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
: 'bg-surface-tertiary text-text-secondary'
}`}>
{s.enabled ? t('common:enabled') : t('common:disabled')}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
disabled={reconnectingId === s.id}
onClick={() => onReconnect(s.id)}
className="p-1 text-text-muted hover:text-text-primary transition-colors disabled:opacity-50"
title={t('reconnect')}
>
<svg className={`h-3.5 w-3.5${reconnectingId === s.id ? ' animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
</svg>
</button>
<button onClick={() => onEdit(s)} className="p-1 text-text-muted hover:text-text-primary transition-colors" title="Edit">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
</svg>
</button>
<button onClick={() => onDelete(s)} className="p-1 text-text-muted hover:text-error transition-colors" title="Delete">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)
}
@@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next'
import type { MCPTestResult } from '../../types/mcp'
interface McpTestResultProps {
state: 'idle' | 'testing' | 'success' | 'error'
result: MCPTestResult | null
}
export function McpTestResult({ state, result }: McpTestResultProps) {
const { t } = useTranslation('mcp')
if (state === 'success' && result) {
return (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
{t('form.toolsFound', { count: result.tool_count })}
</p>
)
}
if (state === 'error' && result) {
return (
<p className="text-[11px] text-error flex items-center gap-1">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
<span className="break-all">{result.error || t('form.errors.connectionFailed')}</span>
</p>
)
}
return null
}
@@ -0,0 +1,57 @@
import { useTranslation } from 'react-i18next'
import { KeyValueEditor } from '../common/KeyValueEditor'
import type { UseFormRegister, UseFormSetValue } from 'react-hook-form'
import type { MCPFormData } from '../../schemas/mcp.schema'
const SENSITIVE_HEADER_KEYS = /(auth|api-key|api_key|bearer|token|secret|password|credential)/i
const SENSITIVE_ENV_KEYS = /(key|secret|token|password|credential)/i
interface McpTransportFieldsProps {
transport: string
headers: Record<string, string>
env: Record<string, string>
register: UseFormRegister<MCPFormData>
setValue: UseFormSetValue<MCPFormData>
}
export function McpTransportFields({ transport, headers, env, register, setValue }: McpTransportFieldsProps) {
const { t } = useTranslation('mcp')
return (
<>
{/* Stdio fields */}
{transport === 'stdio' && (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.command')}</label>
<input {...register('command')} placeholder="npx" className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.args')}</label>
<input {...register('args')} placeholder={t('form.argsPlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
</>
)}
{/* SSE/HTTP fields */}
{transport !== 'stdio' && (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.url')}</label>
<input {...register('url')} placeholder="http://localhost:3001/sse" className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.headers')}</label>
<KeyValueEditor value={headers} onChange={(v) => setValue('headers', v)} sensitivePattern={SENSITIVE_HEADER_KEYS} placeholder={{ key: t('form.headerKeyPlaceholder'), value: t('form.headerValuePlaceholder') }} />
</div>
</>
)}
{/* Env Variables */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.env')}</label>
<KeyValueEditor value={env} onChange={(v) => setValue('env', v)} sensitivePattern={SENSITIVE_ENV_KEYS} placeholder={{ key: t('form.envKeyPlaceholder'), value: t('form.envValuePlaceholder') }} />
</div>
</>
)
}
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'framer-motion'
import { motion } from 'framer-motion'
import { getWsClient } from '../../lib/ws'
import { agentService } from '../../services/agent-service'
import { SummoningProgressSteps } from './summoning-progress-steps'
const SUMMONING_FILE_KEYS = [
{ name: 'SOUL.md', required: true, labelKey: 'summoning.fileLabelSOUL' },
{ name: 'IDENTITY.md', required: true, labelKey: 'summoning.fileLabelIDENTITY' },
]
const SUMMONING_REQUIRED_FILES = ['SOUL.md', 'IDENTITY.md']
interface SummoningModalProps {
agentId: string
@@ -51,8 +50,7 @@ export function SummoningModal({ agentId, agentName, onContinue }: SummoningModa
)
}
if (data.type === 'completed') {
const required = SUMMONING_FILE_KEYS.filter((f) => f.required).map((f) => f.name)
setGeneratedFiles((prev) => [...new Set([...prev, ...required])])
setGeneratedFiles((prev) => [...new Set([...prev, ...SUMMONING_REQUIRED_FILES])])
setStatus('completed')
}
if (data.type === 'failed') {
@@ -74,8 +72,7 @@ export function SummoningModal({ agentId, agentName, onContinue }: SummoningModa
const handleRetry = async () => {
setRetrying(true)
try {
const ws = getWsClient()
await ws.call('agents.resummon', { agentId })
await agentService.resummonWs(agentId)
setGeneratedFiles([])
setStatus('summoning')
setErrorMsg('')
@@ -159,49 +156,7 @@ export function SummoningModal({ agentId, agentName, onContinue }: SummoningModa
</p>
{/* File progress */}
<div className="w-full space-y-2">
<AnimatePresence>
{SUMMONING_FILE_KEYS.map((file, i) => {
const done = generatedFiles.includes(file.name)
return (
<motion.div
key={file.name}
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-3 rounded-md px-3 py-1.5"
>
<motion.div
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${
done
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/40 dark:text-orange-400'
: 'bg-surface-tertiary text-text-muted'
}`}
animate={done ? { scale: [0.8, 1.2, 1] } : {}}
transition={{ duration: 0.3 }}
>
{done ? '✓' : i + 1}
</motion.div>
<div className="flex-1">
<span className={`text-sm ${done ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
{file.name}
</span>
<span className="ml-2 text-xs text-text-muted">{t(`desktop:${file.labelKey}`)}</span>
</div>
{done && (
<motion.span
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="text-xs text-orange-600 dark:text-orange-400"
>
Done
</motion.span>
)}
</motion.div>
)
})}
</AnimatePresence>
</div>
<SummoningProgressSteps generatedFiles={generatedFiles} />
{status === 'summoning' && (
<p className="text-center text-xs text-text-muted tabular-nums">
@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'framer-motion'
const SUMMONING_FILE_KEYS = [
{ name: 'SOUL.md', required: true, labelKey: 'summoning.fileLabelSOUL' },
{ name: 'IDENTITY.md', required: true, labelKey: 'summoning.fileLabelIDENTITY' },
]
interface SummoningProgressStepsProps {
generatedFiles: string[]
}
export function SummoningProgressSteps({ generatedFiles }: SummoningProgressStepsProps) {
const { t } = useTranslation('desktop')
return (
<div className="w-full space-y-2">
<AnimatePresence>
{SUMMONING_FILE_KEYS.map((file, i) => {
const done = generatedFiles.includes(file.name)
return (
<motion.div
key={file.name}
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-3 rounded-md px-3 py-1.5"
>
<motion.div
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${
done
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/40 dark:text-orange-400'
: 'bg-surface-tertiary text-text-muted'
}`}
animate={done ? { scale: [0.8, 1.2, 1] } : {}}
transition={{ duration: 0.3 }}
>
{done ? '✓' : i + 1}
</motion.div>
<div className="flex-1">
<span className={`text-sm ${done ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
{file.name}
</span>
<span className="ml-2 text-xs text-text-muted">{t(`${file.labelKey}`)}</span>
</div>
{done && (
<motion.span
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="text-xs text-orange-600 dark:text-orange-400"
>
Done
</motion.span>
)}
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}
@@ -1,15 +1,17 @@
import { useState, useEffect, useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import { Switch } from '../../common/Switch'
import { PROVIDER_TYPES } from '../../../constants/providers'
import { slugify } from '../../../lib/slug'
import type { ProviderData, ProviderInput } from '../../../types/provider'
import { Combobox } from '../common/Combobox'
import { Switch } from '../common/Switch'
import { PROVIDER_TYPES } from '../../constants/providers'
import { slugify } from '../../lib/slug'
import { providerFormSchema, type ProviderFormData } from '../../schemas/provider.schema'
import type { ProviderData, ProviderInput } from '../../types/provider'
interface ProviderFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** If set, editing this provider. Otherwise creating. */
provider?: ProviderData | null
onSubmit: (input: ProviderInput) => Promise<void>
}
@@ -18,59 +20,52 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
const isEditing = !!provider
const { t } = useTranslation(['providers', 'common'])
const [providerType, setProviderType] = useState(provider?.provider_type ?? '')
const [displayName, setDisplayName] = useState(provider?.display_name ?? '')
const [apiBase, setApiBase] = useState(provider?.api_base ?? '')
const [apiKey, setApiKey] = useState('')
const [enabled, setEnabled] = useState(provider?.enabled ?? true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<ProviderFormData>({
resolver: zodResolver(providerFormSchema),
mode: 'onChange',
})
// Reset form when dialog opens/closes or provider changes
// Reset form when dialog opens
useEffect(() => {
if (open) {
setProviderType(provider?.provider_type ?? '')
setDisplayName(provider?.display_name ?? '')
setApiBase(provider?.api_base ?? '')
setApiKey('')
setEnabled(provider?.enabled ?? true)
setError('')
reset({
providerType: provider?.provider_type ?? '',
displayName: provider?.display_name ?? '',
apiBase: provider?.api_base ?? '',
apiKey: '',
enabled: provider?.enabled ?? true,
})
}
}, [open, provider])
}, [open, provider, reset])
const providerType = watch('providerType')
const displayName = watch('displayName')
const apiBase = watch('apiBase')
// Auto-set api_base from provider type
const typeInfo = useMemo(() => PROVIDER_TYPES.find((t) => t.value === providerType), [providerType])
useEffect(() => {
if (!isEditing && typeInfo?.apiBase && !apiBase) {
setApiBase(typeInfo.apiBase)
setValue('apiBase', typeInfo.apiBase)
}
}, [typeInfo, isEditing, apiBase])
}, [typeInfo, isEditing, apiBase, setValue])
const name = useMemo(() => {
if (isEditing) return provider!.name
return slugify(displayName || providerType) || 'provider'
}, [isEditing, provider, displayName, providerType])
const handleSubmit = async () => {
setLoading(true)
setError('')
try {
const input: ProviderInput = {
name,
display_name: displayName || undefined,
provider_type: providerType,
api_base: apiBase || undefined,
enabled,
}
// Only send api_key if user entered a new one
if (apiKey) input.api_key = apiKey
await onSubmit(input)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save provider')
} finally {
setLoading(false)
const onValid = async (data: ProviderFormData) => {
const input: ProviderInput = {
name,
display_name: data.displayName || undefined,
provider_type: data.providerType,
api_base: data.apiBase || undefined,
enabled: data.enabled,
}
if (data.apiKey) input.api_key = data.apiKey
await onSubmit(input)
onOpenChange(false)
}
if (!open) return null
@@ -89,19 +84,19 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
<label className="text-xs font-medium text-text-secondary">{t('providers:form.providerType').replace(' *', '')}</label>
<Combobox
value={providerType}
onChange={setProviderType}
onChange={(v) => setValue('providerType', v, { shouldValidate: true })}
options={typeOptions}
placeholder={t('common:selectProvider')}
disabled={isEditing}
/>
{errors.providerType && <p className="text-xs text-error">{errors.providerType.message}</p>}
</div>
{/* Display name */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('providers:form.displayName')}</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
{...register('displayName')}
placeholder={typeInfo?.label ?? 'My Provider'}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
@@ -111,8 +106,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('providers:form.apiBase')}</label>
<input
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
{...register('apiBase')}
placeholder="https://api.example.com"
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
@@ -125,8 +119,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
{...register('apiKey')}
placeholder={isEditing ? '••••••••' : 'sk-...'}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
@@ -134,12 +127,10 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
{/* Enabled toggle */}
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Switch checked={watch('enabled')} onCheckedChange={(v) => setValue('enabled', v)} />
<span className="text-xs text-text-secondary">{t('providers:form.enabled')}</span>
</div>
{error && <p className="text-xs text-error">{error}</p>}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => onOpenChange(false)}
@@ -148,11 +139,11 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
{t('providers:form.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={loading || !providerType}
onClick={handleSubmit(onValid)}
disabled={isSubmitting || !providerType}
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50"
>
{loading ? '...' : isEditing ? t('providers:form.save') : t('providers:form.create')}
{isSubmitting ? '...' : isEditing ? t('providers:form.save') : t('providers:form.create')}
</button>
</div>
</div>
@@ -1,10 +1,10 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useProviders } from '../../../hooks/use-providers'
import { useProviders } from '../../hooks/use-providers'
import { ProviderRow } from './ProviderRow'
import { ProviderFormDialog } from './ProviderFormDialog'
import { ConfirmDialog } from '../../common/ConfirmDialog'
import type { ProviderData, ProviderInput } from '../../../types/provider'
import { ConfirmDialog } from '../common/ConfirmDialog'
import type { ProviderData, ProviderInput } from '../../types/provider'
export function ProviderList() {
const { t } = useTranslation(['providers', 'common'])
@@ -1,5 +1,5 @@
import type { ProviderData } from '../../../types/provider'
import { PROVIDER_TYPES } from '../../../constants/providers'
import type { ProviderData } from '../../types/provider'
import { PROVIDER_TYPES } from '../../constants/providers'
interface ProviderRowProps {
provider: ProviderData
@@ -2,15 +2,15 @@ import { useUiStore } from '../../stores/ui-store'
import { SettingsTabBar } from './SettingsTabBar'
import { AppearanceTab } from './AppearanceTab'
import { AboutTab } from './AboutTab'
import { ProviderList } from './providers/ProviderList'
import { AgentList } from './agents/AgentList'
import { McpServerList } from './mcp/McpServerList'
import { SkillList } from './skills/SkillList'
import { ToolList } from './tools/ToolList'
import { CronList } from './cron/CronList'
import { TraceList } from './traces/TraceList'
import { StorageTab } from './storage/StorageTab'
import { ChannelList } from './channels/ChannelList'
import { ProviderList } from '../providers/ProviderList'
import { AgentList } from '../agents/AgentList'
import { McpServerList } from '../mcp/McpServerList'
import { SkillList } from '../skills/SkillList'
import { ToolList } from '../tools/ToolList'
import { CronList } from '../cron/CronList'
import { TraceList } from '../traces/TraceList'
import { StorageTab } from '../storage/StorageTab'
import { ChannelList } from '../channels/ChannelList'
export function SettingsView() {
const settingsTab = useUiStore((s) => s.settingsTab)
@@ -1,333 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../../common/Combobox'
import { useProviders } from '../../../hooks/use-providers'
import { getApiClient } from '../../../lib/api'
import { Switch } from '../../common/Switch'
import { slugify } from '../../../lib/slug'
import type { AgentData, AgentInput } from '../../../types/agent'
// Preset keys match agents.json presets (foxSpirit, artisan, astrologer)
// plus desktop-only extras (researcher, writer, coder) in desktop.json
const PRESET_KEYS = [
{ key: 'foxSpirit', emoji: '🦊', ns: 'agents', agentKey: 'little-fox' },
{ key: 'artisan', emoji: '🎨', ns: 'agents', agentKey: 'artisan' },
{ key: 'astrologer', emoji: '🔮', ns: 'agents', agentKey: 'mimi' },
{ key: 'researcher', emoji: '🔬', ns: 'desktop', agentKey: 'scholar' },
{ key: 'writer', emoji: '✍️', ns: 'desktop', agentKey: 'quill' },
{ key: 'coder', emoji: '👨‍💻', ns: 'desktop', agentKey: 'dev' },
]
interface AgentFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
agent?: AgentData | null
onSubmit: (input: AgentInput) => Promise<AgentData | void>
}
export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFormDialogProps) {
const isEditing = !!agent
const { t } = useTranslation(['agents', 'desktop', 'common'])
const { providers } = useProviders()
const [displayName, setDisplayName] = useState('')
const [providerName, setProviderName] = useState('')
const [model, setModel] = useState('')
const agentType = 'predefined' as const
const [description, setDescription] = useState('')
const [emoji, setEmoji] = useState('🦊')
const [isDefault, setIsDefault] = useState(false)
const [models, setModels] = useState<string[]>([])
const [modelsLoading, setModelsLoading] = useState(false)
const [verifyResult, setVerifyResult] = useState<{ valid: boolean; error?: string } | null>(null)
const [verifying, setVerifying] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selectedPresetKey, setSelectedPresetKey] = useState('')
// Reset form when dialog opens
useEffect(() => {
if (!open) return
setDisplayName(agent?.display_name ?? '')
setProviderName(agent?.provider ?? '')
setModel(agent?.model ?? '')
setDescription((agent?.other_config?.description as string) ?? '')
setEmoji((agent?.other_config?.emoji as string) ?? '🦊')
setIsDefault(agent?.is_default ?? false)
setError('')
setSelectedPresetKey('')
setAgentKeyOverride('')
setVerifyResult(isEditing ? { valid: true } : null) // editing = already verified
setModels([])
}, [open, agent, isEditing])
// Selected provider object
const selectedProvider = useMemo(
() => providers.find((p) => p.name === providerName),
[providers, providerName],
)
// Load models when provider changes
const loadModels = useCallback(async (providerId: string) => {
setModelsLoading(true)
try {
const res = await getApiClient().get<{ models: Array<{ id: string }> }>(
`/v1/providers/${providerId}/models`,
)
setModels((res.models ?? []).map((m) => m.id))
} catch {
setModels([])
} finally {
setModelsLoading(false)
}
}, [])
useEffect(() => {
if (selectedProvider?.id) loadModels(selectedProvider.id)
}, [selectedProvider?.id, loadModels])
// Reset verify when provider or model changes
useEffect(() => {
if (!isEditing) setVerifyResult(null)
}, [providerName, model, isEditing])
const [agentKeyOverride, setAgentKeyOverride] = useState('')
const agentKey = useMemo(() => {
if (isEditing) return agent!.agent_key
if (agentKeyOverride) return agentKeyOverride
// Use preset agentKey if selected, otherwise slugify display name
return selectedPresetKey || slugify(displayName) || 'agent'
}, [isEditing, agent, agentKeyOverride, selectedPresetKey, displayName])
const providerOptions = useMemo(
() => providers.filter((p) => p.enabled).map((p) => ({
value: p.name,
label: p.display_name || p.name,
})),
[providers],
)
const modelOptions = useMemo(
() => models.map((m) => ({ value: m, label: m })),
[models],
)
const handleVerify = async () => {
if (!selectedProvider?.id || !model.trim()) return
setVerifying(true)
try {
const res = await getApiClient().post<{ valid: boolean; error?: string }>(
`/v1/providers/${selectedProvider.id}/verify`,
{ model: model.trim() },
)
setVerifyResult({ valid: res.valid, error: res.error })
} catch (err) {
setVerifyResult({ valid: false, error: err instanceof Error ? err.message : 'Verification failed' })
} finally {
setVerifying(false)
}
}
const handleCreate = async () => {
setLoading(true)
setError('')
try {
const otherConfig: Record<string, unknown> = {}
if (description.trim()) otherConfig.description = description.trim()
if (emoji.trim()) otherConfig.emoji = emoji.trim()
await onSubmit({
agent_key: agentKey,
display_name: displayName.trim() || undefined,
provider: providerName,
model: model.trim(),
agent_type: isEditing ? agent!.agent_type : agentType,
is_default: isDefault || undefined,
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
})
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save agent')
} finally {
setLoading(false)
}
}
if (!open) return null
const canCreate = isEditing
? !!providerName && !!model.trim()
: !!displayName.trim() && !!providerName && !!model.trim() && verifyResult?.valid && !!description.trim()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-3xl w-full mx-4 overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
<h3 className="text-sm font-semibold text-text-primary">
{isEditing ? t('agents:detail.tabs.agent') + ' — ' + t('common:edit') : t('agents:create.title')}
</h3>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Body — 2 columns */}
<div className="flex-1 overflow-y-auto overscroll-contain p-5">
<div className="grid grid-cols-2 gap-5">
{/* Left column — identity & model */}
<div className="space-y-4">
{/* Display name + emoji */}
<div className="flex gap-2">
<div className="space-y-1 flex-1">
<label className="text-xs font-medium text-text-secondary">{t('agents:create.displayName').replace(' *', '')}</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t('agents:create.displayNamePlaceholder')}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<div className="space-y-1 w-16">
<label className="text-xs font-medium text-text-secondary">{t('agents:identity.emoji')}</label>
<input
value={emoji}
onChange={(e) => setEmoji(e.target.value.slice(0, 2))}
maxLength={2}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary text-center focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</div>
{/* Agent key */}
{!isEditing && (
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">Agent Key</label>
<input
value={agentKey}
onChange={(e) => setAgentKeyOverride(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
)}
{/* Provider */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('common:provider')}</label>
<Combobox value={providerName} onChange={setProviderName} options={providerOptions} placeholder={t('agents:create.selectProvider')} />
</div>
{/* Model */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('common:model')}</label>
<Combobox
value={model}
onChange={setModel}
options={modelOptions}
placeholder={modelsLoading ? t('agents:create.loadingModels') : t('agents:create.enterOrSelectModel')}
allowCustom
/>
{verifyResult && !verifyResult.valid && (
<p className="text-xs text-error">{verifyResult.error || t('desktop:agent.verifyFailed')}</p>
)}
{verifyResult?.valid && !isEditing && (
<p className="text-xs text-success">{t('desktop:agent.verified')}</p>
)}
</div>
{/* Default toggle */}
<div className="flex items-center gap-2">
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
<span className="text-xs text-text-secondary">{t('agents:identity.defaultAgent')}</span>
</div>
</div>
{/* Right column — personality */}
<div className="space-y-1.5 flex flex-col">
<label className="text-xs font-medium text-text-secondary">{t('agents:detail.personality')}</label>
{!isEditing && (
<div className="flex flex-wrap gap-1.5">
{PRESET_KEYS.map((p) => {
const label = t(`${p.ns}:presets.${p.key}.label`)
const prompt = t(`${p.ns}:presets.${p.key}.prompt`)
// Extract display name from prompt (first line: "Name: Xxx.")
const nameMatch = prompt.match(/^Name:\s*([^.]+)/)
const displayName = nameMatch ? nameMatch[1].trim() : label.replace(/^\S+\s*/, '')
return (
<button
key={p.key}
type="button"
onClick={() => { setDescription(prompt); setEmoji(p.emoji); setDisplayName(displayName); setSelectedPresetKey(p.agentKey); setAgentKeyOverride('') }}
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
description === prompt
? 'border-accent bg-accent/10 text-accent font-medium'
: 'border-border text-text-secondary hover:bg-surface-tertiary hover:text-text-primary'
}`}
>
{label}
</button>
)
})}
</div>
)}
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('agents:create.descriptionPlaceholder')}
className="flex-1 min-h-[200px] w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y"
/>
</div>
</div>
</div>
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-5 py-4 shrink-0">
{/* Left — error */}
<div>
{verifyResult && !verifyResult.valid && (
<span className="text-[11px] text-error">{verifyResult.error}</span>
)}
</div>
{/* Right — cancel + verify + summon/save */}
<div className="flex items-center gap-2">
<button onClick={() => onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('agents:create.cancel')}
</button>
{!isEditing && selectedProvider?.id && model.trim() && !verifyResult?.valid && (
<button
onClick={handleVerify}
disabled={verifying}
className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 flex items-center gap-1.5"
>
{verifying ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
{t('desktop:agent.verifying')}
</>
) : t('desktop:agent.verifyModel')}
</button>
)}
<button
onClick={handleCreate}
disabled={!canCreate || loading}
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
>
{loading ? '...' : isEditing ? t('common:save') : (
<>
{verifyResult?.valid && (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
)}
{t('desktop:agent.summon')}
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -1,266 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useCron } from '../../../hooks/use-cron'
import { useAgentCrud } from '../../../hooks/use-agent-crud'
import { RefreshButton } from '../../common/RefreshButton'
import { Switch } from '../../common/Switch'
import { ConfirmDialog } from '../../common/ConfirmDialog'
import { CronFormDialog } from './CronFormDialog'
import { CronRunsDialog } from './CronRunsDialog'
import type { CronJob, CronSchedule } from '../../../types/cron'
function formatSchedule(s: CronSchedule): string {
if (s.kind === 'every' && s.everyMs) {
const sec = s.everyMs / 1000
if (sec < 60) return `every ${sec}s`
if (sec < 3600) return `every ${Math.round(sec / 60)}m`
return `every ${Math.round(sec / 3600)}h`
}
if (s.kind === 'cron' && s.expr) return s.expr
if (s.kind === 'at' && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`
return '—'
}
function statusBadgeClass(status?: string): string {
if (!status) return 'bg-surface-tertiary text-text-secondary border border-border'
if (status === 'ok' || status === 'success') {
return 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
}
if (status === 'error' || status === 'failed') {
return 'bg-red-500/15 text-red-700 border border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
}
if (status === 'running') {
return 'bg-blue-500/15 text-blue-700 border border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20 animate-pulse'
}
return 'bg-amber-500/15 text-amber-700 border border-amber-500/25 dark:text-amber-400 dark:bg-amber-500/10 dark:border-amber-500/20'
}
export function CronList() {
const { t } = useTranslation('cron')
const { jobs, loading, fetchJobs, createJob, deleteJob, toggleJob, runJob, fetchRuns } = useCron()
const { agents } = useAgentCrud()
const [formOpen, setFormOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null)
const [toggleTarget, setToggleTarget] = useState<CronJob | null>(null)
const [runsTarget, setRunsTarget] = useState<CronJob | null>(null)
const [runningIds, setRunningIds] = useState<Set<string>>(new Set())
function agentName(agentId: string): string {
if (!agentId) return t('defaultAgent')
return agents.find((a) => a.id === agentId)?.display_name
|| agents.find((a) => a.id === agentId)?.agent_key
|| agentId
}
async function handleRunNow(job: CronJob) {
setRunningIds((prev) => new Set(prev).add(job.id))
try {
await runJob(job.id)
} finally {
setRunningIds((prev) => { const s = new Set(prev); s.delete(job.id); return s })
}
}
async function handleToggleConfirm() {
if (!toggleTarget) return
await toggleJob(toggleTarget.id)
setToggleTarget(null)
}
async function handleDeleteConfirm() {
if (!deleteTarget) return
await deleteJob(deleteTarget.id)
setDeleteTarget(null)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
<p className="text-xs text-text-muted mt-0.5">{t('description')}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setFormOpen(true)}
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover transition-colors flex items-center gap-1.5"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{t('newJob')}
</button>
<RefreshButton onRefresh={fetchJobs} />
</div>
</div>
{/* Loading skeleton */}
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
))}
</div>
) : jobs.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center gap-2 py-12">
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
</div>
) : (
/* Table */
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.schedule')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.agent')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<div className="min-w-0">
<span className="font-mono text-sm text-text-primary">{job.name}</span>
{job.state?.lastStatus && (
<span className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${statusBadgeClass(job.state.lastStatus)}`}>
{job.state.lastStatus}
</span>
)}
</div>
</div>
</td>
{/* Schedule */}
<td className="px-4 py-3 text-xs text-text-muted font-mono">
{formatSchedule(job.schedule)}
</td>
{/* Agent */}
<td className="px-4 py-3 text-xs text-text-muted">
{agentName(job.agentId)}
</td>
{/* Enabled badge */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
job.enabled
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
: 'bg-surface-tertiary text-text-secondary border border-border'
}`}>
{job.enabled ? t('detail.enabled') : t('detail.disabled')}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{/* Run history */}
<button
onClick={() => setRunsTarget(job)}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
title={t('runHistory')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" /><path d="M12 7v5l4 2" />
</svg>
</button>
{/* Run now */}
<button
onClick={() => handleRunNow(job)}
disabled={runningIds.has(job.id)}
className="p-1 text-text-muted hover:text-accent transition-colors disabled:opacity-50"
title={t('runNow')}
>
{runningIds.has(job.id) ? (
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polygon points="6 3 20 12 6 21 6 3" />
</svg>
)}
</button>
{/* Toggle */}
<Switch
checked={job.enabled}
onCheckedChange={() => setToggleTarget(job)}
/>
{/* Delete */}
<button
onClick={() => setDeleteTarget(job)}
className="p-1 text-text-muted hover:text-error transition-colors"
title={t('delete.title')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create dialog */}
<CronFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onSubmit={async (params) => { await createJob(params) }}
/>
{/* Toggle confirm */}
{toggleTarget && (
<ConfirmDialog
open
onOpenChange={() => setToggleTarget(null)}
title={toggleTarget.enabled ? t('disable.title') : t('enable.title')}
description={toggleTarget.enabled
? t('disable.description', { name: toggleTarget.name })
: t('enable.description', { name: toggleTarget.name })
}
confirmLabel={toggleTarget.enabled ? t('disable.confirmLabel') : t('enable.confirmLabel')}
onConfirm={handleToggleConfirm}
/>
)}
{/* Delete confirm */}
{deleteTarget && (
<ConfirmDialog
open
onOpenChange={() => setDeleteTarget(null)}
title={t('delete.title')}
description={t('delete.description', { name: deleteTarget.name })}
confirmLabel={t('delete.confirmLabel')}
variant="destructive"
onConfirm={handleDeleteConfirm}
/>
)}
{/* Run history */}
{runsTarget && (
<CronRunsDialog
open
onOpenChange={() => setRunsTarget(null)}
job={runsTarget}
onFetchRuns={fetchRuns}
/>
)}
</div>
)
}
@@ -1,351 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Switch } from '../../common/Switch'
import { KeyValueEditor } from '../../common/KeyValueEditor'
import type { MCPServerData, MCPServerInput, MCPTestResult } from '../../../types/mcp'
const SENSITIVE_HEADER_KEYS = /(auth|api-key|api_key|bearer|token|secret|password|credential)/i
const SENSITIVE_ENV_KEYS = /(key|secret|token|password|credential)/i
const TRANSPORTS = ['stdio', 'sse', 'streamable-http'] as const
interface McpFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
server?: MCPServerData | null
onSubmit: (data: MCPServerInput) => Promise<unknown>
onTest: (data: { transport: string; command?: string; args?: string[]; url?: string; headers?: Record<string, string>; env?: Record<string, string> }) => Promise<MCPTestResult>
}
function slugify(v: string): string {
return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
}
export function McpFormDialog({ open, onOpenChange, server, onSubmit, onTest }: McpFormDialogProps) {
const { t } = useTranslation(['mcp', 'common'])
const isEdit = Boolean(server)
const [name, setName] = useState('')
const [displayName, setDisplayName] = useState('')
const [transport, setTransport] = useState<string>('stdio')
const [command, setCommand] = useState('')
const [args, setArgs] = useState('')
const [url, setUrl] = useState('')
const [headers, setHeaders] = useState<Record<string, string>>({})
const [env, setEnv] = useState<Record<string, string>>({})
const [toolPrefix, setToolPrefix] = useState('')
const [timeoutSec, setTimeoutSec] = useState(30)
const [enabled, setEnabled] = useState(true)
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState('')
const [testState, setTestState] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
const [testResult, setTestResult] = useState<MCPTestResult | null>(null)
useEffect(() => {
if (!open) return
if (server) {
setName(server.name)
setDisplayName(server.display_name || '')
setTransport(server.transport)
setCommand(server.command || '')
setArgs(Array.isArray(server.args) ? server.args.join(' ') : '')
setUrl(server.url || '')
setHeaders(server.headers ?? {})
setEnv(server.env ?? {})
setToolPrefix(server.tool_prefix?.replace(/^mcp_/, '') ?? '')
setTimeoutSec(server.timeout_sec || 30)
setEnabled(server.enabled)
} else {
setName('')
setDisplayName('')
setTransport('stdio')
setCommand('')
setArgs('')
setUrl('')
setHeaders({})
setEnv({})
setToolPrefix('')
setTimeoutSec(30)
setEnabled(true)
}
setTestState('idle')
setTestResult(null)
setSaving(false)
setSubmitError('')
}, [open, server])
if (!open) return null
function buildInput(): MCPServerInput {
const input: MCPServerInput = {
name,
display_name: displayName || undefined,
transport,
timeout_sec: timeoutSec,
enabled,
}
if (transport === 'stdio') {
input.command = command
input.args = args.trim() ? args.trim().split(/\s+/) : undefined
} else {
input.url = url
if (Object.keys(headers).length > 0) input.headers = headers
}
if (Object.keys(env).length > 0) input.env = env
if (toolPrefix.trim()) input.tool_prefix = toolPrefix.trim()
return input
}
async function handleSubmit() {
setSaving(true)
setSubmitError('')
try {
await onSubmit(buildInput())
onOpenChange(false)
} catch (err) {
setSubmitError((err as Error).message || 'Failed to save')
} finally {
setSaving(false)
}
}
async function handleTest() {
setTestState('testing')
setTestResult(null)
try {
const data: Parameters<typeof onTest>[0] = { transport }
if (transport === 'stdio') {
data.command = command
if (args.trim()) data.args = args.trim().split(/\s+/)
} else {
data.url = url
if (Object.keys(headers).length > 0) data.headers = headers
}
if (Object.keys(env).length > 0) data.env = env
const result = await onTest(data)
setTestResult(result)
setTestState(result.success ? 'success' : 'error')
} catch (err) {
setTestResult({ success: false, error: (err as Error).message })
setTestState('error')
}
}
const canSubmit = name.trim() && (transport === 'stdio' ? command.trim() : url.trim()) && !saving
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
<div className="relative w-full max-w-lg bg-surface-secondary rounded-xl border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<span className="text-sm font-semibold text-text-primary">{isEdit ? t('form.editTitle') : t('form.createTitle')}</span>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Form */}
<div className="max-h-[70vh] overflow-y-auto p-5 space-y-4">
{/* Name */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.name')}</label>
<input
value={name}
onChange={(e) => setName(slugify(e.target.value))}
disabled={isEdit}
placeholder="my-mcp-server"
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
/>
<p className="text-[11px] text-text-muted">{t('form.nameHint')}</p>
</div>
{/* Display Name */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.displayName')}</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t('form.displayNamePlaceholder')}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
{/* Transport */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.transport')}</label>
<div className="grid grid-cols-3 gap-2">
{TRANSPORTS.map((t) => (
<button
key={t}
type="button"
onClick={() => setTransport(t)}
className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${
transport === t
? 'border-accent bg-accent/10 text-accent font-medium'
: 'border-border text-text-secondary hover:bg-surface-tertiary/30'
}`}
>
{t.toUpperCase()}
</button>
))}
</div>
</div>
{/* Stdio fields */}
{transport === 'stdio' && (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.command')}</label>
<input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="npx"
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.args')}</label>
<input
value={args}
onChange={(e) => setArgs(e.target.value)}
placeholder={t('form.argsPlaceholder')}
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</>
)}
{/* SSE/HTTP fields */}
{transport !== 'stdio' && (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.url')}</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://localhost:3001/sse"
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.headers')}</label>
<KeyValueEditor
value={headers}
onChange={setHeaders}
sensitivePattern={SENSITIVE_HEADER_KEYS}
placeholder={{ key: t('form.headerKeyPlaceholder'), value: t('form.headerValuePlaceholder') }}
/>
</div>
</>
)}
{/* Env Variables */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.env')}</label>
<KeyValueEditor
value={env}
onChange={setEnv}
sensitivePattern={SENSITIVE_ENV_KEYS}
placeholder={{ key: t('form.envKeyPlaceholder'), value: t('form.envValuePlaceholder') }}
/>
</div>
{/* Tool Prefix */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.toolPrefix')}</label>
<div className="flex items-stretch">
<span className="inline-flex items-center bg-surface-tertiary/70 border border-r-0 border-border rounded-l-lg px-2.5 text-base md:text-sm text-text-muted select-none">mcp_</span>
<input
value={toolPrefix}
onChange={(e) => setToolPrefix(e.target.value.replace(/[^a-z0-9_]/gi, '_').toLowerCase())}
placeholder={name.replace(/-/g, '_') || 'auto'}
className="flex-1 min-w-0 bg-surface-tertiary border border-border rounded-r-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<p className="text-[11px] text-text-muted">{t('form.toolPrefixHint')}</p>
</div>
{/* Timeout */}
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('form.timeout')}</label>
<input
type="number"
min={1}
value={timeoutSec}
onChange={(e) => setTimeoutSec(Math.max(1, Number(e.target.value)))}
className="w-24 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
{/* Enabled */}
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<span className="text-xs font-medium text-text-primary">{t('form.enabled')}</span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
{/* Error */}
{submitError && (
<div className="px-5"><p className="text-xs text-error">{submitError}</p></div>
)}
{/* Footer */}
<div className="border-t border-border px-5 py-4 space-y-2">
{/* Test result (above buttons so it doesn't break layout) */}
{testState === 'success' && testResult && (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
{t('form.toolsFound', { count: testResult.tool_count })}
</p>
)}
{testState === 'error' && testResult && (
<p className="text-[11px] text-error flex items-center gap-1">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
<span className="break-all">{testResult.error || t('form.errors.connectionFailed')}</span>
</p>
)}
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleTest}
disabled={saving || testState === 'testing'}
className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 shrink-0"
>
{testState === 'testing' ? (
<span className="flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
{t('form.testing')}
</span>
) : t('form.testConnection')}
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
>
{t('form.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors"
>
{saving ? t('form.saving') : isEdit ? t('form.update') : t('form.create')}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,250 +0,0 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMcpServers, MAX_MCP_LITE } from '../../../hooks/use-mcp-servers'
import { useAgentCrud } from '../../../hooks/use-agent-crud'
import { McpFormDialog } from './McpFormDialog'
import { McpGrantsDialog } from './McpGrantsDialog'
import { McpToolsDialog } from './McpToolsDialog'
import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
import { RefreshButton } from '../../common/RefreshButton'
import type { MCPServerData } from '../../../types/mcp'
export function McpServerList() {
const { t } = useTranslation(['mcp', 'common'])
const {
servers, loading, atLimit,
fetchServers, createServer, updateServer, deleteServer,
testConnection, reconnectServer, listServerTools,
listGrants, grantAgent, revokeAgent,
} = useMcpServers()
const { agents } = useAgentCrud()
const [formOpen, setFormOpen] = useState(false)
const [editServer, setEditServer] = useState<MCPServerData | null>(null)
const [deleteTarget, setDeleteTarget] = useState<MCPServerData | null>(null)
const [grantsServer, setGrantsServer] = useState<MCPServerData | null>(null)
const [toolsServer, setToolsServer] = useState<MCPServerData | null>(null)
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
function openCreate() {
setEditServer(null)
setFormOpen(true)
}
function openEdit(s: MCPServerData) {
setEditServer(s)
setFormOpen(true)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
<p className="text-xs text-text-muted mt-0.5">{t('description')} (max {MAX_MCP_LITE})</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={openCreate}
disabled={atLimit}
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{t('addServer')}
</button>
<RefreshButton onRefresh={fetchServers} />
</div>
</div>
{atLimit && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
{t('noMatchTitle')} ({MAX_MCP_LITE}). {t('noMatchDescription')}
</p>
)}
{/* Loading skeleton */}
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
))}
</div>
) : servers.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center gap-2 py-12">
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
</svg>
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
</div>
) : (
/* Table */
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-tertiary/40">
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.transport')}</th>
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.tools')}</th>
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.agents')}</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
</tr>
</thead>
<tbody>
{servers.map((s) => (
<tr key={s.id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
</svg>
<div className="min-w-0">
<div className="text-sm font-medium text-text-primary truncate">{s.display_name || s.name}</div>
<div className="font-mono text-[11px] text-text-muted bg-surface-tertiary/50 px-1.5 py-0.5 rounded inline-block mt-0.5">
mcp_{s.name}
</div>
</div>
</div>
</td>
{/* Transport */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
s.transport === 'sse'
? 'bg-surface-tertiary text-text-secondary'
: 'border border-border text-text-muted'
}`}>
{s.transport.toUpperCase()}
</span>
</td>
{/* Tools */}
<td className="px-4 py-3 text-center">
<button
onClick={() => setToolsServer(s)}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
title={t('viewTools')}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
</button>
</td>
{/* Agents */}
<td className="px-4 py-3 text-center">
<button
onClick={() => setGrantsServer(s)}
className="inline-flex items-center gap-1 text-sm text-text-primary hover:text-accent transition-colors"
title={t('manageGrants')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
{s.agent_count ?? 0}
</button>
</td>
{/* Enabled */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
s.enabled
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
: 'bg-surface-tertiary text-text-secondary'
}`}>
{s.enabled ? t('common:enabled') : t('common:disabled')}
</span>
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
disabled={reconnectingId === s.id}
onClick={async () => {
setReconnectingId(s.id)
try { await reconnectServer(s.id) } finally { setReconnectingId(null) }
}}
className="p-1 text-text-muted hover:text-text-primary transition-colors disabled:opacity-50"
title={t('reconnect')}
>
<svg className={`h-3.5 w-3.5${reconnectingId === s.id ? ' animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
</svg>
</button>
<button onClick={() => openEdit(s)} className="p-1 text-text-muted hover:text-text-primary transition-colors" title="Edit">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
</svg>
</button>
<button onClick={() => setDeleteTarget(s)} className="p-1 text-text-muted hover:text-error transition-colors" title="Delete">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Dialogs */}
<McpFormDialog
open={formOpen}
onOpenChange={setFormOpen}
server={editServer}
onSubmit={async (data) => {
if (editServer) {
await updateServer(editServer.id, data)
} else {
await createServer(data)
}
}}
onTest={testConnection}
/>
{deleteTarget && (
<ConfirmDeleteDialog
open
onOpenChange={() => setDeleteTarget(null)}
title="Delete MCP Server"
description={`This will permanently delete the server "${deleteTarget.display_name || deleteTarget.name}" and all its agent grants.`}
confirmValue={deleteTarget.name}
onConfirm={async () => {
await deleteServer(deleteTarget.id)
setDeleteTarget(null)
}}
/>
)}
{grantsServer && (
<McpGrantsDialog
open
onOpenChange={() => setGrantsServer(null)}
server={grantsServer}
agents={agents}
onLoadGrants={listGrants}
onGrant={grantAgent}
onRevoke={revokeAgent}
/>
)}
{toolsServer && (
<McpToolsDialog
open
onOpenChange={() => setToolsServer(null)}
server={toolsServer}
onLoadTools={listServerTools}
/>
)}
</div>
)
}
@@ -1,250 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Switch } from '../../common/Switch'
import { MediaProviderChainForm } from './MediaProviderChainForm'
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<string, unknown>) => Promise<void>
}
export function ToolSettingsDialog({ open, onOpenChange, tool, onSave }: ToolSettingsDialogProps) {
const { t } = useTranslation(['tools', 'common'])
if (!open) return null
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
<div className="relative w-full max-w-2xl mx-4 bg-surface-secondary rounded-xl border border-border overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('builtin.settingsDialog.title', { name: tool.display_name })}</h3>
<p className="font-mono text-[11px] text-text-muted mt-0.5">{tool.name}</p>
</div>
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Content — route to specialized form or generic JSON */}
{tool.name === 'web_fetch' ? (
<ExtractorChainForm tool={tool} onSave={onSave} onClose={() => onOpenChange(false)} />
) : MEDIA_TOOLS.has(tool.name) ? (
<MediaProviderChainForm tool={tool} onSave={onSave} onClose={() => onOpenChange(false)} />
) : (
<JsonSettingsForm tool={tool} onSave={onSave} onClose={() => onOpenChange(false)} />
)}
</div>
</div>
)
}
/* ─── Extractor Chain Form (web_fetch) ─── */
interface ExtractorEntry {
name: string
enabled: boolean
base_url?: string
timeout?: number
max_retries?: number
}
const EXTRACTOR_DISPLAY: Record<string, string> = {
defuddle: 'Defuddle',
'html-to-markdown': 'HTML to Markdown',
}
function ExtractorChainForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
const { t } = useTranslation(['tools', 'common'])
const [extractors, setExtractors] = useState<ExtractorEntry[]>([])
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<ExtractorEntry>) {
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 (
<>
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-3">
{extractors.map((ext, i) => (
<div key={ext.name} className="rounded-lg border border-border p-3 space-y-3">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{i + 1}</span>
<span className="text-sm font-medium text-text-primary">{EXTRACTOR_DISPLAY[ext.name] ?? ext.name}</span>
</div>
<Switch checked={ext.enabled} onCheckedChange={(v) => updateExtractor(i, { enabled: v })} />
</div>
{/* Defuddle-specific fields */}
{ext.name === 'defuddle' && (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.baseUrl')}</label>
<input
value={ext.base_url ?? ''}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
<input
type="number"
min={0}
max={10}
value={ext.max_retries ?? 2}
onChange={(e) => 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"
/>
</div>
</>
)}
{/* Timeout (show for defuddle or if already set) */}
{(ext.name === 'defuddle' || (ext.timeout && ext.timeout > 0)) && (
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.timeout')}</label>
<input
type="number"
min={0}
max={600}
value={ext.timeout ?? 0}
onChange={(e) => 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"
/>
<p className="text-[10px] text-text-muted">0 = {t('common:default')}</p>
</div>
)}
</div>
))}
{extractors.length === 0 && (
<p className="text-xs text-text-muted text-center py-4">{t('builtin.mediaChain.noProviders')}</p>
)}
</div>
{/* Footer */}
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('builtin.settingsDialog.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{saving ? t('builtin.settingsDialog.saving') : t('builtin.settingsDialog.save')}
</button>
</div>
</>
)
}
/* ─── Generic JSON Settings Form (fallback) ─── */
function JsonSettingsForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
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<string, unknown> | 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<string, unknown>
} 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 (
<>
<div className="p-5">
<textarea
value={value}
onChange={(e) => { setValue(e.target.value); setError('') }}
spellCheck={false}
className="w-full h-64 bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y"
/>
<div className="flex items-center justify-between mt-1 min-h-[20px]">
{error ? <span className="text-xs text-error">{error}</span> : <span />}
<button type="button" onClick={handleFormat} className="text-[11px] text-accent hover:text-accent-hover transition-colors">
{t('builtin.jsonDialog.formatJson')}
</button>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('builtin.jsonDialog.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={!!error || saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{saving ? t('builtin.jsonDialog.saving') : t('builtin.jsonDialog.save')}
</button>
</div>
</>
)
}
@@ -1,11 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useSkills, MAX_SKILLS_LITE } from '../../../hooks/use-skills'
import type { RuntimeStatus } from '../../../hooks/use-skills'
import { Switch } from '../../common/Switch'
import { RefreshButton } from '../../common/RefreshButton'
import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
import type { SkillInfo } from '../../../types/skill'
import { useSkills, MAX_SKILLS_LITE } from '../../hooks/use-skills'
import type { RuntimeStatus } from '../../hooks/use-skills'
import { RefreshButton } from '../common/RefreshButton'
import { ConfirmDeleteDialog } from '../common/ConfirmDeleteDialog'
import { SkillCard } from './skill-card'
import type { SkillInfo } from '../../types/skill'
export function SkillList() {
const { t } = useTranslation(['skills', 'common'])
@@ -75,14 +75,9 @@ export function SkillList() {
</div>
{atLimit && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
{t('deps.runtimeMissing')}
</p>
)}
{uploadError && (
<p className="text-xs text-error">{uploadError}</p>
<p className="text-[11px] text-amber-600 dark:text-amber-400">{t('deps.runtimeMissing')}</p>
)}
{uploadError && <p className="text-xs text-error">{uploadError}</p>}
{/* Runtime status */}
{runtimes && !runtimes.ready && (
@@ -101,7 +96,6 @@ export function SkillList() {
</div>
)}
{/* Loading */}
{loading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
@@ -129,7 +123,7 @@ export function SkillList() {
</thead>
<tbody>
{skills.map((skill) => (
<SkillRow
<SkillCard
key={skill.id ?? skill.name}
skill={skill}
onToggle={toggleSkill}
@@ -141,7 +135,6 @@ export function SkillList() {
</div>
)}
{/* Delete confirmation */}
{deleteTarget && (
<ConfirmDeleteDialog
open
@@ -158,85 +151,3 @@ export function SkillList() {
</div>
)
}
/* --- SkillRow --- */
function SkillRow({ skill, onToggle, onDelete }: {
skill: SkillInfo
onToggle: (id: string, enabled: boolean) => void
onDelete: () => void
}) {
const { t } = useTranslation('skills')
const isArchived = skill.status === 'archived'
const isDisabled = !skill.enabled || isArchived
const hasMissing = (skill.missing_deps?.length ?? 0) > 0
return (
<tr className={`border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors ${isDisabled ? 'opacity-60' : ''}`}>
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
</svg>
<span className="font-medium text-text-primary">{skill.name}</span>
{skill.is_system && (
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 border border-blue-500/20 dark:text-blue-400 dark:bg-blue-500/5 dark:border-blue-500/15">
{t('system')}
</span>
)}
{(skill.version ?? 0) > 0 && (
<span className="text-xs text-text-muted">v{skill.version}</span>
)}
</div>
</td>
{/* Description */}
<td className="px-4 py-3 max-w-xs truncate text-text-muted">
{skill.description || '—'}
</td>
{/* Status */}
<td className="px-4 py-3">
<div>
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium border ${
isArchived
? 'bg-amber-500/10 text-amber-600 border-amber-500/20 dark:text-amber-400 dark:bg-amber-500/5 dark:border-amber-500/15'
: 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
}`}>
{isArchived ? t('deps.statusArchived') : t('deps.statusActive')}
</span>
{hasMissing && (
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-0.5">
{skill.missing_deps!.slice(0, 3).join(', ')}
{skill.missing_deps!.length > 3 && ` +${skill.missing_deps!.length - 3}`}
</p>
)}
</div>
</td>
{/* Actions */}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1.5">
{/* Delete (non-system only) */}
{!skill.is_system && skill.id && (
<button
onClick={onDelete}
className="p-1 text-text-muted hover:text-error transition-colors"
title={t('delete.title')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
<Switch
checked={skill.enabled !== false}
onCheckedChange={(v) => skill.id && onToggle(skill.id, v)}
disabled={!skill.id}
/>
</div>
</td>
</tr>
)
}
@@ -0,0 +1,86 @@
// Skill table row with status badge, toggle switch, and delete action.
import { useTranslation } from 'react-i18next'
import { Switch } from '../common/Switch'
import type { SkillInfo } from '../../types/skill'
interface SkillCardProps {
skill: SkillInfo
onToggle: (id: string, enabled: boolean) => void
onDelete: () => void
}
export function SkillCard({ skill, onToggle, onDelete }: SkillCardProps) {
const { t } = useTranslation('skills')
const isArchived = skill.status === 'archived'
const isDisabled = !skill.enabled || isArchived
const hasMissing = (skill.missing_deps?.length ?? 0) > 0
return (
<tr className={`border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors ${isDisabled ? 'opacity-60' : ''}`}>
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
</svg>
<span className="font-medium text-text-primary">{skill.name}</span>
{skill.is_system && (
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 border border-blue-500/20 dark:text-blue-400 dark:bg-blue-500/5 dark:border-blue-500/15">
{t('system')}
</span>
)}
{(skill.version ?? 0) > 0 && (
<span className="text-xs text-text-muted">v{skill.version}</span>
)}
</div>
</td>
{/* Description */}
<td className="px-4 py-3 max-w-xs truncate text-text-muted">
{skill.description || '—'}
</td>
{/* Status */}
<td className="px-4 py-3">
<div>
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium border ${
isArchived
? 'bg-amber-500/10 text-amber-600 border-amber-500/20 dark:text-amber-400 dark:bg-amber-500/5 dark:border-amber-500/15'
: 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
}`}>
{isArchived ? t('deps.statusArchived') : t('deps.statusActive')}
</span>
{hasMissing && (
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-0.5">
{skill.missing_deps!.slice(0, 3).join(', ')}
{skill.missing_deps!.length > 3 && ` +${skill.missing_deps!.length - 3}`}
</p>
)}
</div>
</td>
{/* Actions */}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1.5">
{!skill.is_system && skill.id && (
<button
onClick={onDelete}
className="p-1 text-text-muted hover:text-error transition-colors"
title={t('delete.title')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
<Switch
checked={skill.enabled !== false}
onCheckedChange={(v) => skill.id && onToggle(skill.id, v)}
disabled={!skill.id}
/>
</div>
</td>
</tr>
)
}
@@ -5,8 +5,8 @@ import { useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileTreePanel } from './StorageFileTree'
import { FileContentPanel } from './StorageFileViewer'
import { formatSize } from '../../../lib/file-helpers'
import type { TreeNode } from '../../../lib/file-helpers'
import { formatSize } from '../../lib/file-helpers'
import type { TreeNode } from '../../lib/file-helpers'
interface StorageFileBrowserProps {
tree: TreeNode[]
@@ -0,0 +1,88 @@
// Recursive file tree with drag-and-drop move support for storage management.
// Uses @dnd-kit/core for DnD — mirrors web UI file-tree.tsx adapted for desktop styling.
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { DndContext, DragOverlay } from '@dnd-kit/core'
import type { TreeNode } from '../../lib/file-helpers'
import { TreeItem, RootDropZone, DragPreview, SpinnerIcon } from './file-tree-node'
import { useFileTreeDnd } from './use-file-tree-dnd'
interface FileTreePanelProps {
tree: TreeNode[]
filesLoading: boolean
activePath: string | null
onSelect: (path: string) => void
onDelete?: (path: string, isDir: boolean) => void
onLoadMore?: (path: string) => void
onMove?: (fromPath: string, toFolder: string) => void
showSize?: boolean
}
export function FileTreePanel({
tree, filesLoading, activePath, onSelect, onDelete, onLoadMore, onMove, showSize,
}: FileTreePanelProps) {
const { t } = useTranslation('common')
const dndEnabled = !!onMove
const {
sensors, activeNode, autoExpandPath,
handleDragStart, handleDragOver, handleDragEnd, handleDragCancel,
} = useFileTreeDnd({ tree, onMove })
if (filesLoading) {
return (
<div className="flex items-center justify-center py-8">
<SpinnerIcon />
</div>
)
}
if (tree.length === 0) {
return <p className="px-3 py-4 text-xs text-text-muted">{t('noFiles')}</p>
}
const treeContent = (
<div className="flex-1 min-h-0">
{dndEnabled ? (
<RootDropZone>
{tree.map((node) => (
<TreeItem
key={node.path} node={node} depth={0} activePath={activePath}
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
dndEnabled showSize={showSize} autoExpandPath={autoExpandPath}
/>
))}
</RootDropZone>
) : (
tree.map((node) => (
<TreeItem
key={node.path} node={node} depth={0} activePath={activePath}
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
dndEnabled={false} showSize={showSize} autoExpandPath={null}
/>
))
)}
</div>
)
if (!dndEnabled) return treeContent
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{treeContent}
{createPortal(
<DragOverlay dropAnimation={null}>
{activeNode ? <DragPreview name={activeNode.name} isDir={activeNode.isDir} /> : null}
</DragOverlay>,
document.body,
)}
</DndContext>
)
}
@@ -0,0 +1,44 @@
// Multi-format file viewer: loading state + empty state wrapper.
// Rendering logic delegated to file-viewer-content.tsx.
import { useTranslation } from 'react-i18next'
import { FileContentBody } from './file-viewer-content'
export function FileContentPanel({
fileContent, contentLoading, fetchBlob, onDownload,
}: {
fileContent: { content: string; path: string; size: number } | null
contentLoading: boolean
fetchBlob?: (path: string) => Promise<Blob>
onDownload?: (path: string) => void
}) {
const { t } = useTranslation('common')
if (contentLoading) {
return (
<div className="flex items-center justify-center py-8">
<svg className="h-5 w-5 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</div>
)
}
if (fileContent) {
return (
<FileContentBody
path={fileContent.path}
content={fileContent.content}
size={fileContent.size}
fetchBlob={fetchBlob}
onDownload={onDownload ? () => onDownload(fileContent.path) : undefined}
/>
)
}
return (
<div className="flex items-center justify-center py-8 text-xs text-text-muted">
{t('selectFileToView')}
</div>
)
}
@@ -3,12 +3,12 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStorage, useStorageSize } from '../../../hooks/use-storage'
import { buildTree, mergeSubtree, setNodeLoading, formatSize, isTextFile } from '../../../lib/file-helpers'
import { getApiClient } from '../../../lib/api'
import { wails } from '../../../lib/wails'
import { ConfirmDialog } from '../../common/ConfirmDialog'
import { RefreshButton } from '../../common/RefreshButton'
import { useStorage, useStorageSize } from '../../hooks/use-storage'
import { buildTree, mergeSubtree, setNodeLoading, formatSize, isTextFile } from '../../lib/file-helpers'
import { getApiClient } from '../../lib/api'
import { wails } from '../../lib/wails'
import { ConfirmDialog } from '../common/ConfirmDialog'
import { RefreshButton } from '../common/RefreshButton'
import { StorageFileBrowser } from './StorageFileBrowser'
import { StorageUploadDialog } from './StorageUploadDialog'
@@ -3,21 +3,13 @@
import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { UploadProgressList, type FileEntry, type FileStatus } from './upload-progress-list'
const BLOCKED_EXTENSIONS = new Set([
'.exe', '.sh', '.bat', '.cmd', '.ps1', '.com', '.msi', '.scr',
])
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
type FileStatus = 'ready' | 'uploading' | 'success' | 'error'
interface FileEntry {
id: string
file: File
status: FileStatus
error?: string
}
let idCounter = 0
function uniqueId(): string {
return `upload-${++idCounter}-${Date.now()}`
@@ -49,12 +41,12 @@ export function StorageUploadDialog({
const newEntries: FileEntry[] = fresh.map((f) => {
const ext = '.' + f.name.split('.').pop()?.toLowerCase()
if (BLOCKED_EXTENSIONS.has(ext)) {
return { id: uniqueId(), file: f, status: 'error' as const, error: t('upload.blockedType', { ext }) }
return { id: uniqueId(), file: f, status: 'error' as FileStatus, error: t('upload.blockedType', { ext }) }
}
if (f.size > MAX_FILE_SIZE) {
return { id: uniqueId(), file: f, status: 'error' as const, error: t('upload.tooLarge') }
return { id: uniqueId(), file: f, status: 'error' as FileStatus, error: t('upload.tooLarge') }
}
return { id: uniqueId(), file: f, status: 'ready' as const }
return { id: uniqueId(), file: f, status: 'ready' as FileStatus }
})
setEntries((prev) => [...prev, ...newEntries])
}
@@ -69,15 +61,15 @@ export function StorageUploadDialog({
setUploading(true)
for (const entry of readyEntries) {
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'uploading' } : e)))
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'uploading' as FileStatus } : e)))
try {
await onUpload(entry.file)
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'success' } : e)))
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'success' as FileStatus } : e)))
} catch (err) {
setEntries((prev) =>
prev.map((e) =>
e.id === entry.id
? { ...e, status: 'error', error: err instanceof Error ? err.message : t('upload.failed') }
? { ...e, status: 'error' as FileStatus, error: err instanceof Error ? err.message : t('upload.failed') }
: e,
),
)
@@ -135,7 +127,6 @@ export function StorageUploadDialog({
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
{/* Upload icon */}
<svg className="h-8 w-8 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
@@ -145,48 +136,12 @@ export function StorageUploadDialog({
{dragging ? t('upload.dropHere') : t('upload.dropOrClick')}
</p>
<p className="text-[10px] text-text-muted/60">{t('upload.maxSize')}</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleInputChange}
/>
<input ref={inputRef} type="file" multiple className="hidden" onChange={handleInputChange} />
</div>
)}
{/* File list */}
{entries.length > 0 && (
<div className="flex flex-col gap-1 overflow-y-auto flex-1 min-h-0">
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs">
<StatusIcon status={entry.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-text-primary">{entry.file.name}</span>
<span className="shrink-0 text-[10px] text-text-muted">
{(entry.file.size / 1024).toFixed(1)} KB
</span>
</div>
{entry.status === 'error' && (
<p className="text-[10px] text-error truncate">{entry.error}</p>
)}
</div>
{!uploading && entry.status !== 'uploading' && entry.status !== 'success' && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeEntry(entry.id) }}
className="shrink-0 rounded-sm p-1 text-text-muted hover:text-text-primary"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
))}
</div>
)}
{/* File progress list */}
<UploadProgressList entries={entries} uploading={uploading} onRemove={removeEntry} />
{/* Summary */}
{entries.length > 0 && !done && !uploading && (
@@ -232,32 +187,3 @@ export function StorageUploadDialog({
</div>
)
}
function StatusIcon({ status }: { status: FileStatus }) {
switch (status) {
case 'uploading':
return (
<svg className="h-4 w-4 shrink-0 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)
case 'ready':
return (
<svg className="h-4 w-4 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
)
case 'success':
return (
<svg className="h-4 w-4 shrink-0 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
)
case 'error':
return (
<svg className="h-4 w-4 shrink-0 text-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
</svg>
)
}
}
@@ -1,34 +1,22 @@
// Recursive file tree with drag-and-drop move support for storage management.
// Uses @dnd-kit/core for DnD — mirrors web UI file-tree.tsx adapted for desktop styling.
// Individual tree node component with icon rendering and DnD wrappers.
// Used by StorageFileTree as the recursive building block.
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
DndContext,
DragOverlay,
useDroppable,
useDraggable,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from '@dnd-kit/core'
import { useDraggable, useDroppable } from '@dnd-kit/core'
import {
extOf,
CODE_EXTENSIONS,
IMAGE_EXTENSIONS,
formatSize,
type TreeNode,
} from '../../../lib/file-helpers'
} from '../../lib/file-helpers'
// --- Inline SVG icons (desktop convention: no lucide-react) ---
const cls = 'h-4 w-4 shrink-0'
function ChevronRightIcon({ className }: { className?: string }) {
export function ChevronRightIcon({ className }: { className?: string }) {
return (
<svg className={className ?? 'h-3 w-3 shrink-0'} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
@@ -36,7 +24,7 @@ function ChevronRightIcon({ className }: { className?: string }) {
)
}
function FolderIcon({ open }: { open?: boolean }) {
export function FolderIcon({ open }: { open?: boolean }) {
if (open) {
return (
<svg className={`${cls} text-yellow-600`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
@@ -52,7 +40,7 @@ function FolderIcon({ open }: { open?: boolean }) {
)
}
function FileIcon({ name }: { name: string }) {
export function FileIcon({ name }: { name: string }) {
const ext = extOf(name)
let color = 'text-text-muted'
if (ext === 'md' || ext === 'mdx') color = 'text-blue-500'
@@ -71,7 +59,7 @@ function FileIcon({ name }: { name: string }) {
)
}
function SpinnerIcon() {
export function SpinnerIcon() {
return (
<svg className="h-3 w-3 shrink-0 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
@@ -90,7 +78,7 @@ function TrashIcon() {
// --- DnD wrappers ---
function DraggableItem({ id, enabled, children }: { id: string; enabled: boolean; children: React.ReactNode }) {
export function DraggableItem({ id, enabled, children }: { id: string; enabled: boolean; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id, disabled: !enabled })
return (
<div ref={setNodeRef} {...(enabled ? { ...listeners, ...attributes } : {})} className={isDragging ? 'opacity-40' : ''}>
@@ -99,19 +87,17 @@ function DraggableItem({ id, enabled, children }: { id: string; enabled: boolean
)
}
function DroppableFolder({ id, enabled, children }: { id: string; enabled: boolean; children: (props: { isDropTarget: boolean }) => React.ReactNode }) {
export function DroppableFolder({ id, enabled, children }: { id: string; enabled: boolean; children: (props: { isDropTarget: boolean }) => React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id, disabled: !enabled })
return <div ref={setNodeRef}>{children({ isDropTarget: isOver })}</div>
}
function RootDropZone({ children }: { children: React.ReactNode }) {
export function RootDropZone({ children }: { children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id: '__root__' })
return <div ref={setNodeRef} className={`min-h-full ${isOver ? 'bg-accent/5' : ''}`}>{children}</div>
}
// --- DragPreview ---
function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
export function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
return (
<div className="flex items-center gap-1.5 rounded-md bg-surface-secondary border border-border px-2.5 py-1.5 text-xs shadow-lg">
{isDir ? <FolderIcon /> : <FileIcon name={name} />}
@@ -122,7 +108,7 @@ function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
// --- TreeItem ---
function TreeItem({
export function TreeItem({
node, depth, activePath, onSelect, onDelete, onLoadMore, dndEnabled, autoExpandPath, showSize,
}: {
node: TreeNode; depth: number; activePath: string | null
@@ -133,7 +119,6 @@ function TreeItem({
const [expanded, setExpanded] = useState(depth === 0)
const isActive = activePath === node.path
// Auto-expand folder when hovered during drag
useEffect(() => {
if (autoExpandPath === node.path && node.isDir && !expanded) {
setExpanded(true)
@@ -238,138 +223,3 @@ function TreeItem({
}
return fileContent
}
// --- Helpers ---
function findNode(tree: TreeNode[], path: string): TreeNode | undefined {
for (const node of tree) {
if (node.path === path) return node
if (node.children.length > 0) {
const found = findNode(node.children, path)
if (found) return found
}
}
return undefined
}
// --- FileTreePanel (main export) ---
interface FileTreePanelProps {
tree: TreeNode[]
filesLoading: boolean
activePath: string | null
onSelect: (path: string) => void
onDelete?: (path: string, isDir: boolean) => void
onLoadMore?: (path: string) => void
onMove?: (fromPath: string, toFolder: string) => void
showSize?: boolean
}
export function FileTreePanel({
tree, filesLoading, activePath, onSelect, onDelete, onLoadMore, onMove, showSize,
}: FileTreePanelProps) {
const { t } = useTranslation('common')
const dndEnabled = !!onMove
const [activeId, setActiveId] = useState<string | null>(null)
const [autoExpandPath, setAutoExpandPath] = useState<string | null>(null)
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
)
const handleDragStart = useCallback((e: DragStartEvent) => {
setActiveId(String(e.active.id))
}, [])
const handleDragOver = useCallback((e: DragOverEvent) => {
const overId = e.over?.id ? String(e.over.id) : null
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
if (overId && overId !== '__root__') {
autoExpandTimerRef.current = setTimeout(() => setAutoExpandPath(overId), 800)
} else {
setAutoExpandPath(null)
}
}, [])
const handleDragEnd = useCallback((e: DragEndEvent) => {
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
setActiveId(null)
setAutoExpandPath(null)
const fromId = String(e.active.id)
const overId = e.over?.id ? String(e.over.id) : null
if (!overId || !onMove) return
const toFolder = overId === '__root__' ? '' : overId
if (fromId !== toFolder) onMove(fromId, toFolder)
}, [onMove])
const handleDragCancel = useCallback(() => {
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
setActiveId(null)
setAutoExpandPath(null)
}, [])
const activeNode = useMemo(
() => (activeId ? findNode(tree, activeId) : undefined),
[activeId, tree],
)
if (filesLoading) {
return (
<div className="flex items-center justify-center py-8">
<SpinnerIcon />
</div>
)
}
if (tree.length === 0) {
return <p className="px-3 py-4 text-xs text-text-muted">{t('noFiles')}</p>
}
const treeContent = (
<div className="flex-1 min-h-0">
{dndEnabled ? (
<RootDropZone>
{tree.map((node) => (
<TreeItem
key={node.path} node={node} depth={0} activePath={activePath}
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
dndEnabled showSize={showSize} autoExpandPath={autoExpandPath}
/>
))}
</RootDropZone>
) : (
tree.map((node) => (
<TreeItem
key={node.path} node={node} depth={0} activePath={activePath}
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
dndEnabled={false} showSize={showSize} autoExpandPath={null}
/>
))
)}
</div>
)
if (!dndEnabled) return treeContent
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{treeContent}
{createPortal(
<DragOverlay dropAnimation={null}>
{activeNode ? <DragPreview name={activeNode.name} isDir={activeNode.isDir} /> : null}
</DragOverlay>,
document.body,
)}
</DndContext>
)
}
@@ -1,17 +1,15 @@
// Multi-format file viewer: code (syntax highlight), markdown, CSV table, images, unsupported.
// Uses react-syntax-highlighter (Prism + oneDark) matching desktop CodeBlock pattern.
// File content renderers: code (syntax highlight), markdown, CSV table, images, unsupported.
// Extracted from StorageFileViewer for focused rendering logic.
import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { MarkdownRenderer } from '../../chat/MarkdownRenderer'
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
import {
extOf, langFor, stripFrontmatter, formatSize,
isImageFile, isTextFile, CODE_EXTENSIONS,
} from '../../../lib/file-helpers'
// --- Code viewer with syntax highlighting + copy ---
} from '../../lib/file-helpers'
function CodeViewer({ content, language }: { content: string; language: string }) {
const { t } = useTranslation('common')
@@ -48,8 +46,6 @@ function CodeViewer({ content, language }: { content: string; language: string }
)
}
// --- CSV viewer as table ---
function CsvViewer({ content }: { content: string }) {
const { t } = useTranslation('common')
const [copied, setCopied] = useState(false)
@@ -116,8 +112,6 @@ function CsvViewer({ content }: { content: string }) {
)
}
// --- Image viewer (blob fetch → object URL) ---
function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise<Blob> }) {
const [src, setSrc] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -170,8 +164,6 @@ function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: stri
)
}
// --- Unsupported file type ---
function UnsupportedViewer({ path, size, onDownload }: { path: string; size: number; onDownload?: () => void }) {
const { t } = useTranslation('storage')
const fileName = path.split('/').pop() ?? path
@@ -198,8 +190,6 @@ function UnsupportedViewer({ path, size, onDownload }: { path: string; size: num
)
}
// --- FileContentBody (renders by type) ---
export function FileContentBody({
path, content, size, fetchBlob, onDownload,
}: {
@@ -226,44 +216,3 @@ export function FileContentBody({
return <UnsupportedViewer path={path} size={size ?? 0} onDownload={onDownload} />
}
// --- FileContentPanel (loading state + empty state wrapper) ---
export function FileContentPanel({
fileContent, contentLoading, fetchBlob, onDownload,
}: {
fileContent: { content: string; path: string; size: number } | null
contentLoading: boolean
fetchBlob?: (path: string) => Promise<Blob>
onDownload?: (path: string) => void
}) {
const { t } = useTranslation('common')
if (contentLoading) {
return (
<div className="flex items-center justify-center py-8">
<svg className="h-5 w-5 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</div>
)
}
if (fileContent) {
return (
<FileContentBody
path={fileContent.path}
content={fileContent.content}
size={fileContent.size}
fetchBlob={fetchBlob}
onDownload={onDownload ? () => onDownload(fileContent.path) : undefined}
/>
)
}
return (
<div className="flex items-center justify-center py-8 text-xs text-text-muted">
{t('selectFileToView')}
</div>
)
}
@@ -0,0 +1,87 @@
// File upload progress list — renders per-file status icons and entry rows.
// Extracted from StorageUploadDialog for focused display logic.
import { useTranslation } from 'react-i18next'
export type FileStatus = 'ready' | 'uploading' | 'success' | 'error'
export interface FileEntry {
id: string
file: File
status: FileStatus
error?: string
}
function StatusIcon({ status }: { status: FileStatus }) {
switch (status) {
case 'uploading':
return (
<svg className="h-4 w-4 shrink-0 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)
case 'ready':
return (
<svg className="h-4 w-4 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
)
case 'success':
return (
<svg className="h-4 w-4 shrink-0 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
)
case 'error':
return (
<svg className="h-4 w-4 shrink-0 text-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
</svg>
)
}
}
interface UploadProgressListProps {
entries: FileEntry[]
uploading: boolean
onRemove: (id: string) => void
}
export function UploadProgressList({ entries, uploading, onRemove }: UploadProgressListProps) {
const { t } = useTranslation('common')
if (entries.length === 0) return null
return (
<div className="flex flex-col gap-1 overflow-y-auto flex-1 min-h-0">
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs">
<StatusIcon status={entry.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-text-primary">{entry.file.name}</span>
<span className="shrink-0 text-[10px] text-text-muted">
{(entry.file.size / 1024).toFixed(1)} KB
</span>
</div>
{entry.status === 'error' && (
<p className="text-[10px] text-error truncate">{entry.error}</p>
)}
</div>
{!uploading && entry.status !== 'uploading' && entry.status !== 'success' && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(entry.id) }}
className="shrink-0 rounded-sm p-1 text-text-muted hover:text-text-primary"
title={t('remove', 'Remove')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
))}
</div>
)
}
@@ -0,0 +1,88 @@
// Drag-and-drop hook for the storage file tree.
// Encapsulates @dnd-kit sensor setup, auto-expand-on-hover logic, and move dispatch.
import { useState, useCallback, useRef, useMemo } from 'react'
import {
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from '@dnd-kit/core'
import type { TreeNode } from '../../lib/file-helpers'
function findNode(tree: TreeNode[], path: string): TreeNode | undefined {
for (const node of tree) {
if (node.path === path) return node
if (node.children.length > 0) {
const found = findNode(node.children, path)
if (found) return found
}
}
return undefined
}
interface UseFileTreeDndOptions {
tree: TreeNode[]
onMove?: (fromPath: string, toFolder: string) => void
}
export function useFileTreeDnd({ tree, onMove }: UseFileTreeDndOptions) {
const [activeId, setActiveId] = useState<string | null>(null)
const [autoExpandPath, setAutoExpandPath] = useState<string | null>(null)
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
)
const handleDragStart = useCallback((e: DragStartEvent) => {
setActiveId(String(e.active.id))
}, [])
const handleDragOver = useCallback((e: DragOverEvent) => {
const overId = e.over?.id ? String(e.over.id) : null
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
if (overId && overId !== '__root__') {
autoExpandTimerRef.current = setTimeout(() => setAutoExpandPath(overId), 800)
} else {
setAutoExpandPath(null)
}
}, [])
const handleDragEnd = useCallback((e: DragEndEvent) => {
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
setActiveId(null)
setAutoExpandPath(null)
const fromId = String(e.active.id)
const overId = e.over?.id ? String(e.over.id) : null
if (!overId || !onMove) return
const toFolder = overId === '__root__' ? '' : overId
if (fromId !== toFolder) onMove(fromId, toFolder)
}, [onMove])
const handleDragCancel = useCallback(() => {
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
setActiveId(null)
setAutoExpandPath(null)
}, [])
const activeNode = useMemo(
() => (activeId ? findNode(tree, activeId) : undefined),
[activeId, tree],
)
return {
sensors,
activeId,
activeNode,
autoExpandPath,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDragCancel,
}
}
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
import { Combobox } from '../common/Combobox'
import { ConfirmDialog } from '../common/ConfirmDialog'
import { IconClose, IconChevronDown, IconDocument, IconCheckCircle, IconTrash } from '../common/Icons'
import { getApiClient } from '../../lib/api'
import { STATUS_BADGE, PRIORITY_BADGE, isTaskLocked, TERMINAL_STATUSES } from '../../types/team'
import { IconClose, IconTrash } from '../common/Icons'
import { TERMINAL_STATUSES } from '../../types/team'
import { TaskDetailBody } from './task-detail-meta'
import type { TeamTaskData, TeamMemberData, TeamTaskAttachment } from '../../types/team'
interface TaskDetailModalProps {
@@ -17,54 +16,11 @@ interface TaskDetailModalProps {
onFetchDetail?: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; attachments: TeamTaskAttachment[] } | null>
}
/** Metadata label + value pair */
function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs text-text-muted mb-0.5">{label}</dt>
<dd className="text-sm font-medium text-text-primary">{children}</dd>
</div>
)
}
/** Collapsible bordered section — matches web UI TaskDetailContent pattern */
function CollapsibleSection({ title, icon, defaultOpen = true, children }: {
title: string; icon: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-lg border border-border">
<button
type="button"
onClick={() => 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}
<span>{title}</span>
<IconChevronDown className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
{open && (
<div className="border-t border-border px-4 py-3">
{children}
</div>
)}
</div>
)
}
/** Format bytes to human-readable size */
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`
}
export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, onFetchDetail }: TaskDetailModalProps) {
const { t } = useTranslation('teams')
const [confirmDelete, setConfirmDelete] = useState(false)
const [attachments, setAttachments] = useState<TeamTaskAttachment[]>([])
// Fetch full detail (with attachments) on mount
useEffect(() => {
if (!onFetchDetail) return
onFetchDetail(task.team_id, task.id).then((res) => {
@@ -72,9 +28,6 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, on
})
}, [task.id, task.team_id, onFetchDetail])
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
@@ -98,26 +51,11 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, on
onClick={(e) => e.stopPropagation()}
className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-4xl max-h-[85vh] flex flex-col mx-4"
>
{/* ── Header: badges + title ── */}
{/* Header */}
<div className="px-6 pt-5 pb-4 border-b border-border shrink-0">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{task.identifier && (
<span className="text-xs font-mono text-text-muted bg-surface-tertiary px-2 py-0.5 rounded border border-border">
{task.identifier}
</span>
)}
<span className={`text-xs font-medium px-2 py-0.5 rounded capitalize ${statusCls}`}>
{task.status.replace(/_/g, ' ')}
</span>
{locked && (
<span className="text-xs font-medium px-2 py-0.5 rounded bg-green-500/15 text-green-600 dark:text-green-400 animate-pulse">
Running
</span>
)}
</div>
<h3 className="text-base font-semibold text-text-primary leading-snug sm:text-lg">{task.subject}</h3>
<h3 className="text-base font-semibold text-text-primary leading-snug sm:text-lg mt-1">{task.subject}</h3>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer shrink-0 rounded-lg hover:bg-surface-tertiary">
<IconClose />
@@ -125,137 +63,12 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, on
</div>
</div>
{/* ── Scrollable body ── */}
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto overscroll-contain space-y-4 px-6 py-4">
{/* Progress bar */}
{task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (() => {
const pct = Math.min(100, Math.max(0, task.progress_percent))
return (
<div className="space-y-1">
<div className="flex justify-between text-xs text-text-muted">
<span>{t('progress', 'Progress')}</span>
<span>{pct}%</span>
</div>
<div className="h-2 w-full rounded-full bg-surface-tertiary overflow-hidden">
<div className="h-2 rounded-full bg-accent transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
{task.progress_step && <p className="text-xs text-text-muted">{task.progress_step}</p>}
</div>
)
})()}
{/* Metadata grid */}
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 rounded-lg bg-surface-tertiary/30 p-4">
<MetaItem label={t('priority', 'Priority')}>
<span className={`inline-flex items-center gap-1.5 capitalize`}>
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${prio.cls}`}>{prio.label}</span>
</span>
</MetaItem>
<MetaItem label={t('owner', 'Owner')}>
{member ? (
<span className="flex items-center gap-1.5">
{member.emoji && <span className="text-base">{member.emoji}</span>}
{member.display_name || member.agent_key}
</span>
) : task.owner_agent_key || '—'}
</MetaItem>
{task.task_type && task.task_type !== 'general' && (
<MetaItem label={t('type', 'Type')}>
<span className="text-xs bg-surface-tertiary border border-border px-2 py-0.5 rounded">{task.task_type}</span>
</MetaItem>
)}
{task.created_at && (
<MetaItem label={t('created', 'Created')}>
{new Date(task.created_at).toLocaleString()}
</MetaItem>
)}
{task.updated_at && (
<MetaItem label={t('updated', 'Updated')}>
{new Date(task.updated_at).toLocaleString()}
</MetaItem>
)}
</dl>
{/* Blocked by */}
{task.blocked_by && task.blocked_by.length > 0 && (
<div>
<span className="text-xs text-text-muted">{t('blockedBy', 'Blocked by')}</span>
<div className="mt-1 flex flex-wrap gap-1.5">
{task.blocked_by.map((id) => (
<span key={id} className="text-xs font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded border border-amber-500/20">
{id.slice(0, 8)}
</span>
))}
</div>
</div>
)}
{/* Description section */}
{task.description && (
<CollapsibleSection
title={t('description', 'Description')}
icon={<IconDocument />}
>
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-60 overflow-y-auto">
<MarkdownRenderer content={task.description} />
</div>
</CollapsibleSection>
)}
{/* Result section */}
{task.result && (
<CollapsibleSection
title={t('result', 'Result')}
icon={<IconCheckCircle />}
>
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-[40vh] overflow-y-auto">
<MarkdownRenderer content={task.result} />
</div>
</CollapsibleSection>
)}
{/* Attachments section */}
{attachments.length > 0 && (
<CollapsibleSection
title={`${t('attachments', 'Attachments')} (${attachments.length})`}
icon={<IconDocument />}
>
<div className="space-y-2">
{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 (
<div key={a.id} className="flex items-center gap-3 rounded-lg border border-border bg-surface-tertiary/30 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent/10">
<IconDocument className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium text-text-primary">{fileName}</p>
{a.file_size > 0 && (
<p className="text-xs text-text-muted">{formatFileSize(a.file_size)}</p>
)}
</div>
{a.download_url && (
<a
href={fullUrl}
download
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-xs text-accent hover:text-accent/80 px-3 py-1.5 rounded-lg border border-accent/30 hover:bg-accent/10 transition-colors cursor-pointer"
>
{t('download', 'Download')}
</a>
)}
</div>
)
})}
</div>
</CollapsibleSection>
)}
<TaskDetailBody task={task} members={members} attachments={attachments} />
</div>
{/* ── Footer ── */}
{/* Footer */}
<div className="flex items-center gap-3 px-6 py-3 border-t border-border shrink-0">
{!isTerminal && (
<div className="max-w-[240px]">
@@ -7,7 +7,8 @@ import { IconChevronLeft, IconGear, IconChevronDown, IconPlus, IconChat } from '
import { KanbanColumn } from './KanbanColumn'
import { TaskDetailModal } from './TaskDetailModal'
import { TeamSettingsModal } from './TeamSettingsModal'
import { KANBAN_STATUSES, TERMINAL_STATUSES, groupByStatus } from '../../types/team'
import { TeamTaskListView } from './team-task-list-view'
import { KANBAN_STATUSES, groupByStatus } from '../../types/team'
import type { TeamTaskData } from '../../types/team'
type ViewMode = 'kanban' | 'list'
@@ -34,17 +35,12 @@ export function TeamBoard() {
const team = teams.find((t) => t.id === activeTeamId)
useEffect(() => { fetchTeams() }, [fetchTeams])
useEffect(() => {
if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined)
}, [activeTeamId, statusFilter, fetchTasks])
const grouped = useMemo(() => groupByStatus(tasks), [tasks])
const handleRefresh = () => {
if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined)
}
// Keep selected task in sync with tasks
useEffect(() => {
if (selectedTask) {
@@ -76,18 +72,10 @@ export function TeamBoard() {
<button onClick={closeSettings} className="text-text-muted hover:text-text-primary cursor-pointer" title="Back to chat">
<IconChevronLeft size={16} />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-text-primary truncate">
{team?.name || t('team', 'Team')}
</h2>
{/* Gear — team settings */}
<button
onClick={() => setSettingsOpen(true)}
className="text-text-muted hover:text-text-primary cursor-pointer p-1 rounded hover:bg-surface-tertiary"
title={t('teamSettings', 'Team Settings')}
>
<h2 className="text-sm font-semibold text-text-primary truncate">{team?.name || t('team', 'Team')}</h2>
<button onClick={() => setSettingsOpen(true)} className="text-text-muted hover:text-text-primary cursor-pointer p-1 rounded hover:bg-surface-tertiary" title={t('teamSettings', 'Team Settings')}>
<IconGear />
</button>
</div>
@@ -95,26 +83,17 @@ export function TeamBoard() {
{/* View toggle */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
<button
onClick={() => setViewMode('kanban')}
className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'kanban' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}
>
<button onClick={() => setViewMode('kanban')} className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'kanban' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}>
{t('kanban', 'Kanban')}
</button>
<button
onClick={() => setViewMode('list')}
className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'list' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}
>
<button onClick={() => setViewMode('list')} className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'list' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}>
{t('list', 'List')}
</button>
</div>
{/* Custom status filter dropdown */}
{/* Status filter */}
<div className="relative">
<button
onClick={() => setFilterOpen((v) => !v)}
className="flex items-center gap-1.5 text-[11px] bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-text-secondary hover:border-accent/30 transition-colors cursor-pointer"
>
<button onClick={() => setFilterOpen((v) => !v)} className="flex items-center gap-1.5 text-[11px] bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-text-secondary hover:border-accent/30 transition-colors cursor-pointer">
<span>{t(currentFilterLabel)}</span>
<IconChevronDown size={10} />
</button>
@@ -123,13 +102,8 @@ export function TeamBoard() {
<div className="fixed inset-0 z-40" onClick={() => setFilterOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-50 bg-surface-primary border border-border rounded-lg shadow-lg py-1 min-w-[120px]">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => { setStatusFilter(opt.value); setFilterOpen(false) }}
className={[
'w-full text-left px-3 py-1.5 text-[11px] transition-colors cursor-pointer',
statusFilter === opt.value ? 'text-accent bg-accent/10' : 'text-text-secondary hover:bg-surface-tertiary',
].join(' ')}
<button key={opt.value} onClick={() => { setStatusFilter(opt.value); setFilterOpen(false) }}
className={['w-full text-left px-3 py-1.5 text-[11px] transition-colors cursor-pointer', statusFilter === opt.value ? 'text-accent bg-accent/10' : 'text-text-secondary hover:bg-surface-tertiary'].join(' ')}
>
{t(opt.label)}
</button>
@@ -139,14 +113,9 @@ export function TeamBoard() {
)}
</div>
<RefreshButton onRefresh={async () => { handleRefresh() }} />
<RefreshButton onRefresh={async () => { if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }} />
{/* "+" info button */}
<button
onClick={() => setInfoOpen(true)}
className="flex items-center justify-center w-7 h-7 rounded-lg bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer transition-colors"
title={t('createTask', 'Create Task')}
>
<button onClick={() => setInfoOpen(true)} className="flex items-center justify-center w-7 h-7 rounded-lg bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer transition-colors" title={t('createTask', 'Create Task')}>
<IconPlus />
</button>
</div>
@@ -156,111 +125,30 @@ export function TeamBoard() {
<div className="flex-1 overflow-x-auto overflow-y-hidden p-3">
<div className="flex gap-3 h-full">
{KANBAN_STATUSES.map((status) => (
<KanbanColumn
key={status}
status={status}
tasks={grouped.get(status) ?? []}
members={members}
onTaskClick={setSelectedTask}
/>
<KanbanColumn key={status} status={status} tasks={grouped.get(status) ?? []} members={members} onTaskClick={setSelectedTask} />
))}
</div>
</div>
) : (
/* List view */
<div className="flex-1 overflow-auto p-3">
{selected.size > 0 && (
<div className="flex items-center gap-2 mb-2 px-2">
<span className="text-xs text-text-muted">{selected.size} selected</span>
<button onClick={handleBulkDelete} className="text-xs text-error hover:underline cursor-pointer">
{t('deleteSelected', 'Delete selected')}
</button>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs min-w-[600px]">
<thead>
<tr className="text-text-muted text-left border-b border-border">
<th className="pb-2 pr-2 w-8">
<input
type="checkbox"
checked={selected.size > 0 && selected.size === tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).length}
onChange={(e) => {
if (e.target.checked) {
setSelected(new Set(tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).map((t) => t.id)))
} else {
setSelected(new Set())
}
}}
/>
</th>
<th className="pb-2 pr-2 font-medium">{t('id', 'ID')}</th>
<th className="pb-2 pr-2 font-medium">{t('subject', 'Subject')}</th>
<th className="pb-2 pr-2 font-medium">{t('status', 'Status')}</th>
<th className="pb-2 pr-2 font-medium">{t('owner', 'Owner')}</th>
<th className="pb-2 font-medium">{t('priority', 'Priority')}</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
const canSelect = TERMINAL_STATUSES.has(task.status)
return (
<tr
key={task.id}
onClick={() => setSelectedTask(task)}
className="border-b border-border/50 hover:bg-surface-tertiary/50 cursor-pointer transition-colors"
>
<td className="py-2 pr-2" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
disabled={!canSelect}
checked={selected.has(task.id)}
onChange={(e) => {
const next = new Set(selected)
e.target.checked ? next.add(task.id) : next.delete(task.id)
setSelected(next)
}}
/>
</td>
<td className="py-2 pr-2 font-mono text-text-muted">{task.identifier || task.task_number}</td>
<td className="py-2 pr-2 text-text-primary truncate max-w-[250px]">{task.subject}</td>
<td className="py-2 pr-2">
<span className="capitalize text-text-secondary">{task.status.replace('_', ' ')}</span>
</td>
<td className="py-2 pr-2 text-text-secondary">{member?.display_name || task.owner_agent_key || '—'}</td>
<td className="py-2 text-text-secondary">P-{task.priority}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{tasks.length === 0 && !loading && (
<div className="text-center py-8 text-text-muted text-xs">{t('noTasks', 'No tasks yet')}</div>
)}
</div>
<TeamTaskListView
tasks={tasks}
members={members}
loading={loading}
selected={selected}
onSelectChange={setSelected}
onTaskClick={setSelectedTask}
onBulkDelete={handleBulkDelete}
/>
)}
{/* Task detail modal */}
{selectedTask && (
<TaskDetailModal
task={selectedTask}
members={members}
onClose={() => setSelectedTask(null)}
onAssign={assignTask}
onDelete={deleteTask}
onFetchDetail={fetchTaskDetail}
/>
<TaskDetailModal task={selectedTask} members={members} onClose={() => setSelectedTask(null)} onAssign={assignTask} onDelete={deleteTask} onFetchDetail={fetchTaskDetail} />
)}
{/* Team settings modal */}
{settingsOpen && activeTeamId && (
<TeamSettingsModal
teamId={activeTeamId}
onClose={() => setSettingsOpen(false)}
onSaved={() => { fetchTeams(); if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }}
/>
<TeamSettingsModal teamId={activeTeamId} onClose={() => setSettingsOpen(false)} onSaved={() => { fetchTeams(); if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }} />
)}
{/* Task create info modal */}
@@ -276,16 +164,12 @@ export function TeamBoard() {
leader: team?.lead_display_name || team?.lead_agent_key || 'the leader',
})}
</p>
<button
onClick={() => setInfoOpen(false)}
className="mt-2 px-4 py-1.5 text-xs font-medium bg-surface-tertiary text-text-primary rounded-lg hover:bg-surface-tertiary/80 cursor-pointer"
>
<button onClick={() => setInfoOpen(false)} className="mt-2 px-4 py-1.5 text-xs font-medium bg-surface-tertiary text-text-primary rounded-lg hover:bg-surface-tertiary/80 cursor-pointer">
{t('settings.ok', 'OK')}
</button>
</div>
</div>
)}
</div>
)
}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getWsClient } from '../../lib/ws'
import { teamService } from '../../services/team-service'
import { toast } from '../../stores/toast-store'
import { Combobox } from '../common/Combobox'
import { IconClose, IconCheck } from '../common/Icons'
@@ -34,12 +34,11 @@ export function TeamCreateDialog({ agents, onClose, onCreated }: TeamCreateDialo
if (!name.trim() || !lead) return
setSaving(true)
try {
const ws = getWsClient()
const res = await ws.call('teams.create', {
const res = await teamService.create({
name: name.trim(),
lead,
members: memberKeys.filter((k) => k !== lead),
}) as { team: TeamData }
})
toast.success(t('teamCreated', 'Team created'), name.trim())
onCreated(res.team)
onClose()
@@ -1,24 +1,20 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Combobox } from '../common/Combobox'
import { IconClose, IconPlus, IconUser, IconSpinner } from '../common/Icons'
import { IconClose, IconSpinner } from '../common/Icons'
import { useAgents } from '../../hooks/use-agents'
import { useTeamManage } from '../../hooks/use-team-manage'
import { TeamMemberList } from './team-member-list'
import { teamSettingsSchema, type TeamSettingsFormData } from '../../schemas/team-settings.schema'
import type { TeamData, TeamMemberData, TeamNotifyConfig } from '../../types/team'
interface TeamSettingsModalProps {
teamId: string
onClose: () => void
/** Called after successful save so parent can refresh */
onSaved?: () => void
}
const ROLE_COLORS: Record<string, string> = {
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',
}
const NOTIFY_KEYS = ['dispatched', 'progress', 'failed', 'completed', 'new_task'] as const
type NotifyKey = typeof NOTIFY_KEYS[number]
@@ -31,70 +27,54 @@ export function TeamSettingsModal({ teamId, onClose, onSaved }: TeamSettingsModa
const [members, setMembers] = useState<TeamMemberData[]>([])
const [loading, setLoading] = useState(true)
// Editable fields
const [name, setName] = useState('')
const [description, setDescription] = useState('')
// Notification settings
const [notify, setNotify] = useState<Record<NotifyKey, boolean>>({
dispatched: true, progress: true, failed: true, completed: true, new_task: true,
})
const [notifyMode, setNotifyMode] = useState<'direct' | 'leader'>('direct')
// Add member
const [showAdd, setShowAdd] = useState(false)
const [addAgent, setAddAgent] = useState('')
const [adding, setAdding] = useState(false)
const [removing, setRemoving] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// Load team detail (once on mount)
const { watch, setValue, reset, handleSubmit, formState: { isSubmitting } } = useForm<TeamSettingsFormData>({
resolver: zodResolver(teamSettingsSchema),
mode: 'onChange',
})
useEffect(() => {
refreshAgents()
fetchTeamDetail(teamId).then((res) => {
if (!res) return
setTeam(res.team)
setMembers(res.members ?? [])
setName(res.team.name)
setDescription(res.team.description ?? '')
// Parse notification settings
const settings = (res.team.settings ?? {}) as Record<string, unknown>
const n = (settings.notifications ?? {}) as Record<string, unknown>
setNotify({
dispatched: n.dispatched !== false,
progress: n.progress !== false,
failed: n.failed !== false,
completed: n.completed !== false,
new_task: n.new_task !== false,
reset({
name: res.team.name,
description: res.team.description ?? '',
notify: {
dispatched: n.dispatched !== false,
progress: n.progress !== false,
failed: n.failed !== false,
completed: n.completed !== false,
new_task: n.new_task !== false,
},
notifyMode: (n.mode as 'direct' | 'leader') || 'direct',
})
setNotifyMode((n.mode as 'direct' | 'leader') || 'direct')
setLoading(false)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamId])
const leadMember = members.find((m) => m.role === 'lead')
const sorted = useMemo(
() => [...members].sort((a, b) => {
if (a.role === 'lead' && b.role !== 'lead') return -1
if (b.role === 'lead' && a.role !== 'lead') return 1
return (a.display_name || a.agent_key || '').localeCompare(b.display_name || b.agent_key || '')
}),
[members],
)
const sorted = useMemo(() => [...members].sort((a, b) => {
if (a.role === 'lead' && b.role !== 'lead') return -1
if (b.role === 'lead' && a.role !== 'lead') return 1
return (a.display_name || a.agent_key || '').localeCompare(b.display_name || b.agent_key || '')
}), [members])
const memberIds = useMemo(() => new Set(members.map((m) => m.agent_id)), [members])
const availableAgents = useMemo(
() => agents
.filter((a) => !memberIds.has(a.id))
.map((a) => ({ value: a.id, label: `${a.emoji || ''} ${a.name}`.trim() })),
[agents, memberIds],
)
const availableAgents = useMemo(() => agents.filter((a) => !memberIds.has(a.id)).map((a) => ({ value: a.id, label: `${a.emoji || ''} ${a.name}`.trim() })), [agents, memberIds])
const toggleNotify = useCallback((key: NotifyKey) => {
setNotify((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
setValue(`notify.${key}`, !watch(`notify.${key}`))
}, [setValue, watch])
const handleAddMember = async () => {
if (!addAgent) return
@@ -116,18 +96,15 @@ export function TeamSettingsModal({ teamId, onClose, onSaved }: TeamSettingsModa
} catch { /* toast in hook */ } finally { setRemoving(null) }
}
const handleSave = async () => {
setSaving(true)
try {
const notifications: TeamNotifyConfig = { ...notify, mode: notifyMode }
const settings = { ...(team?.settings ?? {}), notifications } as Record<string, unknown>
await updateTeam(teamId, {
name: name !== team?.name ? name : undefined,
description: description !== (team?.description ?? '') ? description : undefined,
settings,
})
onSaved?.()
} catch { /* toast in hook */ } finally { setSaving(false) }
const onValid = async (data: TeamSettingsFormData) => {
const notifications: TeamNotifyConfig = { ...data.notify, mode: data.notifyMode }
const settings = { ...(team?.settings ?? {}), notifications } as Record<string, unknown>
await updateTeam(teamId, {
name: data.name !== team?.name ? data.name : undefined,
description: data.description !== (team?.description ?? '') ? data.description : undefined,
settings,
})
onSaved?.()
}
if (loading) {
@@ -140,178 +117,85 @@ export function TeamSettingsModal({ teamId, onClose, onSaved }: TeamSettingsModa
)
}
const notifyMode = watch('notifyMode')
const notify = watch('notify')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
onClick={(e) => 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"
>
<div onClick={(e) => 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 */}
<div className="px-6 pt-5 pb-4 border-b border-border shrink-0 flex items-center justify-between">
<h2 className="text-base font-semibold text-text-primary">{t('teamSettings', 'Team Settings')}</h2>
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer rounded-lg hover:bg-surface-tertiary">
<IconClose />
</button>
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer rounded-lg hover:bg-surface-tertiary"><IconClose /></button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto overscroll-contain space-y-5 px-6 py-4">
{/* ── Section 1: Team Info ── */}
{/* Team Info */}
<section className="space-y-3">
<h3 className="text-sm font-medium text-text-primary">{t('settings.teamInfo', 'Team Info')}</h3>
<div className="space-y-2">
<label className="block text-xs text-text-muted">{t('teamName', 'Team name')}</label>
<input
value={name}
onChange={(e) => 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"
/>
<input value={watch('name')} onChange={(e) => 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" />
</div>
<div className="space-y-2">
<label className="block text-xs text-text-muted">{t('description', 'Description')}</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
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 resize-none"
/>
<textarea value={watch('description')} onChange={(e) => setValue('description', e.target.value)} rows={2} 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 resize-none" />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-xs text-text-muted">{t('status', 'Status')}</span>
<p className="mt-0.5 font-medium capitalize text-text-primary">{team?.status || 'active'}</p>
</div>
<div>
<span className="text-xs text-text-muted">{t('leadAgent', 'Lead agent')}</span>
<p className="mt-0.5 font-medium text-text-primary">
{leadMember?.emoji && <span className="mr-1">{leadMember.emoji}</span>}
{leadMember?.display_name || leadMember?.agent_key || '—'}
</p>
</div>
<div><span className="text-xs text-text-muted">{t('status', 'Status')}</span><p className="mt-0.5 font-medium capitalize text-text-primary">{team?.status || 'active'}</p></div>
<div><span className="text-xs text-text-muted">{t('leadAgent', 'Lead agent')}</span><p className="mt-0.5 font-medium text-text-primary">{leadMember?.emoji && <span className="mr-1">{leadMember.emoji}</span>}{leadMember?.display_name || leadMember?.agent_key || '—'}</p></div>
</div>
</section>
{/* ── Section 2: Members ── */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">{t('members', 'Members')} ({members.length})</h3>
<button
onClick={() => setShowAdd(!showAdd)}
className="text-xs text-accent hover:text-accent/80 cursor-pointer flex items-center gap-1"
>
<IconPlus size={12} />
{t('settings.addMember', 'Add member')}
</button>
</div>
{/* Members */}
<TeamMemberList
members={members}
sorted={sorted}
availableAgents={availableAgents}
showAdd={showAdd}
addAgent={addAgent}
adding={adding}
removing={removing}
onToggleAdd={() => setShowAdd(!showAdd)}
onAddAgentChange={setAddAgent}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
/>
{showAdd && (
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<Combobox
value={addAgent}
onChange={setAddAgent}
options={availableAgents}
placeholder={t('settings.searchAgent', 'Search agent...')}
allowCustom={false}
/>
</div>
<button
onClick={handleAddMember}
disabled={!addAgent || adding}
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{adding ? '...' : t('settings.add', 'Add')}
</button>
</div>
)}
<div className="rounded-lg border border-border divide-y divide-border max-h-[200px] overflow-y-auto">
{sorted.map((m) => (
<div key={m.agent_id} className="group flex items-center gap-3 px-3 py-2.5 hover:bg-surface-tertiary/50">
{m.emoji ? (
<span className="text-base shrink-0">{m.emoji}</span>
) : (
<IconUser className="text-text-muted shrink-0" />
)}
<span className="text-sm text-text-primary truncate flex-1">{m.display_name || m.agent_key || m.agent_id.slice(0, 8)}</span>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${ROLE_COLORS[m.role] ?? ''}`}>
{m.role}
</span>
{m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
<button
onClick={() => handleRemoveMember(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 ? (
<IconSpinner size={14} className="border-text-muted" />
) : (
<IconClose size={14} />
)}
</button>
)}
</div>
))}
{members.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-text-muted">{t('settings.noMembers', 'No members')}</div>
)}
</div>
</section>
{/* ── Section 3: Notifications ── */}
{/* Notifications */}
<section className="space-y-3">
<h3 className="text-sm font-medium text-text-primary">{t('settings.notifications', 'Notifications')}</h3>
<div className="space-y-2">
{NOTIFY_KEYS.map((key) => (
<div key={key} className="flex items-center justify-between py-1.5">
<span className="text-sm text-text-secondary">{t(`settings.notify_${key}`, key)}</span>
<button
type="button"
onClick={() => toggleNotify(key)}
className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${notify[key] ? 'bg-accent' : 'bg-surface-tertiary border border-border'}`}
>
<button type="button" onClick={() => toggleNotify(key)} className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${notify[key] ? 'bg-accent' : 'bg-surface-tertiary border border-border'}`}>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${notify[key] ? 'translate-x-4' : ''}`} />
</button>
</div>
))}
</div>
{/* Notification mode */}
<div className="space-y-2 pt-2 border-t border-border">
<span className="text-xs text-text-muted">{t('settings.notifyMode', 'Notification mode')}</span>
<div className="grid grid-cols-2 gap-2">
{(['direct', 'leader'] as const).map((mode) => (
<button
type="button"
key={mode}
onClick={() => 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'
}`}
>
<button type="button" key={mode} onClick={() => 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'}`}>
<div className="text-sm font-medium text-text-primary">{t(`settings.notifyMode_${mode}`, mode)}</div>
<div className="text-[11px] text-text-muted mt-0.5">{t(`settings.notifyMode_${mode}_desc`)}</div>
</button>
))}
</div>
{notifyMode === 'leader' && (
<p className="text-xs text-amber-500">{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}</p>
)}
{notifyMode === 'leader' && <p className="text-xs text-amber-500">{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}</p>}
</div>
</section>
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-border shrink-0 flex justify-end">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer flex items-center gap-2"
>
{saving && <IconSpinner size={14} className="border-white" />}
<button onClick={handleSubmit(onValid)} disabled={isSubmitting || !watch('name').trim()} className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer flex items-center gap-2">
{isSubmitting && <IconSpinner size={14} className="border-white" />}
{t('settings.save', 'Save')}
</button>
</div>
@@ -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 (
<div>
<dt className="text-xs text-text-muted mb-0.5">{label}</dt>
<dd className="text-sm font-medium text-text-primary">{children}</dd>
</div>
)
}
/** 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 (
<div className="rounded-lg border border-border">
<button
type="button"
onClick={() => 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}
<span>{title}</span>
<IconChevronDown className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
{open && (
<div className="border-t border-border px-4 py-3">
{children}
</div>
)}
</div>
)
}
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 (
<div className="space-y-1">
<div className="flex justify-between text-xs text-text-muted">
<span>{t('progress', 'Progress')}</span>
<span>{pct}%</span>
</div>
<div className="h-2 w-full rounded-full bg-surface-tertiary overflow-hidden">
<div className="h-2 rounded-full bg-accent transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
{task.progress_step && <p className="text-xs text-text-muted">{task.progress_step}</p>}
</div>
)
})()}
{/* Status badges row */}
<div className="flex items-center gap-2">
{task.identifier && (
<span className="text-xs font-mono text-text-muted bg-surface-tertiary px-2 py-0.5 rounded border border-border">
{task.identifier}
</span>
)}
<span className={`text-xs font-medium px-2 py-0.5 rounded capitalize ${statusCls}`}>
{task.status.replace(/_/g, ' ')}
</span>
{locked && (
<span className="text-xs font-medium px-2 py-0.5 rounded bg-green-500/15 text-green-600 dark:text-green-400 animate-pulse">
Running
</span>
)}
</div>
{/* Metadata grid */}
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 rounded-lg bg-surface-tertiary/30 p-4">
<MetaItem label={t('priority', 'Priority')}>
<span className="inline-flex items-center gap-1.5 capitalize">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${prio.cls}`}>{prio.label}</span>
</span>
</MetaItem>
<MetaItem label={t('owner', 'Owner')}>
{member ? (
<span className="flex items-center gap-1.5">
{member.emoji && <span className="text-base">{member.emoji}</span>}
{member.display_name || member.agent_key}
</span>
) : task.owner_agent_key || '—'}
</MetaItem>
{task.task_type && task.task_type !== 'general' && (
<MetaItem label={t('type', 'Type')}>
<span className="text-xs bg-surface-tertiary border border-border px-2 py-0.5 rounded">{task.task_type}</span>
</MetaItem>
)}
{task.created_at && (
<MetaItem label={t('created', 'Created')}>{new Date(task.created_at).toLocaleString()}</MetaItem>
)}
{task.updated_at && (
<MetaItem label={t('updated', 'Updated')}>{new Date(task.updated_at).toLocaleString()}</MetaItem>
)}
</dl>
{/* Blocked by */}
{task.blocked_by && task.blocked_by.length > 0 && (
<div>
<span className="text-xs text-text-muted">{t('blockedBy', 'Blocked by')}</span>
<div className="mt-1 flex flex-wrap gap-1.5">
{task.blocked_by.map((id) => (
<span key={id} className="text-xs font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded border border-amber-500/20">
{id.slice(0, 8)}
</span>
))}
</div>
</div>
)}
{/* Description */}
{task.description && (
<CollapsibleSection title={t('description', 'Description')} icon={<IconDocument />}>
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-60 overflow-y-auto">
<MarkdownRenderer content={task.description} />
</div>
</CollapsibleSection>
)}
{/* Result */}
{task.result && (
<CollapsibleSection title={t('result', 'Result')} icon={<IconCheckCircle />}>
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-[40vh] overflow-y-auto">
<MarkdownRenderer content={task.result} />
</div>
</CollapsibleSection>
)}
{/* Attachments */}
{attachments.length > 0 && (
<CollapsibleSection title={`${t('attachments', 'Attachments')} (${attachments.length})`} icon={<IconDocument />}>
<div className="space-y-2">
{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 (
<div key={a.id} className="flex items-center gap-3 rounded-lg border border-border bg-surface-tertiary/30 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent/10">
<IconDocument className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium text-text-primary">{fileName}</p>
{a.file_size > 0 && <p className="text-xs text-text-muted">{formatFileSize(a.file_size)}</p>}
</div>
{a.download_url && (
<a
href={fullUrl}
download
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-xs text-accent hover:text-accent/80 px-3 py-1.5 rounded-lg border border-accent/30 hover:bg-accent/10 transition-colors cursor-pointer"
>
{t('download', 'Download')}
</a>
)}
</div>
)
})}
</div>
</CollapsibleSection>
)}
</>
)
}
@@ -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<string, string> = {
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 (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">{t('members', 'Members')} ({members.length})</h3>
<button onClick={onToggleAdd} className="text-xs text-accent hover:text-accent/80 cursor-pointer flex items-center gap-1">
<IconPlus size={12} />{t('settings.addMember', 'Add member')}
</button>
</div>
{showAdd && (
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<Combobox value={addAgent} onChange={onAddAgentChange} options={availableAgents} placeholder={t('settings.searchAgent', 'Search agent...')} allowCustom={false} />
</div>
<button
onClick={onAddMember}
disabled={!addAgent || adding}
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
>
{adding ? '...' : t('settings.add', 'Add')}
</button>
</div>
)}
<div className="rounded-lg border border-border divide-y divide-border max-h-[200px] overflow-y-auto">
{sorted.map((m) => (
<div key={m.agent_id} className="group flex items-center gap-3 px-3 py-2.5 hover:bg-surface-tertiary/50">
{m.emoji ? <span className="text-base shrink-0">{m.emoji}</span> : <IconUser className="text-text-muted shrink-0" />}
<span className="text-sm text-text-primary truncate flex-1">{m.display_name || m.agent_key || m.agent_id.slice(0, 8)}</span>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${ROLE_COLORS[m.role] ?? ''}`}>{m.role}</span>
{m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
<button
onClick={() => 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 ? <IconSpinner size={14} className="border-text-muted" /> : <IconClose size={14} />}
</button>
)}
</div>
))}
{members.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-text-muted">{t('settings.noMembers', 'No members')}</div>
)}
</div>
</section>
)
}
@@ -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<string>
onSelectChange: (next: Set<string>) => void
onTaskClick: (task: TeamTaskData) => void
onBulkDelete: () => void
}
export function TeamTaskListView({
tasks, members, loading, selected, onSelectChange, onTaskClick, onBulkDelete,
}: TeamTaskListViewProps) {
const { t } = useTranslation('teams')
return (
<div className="flex-1 overflow-auto p-3">
{selected.size > 0 && (
<div className="flex items-center gap-2 mb-2 px-2">
<span className="text-xs text-text-muted">{selected.size} selected</span>
<button onClick={onBulkDelete} className="text-xs text-error hover:underline cursor-pointer">
{t('deleteSelected', 'Delete selected')}
</button>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs min-w-[600px]">
<thead>
<tr className="text-text-muted text-left border-b border-border">
<th className="pb-2 pr-2 w-8">
<input
type="checkbox"
checked={selected.size > 0 && selected.size === tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).length}
onChange={(e) => {
if (e.target.checked) {
onSelectChange(new Set(tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).map((t) => t.id)))
} else {
onSelectChange(new Set())
}
}}
/>
</th>
<th className="pb-2 pr-2 font-medium">{t('id', 'ID')}</th>
<th className="pb-2 pr-2 font-medium">{t('subject', 'Subject')}</th>
<th className="pb-2 pr-2 font-medium">{t('status', 'Status')}</th>
<th className="pb-2 pr-2 font-medium">{t('owner', 'Owner')}</th>
<th className="pb-2 font-medium">{t('priority', 'Priority')}</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
const canSelect = TERMINAL_STATUSES.has(task.status)
return (
<tr
key={task.id}
onClick={() => onTaskClick(task)}
className="border-b border-border/50 hover:bg-surface-tertiary/50 cursor-pointer transition-colors"
>
<td className="py-2 pr-2" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
disabled={!canSelect}
checked={selected.has(task.id)}
onChange={(e) => {
const next = new Set(selected)
e.target.checked ? next.add(task.id) : next.delete(task.id)
onSelectChange(next)
}}
/>
</td>
<td className="py-2 pr-2 font-mono text-text-muted">{task.identifier || task.task_number}</td>
<td className="py-2 pr-2 text-text-primary truncate max-w-[250px]">{task.subject}</td>
<td className="py-2 pr-2">
<span className="capitalize text-text-secondary">{task.status.replace('_', ' ')}</span>
</td>
<td className="py-2 pr-2 text-text-secondary">{member?.display_name || task.owner_agent_key || '—'}</td>
<td className="py-2 text-text-secondary">P-{task.priority}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{tasks.length === 0 && !loading && (
<div className="text-center py-8 text-text-muted text-xs">{t('noTasks', 'No tasks yet')}</div>
)}
</div>
)
}
@@ -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<string, unknown>
}
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<ProviderEntry>) => 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 (
<div ref={setNodeRef} style={style} className="rounded-lg border border-border p-3 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Drag handle */}
<button
type="button"
className="cursor-grab text-text-muted hover:text-text-primary shrink-0 touch-none"
{...attributes}
{...listeners}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
</svg>
</button>
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{index + 1}</span>
<span className="text-sm font-medium text-text-primary">{entry.provider || t('builtin.mediaChain.newProvider')}</span>
{entry.model && <span className="text-xs text-text-muted">/ {entry.model}</span>}
</div>
<div className="flex items-center gap-2">
<Switch checked={entry.enabled} onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })} />
<button onClick={() => onRemove(entry.id)} className="p-1 text-text-muted hover:text-error transition-colors">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
{/* Provider + Model */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.provider')}</label>
<Combobox
value={entry.provider}
onChange={(v) => onProviderChange(entry.id, v)}
options={providerOptions}
placeholder={t('builtin.mediaChain.selectProvider')}
allowCustom
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.model')}</label>
<Combobox
value={entry.model}
onChange={(v) => onUpdate(entry.id, { model: v })}
options={modelOptions}
placeholder={t('builtin.mediaChain.selectModel')}
loading={modelLoading}
allowCustom
/>
</div>
</div>
{/* Timeout + Retries */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.timeout')}</label>
<input
type="number"
min={1}
max={600}
value={entry.timeout}
onChange={(e) => onUpdate(entry.id, { timeout: Math.max(1, Number(e.target.value)) })}
className="w-full 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
<input
type="number"
min={0}
max={10}
value={entry.max_retries}
onChange={(e) => onUpdate(entry.id, { max_retries: Math.max(0, Number(e.target.value)) })}
className="w-full 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"
/>
</div>
</div>
</div>
)
}
/* ─── Main Form ─── */
export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderChainFormProps) {
const { t } = useTranslation(['tools', 'common'])
const [chain, setChain] = useState<ProviderEntry[]>([])
@@ -169,11 +38,8 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
const [dbProviders, setDbProviders] = useState<{ id: string; name: string }[]>([])
const [modelsByProvider, setModelsByProvider] = useState<Record<string, ModelInfo[]>>({})
// Track which providers we've already started loading models for
const loadingRef = useRef<Set<string>>(new Set())
// Parse initial chain entries with stable IDs
useEffect(() => {
const settings = tool.settings as { providers?: Omit<ProviderEntry, 'id'>[] }
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 (
@@ -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',
@@ -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<string, unknown>) => Promise<void>
}
export function ToolSettingsDialog({ open, onOpenChange, tool, onSave }: ToolSettingsDialogProps) {
const { t } = useTranslation(['tools', 'common'])
if (!open) return null
const handleClose = () => onOpenChange(false)
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={handleClose} />
<div className="relative w-full max-w-2xl mx-4 bg-surface-secondary rounded-xl border border-border overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('builtin.settingsDialog.title', { name: tool.display_name })}</h3>
<p className="font-mono text-[11px] text-text-muted mt-0.5">{tool.name}</p>
</div>
<button onClick={handleClose} className="p-1 text-text-muted hover:text-text-primary transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
{/* Route to specialized form */}
{tool.name === 'web_fetch' ? (
<ExtractorChainForm tool={tool} onSave={onSave} onClose={handleClose} />
) : MEDIA_TOOLS.has(tool.name) ? (
<MediaProviderChainForm tool={tool} onSave={onSave} onClose={handleClose} />
) : (
<JsonSettingsForm tool={tool} onSave={onSave} onClose={handleClose} />
)}
</div>
</div>
)
}
@@ -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<string, string> = {
defuddle: 'Defuddle',
'html-to-markdown': 'HTML to Markdown',
}
interface ExtractorChainFormProps {
tool: BuiltinToolData
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>
onClose: () => void
}
export function ExtractorChainForm({ tool, onSave, onClose }: ExtractorChainFormProps) {
const { t } = useTranslation(['tools', 'common'])
const [extractors, setExtractors] = useState<ExtractorEntry[]>([])
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<ExtractorEntry>) {
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 (
<>
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-3">
{extractors.map((ext, i) => (
<div key={ext.name} className="rounded-lg border border-border p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{i + 1}</span>
<span className="text-sm font-medium text-text-primary">{EXTRACTOR_DISPLAY[ext.name] ?? ext.name}</span>
</div>
<Switch checked={ext.enabled} onCheckedChange={(v) => updateExtractor(i, { enabled: v })} />
</div>
{ext.name === 'defuddle' && (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.baseUrl')}</label>
<input
value={ext.base_url ?? ''}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
<input
type="number" min={0} max={10}
value={ext.max_retries ?? 2}
onChange={(e) => 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"
/>
</div>
</>
)}
{(ext.name === 'defuddle' || (ext.timeout && ext.timeout > 0)) && (
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.timeout')}</label>
<input
type="number" min={0} max={600}
value={ext.timeout ?? 0}
onChange={(e) => 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"
/>
<p className="text-[10px] text-text-muted">0 = {t('common:default')}</p>
</div>
)}
</div>
))}
{extractors.length === 0 && (
<p className="text-xs text-text-muted text-center py-4">{t('builtin.mediaChain.noProviders')}</p>
)}
</div>
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('builtin.settingsDialog.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{saving ? t('builtin.settingsDialog.saving') : t('builtin.settingsDialog.save')}
</button>
</div>
</>
)
}
@@ -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<string, unknown>) => Promise<void>
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<string, unknown> | 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<string, unknown>
} 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 (
<>
<div className="p-5">
<textarea
value={value}
onChange={(e) => { setValue(e.target.value); setError('') }}
spellCheck={false}
className="w-full h-64 bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y"
/>
<div className="flex items-center justify-between mt-1 min-h-[20px]">
{error ? <span className="text-xs text-error">{error}</span> : <span />}
<button type="button" onClick={handleFormat} className="text-[11px] text-accent hover:text-accent-hover transition-colors">
{t('builtin.jsonDialog.formatJson')}
</button>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('builtin.jsonDialog.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={!!error || saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{saving ? t('builtin.jsonDialog.saving') : t('builtin.jsonDialog.save')}
</button>
</div>
</>
)
}
@@ -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<string, unknown>
}
interface SortableProviderCardProps {
entry: ProviderEntry
index: number
providerOptions: { value: string; label: string }[]
modelOptions: { value: string; label: string }[]
modelLoading: boolean
onUpdate: (id: string, updates: Partial<ProviderEntry>) => 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 (
<div ref={setNodeRef} style={style} className="rounded-lg border border-border p-3 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button type="button" className="cursor-grab text-text-muted hover:text-text-primary shrink-0 touch-none" {...attributes} {...listeners}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
</svg>
</button>
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{index + 1}</span>
<span className="text-sm font-medium text-text-primary">{entry.provider || t('builtin.mediaChain.newProvider')}</span>
{entry.model && <span className="text-xs text-text-muted">/ {entry.model}</span>}
</div>
<div className="flex items-center gap-2">
<Switch checked={entry.enabled} onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })} />
<button onClick={() => onRemove(entry.id)} className="p-1 text-text-muted hover:text-error transition-colors">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
{/* Provider + Model */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.provider')}</label>
<Combobox value={entry.provider} onChange={(v) => onProviderChange(entry.id, v)} options={providerOptions} placeholder={t('builtin.mediaChain.selectProvider')} allowCustom />
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.model')}</label>
<Combobox value={entry.model} onChange={(v) => onUpdate(entry.id, { model: v })} options={modelOptions} placeholder={t('builtin.mediaChain.selectModel')} loading={modelLoading} allowCustom />
</div>
</div>
{/* Timeout + Retries */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.timeout')}</label>
<input type="number" min={1} max={600} value={entry.timeout}
onChange={(e) => onUpdate(entry.id, { timeout: Math.max(1, Number(e.target.value)) })}
className="w-full 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
<input type="number" min={0} max={10} value={entry.max_retries}
onChange={(e) => onUpdate(entry.id, { max_retries: Math.max(0, Number(e.target.value)) })}
className="w-full 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"
/>
</div>
</div>
</div>
)
}
@@ -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 (
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
<MarkdownRenderer content={'```json\n' + formatted + '\n```'} />
</div>
)
} catch { /* not valid JSON, fall through */ }
}
// Render as markdown (handles code blocks, headings, lists, etc.)
return (
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
<MarkdownRenderer content={text} />
</div>
)
}
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<string, SpanNode>()
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 (
<div className="border-b border-border last:border-0">
{/* Row */}
<button
type="button"
onClick={hasExpandContent ? onToggle : undefined}
className={`w-full flex items-center gap-1.5 px-3 py-2 text-left transition-colors ${hasExpandContent ? 'hover:bg-surface-tertiary/30 cursor-pointer' : ''}`}
style={{ paddingLeft: `${12 + depth * 16}px` }}
>
{/* Toggle arrow — only visible if expandable */}
<svg
className={`h-3 w-3 shrink-0 text-text-muted transition-transform ${expanded ? 'rotate-90' : ''} ${!hasExpandContent ? 'invisible' : ''}`}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}
>
<path d="M9 18l6-6-6-6" />
</svg>
<span className="text-sm shrink-0">{spanTypeIcon(span.span_type)}</span>
<span className="text-xs font-medium text-text-primary truncate min-w-0 flex-1">{span.name}</span>
{subtitle && <span className="text-[10px] text-text-muted truncate max-w-[120px] shrink-0">{subtitle}</span>}
<div className="flex items-center gap-2 shrink-0 text-[11px] text-text-muted ml-auto">
{hasTokens && (
<span className="font-mono">
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
{cacheRead > 0 && <span className="ml-1 text-emerald-600 dark:text-emerald-400">({formatTokens(cacheRead)} cached)</span>}
{thinkingTokens > 0 && <span className="ml-1 text-orange-600 dark:text-orange-400">({formatTokens(thinkingTokens)} thinking)</span>}
</span>
)}
<span>{formatDuration(span.duration_ms, span.start_time, span.end_time)}</span>
<span className={`rounded-full px-1.5 py-0.5 border text-[10px] font-medium ${statusClass(span.status)}`}>
{span.status}
</span>
</div>
</button>
{/* Expanded details — only input/output/error, no duplicate metadata */}
{expanded && hasExpandContent && (
<div className="px-4 pb-3 space-y-2" style={{ paddingLeft: `${28 + depth * 16}px` }}>
{/* Cache breakdown (only shows extra info not in collapsed row) */}
{cacheCreate > 0 && (
<div className="text-[11px]">
<span className="text-yellow-600 dark:text-yellow-400">+{formatTokens(cacheCreate)} cache write</span>
</div>
)}
{span.error && <p className="text-[11px] text-red-600 dark:text-red-400">{span.error}</p>}
{span.input_preview && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.input')}</p>
<TraceContentPreview text={span.input_preview} />
</div>
)}
{span.output_preview && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.output')}</p>
<TraceContentPreview text={span.output_preview} />
</div>
)}
</div>
)}
</div>
)
}
export function TraceDetailDialog({ traceId, onClose }: Props) {
const { t } = useTranslation('traces')
const [trace, setTrace] = useState<TraceData | null>(null)
@@ -263,7 +91,6 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
<div className="flex items-center gap-1 shrink-0">
{trace && (
<>
{/* Copy trace ID */}
<button
onClick={handleCopy}
className={`p-1.5 transition-colors cursor-pointer ${copied ? 'text-emerald-500' : 'text-text-muted hover:text-text-primary'}`}
@@ -279,7 +106,6 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
</svg>
)}
</button>
{/* Export trace */}
<button
onClick={handleExport}
className="p-1.5 text-text-muted hover:text-text-primary transition-colors cursor-pointer"
@@ -351,7 +177,11 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
</svg>
{t('detail.input')}
</button>
{inputOpen && <div className="mt-1.5"><TraceContentPreview text={trace.input_preview} /></div>}
{inputOpen && (
<div className="mt-1.5 max-h-[40vh] overflow-y-auto overflow-x-hidden">
<pre className="text-xs text-text-primary whitespace-pre-wrap">{trace.input_preview}</pre>
</div>
)}
</div>
)}
{trace.output_preview && (
@@ -365,7 +195,11 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
</svg>
{t('detail.output')}
</button>
{outputOpen && <div className="mt-1.5"><TraceContentPreview text={trace.output_preview} /></div>}
{outputOpen && (
<div className="mt-1.5 max-h-[40vh] overflow-y-auto overflow-x-hidden">
<pre className="text-xs text-text-primary whitespace-pre-wrap">{trace.output_preview}</pre>
</div>
)}
</div>
)}
</div>
@@ -1,116 +1,11 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTraces } from '../../../hooks/use-traces'
import { useAgentCrud } from '../../../hooks/use-agent-crud'
import { RefreshButton } from '../../common/RefreshButton'
import { Combobox } from '../../common/Combobox'
import { useTraces } from '../../hooks/use-traces'
import { useAgentCrud } from '../../hooks/use-agent-crud'
import { RefreshButton } from '../common/RefreshButton'
import { Combobox } from '../common/Combobox'
import { TraceDetailDialog } from './TraceDetailDialog'
import type { TraceData } from '../../../types/trace'
function formatDuration(ms: number | undefined | null, startTime?: string, endTime?: string): string {
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 formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
if (diff < 60000) return 'just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return `${Math.floor(diff / 86400000)}d ago`
}
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 dark:bg-emerald-500/10 dark:border-emerald-500/20'
}
if (s === 'error' || s === 'failed') {
return 'bg-red-500/15 text-red-700 border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
}
return 'bg-blue-500/15 text-blue-700 border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20'
}
function TraceRow({ trace, onClick }: { trace: TraceData; onClick: () => void }) {
const { t } = useTranslation('traces')
const cacheRead = trace.metadata?.total_cache_read_tokens as number | undefined
const nameDisplay = trace.name.length > 30 ? trace.name.slice(0, 30) + '…' : trace.name
return (
<tr
onClick={onClick}
className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors cursor-pointer [&>td]:align-middle"
>
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm text-text-primary truncate">{nameDisplay || t('unnamed')}</span>
{trace.parent_trace_id && (
<span title="Delegated" className="text-text-muted text-xs shrink-0">🔀</span>
)}
{trace.channel && (
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
{trace.channel}
</span>
)}
</div>
</td>
{/* Status */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium border ${statusClass(trace.status)}`}>
{trace.status}
</span>
</td>
{/* Duration */}
<td className="px-4 py-3 text-xs text-text-muted">
{formatDuration(trace.duration_ms, trace.start_time, trace.end_time)}
</td>
{/* Tokens */}
<td className="px-4 py-3">
<div className="font-mono text-xs text-text-primary">
{formatTokens(trace.total_input_tokens)} / {formatTokens(trace.total_output_tokens)}
</div>
{(cacheRead ?? 0) > 0 && (
<div className="text-[11px] text-emerald-600 dark:text-emerald-400">
{formatTokens(cacheRead)} {t('cached')}
</div>
)}
</td>
{/* Spans */}
<td className="px-4 py-3 text-xs text-text-muted text-center">
{trace.span_count}
</td>
{/* Time */}
<td className="px-4 py-3 text-xs text-text-muted">
{formatRelativeTime(trace.created_at)}
</td>
</tr>
)
}
import { TraceListRow } from './trace-list-row'
export function TraceList() {
const { t } = useTranslation('traces')
@@ -178,7 +73,7 @@ export function TraceList() {
</thead>
<tbody>
{traces.map((trace) => (
<TraceRow
<TraceListRow
key={trace.id}
trace={trace}
onClick={() => setSelectedTraceId(trace.id)}
@@ -188,7 +83,6 @@ export function TraceList() {
</table>
</div>
{/* Load more */}
{!allLoaded && (
<div className="flex justify-center pt-1">
<button
@@ -212,7 +106,6 @@ export function TraceList() {
</>
)}
{/* Detail dialog */}
{selectedTraceId && (
<TraceDetailDialog
traceId={selectedTraceId}
@@ -0,0 +1,47 @@
// Pure formatting utilities for trace detail display.
export 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`
}
export 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()
}
export 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'
}
export function spanTypeIcon(spanType: string): string {
switch (spanType) {
case 'llm_call': return '🤖'
case 'tool_call': return '🔧'
case 'agent': return '👤'
case 'embedding': return '📊'
default: return '📌'
}
}
@@ -0,0 +1,80 @@
// Trace table row with status badge, duration, token counts, and relative time.
import { useTranslation } from 'react-i18next'
import type { TraceData } from '../../types/trace'
import { formatDuration, formatTokens, statusClass } from './trace-detail-formatters'
export function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
if (diff < 60000) return 'just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return `${Math.floor(diff / 86400000)}d ago`
}
interface TraceListRowProps {
trace: TraceData
onClick: () => void
}
export function TraceListRow({ trace, onClick }: TraceListRowProps) {
const { t } = useTranslation('traces')
const cacheRead = trace.metadata?.total_cache_read_tokens as number | undefined
const nameDisplay = trace.name.length > 30 ? trace.name.slice(0, 30) + '…' : trace.name
return (
<tr
onClick={onClick}
className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors cursor-pointer [&>td]:align-middle"
>
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm text-text-primary truncate">{nameDisplay || t('unnamed')}</span>
{trace.parent_trace_id && (
<span title="Delegated" className="text-text-muted text-xs shrink-0">🔀</span>
)}
{trace.channel && (
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
{trace.channel}
</span>
)}
</div>
</td>
{/* Status */}
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium border ${statusClass(trace.status)}`}>
{trace.status}
</span>
</td>
{/* Duration */}
<td className="px-4 py-3 text-xs text-text-muted">
{formatDuration(trace.duration_ms, trace.start_time, trace.end_time)}
</td>
{/* Tokens */}
<td className="px-4 py-3">
<div className="font-mono text-xs text-text-primary">
{formatTokens(trace.total_input_tokens)} / {formatTokens(trace.total_output_tokens)}
</div>
{(cacheRead ?? 0) > 0 && (
<div className="text-[11px] text-emerald-600 dark:text-emerald-400">
{formatTokens(cacheRead)} {t('cached')}
</div>
)}
</td>
{/* Spans */}
<td className="px-4 py-3 text-xs text-text-muted text-center">
{trace.span_count}
</td>
{/* Time */}
<td className="px-4 py-3 text-xs text-text-muted">
{formatRelativeTime(trace.created_at)}
</td>
</tr>
)
}
@@ -0,0 +1,126 @@
// Span tree rendering — builds and displays hierarchical trace spans.
import { useTranslation } from 'react-i18next'
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
import type { SpanData } from '../../types/trace'
import { formatDuration, formatTokens, statusClass, spanTypeIcon } from './trace-detail-formatters'
// Build span tree from flat list using parent_span_id
export interface SpanNode { span: SpanData; children: SpanNode[]; depth: number }
export function buildSpanTree(spans: SpanData[]): SpanNode[] {
const byId = new Map<string, SpanNode>()
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 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 (
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
<MarkdownRenderer content={'```json\n' + formatted + '\n```'} />
</div>
)
} catch { /* not valid JSON, fall through */ }
}
return (
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
<MarkdownRenderer content={text} />
</div>
)
}
export 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
const hasExpandContent = !!span.input_preview || !!span.output_preview || !!span.error || cacheCreate > 0
return (
<div className="border-b border-border last:border-0">
<button
type="button"
onClick={hasExpandContent ? onToggle : undefined}
className={`w-full flex items-center gap-1.5 px-3 py-2 text-left transition-colors ${hasExpandContent ? 'hover:bg-surface-tertiary/30 cursor-pointer' : ''}`}
style={{ paddingLeft: `${12 + depth * 16}px` }}
>
<svg
className={`h-3 w-3 shrink-0 text-text-muted transition-transform ${expanded ? 'rotate-90' : ''} ${!hasExpandContent ? 'invisible' : ''}`}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}
>
<path d="M9 18l6-6-6-6" />
</svg>
<span className="text-sm shrink-0">{spanTypeIcon(span.span_type)}</span>
<span className="text-xs font-medium text-text-primary truncate min-w-0 flex-1">{span.name}</span>
{subtitle && <span className="text-[10px] text-text-muted truncate max-w-[120px] shrink-0">{subtitle}</span>}
<div className="flex items-center gap-2 shrink-0 text-[11px] text-text-muted ml-auto">
{hasTokens && (
<span className="font-mono">
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
{cacheRead > 0 && <span className="ml-1 text-emerald-600 dark:text-emerald-400">({formatTokens(cacheRead)} cached)</span>}
{thinkingTokens > 0 && <span className="ml-1 text-orange-600 dark:text-orange-400">({formatTokens(thinkingTokens)} thinking)</span>}
</span>
)}
<span>{formatDuration(span.duration_ms, span.start_time, span.end_time)}</span>
<span className={`rounded-full px-1.5 py-0.5 border text-[10px] font-medium ${statusClass(span.status)}`}>
{span.status}
</span>
</div>
</button>
{expanded && hasExpandContent && (
<div className="px-4 pb-3 space-y-2" style={{ paddingLeft: `${28 + depth * 16}px` }}>
{cacheCreate > 0 && (
<div className="text-[11px]">
<span className="text-yellow-600 dark:text-yellow-400">+{formatTokens(cacheCreate)} cache write</span>
</div>
)}
{span.error && <p className="text-[11px] text-red-600 dark:text-red-400">{span.error}</p>}
{span.input_preview && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.input')}</p>
<TraceContentPreview text={span.input_preview} />
</div>
)}
{span.output_preview && (
<div>
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.output')}</p>
<TraceContentPreview text={span.output_preview} />
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,23 @@
import { getApiClient, isApiClientReady } from '../lib/api'
/** Resolve relative path to absolute URL using API base */
function resolveBase(path: string): string {
if (path.startsWith('http')) return path
if (isApiClientReady()) return getApiClient().getBaseUrl() + path
return path
}
/**
* Convert any file path to /v1/files/ URL for serving.
* Handles absolute Unix paths, Windows paths, and relative filenames.
*/
export function toFileUrl(path: string): string {
if (!path) return ''
if (path.includes('/v1/files/')) return resolveBase(path)
// Normalize backslashes (Windows paths: C:\Users\... → C:/Users/...)
const normalized = path.replace(/\\/g, '/')
// Absolute: Unix /... or Windows C:/...
if (normalized.startsWith('/')) return resolveBase(`/v1/files${normalized}`)
if (/^[a-zA-Z]:\//.test(normalized)) return resolveBase(`/v1/files/${normalized}`)
return resolveBase(`/v1/files/${normalized.split('/').pop() ?? normalized}`)
}
@@ -0,0 +1,73 @@
import { toFileUrl } from './chat-file-helpers'
import type { ChatMessage, ToolCall } from '../stores/chat-store'
/** Raw message shape from WS chat.history response */
export interface RawHistoryMessage {
id?: string
role: string
content?: string
thinking?: string
timestamp?: number
tool_call_id?: string
is_error?: boolean
tool_calls?: Array<{
id: string
function?: { name: string; arguments?: Record<string, unknown> }
name?: string
input?: Record<string, unknown>
}>
media_refs?: Array<{
id?: string
mime_type?: string
content_type?: string
kind?: string
path?: string
url?: string
}>
}
/**
* Transform raw WS history messages into ChatMessage[] for the store.
* Builds tool result map, filters system messages, resolves media URLs.
*/
export function mapHistoryMessages(raw: RawHistoryMessage[]): ChatMessage[] {
// Build tool result map for enriching assistant tool_calls
const toolResultMap = new Map<string, { content: string; isError: boolean }>()
for (const m of raw) {
if (m.role === 'tool' && m.tool_call_id) {
toolResultMap.set(m.tool_call_id, {
content: m.content ?? '',
isError: !!m.is_error,
})
}
}
// Filter: only user + assistant messages, exclude internal system nudges
const filtered = raw.filter((m) =>
(m.role === 'user' || m.role === 'assistant') &&
!(m.role === 'user' && m.content?.startsWith('[System]'))
)
return filtered.map((m) => ({
id: m.id ?? crypto.randomUUID(),
role: m.role as 'user' | 'assistant',
content: m.content ?? '',
timestamp: m.timestamp ?? Date.now(),
thinkingText: m.thinking,
toolCalls: m.tool_calls?.map((tc): ToolCall => {
const toolResult = toolResultMap.get(tc.id)
return {
toolId: tc.id,
toolName: tc.function?.name ?? tc.name ?? 'unknown',
arguments: tc.function?.arguments ?? tc.input ?? {},
state: (toolResult?.isError ? 'error' : 'completed') as 'error' | 'completed',
result: toolResult && !toolResult.isError ? toolResult.content : undefined,
error: toolResult?.isError ? toolResult.content : undefined,
}
}),
media: m.media_refs?.map((ref) => ({
type: ref.mime_type ?? ref.content_type ?? 'image',
url: toFileUrl(ref.path ?? ref.id ?? ref.url ?? ''),
})),
}))
}
@@ -0,0 +1,76 @@
import { useRef, useCallback } from 'react'
import type { TeamTaskData } from '../types/team'
/** Event payload shape from team.task.* WS events */
export interface TaskEventPayload {
team_id: string
task_id: string
status?: string
progress_percent?: number
progress_step?: string
}
interface UseDebouncerOptions {
activeTeamRef: React.MutableRefObject<string | null>
fetchOneTask: (taskId: string) => Promise<void>
setTasks: React.Dispatch<React.SetStateAction<TeamTaskData[]>>
}
export function useTaskDebouncer({ activeTeamRef, fetchOneTask, setTasks }: UseDebouncerOptions) {
const fetchTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const progressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const pendingProgressRef = useRef(new Map<string, { percent: number; step: string }>())
const clearAll = useCallback(() => {
fetchTimersRef.current.forEach((t) => clearTimeout(t))
clearTimeout(progressTimerRef.current)
}, [])
/** Debounced fetch-one (300ms per task) */
const debouncedFetchTask = useCallback((taskId: string) => {
const timers = fetchTimersRef.current
const existing = timers.get(taskId)
if (existing) clearTimeout(existing)
pendingProgressRef.current.delete(taskId)
timers.set(taskId, setTimeout(() => {
timers.delete(taskId)
fetchOneTask(taskId)
}, 300))
}, [fetchOneTask])
/** Progress event handler — batches updates with 1s debounce */
const handleProgress = useCallback((payload: unknown) => {
const p = payload as TaskEventPayload
if (!p.task_id || p.team_id !== activeTeamRef.current) return
pendingProgressRef.current.set(p.task_id, {
percent: p.progress_percent ?? 0,
step: p.progress_step ?? '',
})
clearTimeout(progressTimerRef.current)
progressTimerRef.current = setTimeout(() => {
const patches = new Map(pendingProgressRef.current)
pendingProgressRef.current.clear()
setTasks((prev) => prev.map((t) => {
const patch = patches.get(t.id)
if (!patch) return t
return { ...t, progress_percent: patch.percent, progress_step: patch.step }
}))
}, 1000)
}, [activeTeamRef, setTasks])
/** Deletion handler — immediate remove */
const handleDeleted = useCallback((payload: unknown) => {
const p = payload as TaskEventPayload
if (!p.task_id || p.team_id !== activeTeamRef.current) return
setTasks((prev) => prev.filter((t) => t.id !== p.task_id))
}, [activeTeamRef, setTasks])
/** Status-change handler — debounced fetch-one */
const handleFetchOne = useCallback((payload: unknown) => {
const p = payload as TaskEventPayload
if (!p.task_id || p.team_id !== activeTeamRef.current) return
debouncedFetchTask(p.task_id)
}, [activeTeamRef, debouncedFetchTask])
return { clearAll, debouncedFetchTask, handleProgress, handleDeleted, handleFetchOne }
}
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { getApiClient } from '../lib/api'
import { agentService } from '../services/agent-service'
import { toast } from '../stores/toast-store'
import type { AgentData, AgentInput } from '../types/agent'
@@ -11,7 +11,7 @@ export function useAgentCrud() {
const fetchAgents = useCallback(async () => {
try {
const res = await getApiClient().get<{ agents: AgentData[] | null }>('/v1/agents')
const res = await agentService.list()
setAgents(res.agents ?? [])
} catch (err) {
console.error('Failed to fetch agents:', err)
@@ -24,7 +24,7 @@ export function useAgentCrud() {
const createAgent = useCallback(async (input: AgentInput) => {
try {
const res = await getApiClient().post<AgentData>('/v1/agents', input)
const res = await agentService.create(input)
setAgents((prev) => [...prev, res])
toast.success('Agent created')
return res
@@ -36,7 +36,7 @@ export function useAgentCrud() {
const updateAgent = useCallback(async (id: string, input: Partial<AgentData>) => {
try {
const res = await getApiClient().put<AgentData>(`/v1/agents/${id}`, input)
const res = await agentService.update(id, input)
setAgents((prev) => prev.map((a) => a.id === id ? res : a))
toast.success('Agent updated')
return res
@@ -48,7 +48,7 @@ export function useAgentCrud() {
const deleteAgent = useCallback(async (id: string) => {
try {
await getApiClient().delete(`/v1/agents/${id}`)
await agentService.delete(id)
setAgents((prev) => prev.filter((a) => a.id !== id))
toast.success('Agent deleted')
} catch (err) {
@@ -58,7 +58,7 @@ export function useAgentCrud() {
}, [])
const resummonAgent = useCallback(async (id: string) => {
await getApiClient().post(`/v1/agents/${id}/resummon`, {})
await agentService.resummon(id)
// Update status to summoning
setAgents((prev) => prev.map((a) => a.id === id ? { ...a, status: 'summoning' } : a))
}, [])
+3 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useCallback } from 'react'
import { getApiClient } from '../lib/api'
import { agentService } from '../services/agent-service'
import { useAgentStore } from '../stores/agent-store'
import type { Agent } from '../stores/agent-store'
@@ -9,19 +9,10 @@ let didFetchAgents = false
export function useAgents() {
const { agents, selectedAgentId, setAgents, selectAgent } = useAgentStore()
const api = getApiClient()
const fetchAgents = useCallback(async () => {
if (!api) return
try {
const result = await api.get<{ agents: Array<{
id: string
agent_key: string
display_name?: string
model?: string
provider?: string
other_config?: Record<string, unknown> | null
}> }>('/v1/agents')
const result = await agentService.listItems()
const mapped: Agent[] = (result.agents ?? []).map((a) => {
const otherCfg = a.other_config ?? {}
@@ -41,7 +32,7 @@ export function useAgents() {
console.error('Failed to fetch agents:', err)
return []
}
}, [api, setAgents])
}, [setAgents])
// Fetch once globally, auto-select first agent only if none selected
useEffect(() => {
+51 -278
View File
@@ -1,84 +1,30 @@
import { useEffect, useRef, useCallback } from 'react'
import { getWsClient } from '../lib/ws'
import { getApiClient, isApiClientReady } from '../lib/api'
import { useChatStore } from '../stores/chat-store'
import { chatService } from '../services/chat-service'
import { useChatMessageStore } from '../stores/chat-message-store'
import { useChatActivityStore } from '../stores/chat-activity-store'
import { useSessionStore } from '../stores/session-store'
import { toFileUrl } from './chat-file-helpers'
import { useStreamBatcher } from './use-stream-batcher'
import { mapHistoryMessages } from './chat-history-mapper'
import type { AttachedFile } from '../components/chat/InputBar'
// Convert any file path to /v1/files/ URL for serving.
// Absolute paths (from media_refs.path) use the full path so the backend
// can resolve the file directly without findInWorkspace fallback.
function toFileUrl(path: string): string {
if (!path) return ''
if (path.includes('/v1/files/')) return resolveBase(path)
// Normalize backslashes (Windows paths: C:\Users\... → C:/Users/...)
const normalized = path.replace(/\\/g, '/')
// Absolute: Unix /... or Windows C:/...
if (normalized.startsWith('/')) return resolveBase(`/v1/files${normalized}`)
if (/^[a-zA-Z]:\//.test(normalized)) return resolveBase(`/v1/files/${normalized}`)
return resolveBase(`/v1/files/${normalized.split('/').pop() ?? normalized}`)
}
function resolveBase(path: string): string {
if (path.startsWith('http')) return path
if (isApiClientReady()) return getApiClient().getBaseUrl() + path
return path
}
// RAF batching — prevents 100+ setState calls/sec during streaming
function useStreamBatcher(onFlush: (text: string) => void) {
const bufferRef = useRef('')
const rafRef = useRef(0)
const append = useCallback(
(text: string) => {
bufferRef.current += text
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
onFlush(bufferRef.current)
bufferRef.current = ''
rafRef.current = 0
})
}
},
[onFlush],
)
const flush = useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = 0
}
if (bufferRef.current) {
onFlush(bufferRef.current)
bufferRef.current = ''
}
}, [onFlush])
return { append, flush }
}
export function useChat() {
const ws = getWsClient()
const {
messages,
isRunning,
activity,
addUserMessage,
startRun,
appendChunk,
appendThinking,
addToolCall,
updateToolResult,
setActivity,
completeRun,
failRun,
setMessages,
} = useChatStore()
addUserMessage, addAssistantMessage, appendChunk, appendThinking,
addToolCall, updateToolResult, finalizeMessage, appendErrorToLastMessage, setMessages,
} = useChatMessageStore()
const {
isRunning, activity,
startRun, setActivity, completeRun, failRun,
} = useChatActivityStore()
const activeSessionKey = useSessionStore((s) => s.activeSessionKey)
const sessionKeyRef = useRef(activeSessionKey)
sessionKeyRef.current = activeSessionKey // always up-to-date, no stale closure
sessionKeyRef.current = activeSessionKey
const currentRunIdRef = useRef<string | null>(null)
const chunkBatcher = useStreamBatcher(
@@ -88,177 +34,102 @@ export function useChat() {
useCallback((text: string) => appendThinking(text), [appendThinking]),
)
// Subscribe to agent events from WS
useEffect(() => {
if (!ws) return
const unsub = ws.on('agent', (raw: unknown) => {
const event = raw as {
type: string
runId: string
sessionKey: string
payload: Record<string, unknown>
}
// Use ref to avoid stale closure — activeSessionKey may change during auto-session-creation
const event = raw as { type: string; runId: string; sessionKey: string; payload: Record<string, unknown> }
if (sessionKeyRef.current && event.sessionKey !== sessionKeyRef.current) return
const p = event.payload ?? {}
switch (event.type) {
case 'run.started':
currentRunIdRef.current = event.runId
addAssistantMessage(event.runId)
startRun(event.runId)
break
// Backend sends: { content: "..." }
case 'chunk':
chunkBatcher.append((p.content as string) ?? '')
break
// Backend sends: { content: "thinking text..." }
case 'thinking':
thinkingBatcher.append((p.content as string) ?? '')
break
// Backend sends: { name, id, arguments }
case 'tool.call':
chunkBatcher.flush()
addToolCall({
toolId: (p.id as string) ?? '',
toolName: (p.name as string) ?? 'unknown',
arguments: (p.arguments as Record<string, unknown>) ?? {},
})
addToolCall({ toolId: (p.id as string) ?? '', toolName: (p.name as string) ?? 'unknown', arguments: (p.arguments as Record<string, unknown>) ?? {} })
break
// Backend sends: { name, id, is_error, result, content (when error), arguments }
case 'tool.result': {
const isError = p.is_error as boolean
const toolId = (p.id as string) ?? ''
updateToolResult(
toolId,
isError ? '' : ((p.result as string) ?? ''),
isError ? ((p.content as string) ?? (p.result as string) ?? 'Error') : undefined,
)
updateToolResult((p.id as string) ?? '', isError ? '' : ((p.result as string) ?? ''), isError ? ((p.content as string) ?? (p.result as string) ?? 'Error') : undefined)
break
}
// Backend sends: { content: "intermediate reply text" }
case 'block.reply':
// Intermediate assistant content during tool iterations — append as chunk
chunkBatcher.flush()
appendChunk((p.content as string) ?? '')
break
// Backend sends: { phase, tool, tools[], iteration }
case 'activity':
setActivity({
phase: (p.phase as string) ?? 'thinking',
tool: p.tool as string | undefined,
iteration: p.iteration as number | undefined,
})
setActivity({ phase: (p.phase as string) ?? 'thinking', tool: p.tool as string | undefined, iteration: p.iteration as number | undefined })
break
// Backend sends: { content, usage: { prompt_tokens, completion_tokens, total_tokens, ... }, media }
case 'run.completed': {
chunkBatcher.flush()
thinkingBatcher.flush()
const usage = p.usage as Record<string, number> | undefined
completeRun(
finalizeMessage(
(p.content as string) ?? '',
usage ? {
inputTokens: usage.prompt_tokens ?? 0,
outputTokens: usage.completion_tokens ?? 0,
} : undefined,
usage ? { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0 } : undefined,
(p.media as { path?: string; content_type?: string; url?: string; type?: string }[] | undefined)
?.map((m) => ({ type: m.content_type ?? m.type ?? 'file', url: toFileUrl(m.path ?? m.url ?? '') })),
)
completeRun()
currentRunIdRef.current = null
break
}
// Backend sends: { error: "..." }
case 'run.failed':
chunkBatcher.flush()
thinkingBatcher.flush()
failRun((p.error as string) ?? 'Unknown error')
appendErrorToLastMessage((p.error as string) ?? 'Unknown error')
failRun()
currentRunIdRef.current = null
break
// User-initiated cancellation — preserve partial content, no error message.
case 'run.cancelled':
chunkBatcher.flush()
thinkingBatcher.flush()
useChatStore.getState().cancelRun()
useChatActivityStore.getState().cancelRun()
currentRunIdRef.current = null
break
// Backend sends: { attempt, maxAttempts, error }
case 'run.retrying':
setActivity({
phase: 'retrying',
tool: undefined,
iteration: Number(p.attempt) || 0,
})
setActivity({ phase: 'retrying', tool: undefined, iteration: Number(p.attempt) || 0 })
break
}
})
return unsub
}, [
ws,
startRun,
appendChunk,
addToolCall,
updateToolResult,
setActivity,
completeRun,
failRun,
chunkBatcher,
thinkingBatcher,
]) // sessionKeyRef used instead of activeSessionKey — no re-subscribe on session change
}, [ws, addAssistantMessage, startRun, appendChunk, addToolCall, updateToolResult, setActivity, finalizeMessage, appendErrorToLastMessage, completeRun, failRun, chunkBatcher, thinkingBatcher])
const sendMessage = useCallback(
async (text: string, agentId: string, attachedFiles?: AttachedFile[]) => {
if (!ws || (!text.trim() && !attachedFiles?.length)) return
// Auto-create session if none active
let sessionKey = activeSessionKey
if (!sessionKey) {
sessionKey = `agent:${agentId}:ws:direct:system:${crypto.randomUUID().slice(0, 8)}`
const { useSessionStore } = await import('../stores/session-store')
useSessionStore.getState().addSession({
key: sessionKey,
agentId,
title: text.trim().slice(0, 40) || 'New Chat',
lastMessageAt: Date.now(),
messageCount: 0,
})
useSessionStore.getState().addSession({ key: sessionKey, agentId, title: text.trim().slice(0, 40) || 'New Chat', lastMessageAt: Date.now(), messageCount: 0 })
useSessionStore.getState().setActiveSession(sessionKey)
}
addUserMessage(text)
// Resolve attached files to server paths for chat.send:
// - localPath files (pasted paths): pass directly, no upload needed
// - File objects (picker/drag/paste): upload via HTTP first
let media: { path: string; filename: string }[] | undefined
if (attachedFiles?.length) {
const api = getApiClient()
const uploads = await Promise.all(
attachedFiles.map(async (af) => {
if (af.localPath) {
return { path: af.localPath, filename: af.name }
}
if (af.localPath) return { path: af.localPath, filename: af.name }
if (!af.file) return null
try {
const res = await api.uploadFile<{ path: string; mime_type: string; filename: string }>(
'/v1/media/upload', af.file,
)
const res = await chatService.uploadMedia(af.file)
return { path: res.path, filename: res.filename }
} catch (err) {
console.error('File upload failed:', af.name, err)
return null
}
} catch (err) { console.error('File upload failed:', af.name, err); return null }
}),
)
media = uploads.filter((u): u is { path: string; filename: string } => u !== null)
@@ -266,154 +137,56 @@ export function useChat() {
}
try {
await ws.call('chat.send', {
message: text,
agentId,
sessionKey,
stream: true,
...(media && { media }),
})
} catch (err) {
console.error('chat.send failed:', err)
}
await chatService.send({ message: text, agentId, sessionKey, stream: true, ...(media && { media }) })
} catch (err) { console.error('chat.send failed:', err) }
},
[ws, activeSessionKey, addUserMessage],
)
const loadHistory = useCallback(
async (sessionKey: string) => {
if (!ws) return
try {
const result = (await ws.call('chat.history', { sessionKey })) as {
messages?: Array<{
id?: string
role: string
content?: string
thinking?: string
timestamp?: number
tool_call_id?: string
is_error?: boolean
tool_calls?: Array<{
id: string
function?: { name: string; arguments?: Record<string, unknown> }
name?: string
input?: Record<string, unknown>
}>
media_refs?: Array<{
id?: string
mime_type?: string
content_type?: string
kind?: string
path?: string
url?: string
}>
}>
}
if (result?.messages) {
// Build tool result map for enriching assistant tool_calls
const toolResultMap = new Map<string, { content: string; isError: boolean }>()
for (const m of result.messages) {
if (m.role === 'tool' && m.tool_call_id) {
toolResultMap.set(m.tool_call_id, {
content: m.content ?? '',
isError: !!m.is_error,
})
}
}
// Filter: only user + assistant messages, exclude internal system nudges
const filtered = result.messages.filter((m) =>
(m.role === 'user' || m.role === 'assistant') &&
!(m.role === 'user' && m.content?.startsWith('[System]'))
)
setMessages(
filtered.map((m) => ({
id: m.id ?? crypto.randomUUID(),
role: m.role as 'user' | 'assistant',
content: m.content ?? '',
timestamp: m.timestamp ?? Date.now(),
thinkingText: m.thinking,
toolCalls: m.tool_calls?.map((tc) => {
const toolResult = toolResultMap.get(tc.id)
return {
toolId: tc.id,
toolName: tc.function?.name ?? tc.name ?? 'unknown',
arguments: tc.function?.arguments ?? tc.input ?? {},
state: (toolResult?.isError ? 'error' : 'completed') as 'error' | 'completed',
result: toolResult && !toolResult.isError ? toolResult.content : undefined,
error: toolResult?.isError ? toolResult.content : undefined,
}
}),
media: m.media_refs?.map((ref) => ({
type: ref.mime_type ?? ref.content_type ?? 'image',
url: toFileUrl(ref.path ?? ref.id ?? ref.url ?? ''),
})),
})),
)
}
} catch (err) {
console.error('Failed to load history:', err)
}
const result = await chatService.history(sessionKey)
if (result?.messages) setMessages(mapHistoryMessages(result.messages))
} catch (err) { console.error('Failed to load history:', err) }
},
[ws, setMessages],
[setMessages],
)
// Abort the current run for the active session.
const abort = useCallback(async () => {
const sk = sessionKeyRef.current
if (!ws || !sk) return
try {
await ws.call('chat.abort', { sessionKey: sk })
} catch {
// ignore abort errors
}
}, [ws])
if (!sk) return
try { await chatService.abort(sk) } catch { /* ignore */ }
}, [])
// Reset streaming state + load history when session changes.
// Messages are NOT cleared here — loadHistory() replaces them atomically.
// Clearing would race with sendMessage's auto-session-creation flow.
// Reset streaming state + load history when session changes
const prevSessionRef = useRef<string | null>(null)
useEffect(() => {
if (activeSessionKey === prevSessionRef.current) return
prevSessionRef.current = activeSessionKey
// Reset streaming state only (not messages)
chunkBatcher.flush()
thinkingBatcher.flush()
currentRunIdRef.current = null
if (!activeSessionKey) {
useChatStore.getState().clear()
useChatMessageStore.getState().clear()
useChatActivityStore.getState().clear()
return
}
// Load history — setMessages() atomically replaces, no flash
let cancelled = false
loadHistory(activeSessionKey).then(() => {
if (cancelled) return
})
loadHistory(activeSessionKey).then(() => { if (cancelled) return })
// Restore session running state (mirrors web UI pattern).
// If user switches to a session with an active run, show the stop button.
ws?.call('chat.session_status', { sessionKey: activeSessionKey })
.then((raw: unknown) => {
chatService.sessionStatus(activeSessionKey)
.then((res) => {
if (cancelled) return
const res = raw as { isRunning?: boolean; activity?: { phase: string; tool?: string; iteration?: number } }
if (res?.isRunning) {
useChatStore.getState().restoreRunning(res.activity ?? null)
}
if (res?.isRunning) useChatActivityStore.getState().restoreRunning(res.activity ?? null)
})
.catch(() => {})
return () => { cancelled = true }
}, [activeSessionKey, ws, loadHistory, chunkBatcher, thinkingBatcher])
}, [activeSessionKey, loadHistory, chunkBatcher, thinkingBatcher])
return {
messages,
isRunning,
activity,
sendMessage,
loadHistory,
abort,
}
return { messages, isRunning, activity, sendMessage, loadHistory, abort }
}
+7 -15
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { getWsClient } from '../lib/ws'
import { cronService } from '../services/cron-service'
import { toast } from '../stores/toast-store'
import type { CronJob, CronRunLog, CronSchedule, CronPayload } from '../types/cron'
@@ -17,10 +17,7 @@ export function useCron(agentId?: string) {
const fetchJobs = useCallback(async () => {
try {
const ws = getWsClient()
const params: Record<string, unknown> = {}
if (agentId) params.agentId = agentId
const res = await ws.call('cron.list', params) as { jobs: CronJob[] | null }
const res = await cronService.list(agentId)
setJobs(res.jobs ?? [])
} catch (err) {
console.error('Failed to fetch cron jobs:', err)
@@ -33,8 +30,7 @@ export function useCron(agentId?: string) {
const createJob = useCallback(async (params: CreateJobParams) => {
try {
const ws = getWsClient()
const job = await ws.call('cron.create', params as unknown as Record<string, unknown>) as CronJob
const job = await cronService.create(params)
setJobs((prev) => [...prev, job])
toast.success('Cron job created', params.name)
return job
@@ -46,8 +42,7 @@ export function useCron(agentId?: string) {
const deleteJob = useCallback(async (jobId: string) => {
try {
const ws = getWsClient()
await ws.call('cron.delete', { jobId })
await cronService.delete(jobId)
setJobs((prev) => prev.filter((j) => j.id !== jobId))
toast.success('Cron job deleted')
} catch (err) {
@@ -58,8 +53,7 @@ export function useCron(agentId?: string) {
const toggleJob = useCallback(async (jobId: string) => {
try {
const ws = getWsClient()
const res = await ws.call('cron.toggle', { jobId }) as { enabled: boolean }
const res = await cronService.toggle(jobId)
setJobs((prev) => prev.map((j) => j.id === jobId ? { ...j, enabled: res.enabled } : j))
} catch (err) {
toast.error('Failed to toggle cron job', (err as Error).message)
@@ -69,8 +63,7 @@ export function useCron(agentId?: string) {
const runJob = useCallback(async (jobId: string) => {
try {
const ws = getWsClient()
await ws.call('cron.run', { jobId })
await cronService.run(jobId)
toast.success('Cron job triggered')
} catch (err) {
toast.error('Failed to run cron job', (err as Error).message)
@@ -79,8 +72,7 @@ export function useCron(agentId?: string) {
}, [])
const fetchRuns = useCallback(async (jobId: string): Promise<CronRunLog[]> => {
const ws = getWsClient()
const res = await ws.call('cron.runs', { jobId, limit: 20 }) as { runs: CronRunLog[] | null }
const res = await cronService.runs(jobId)
return res.runs ?? []
}, [])
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { getApiClient } from '../lib/api'
import { mcpService } from '../services/mcp-service'
import { toast } from '../stores/toast-store'
import type { MCPServerData, MCPServerInput, MCPAgentGrant, MCPToolInfo, MCPTestResult } from '../types/mcp'
import type { MCPServerData, MCPServerInput, MCPAgentGrant, MCPToolInfo } from '../types/mcp'
export const MAX_MCP_LITE = 5
@@ -11,7 +11,7 @@ export function useMcpServers() {
const fetchServers = useCallback(async () => {
try {
const res = await getApiClient().get<{ servers: MCPServerData[] | null }>('/v1/mcp/servers')
const res = await mcpService.list()
setServers(res.servers ?? [])
} catch (err) {
console.error('Failed to fetch MCP servers:', err)
@@ -24,7 +24,7 @@ export function useMcpServers() {
const createServer = useCallback(async (input: MCPServerInput) => {
try {
const res = await getApiClient().post<MCPServerData>('/v1/mcp/servers', input)
const res = await mcpService.create(input)
await fetchServers()
toast.success('Server created')
return res
@@ -36,7 +36,7 @@ export function useMcpServers() {
const updateServer = useCallback(async (id: string, input: Partial<MCPServerInput>) => {
try {
const res = await getApiClient().put<MCPServerData>(`/v1/mcp/servers/${id}`, input)
const res = await mcpService.update(id, input)
await fetchServers()
toast.success('Server updated')
return res
@@ -48,7 +48,7 @@ export function useMcpServers() {
const deleteServer = useCallback(async (id: string) => {
try {
await getApiClient().delete(`/v1/mcp/servers/${id}`)
await mcpService.delete(id)
setServers((prev) => prev.filter((s) => s.id !== id))
toast.success('Server deleted')
} catch (err) {
@@ -65,12 +65,12 @@ export function useMcpServers() {
headers?: Record<string, string>
env?: Record<string, string>
}) => {
return getApiClient().post<MCPTestResult>('/v1/mcp/servers/test', data)
return mcpService.testConnection(data)
}, [])
const reconnectServer = useCallback(async (id: string) => {
try {
await getApiClient().post(`/v1/mcp/servers/${id}/reconnect`, {})
await mcpService.reconnect(id)
toast.success('Connection reset')
} catch (err) {
toast.error('Failed to reconnect', (err as Error).message)
@@ -78,23 +78,23 @@ export function useMcpServers() {
}
}, [])
const listServerTools = useCallback(async (serverId: string) => {
const res = await getApiClient().get<{ tools: MCPToolInfo[] | null }>(`/v1/mcp/servers/${serverId}/tools`)
const listServerTools = useCallback(async (serverId: string): Promise<MCPToolInfo[]> => {
const res = await mcpService.listTools(serverId)
return res.tools ?? []
}, [])
const listGrants = useCallback(async (serverId: string) => {
const res = await getApiClient().get<{ grants: MCPAgentGrant[] | null }>(`/v1/mcp/servers/${serverId}/grants`)
const listGrants = useCallback(async (serverId: string): Promise<MCPAgentGrant[]> => {
const res = await mcpService.listGrants(serverId)
return res.grants ?? []
}, [])
const grantAgent = useCallback(async (serverId: string, agentId: string) => {
await getApiClient().post(`/v1/mcp/servers/${serverId}/grants/agent`, { agent_id: agentId })
await mcpService.grantAgent(serverId, agentId)
await fetchServers()
}, [fetchServers])
const revokeAgent = useCallback(async (serverId: string, agentId: string) => {
await getApiClient().delete(`/v1/mcp/servers/${serverId}/grants/agent/${agentId}`)
await mcpService.revokeAgent(serverId, agentId)
await fetchServers()
}, [fetchServers])
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { getApiClient } from '../lib/api'
import { providerService } from '../services/provider-service'
import { toast } from '../stores/toast-store'
import type { ProviderData, ProviderInput } from '../types/provider'
@@ -9,7 +9,7 @@ export function useProviders() {
const fetchProviders = useCallback(async () => {
try {
const res = await getApiClient().get<{ providers: ProviderData[] | null }>('/v1/providers')
const res = await providerService.list()
setProviders(res.providers ?? [])
} catch (err) {
console.error('Failed to fetch providers:', err)
@@ -22,7 +22,7 @@ export function useProviders() {
const createProvider = useCallback(async (input: ProviderInput) => {
try {
const res = await getApiClient().post<ProviderData>('/v1/providers', input)
const res = await providerService.create(input)
setProviders((prev) => [...prev, res])
toast.success('Provider created')
return res
@@ -34,7 +34,7 @@ export function useProviders() {
const updateProvider = useCallback(async (id: string, input: Partial<ProviderInput>) => {
try {
const res = await getApiClient().put<ProviderData>(`/v1/providers/${id}`, input)
const res = await providerService.update(id, input)
setProviders((prev) => prev.map((p) => p.id === id ? res : p))
toast.success('Provider updated')
return res
@@ -46,7 +46,7 @@ export function useProviders() {
const deleteProvider = useCallback(async (id: string) => {
try {
await getApiClient().delete(`/v1/providers/${id}`)
await providerService.delete(id)
setProviders((prev) => prev.filter((p) => p.id !== id))
toast.success('Provider deleted')
} catch (err) {
@@ -56,7 +56,7 @@ export function useProviders() {
}, [])
const verifyProvider = useCallback(async (input: { provider_type: string; api_base?: string; api_key?: string }) => {
return getApiClient().post<{ success: boolean; error?: string; models?: string[] }>('/v1/providers/verify', input)
return providerService.verify(input)
}, [])
return { providers, loading, fetchProviders, createProvider, updateProvider, deleteProvider, verifyProvider }
+16 -24
View File
@@ -1,35 +1,25 @@
import { useEffect, useCallback } from 'react'
import { getWsClient } from '../lib/ws'
import { sessionService } from '../services/session-service'
import { useSessionStore } from '../stores/session-store'
import { useAgentStore } from '../stores/agent-store'
import { useChatStore } from '../stores/chat-store'
// Backend SessionInfo: { key, messageCount, created, updated, label, channel, userID }
interface SessionInfoResponse {
key: string
messageCount: number
created: string // ISO timestamp
updated: string // ISO timestamp
label?: string
channel?: string
}
import { useChatMessageStore } from '../stores/chat-message-store'
import { useChatActivityStore } from '../stores/chat-activity-store'
export function useSessions() {
const ws = getWsClient()
const { sessions, activeSessionKey, setActiveSession, setSessions, removeSession } = useSessionStore()
const selectedAgentId = useAgentStore((s) => s.selectedAgentId)
// When agent changes, clear active session + chat, then fetch new sessions
useEffect(() => {
if (!ws || !selectedAgentId) return
if (!selectedAgentId) return
setActiveSession(null)
useChatStore.getState().clear()
useChatMessageStore.getState().clear()
useChatActivityStore.getState().clear()
let cancelled = false
ws.call('sessions.list', { agentId: selectedAgentId, limit: 30 })
.then((result: unknown) => {
sessionService.list(selectedAgentId)
.then((result) => {
if (cancelled) return
const r = result as { sessions?: SessionInfoResponse[] }
const list = (r?.sessions || []).map((s) => ({
const list = (result?.sessions || []).map((s) => ({
key: s.key,
agentId: selectedAgentId,
title: s.label || 'Untitled',
@@ -40,25 +30,27 @@ export function useSessions() {
})
.catch(console.error)
return () => { cancelled = true }
}, [ws, selectedAgentId, setSessions, setActiveSession])
}, [selectedAgentId, setSessions, setActiveSession])
// "New Chat" just clears active session + chat.
// Actual session is created by sendMessage on first message (auto-session-creation).
const createSession = useCallback(() => {
setActiveSession(null)
useChatStore.getState().clear()
useChatMessageStore.getState().clear()
useChatActivityStore.getState().clear()
}, [setActiveSession])
const deleteSession = useCallback(async (key: string) => {
try {
await ws.call('sessions.delete', { key })
await sessionService.delete(key)
} catch { /* best effort */ }
removeSession(key)
if (activeSessionKey === key) {
setActiveSession(null)
useChatStore.getState().clear()
useChatMessageStore.getState().clear()
useChatActivityStore.getState().clear()
}
}, [ws, activeSessionKey, removeSession, setActiveSession])
}, [activeSessionKey, removeSession, setActiveSession])
return { sessions, activeSessionKey, setActiveSession, createSession, deleteSession }
}
+12 -42
View File
@@ -2,21 +2,9 @@
// Adapted from ui/web/src/pages/storage/hooks/use-storage.ts for desktop edition.
import { useState, useCallback, useEffect, useRef } from 'react'
import { getApiClient } from '../lib/api'
import { storageService } from '../services/storage-service'
export interface StorageFile {
path: string
name: string
isDir: boolean
size: number
hasChildren?: boolean
protected: boolean
}
interface StorageListResponse {
files: StorageFile[]
baseDir: string
}
export type { StorageFile } from '../services/storage-service'
interface StorageFileContent {
content: string
@@ -25,15 +13,14 @@ interface StorageFileContent {
}
export function useStorage() {
const [files, setFiles] = useState<StorageFile[]>([])
const [files, setFiles] = useState<import('../services/storage-service').StorageFile[]>([])
const [baseDir, setBaseDir] = useState('')
const [loading, setLoading] = useState(false)
const listFiles = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setLoading(true)
try {
const api = getApiClient()
const res = await api.get<StorageListResponse>('/v1/storage/files')
const res = await storageService.listFiles()
setFiles(res.files ?? [])
setBaseDir(res.baseDir ?? '')
} finally {
@@ -41,47 +28,31 @@ export function useStorage() {
}
}, [])
const loadSubtree = useCallback(async (path: string): Promise<StorageFile[]> => {
const api = getApiClient()
const res = await api.get<StorageListResponse>(
`/v1/storage/files?path=${encodeURIComponent(path)}`,
)
const loadSubtree = useCallback(async (path: string): Promise<import('../services/storage-service').StorageFile[]> => {
const res = await storageService.loadSubtree(path)
return res.files ?? []
}, [])
const readFile = useCallback(async (path: string): Promise<StorageFileContent> => {
const api = getApiClient()
return api.get<StorageFileContent>(
`/v1/storage/files/${encodeURIComponent(path)}`,
)
return storageService.readFile(path)
}, [])
const deleteFile = useCallback(async (path: string) => {
const api = getApiClient()
await api.delete(`/v1/storage/files/${encodeURIComponent(path)}`)
await storageService.deleteFile(path)
}, [])
const uploadFile = useCallback(async (file: File, folder?: string) => {
const api = getApiClient()
const params = folder ? `?path=${encodeURIComponent(folder)}` : ''
await api.uploadFile(`/v1/storage/files${params}`, file)
await storageService.uploadFile(file, folder)
}, [])
const moveFile = useCallback(async (fromPath: string, toFolder: string) => {
const fileName = fromPath.split('/').pop() ?? fromPath
const newPath = toFolder ? `${toFolder}/${fileName}` : fileName
if (fromPath === newPath) return
const api = getApiClient()
await api.putRaw(
`/v1/storage/move?from=${encodeURIComponent(fromPath)}&to=${encodeURIComponent(newPath)}`,
)
await storageService.moveFile(fromPath, toFolder)
}, [])
const fetchRawBlob = useCallback((path: string, download?: boolean): Promise<Blob> => {
const api = getApiClient()
const params: Record<string, string> = { raw: 'true' }
if (download) params.download = 'true'
return api.fetchBlob(`/v1/storage/files/${encodeURIComponent(path)}`, params)
return storageService.fetchBlob(path, params)
}, [])
return { files, baseDir, loading, listFiles, loadSubtree, readFile, deleteFile, uploadFile, moveFile, fetchRawBlob }
@@ -106,8 +77,7 @@ export function useStorageSize() {
setState((s) => ({ ...s, loading: true, totalSize: 0, fileCount: 0 }))
try {
const api = getApiClient()
const res = await api.streamFetch('/v1/storage/size', controller.signal)
const res = await storageService.streamSize(controller.signal)
if (!res.body) {
setState((s) => ({ ...s, loading: false }))
return
@@ -0,0 +1,37 @@
import { useRef, useCallback } from 'react'
/**
* RAF-based stream batcher — prevents 100+ setState calls/sec during streaming.
* Buffers text and flushes once per animation frame.
*/
export function useStreamBatcher(onFlush: (text: string) => void) {
const bufferRef = useRef('')
const rafRef = useRef(0)
const append = useCallback(
(text: string) => {
bufferRef.current += text
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
onFlush(bufferRef.current)
bufferRef.current = ''
rafRef.current = 0
})
}
},
[onFlush],
)
const flush = useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = 0
}
if (bufferRef.current) {
onFlush(bufferRef.current)
bufferRef.current = ''
}
}, [onFlush])
return { append, flush }
}
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more