feat(processors): streamline event debugging and run inspection

This commit is contained in:
RockChinQ
2026-09-08 02:30:59 +08:00
parent 237fa6545d
commit ea3e32c904
34 changed files with 1464 additions and 466 deletions
@@ -174,6 +174,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
id={id}
agent={agent}
canManage={canManage}
canOperate={canOperate}
availableEventTypes={availableEventTypes}
onDelete={() => setDeleteConfirmOpen(true)}
onEdit={() => setBasicInfoOpen(true)}
onSaved={() => {
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { FileCode2, RefreshCw, Settings2, Trash2, Pencil } from 'lucide-react';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { RefreshCw, Trash2, ScrollText } from 'lucide-react';
import isEqual from 'lodash/isEqual';
import { toast } from 'sonner';
import type {
Agent,
@@ -14,12 +16,23 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
import AgentDebugPanel from './components/AgentDebugPanel';
import EventProcessorTrace, {
ProcessorPayload,
} from './components/EventProcessorTrace';
import ProcessorRunList from './components/ProcessorRunList';
import EventProcessorSettings from './components/EventProcessorSettings';
export default function EventProcessorDetailContent({
agent,
id,
canManage,
canOperate,
availableEventTypes,
onDelete,
onEdit,
onSaved,
@@ -27,6 +40,8 @@ export default function EventProcessorDetailContent({
agent: Agent;
id: string;
canManage: boolean;
canOperate: boolean;
availableEventTypes: string[];
onDelete: () => void;
onEdit: () => void;
onSaved: () => void;
@@ -46,6 +61,13 @@ export default function EventProcessorDetailContent({
>
)[agent.component_ref ?? ''] ?? {};
const [parameters, setParameters] = useState(initialParameters);
const [savedConfig, setSavedConfig] = useState({
componentRef,
parameters: initialParameters,
});
const dirty =
componentRef !== savedConfig.componentRef ||
!isEqual(parameters, savedConfig.parameters);
const [runs, setRuns] = useState<ProcessorRun[]>([]);
const [cursor, setCursor] = useState<number | null>(null);
const [selected, setSelected] = useState<ProcessorRun | null>(null);
@@ -55,11 +77,11 @@ export default function EventProcessorDetailContent({
const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const [failed, setFailed] = useState(false);
const validate = useRef<(() => Promise<boolean>) | null>(null);
const requestVersion = useRef(0);
const available = components.some((item) => item.id === agent.component_ref);
const component = components.find((item) => item.id === componentRef);
const available = Boolean(component);
const load = useCallback(async () => {
setFailed(false);
@@ -88,20 +110,38 @@ export default function EventProcessorDetailContent({
[id],
);
async function openRun(run: ProcessorRun) {
const version = ++requestVersion.current;
setSelected(run);
setEvents([]);
setEventCursor(null);
const openRun = useCallback(
async (run: ProcessorRun) => {
const version = ++requestVersion.current;
setSelected(run);
setEvents([]);
setEventCursor(null);
try {
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
if (version !== requestVersion.current) return;
setSelected(page.run);
setEvents(page.items);
setEventCursor(page.has_more ? page.next_cursor : null);
} catch {
if (version === requestVersion.current)
toast.error(t('agents.eventProcessor.loadError'));
}
},
[id, t],
);
useEffect(() => {
if (!selected && runs.length > 0) void openRun(runs[0]);
}, [selected, runs, openRun]);
async function refreshLatestRun() {
try {
const page = await httpClient.getProcessorRunEvents(id, run.run_id);
if (version !== requestVersion.current) return;
setSelected(page.run);
setEvents(page.items);
setEventCursor(page.has_more ? page.next_cursor : null);
const page = await httpClient.getProcessorRuns(id);
setRuns(page.items);
setCursor(page.has_more ? page.next_cursor : null);
if (page.items[0]) await openRun(page.items[0]);
} catch {
if (version === requestVersion.current)
toast.error(t('agents.eventProcessor.loadError'));
toast.error(t('agents.eventProcessor.loadError'));
}
}
@@ -195,7 +235,13 @@ export default function EventProcessorDetailContent({
}, [id, selected, eventCursor, events]);
async function save() {
if (!componentRef || !((await validate.current?.()) ?? true)) return;
if (
!canManage ||
saving ||
!component ||
!((await validate.current?.()) ?? true)
)
return false;
setSaving(true);
try {
await httpClient.updateAgent(id, {
@@ -208,230 +254,213 @@ export default function EventProcessorDetailContent({
});
toast.success(t('agents.saveSuccess'));
onSaved();
setConfigOpen(false);
setSavedConfig({ componentRef, parameters });
await load();
return true;
} catch {
toast.error(t('agents.saveError'));
return false;
} finally {
setSaving(false);
}
}
function payload(value: unknown) {
return (
<pre className="mt-2 whitespace-pre-wrap break-all rounded-md bg-muted/50 p-3 text-xs">
{JSON.stringify(value, null, 2)}
</pre>
);
}
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<header className="flex flex-wrap items-center gap-3">
<FileCode2 className="size-6" />
<h1 className="text-2xl font-semibold">{agent.name}</h1>
{canManage && (
<Button
variant="ghost"
size="icon"
onClick={onEdit}
aria-label={t('common.edit')}
>
<Pencil className="size-4" />
<ProcessorDetailWorkbench
title={`${agent.emoji || '⚡'} ${agent.name}`}
titleAction={
canManage ? <EntityTitleEditButton onClick={onEdit} /> : undefined
}
titleControls={
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
disabled={!canManage || saving || loading}
onChange={(value) => {
setComponentRef(value);
const descriptor = components.find((item) => item.id === value);
setParameters(
Object.fromEntries(
(descriptor?.config_schema ?? [])
.filter((field) => field.default !== undefined)
.map((field) => [field.name, field.default]),
),
);
validate.current = null;
}}
onParametersChange={setParameters}
onValidate={(fn) => {
validate.current = fn;
}}
/>
}
status={
!loading && componentRef && !available
? { label: t('agents.eventProcessor.unavailable'), tone: 'error' }
: undefined
}
saveLabel={t('common.save')}
saveFormId="event-processor-form"
canSave={canManage && available}
isDirty={dirty}
isSaving={saving}
headerActions={
canManage ? (
<Button variant="destructive" disabled={saving} onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
)}
<Badge variant="outline">{t('agents.eventProcessor.type')}</Badge>
{!loading && !failed && !available && (
<Badge variant="destructive">
{t('agents.eventProcessor.unavailable')}
</Badge>
)}
<div className="ml-auto flex gap-2">
<Button
variant="outline"
onClick={() => {
void load();
if (selected) void openRun(selected);
}}
aria-label={t('agents.eventProcessor.refresh')}
>
<RefreshCw className="size-4" />
</Button>
{canManage && (
<>
<Button
variant="outline"
onClick={() => setConfigOpen(!configOpen)}
>
<Settings2 className="size-4" />
{t('pipelines.configuration')}
</Button>
<Button variant="destructive" onClick={onDelete}>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
</>
)}
</div>
</header>
<p className="shrink-0 break-all text-xs text-muted-foreground">
{agent.component_ref}
</p>
{configOpen && (
<div className="max-h-[45vh] shrink-0 overflow-y-auto rounded-xl border p-4">
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validate.current = null;
}}
onParametersChange={setParameters}
onValidate={(fn) => {
validate.current = fn;
) : undefined
}
configTitle={t('agents.eventProcessor.trace')}
configIcon={<ScrollText className="size-4" />}
configContent={
<div className="flex h-full min-h-0 flex-col gap-3">
<form
id="event-processor-form"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
/>
<Button
className="mt-4"
disabled={
saving || !components.some((item) => item.id === componentRef)
}
onClick={() => void save()}
>
{t('common.save')}
</Button>
</div>
)}
{failed && (
<p role="alert" className="text-destructive">
{t('agents.eventProcessor.loadError')}
</p>
)}
<div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[minmax(240px,0.7fr)_minmax(0,1.3fr)]">
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.runs')}
</h2>
{loading ? (
<p>{t('common.loading')}</p>
) : runs.length === 0 && !failed ? (
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('agents.eventProcessor.noRuns')}</p>
<Link className="text-primary underline" to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</div>
) : (
runs.map((run) => (
<button
key={run.run_id}
onClick={() => void openRun(run)}
className={`mb-2 block w-full rounded-lg border p-3 text-left text-sm ${selected?.run_id === run.run_id ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'}`}
>
<span className="block break-all font-medium">
{run.metadata.event_type}
</span>
<span className="mt-1 flex flex-wrap justify-between gap-1 text-xs text-muted-foreground">
<span>
{new Date(run.created_at * 1000).toLocaleString()}
</span>
<span>
{t(`agents.eventProcessor.status_${run.status}`, {
defaultValue: run.status,
})}
</span>
</span>
</button>
))
{failed && (
<Alert variant="destructive">
<AlertDescription>
{t('agents.eventProcessor.loadError')}
</AlertDescription>
</Alert>
)}
{cursor !== null && (
<div className="flex shrink-0 items-center justify-between gap-2">
<span className="text-sm font-medium">
{t('agents.eventProcessor.runs')}{' '}
<span className="text-muted-foreground">({runs.length})</span>
</span>
<Button
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
size="icon"
aria-label={t('agents.eventProcessor.refresh')}
onClick={() => void refreshLatestRun()}
>
{t('agents.eventProcessor.loadMore')}
<RefreshCw className="size-4" />
</Button>
</div>
{runs.length > 0 && (
<ProcessorRunList
runs={runs}
selectedId={selected?.run_id}
onSelect={(run) => void openRun(run)}
footer={
cursor !== null ? (
<Button
className="w-full"
variant="ghost"
disabled={pagingRuns}
onClick={() => void loadMoreRuns()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
) : undefined
}
/>
)}
</section>
<section className="min-h-0 overflow-y-auto rounded-xl border p-4">
<h2 className="mb-3 font-semibold">
{t('agents.eventProcessor.trace')}
</h2>
{!selected ? (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.selectRun')}
</p>
) : (
<div className="space-y-3">
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.input')}
</summary>
{payload(selected.metadata.input_event)}
</details>
{selected.metadata.delivery != null && (
<details className="rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{t('agents.eventProcessor.destination')}
</summary>
{payload(selected.metadata.delivery)}
</details>
)}
{events.map((event) =>
event.type === 'processor.log' ? (
<div
key={event.sequence}
className="rounded-lg bg-muted/40 p-3 text-sm"
>
<span className="mr-2 text-xs text-muted-foreground">
{String(event.data.level)}
</span>
<span className="whitespace-pre-wrap break-words">
{String(event.data.text)}
</span>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-2 pr-3">
{!selected ? (
<Alert>
<AlertDescription>
{loading
? t('common.loading')
: t('agents.eventProcessor.noRuns')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/bots">
{t('agents.eventProcessor.bindBot')}
</Link>
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="border-b pb-2">
<p className="text-sm font-medium">
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(selected.created_at * 1000).toLocaleString()}
</p>
</div>
) : (
<details
key={event.sequence}
className="rounded-lg border p-3"
<Badge
variant={
selected.status === 'failed' ? 'destructive' : 'outline'
}
>
<summary className="cursor-pointer break-all text-sm font-medium">
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] ||
event.data.tool_name}
</span>
)}
</summary>
{payload(event.data)}
</details>
),
)}
{selected.status === 'failed' && selected.status_reason && (
<p className="break-words text-sm text-destructive">
{selected.status_reason}
</p>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
{t(`agents.eventProcessor.status_${selected.status}`, {
defaultValue: selected.status,
})}
</Badge>
<ProcessorPayload
title={t('agents.eventProcessor.input')}
value={selected.metadata.input_event}
/>
{selected.metadata.delivery != null && (
<ProcessorPayload
title={t('agents.eventProcessor.destination')}
value={selected.metadata.delivery}
/>
)}
<EventProcessorTrace
events={events}
toolLabels={toolLabels}
/>
{selected.status === 'failed' && selected.status_reason && (
<Alert variant="destructive">
<AlertDescription className="break-words">
{selected.status_reason}
</AlertDescription>
</Alert>
)}
{eventCursor !== null && (
<Button
variant="ghost"
disabled={pagingEvents}
onClick={() => void loadMoreEvents()}
>
{t('agents.eventProcessor.loadMore')}
</Button>
)}
</>
)}
</div>
)}
</section>
</div>
</div>
</ScrollArea>
</div>
}
debugTitle={canOperate ? t('agents.debugTab') : undefined}
debugDescription={t('agents.eventProcessor.debugNotice')}
debugContent={
canOperate ? (
!component ? (
<Alert className="m-3 w-auto">
<AlertDescription>
{t('agents.eventProcessor.selectToDebug')}
</AlertDescription>
</Alert>
) : (
<AgentDebugPanel
agentId={id}
processor
platformTools={platformTools}
hasUnsavedChanges={dirty}
beforeRun={save}
onRunFinished={() => {
void refreshLatestRun();
}}
supportedEventPatterns={component.supported_event_patterns}
availableEventTypes={availableEventTypes}
/>
)
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -6,8 +6,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Bot, Workflow, FileCode2 } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { AgentKind, EventProcessorDescriptor } from '@/app/infra/entities/api';
import EventProcessorSettings from './EventProcessorSettings';
import { AgentKind } from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
@@ -36,23 +35,6 @@ export default function AgentCreateContent({
}) {
const { t } = useTranslation();
const [kind, setKind] = useState<AgentKind>('agent');
const [components, setComponents] = useState<EventProcessorDescriptor[]>([]);
const [componentRef, setComponentRef] = useState('');
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const validateParameters = useRef<(() => Promise<boolean>) | null>(null);
useEffect(() => {
if (kind !== 'event_processor') return;
let cancelled = false;
httpClient
.getAgentMetadata()
.then((metadata) => {
if (!cancelled) setComponents(metadata.event_processors ?? []);
})
.catch(() => toast.error(t('agents.eventProcessor.loadError')));
return () => {
cancelled = true;
};
}, [kind, t]);
const formSchema = z.object({
name: z.string().min(1, { message: t('agents.nameRequired') }),
description: z.string().optional(),
@@ -85,23 +67,9 @@ export default function AgentCreateContent({
}
async function handleSubmit(values: FormValues) {
if (
kind === 'event_processor' &&
(!componentRef || !((await validateParameters.current?.()) ?? true))
)
return;
httpClient
return httpClient
.createAgent({
kind,
...(kind === 'event_processor'
? {
component_ref: componentRef,
config: {
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
}
: {}),
name: values.name,
description: values.description ?? '',
emoji: values.emoji || (kind === 'pipeline' ? '⚙️' : '🤖'),
@@ -143,10 +111,7 @@ export default function AgentCreateContent({
<Button
type="submit"
form="agent-create-form"
disabled={
form.formState.isSubmitting ||
(kind === 'event_processor' && !componentRef)
}
disabled={form.formState.isSubmitting}
>
{t('common.submit')}
</Button>
@@ -209,22 +174,6 @@ export default function AgentCreateContent({
</ToggleGroup>
</section>
{kind === 'event_processor' && (
<EventProcessorSettings
components={components}
value={componentRef}
parameters={parameters}
onChange={(value) => {
setComponentRef(value);
setParameters({});
validateParameters.current = null;
}}
onParametersChange={setParameters}
onValidate={(validate) => {
validateParameters.current = validate;
}}
/>
)}
<Card>
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
@@ -46,6 +46,7 @@ import {
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
import EventProcessorTrace from './EventProcessorTrace';
import AgentExecutionTrace from './AgentExecutionTrace';
import AgentEventDataEditor from './AgentEventDataEditor';
import {
@@ -53,15 +54,18 @@ import {
debugEventInputText,
invalidDebugEventField,
parseDebugEventData,
processorDebugEventTypes,
} from './debug-event-data';
import { executionSteps, type DebugExecutionEvent } from './debug-execution';
interface AgentDebugPanelProps {
agentId: string;
processor?: boolean;
availableEventTypes: string[];
platformTools?: AgentPlatformTool[];
supportedEventPatterns?: string[];
beforeRun?: () => Promise<boolean>;
onRunFinished?: () => void;
hasUnsavedChanges?: boolean;
onOpenRunnerConfig?: () => void;
}
@@ -89,10 +93,12 @@ function matchesEventPattern(pattern: string, eventType: string) {
export default function AgentDebugPanel({
agentId,
processor = false,
availableEventTypes,
platformTools = [],
supportedEventPatterns = ['*'],
beforeRun,
onRunFinished,
hasUnsavedChanges = false,
onOpenRunnerConfig,
}: AgentDebugPanelProps) {
@@ -105,15 +111,19 @@ export default function AgentDebugPanel({
const newEventData = useCallback(
(type: string) =>
JSON.stringify(
createDebugEventData(type, {
user: t('agents.debugData.sampleUser'),
message: t('agents.debugData.sampleMessage'),
feedback: t('agents.debugData.sampleFeedback'),
}),
createDebugEventData(
type,
{
user: t('agents.debugData.sampleUser'),
message: t('agents.debugData.sampleMessage'),
feedback: t('agents.debugData.sampleFeedback'),
},
processor,
),
null,
2,
),
[t],
[t, processor],
);
const [eventDataText, setEventDataText] = useState(() =>
newEventData('message.received'),
@@ -140,21 +150,28 @@ export default function AgentDebugPanel({
const concretePatterns = supportedEventPatterns.filter(
(pattern) => pattern !== '*' && !pattern.endsWith('.*'),
);
return Array.from(new Set([...availableEventTypes, ...concretePatterns]))
return Array.from(
new Set([
...(processor ? processorDebugEventTypes : availableEventTypes),
...concretePatterns,
]),
)
.filter((candidate) =>
supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, candidate),
),
)
.sort();
}, [availableEventTypes, supportedEventPatterns]);
}, [availableEventTypes, supportedEventPatterns, processor]);
const eventGroups = useMemo(
() => groupEventPatterns(availableEvents),
[availableEvents],
);
const supportsCustomEvent = supportedEventPatterns.some(
(pattern) => pattern === '*' || pattern.endsWith('.*'),
);
const supportsCustomEvent =
!processor &&
supportedEventPatterns.some(
(pattern) => pattern === '*' || pattern.endsWith('.*'),
);
const selectPreset = useCallback(
(value: string) => {
@@ -193,7 +210,11 @@ export default function AgentDebugPanel({
toast.error(t('agents.debugInvalidPayload'));
return;
}
const invalidField = invalidDebugEventField(eventType, eventData);
const invalidField = invalidDebugEventField(
eventType,
eventData,
processor,
);
if (invalidField) {
toast.error(
t('agents.debugData.invalidField', {
@@ -202,7 +223,7 @@ export default function AgentDebugPanel({
);
return;
}
const inputText = debugEventInputText(eventType, eventData);
const inputText = debugEventInputText(eventType, eventData, processor);
let mockOptions: Record<string, unknown>;
try {
const parsed = JSON.parse(mockOptionsText || '{}');
@@ -275,12 +296,14 @@ export default function AgentDebugPanel({
? {
...entry,
finished: true,
text: executionSteps(entry.events ?? []).some(
(step) =>
step.kind === 'tool' || step.text || step.reasoning,
)
? ''
: result.final_text || t('agents.debugNoTextOutput'),
text:
processor ||
executionSteps(entry.events ?? []).some(
(step) =>
step.kind === 'tool' || step.text || step.reasoning,
)
? ''
: result.final_text || t('agents.debugNoTextOutput'),
}
: entry,
)
@@ -354,6 +377,7 @@ export default function AgentDebugPanel({
if (requestRef.current === controller) {
requestRef.current = null;
setRunning(false);
onRunFinished?.();
}
}
}
@@ -364,15 +388,25 @@ export default function AgentDebugPanel({
<div className="mb-3">
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
<p className="text-xs text-muted-foreground">
{t('agents.debugTranscriptDescription')}
{t(
processor
? 'agents.eventProcessor.debugDescription'
: 'agents.debugTranscriptDescription',
)}
</p>
</div>
{entries.length === 0 ? (
<Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" />
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
<AlertTitle>
{t(processor ? 'agents.debugTab' : 'agents.debugEmptyTitle')}
</AlertTitle>
<AlertDescription>
{t('agents.debugEmptyTranscript')}
{t(
processor
? 'agents.eventProcessor.debugDescription'
: 'agents.debugEmptyTranscript',
)}
</AlertDescription>
</Alert>
) : (
@@ -382,6 +416,7 @@ export default function AgentDebugPanel({
(entry) =>
entry.direction !== 'output' ||
entry.text ||
processor ||
executionSteps(entry.events ?? []).some(
(step) =>
step.kind === 'tool' || step.text || step.reasoning,
@@ -412,19 +447,29 @@ export default function AgentDebugPanel({
</Badge>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.direction === 'output'
? t('agents.debugAgentOutput')
? t(
processor
? 'agents.eventProcessor.debugOutput'
: 'agents.debugAgentOutput',
)
: entry.direction === 'error'
? t('common.error')
: t('agents.debugTestInput')}
</span>
</div>
{entry.events && (
<AgentExecutionTrace
events={entry.events}
finished={entry.finished}
toolLabels={toolLabels}
/>
)}
{entry.events &&
(processor ? (
<EventProcessorTrace
events={entry.events}
toolLabels={toolLabels}
/>
) : (
<AgentExecutionTrace
events={entry.events}
finished={entry.finished}
toolLabels={toolLabels}
/>
))}
{entry.text && (
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
{entry.text}
@@ -546,26 +591,37 @@ export default function AgentDebugPanel({
<AgentEventDataEditor
key={eventType}
eventType={eventType}
processor={processor}
custom={preset === 'custom'}
value={eventDataText}
onChange={setEventDataText}
/>
<details className="text-muted-foreground">
<summary className="cursor-pointer text-xs font-medium">
{t('agents.debugMockOptions')}
</summary>
<p className="my-2 text-xs text-muted-foreground">
{t('agents.debugMockOptionsHelp')}
</p>
<Textarea
aria-label={t('agents.debugMockOptions')}
value={mockOptionsText}
onChange={(event) => setMockOptionsText(event.target.value)}
className="min-h-24 font-mono text-xs"
spellCheck={false}
/>
</details>
<Collapsible>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="group"
>
<ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
{t('agents.debugMockOptions')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p className="my-2 text-xs text-muted-foreground">
{t('agents.debugMockOptionsHelp')}
</p>
<Textarea
aria-label={t('agents.debugMockOptions')}
value={mockOptionsText}
onChange={(event) => setMockOptionsText(event.target.value)}
className="min-h-24 font-mono text-xs"
spellCheck={false}
/>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
@@ -5,27 +5,48 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { debugEventDefinition, parseDebugEventData } from './debug-event-data';
import {
debugEventDefinition,
parseDebugEventData,
getDebugEventField,
setDebugEventField,
} from './debug-event-data';
export default function AgentEventDataEditor({
eventType,
value,
onChange,
custom = false,
processor = false,
}: {
eventType: string;
custom?: boolean;
processor?: boolean;
value: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [showJson, setShowJson] = useState(false);
const fields = custom ? [] : (debugEventDefinition(eventType)?.fields ?? []);
const fields = custom
? []
: (debugEventDefinition(eventType, processor)?.fields ?? []);
const data = parseDebugEventData(value);
const jsonMode = showJson || !fields.length;
const chainKey =
processor &&
(eventType === 'message.received'
? 'message_chain'
: eventType === 'message.edited'
? 'new_content'
: undefined);
const chain = chainKey && data?.[chainKey];
const richMessage =
chainKey &&
(!Array.isArray(chain) || chain.length !== 1 || chain[0]?.type !== 'Plain');
const jsonMode = showJson || !fields.length || !!richMessage;
function updateField(key: string, next: string | number | undefined) {
if (data) onChange(JSON.stringify({ ...data, [key]: next }, null, 2));
if (data)
onChange(JSON.stringify(setDebugEventField(data, key, next), null, 2));
}
return (
@@ -34,7 +55,7 @@ export default function AgentEventDataEditor({
<span className="text-xs font-medium">
{t('agents.debugData.title')}
</span>
{fields.length > 0 && (
{fields.length > 0 && !richMessage && (
<Button
type="button"
variant="ghost"
@@ -69,7 +90,7 @@ export default function AgentEventDataEditor({
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
{fields.map((field) => {
const id = `agent-debug-data-${field.key}`;
const current = data?.[field.key];
const current = getDebugEventField(data, field.key);
const text =
typeof current === 'string' || typeof current === 'number'
? current
@@ -1,6 +1,14 @@
import { useState } from 'react';
import { Settings2, Puzzle } from 'lucide-react';
import {
Popover,
PopoverTrigger,
PopoverContent,
} from '@/components/ui/popover';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import type { EventProcessorDescriptor } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import {
Select,
@@ -9,8 +17,58 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
function ProcessorComponentContent({
component,
option = false,
}: {
component: EventProcessorDescriptor;
option?: boolean;
}) {
const label = extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
});
const pluginId = `${component.plugin_author}/${component.plugin_name}`;
return (
<span
className={
option
? 'grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left'
: 'flex min-w-0 items-center gap-2'
}
>
<img
src={httpClient.getPluginIconURL(
component.plugin_author,
component.plugin_name,
)}
alt=""
className={
option
? 'row-span-2 size-7 shrink-0 rounded-md object-cover'
: 'size-5 shrink-0 rounded object-cover'
}
/>
<span className={option ? 'truncate font-medium leading-5' : 'truncate'}>
{label}
</span>
{option && (
<span
className="truncate text-xs leading-4 text-muted-foreground"
title={pluginId}
>
{pluginId}
</span>
)}
</span>
);
}
export default function EventProcessorSettings({
components,
value,
@@ -18,6 +76,7 @@ export default function EventProcessorSettings({
onChange,
onParametersChange,
onValidate,
disabled = false,
}: {
components: EventProcessorDescriptor[];
value: string;
@@ -25,66 +84,112 @@ export default function EventProcessorSettings({
onChange: (value: string) => void;
onParametersChange: (value: Record<string, unknown>) => void;
onValidate?: (validate: () => Promise<boolean>) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const selected = components.find((item) => item.id === value);
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="space-y-4">
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="event-processor-component"
<div className="flex min-w-0 items-center gap-2">
<Label className="sr-only" htmlFor="event-processor-component">
{t('agents.eventProcessor.component')}
</Label>
<Select
value={value}
disabled={disabled}
onValueChange={(next) => {
onChange(next);
const component = components.find((item) => item.id === next);
setSettingsOpen(
Boolean(
component?.config_schema.some(
(field) =>
field.required &&
(field.default == null || field.default === ''),
),
),
);
}}
>
<SelectTrigger
id="event-processor-component"
className="w-[22rem] max-w-[calc(100vw-8rem)] bg-[#ffffff] dark:bg-[#2a2a2e]"
>
{t('agents.eventProcessor.component')}
</label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="event-processor-component">
<SelectValue
placeholder={t('agents.eventProcessor.selectComponent')}
/>
</SelectTrigger>
<SelectContent>
{value && !selected && (
<SelectItem value={value}>
{t('agents.eventProcessor.unavailable')}
</SelectItem>
)}
{components.map((component) => (
<SelectItem key={component.id} value={component.id}>
{extractI18nObject({
en_US: component.id,
zh_Hans: component.id,
...component.label,
})}{' '}
· {component.plugin_author}/{component.plugin_name}
</SelectItem>
))}
</SelectContent>
</Select>
{components.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}{' '}
<Link className="text-primary underline" to="/home/plugins">
{t('agents.eventProcessor.installPlugin')}
</Link>
</p>
)}
</div>
{selected && (
<p className="break-words text-xs text-muted-foreground">
{selected.supported_event_patterns.join(' · ')}
</p>
)}
{selected ? (
<ProcessorComponentContent component={selected} />
) : (
<span className="flex min-w-0 items-center gap-2">
<Puzzle className="size-4 shrink-0 text-muted-foreground" />
<SelectValue
placeholder={t('agents.eventProcessor.selectComponent')}
/>
</span>
)}
</SelectTrigger>
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
{value && !selected && (
<SelectItem value={value}>
{t('agents.eventProcessor.unavailable')}
</SelectItem>
)}
{components.map((component) => (
<SelectItem
key={component.id}
value={component.id}
className="py-1.5 [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<ProcessorComponentContent component={component} option />
</SelectItem>
))}
{components.length === 0 && (
<div className="p-2 text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/plugins">
{t('agents.eventProcessor.installPlugin')}
</Link>
</Button>
</div>
)}
</SelectContent>
</Select>
{selected && selected.config_schema.length > 0 && (
<DynamicFormComponent
key={value}
itemConfigList={selected.config_schema}
initialValues={parameters}
onSubmit={(values) =>
onParametersChange(values as Record<string, unknown>)
}
onValidate={onValidate}
/>
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={t('agents.eventProcessor.pluginSettings')}
title={t('agents.eventProcessor.pluginSettings')}
>
<Settings2 className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[70vh] overflow-y-auto space-y-3"
>
<p className="text-sm font-medium">
{t('agents.eventProcessor.pluginSettings')}
</p>
<p className="text-xs text-muted-foreground">
{t('agents.eventProcessor.pluginSettingsDescription')}
</p>
<fieldset disabled={disabled}>
<DynamicFormComponent
key={value}
itemConfigList={selected.config_schema}
initialValues={parameters}
onSubmit={(values) =>
onParametersChange(values as Record<string, unknown>)
}
onValidate={onValidate}
/>
</fieldset>
</PopoverContent>
</Popover>
)}
</div>
);
@@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import type { DebugExecutionEvent } from './debug-execution';
export function ProcessorPayload({
title,
value,
}: {
title: ReactNode;
value: unknown;
}) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="h-auto w-full justify-start whitespace-normal text-left group"
>
<ChevronRight className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
<span className="min-w-0 break-words">{title}</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="p-3 text-xs whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{JSON.stringify(value, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
export default function EventProcessorTrace({
events,
toolLabels = {},
}: {
events: DebugExecutionEvent[];
toolLabels?: Record<string, string>;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
{events.map((event, index) =>
event.type === 'processor.log' ? (
<Alert
key={event.sequence ?? index}
variant={event.data.level === 'error' ? 'destructive' : 'default'}
>
<AlertDescription className="flex min-w-0 items-start gap-2">
<Badge variant="outline">{String(event.data.level)}</Badge>
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{String(event.data.text)}
</span>
</AlertDescription>
</Alert>
) : (
<ProcessorPayload
key={event.sequence ?? index}
title={
<>
{t(
`agents.eventProcessor.trace_${event.type.replaceAll('.', '_')}`,
{ defaultValue: event.type },
)}
{typeof event.data.tool_name === 'string' && (
<span className="ml-2 text-muted-foreground">
{toolLabels[event.data.tool_name] || event.data.tool_name}
</span>
)}
</>
}
value={event.data}
/>
),
)}
</div>
);
}
@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight } from 'lucide-react';
import type { ProcessorRun } from '@/app/infra/entities/api';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
processorRunDuration,
formatRunDuration,
} from './processor-run-timing';
export default function ProcessorRunList({
runs,
selectedId,
onSelect,
footer,
}: {
runs: ProcessorRun[];
selectedId?: string;
onSelect: (run: ProcessorRun) => void;
footer?: ReactNode;
}) {
const { t } = useTranslation();
return (
<div
role="group"
aria-label={t('agents.eventProcessor.runs')}
className="max-h-56 shrink-0 overflow-y-auto rounded-md border"
>
{runs.map((run) => {
const duration = processorRunDuration(run);
const selected = selectedId === run.run_id;
const event = run.metadata.event_type ?? '';
const label = eventPatternLabel(event, t);
return (
<Button
key={run.run_id}
variant="ghost"
aria-pressed={selected}
onClick={() => onSelect(run)}
aria-label={`${label} ${event} ${new Date(run.created_at * 1000).toLocaleString()}`}
title={event}
className="flex h-auto min-h-12 w-full justify-start gap-3 rounded-none border-b px-3 py-2 text-left text-sm font-normal last:border-b-0 aria-pressed:bg-accent"
>
<span className="min-w-0 flex-1 space-y-1">
<span className="block truncate font-medium">{label}</span>
<time
className="block text-xs text-muted-foreground"
dateTime={new Date(run.created_at * 1000).toISOString()}
>
{new Date(run.created_at * 1000).toLocaleString()}
</time>
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<Badge
variant={
run.status === 'failed' || run.status === 'timeout'
? 'destructive'
: 'outline'
}
className="px-1.5 py-0 text-xs"
>
{t(`agents.eventProcessor.status_${run.status}`, {
defaultValue: run.status,
})}
</Badge>
{duration !== null && (
<span className="font-mono text-xs text-muted-foreground">
{formatRunDuration(duration)}
</span>
)}
</span>
<ChevronRight
className={`size-4 shrink-0 ${selected ? 'text-foreground' : 'text-muted-foreground'}`}
/>
</Button>
);
})}
{footer}
</div>
);
}
@@ -155,28 +155,155 @@ const DEBUG_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> = {
},
};
// Event processors receive the SDK's typed EBA payload, rather than Agent debug aliases.
const PROCESSOR_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> =
Object.fromEntries(
Object.entries(DEBUG_EVENT_DEFINITIONS)
.filter(([type]) => type !== 'message.recalled')
.map(([type, definition]) => {
const paths: Record<string, string> = {
group_id: 'group.id',
group_name: 'group.name',
member_id: 'member.id',
member_name: 'member.nickname',
user_id: 'user.id',
user_name: 'user.nickname',
requester_id:
type === 'bot.invited_to_group' ? 'inviter.id' : 'user.id',
requester_name: 'user.nickname',
event_name: 'action',
};
return [
type,
{
...definition,
fields: definition.fields.map((field) => ({
...field,
key: paths[field.key] ?? field.key,
})),
},
];
}),
);
for (const [type, chain, sender] of [
['message.received', 'message_chain', 'sender'],
['message.edited', 'new_content', 'editor'],
]) {
PROCESSOR_EVENT_DEFINITIONS[type] = {
fields: [
{ ...message, key: `${chain}.0.text` },
{ ...userName, key: `${sender}.nickname` },
{ ...user, key: `${sender}.id` },
{ key: 'chat_id', label: 'chatId', value: 'debug-user' },
],
defaults: {
[chain]: [{ type: 'Plain', text: '' }],
chat_type: 'private',
message_id: 'debug-message',
},
messageField: `${chain}.0.text`,
};
}
for (const type of ['message.deleted', 'message.reaction']) {
const definition = PROCESSOR_EVENT_DEFINITIONS[type];
definition.fields = definition.fields.filter(
(field) => field.key !== 'group.id',
);
definition.fields.push({
key: 'chat_id',
label: 'chatId',
value: 'debug-user',
});
definition.defaults = { ...definition.defaults, chat_type: 'private' };
}
PROCESSOR_EVENT_DEFINITIONS['feedback.received'] = {
fields: [
{
key: 'feedback_content',
label: 'feedback',
value: '',
sample: 'feedback',
multiline: true,
},
{
key: 'feedback_type',
label: 'feedbackType',
value: 1,
min: 1,
max: 3,
required: true,
},
user,
messageId,
],
defaults: { feedback_id: 'debug-feedback' },
};
export const processorDebugEventTypes = Object.keys(
PROCESSOR_EVENT_DEFINITIONS,
);
export function getDebugEventField(data: unknown, path: string): unknown {
return path
.split('.')
.reduce<unknown>(
(value, key) =>
value && typeof value === 'object' && Object.hasOwn(value, key)
? (value as Record<string, unknown>)[key]
: undefined,
data,
);
}
export function setDebugEventField(
data: Record<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> {
const [key, ...rest] = path.split('.');
if (['__proto__', 'constructor', 'prototype'].includes(key)) return data;
const current = data[key];
const next = rest.length
? setDebugEventField(
current && typeof current === 'object'
? (current as Record<string, unknown>)
: {},
rest.join('.'),
value,
)
: value;
return Array.isArray(data)
? Object.assign([...data], { [key]: next })
: { ...data, [key]: next };
}
export function debugEventDefinition(
eventType: string,
processor = false,
): DebugEventDefinition | undefined {
return Object.hasOwn(DEBUG_EVENT_DEFINITIONS, eventType)
? DEBUG_EVENT_DEFINITIONS[eventType]
const definitions = processor
? PROCESSOR_EVENT_DEFINITIONS
: DEBUG_EVENT_DEFINITIONS;
return Object.hasOwn(definitions, eventType)
? definitions[eventType]
: undefined;
}
export function createDebugEventData(
eventType: string,
samples: Record<'user' | 'message' | 'feedback', string>,
processor = false,
) {
const definition = debugEventDefinition(eventType);
return {
...definition?.defaults,
...Object.fromEntries(
(definition?.fields ?? []).map((field) => [
const definition = debugEventDefinition(eventType, processor);
return (definition?.fields ?? []).reduce<Record<string, unknown>>(
(data, field) =>
setDebugEventField(
data,
field.key,
field.sample ? samples[field.sample] : field.value,
]),
),
};
),
{ ...definition?.defaults },
);
}
export function parseDebugEventData(
@@ -195,9 +322,20 @@ export function parseDebugEventData(
export function invalidDebugEventField(
eventType: string,
data: Record<string, unknown>,
processor = false,
) {
return debugEventDefinition(eventType)?.fields.find((field) => {
const value = data[field.key];
return debugEventDefinition(eventType, processor)?.fields.find((field) => {
const value = getDebugEventField(data, field.key);
// Rich messages are edited as full JSON; a first Plain component is not required.
if (processor && field.key.endsWith('.0.text')) {
const chain = data[field.key.split('.')[0]];
if (
Array.isArray(chain) &&
chain.length > 0 &&
(chain.length > 1 || chain[0]?.type !== 'Plain')
)
return false;
}
if (
value === undefined ||
value === null ||
@@ -215,7 +353,10 @@ export function invalidDebugEventField(
}
return (
typeof value !== 'string' &&
!(field.key.endsWith('_id') && typeof value === 'number')
!(
(field.key.endsWith('_id') || field.key.endsWith('.id')) &&
typeof value === 'number'
)
);
});
}
@@ -223,7 +364,9 @@ export function invalidDebugEventField(
export function debugEventInputText(
eventType: string,
data: Record<string, unknown>,
processor = false,
) {
const key = debugEventDefinition(eventType)?.messageField;
return key && typeof data[key] === 'string' ? data[key].trim() : '';
const key = debugEventDefinition(eventType, processor)?.messageField;
const value = key ? getDebugEventField(data, key) : undefined;
return typeof value === 'string' ? value.trim() : '';
}
@@ -0,0 +1,23 @@
import type { ProcessorRun } from '@/app/infra/entities/api';
function milliseconds(
precise: number | null | undefined,
seconds: number | null | undefined,
) {
if (typeof precise === 'number' && Number.isFinite(precise)) return precise;
if (typeof seconds === 'number' && Number.isFinite(seconds))
return seconds * 1000;
return null;
}
export function processorRunDuration(run: ProcessorRun): number | null {
const start = milliseconds(run.started_at_ms, run.started_at);
const end = milliseconds(run.finished_at_ms, run.finished_at);
return start !== null && end !== null && end >= start ? end - start : null;
}
export function formatRunDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`;
return `${(ms / 60000).toFixed(1)} min`;
}
@@ -900,7 +900,7 @@ function NavItems({
}
>
{item.kind === 'event_processor' ? (
<span className="text-xs"></span>
<Puzzle className="size-3.5" />
) : item.kind === 'pipeline' ? (
<Workflow className="size-3.5" />
) : (
@@ -8,6 +8,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface ProcessorMonitoringView {
@@ -26,6 +27,7 @@ interface ProcessorDetailWorkbenchProps {
title: string;
titleBadge?: ReactNode;
titleAction?: ReactNode;
titleControls?: ReactNode;
headerActions?: ReactNode;
status?: ProcessorDetailStatus | null;
saveLabel: string;
@@ -34,6 +36,7 @@ interface ProcessorDetailWorkbenchProps {
isDirty: boolean;
isSaving: boolean;
configTitle: string;
configIcon?: ReactNode;
configContent: ReactNode;
debugTitle?: string;
debugDescription?: string;
@@ -49,6 +52,7 @@ export default function ProcessorDetailWorkbench({
title,
titleBadge,
titleAction,
titleControls,
headerActions,
status,
saveLabel,
@@ -57,6 +61,7 @@ export default function ProcessorDetailWorkbench({
isDirty,
isSaving,
configTitle,
configIcon,
configContent,
debugTitle,
debugDescription,
@@ -81,10 +86,11 @@ export default function ProcessorDetailWorkbench({
className="flex h-full min-h-0 min-w-0 flex-col gap-0"
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="truncate text-xl font-semibold">{title}</h1>
{titleBadge}
{titleAction}
{titleControls}
{monitoring && (
<TabsList
aria-label={`${monitoring.workbenchLabel} / ${monitoring.label}`}
@@ -168,18 +174,20 @@ export default function ProcessorDetailWorkbench({
value="monitoring"
className="mt-0 min-h-0 flex-1 overflow-hidden"
>
<section
<Card
role="region"
aria-label={monitoring.label}
className="h-full min-h-0 overflow-y-auto rounded-xl border bg-card p-4"
className="h-full min-h-0 overflow-y-auto gap-0 p-4"
>
{monitoring.content}
</section>
</Card>
</TabsContent>
)}
<TabsContent
value="workbench"
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden"
forceMount
className="mt-0 min-h-0 flex-1 overflow-y-auto lg:overflow-hidden data-[state=inactive]:hidden"
>
<div
className={cn(
@@ -190,24 +198,27 @@ export default function ProcessorDetailWorkbench({
)}
>
{hasDebug && (
<section
<Card
role="region"
aria-label={debugTitle}
className="flex min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
className="min-h-[32rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<CardHeader className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4 [.border-b]:pb-0">
<div className="flex min-w-0 items-center gap-2 font-medium">
<Bug className="size-4 shrink-0" />
<span className="truncate">{debugTitle}</span>
{debugDescription && (
<Tooltip>
<TooltipTrigger asChild>
<button
<Button
type="button"
variant="ghost"
size="icon"
aria-label={debugDescription}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
className="size-6 shrink-0 text-muted-foreground"
>
<Info className="size-4" />
</button>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs whitespace-normal leading-relaxed">
{debugDescription}
@@ -228,19 +239,20 @@ export default function ProcessorDetailWorkbench({
: debugDisconnectedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
</CardHeader>
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden px-0">
{debugContent}
</div>
</section>
</CardContent>
</Card>
)}
<section
<Card
role="region"
aria-label={configTitle}
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
className="min-h-[36rem] min-w-0 gap-0 overflow-hidden py-0 lg:min-h-0"
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 font-medium">
<Settings className="size-4" />
<CardHeader className="flex h-12 shrink-0 flex-row items-center gap-2 border-b px-4 font-medium [.border-b]:pb-0">
{configIcon ?? <Settings className="size-4" />}
<span className="truncate">{configTitle}</span>
{isDirty && (
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
@@ -248,11 +260,11 @@ export default function ProcessorDetailWorkbench({
{unsavedLabel}
</span>
)}
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
</CardHeader>
<CardContent className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
{configContent}
</div>
</section>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>