import { useActionQuery } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconActivity, IconAdjustmentsHorizontal, IconAlertTriangle, IconCopy, IconDatabase, IconFileSearch, IconInfoCircle, IconRefresh, IconSearch, IconTool, } from "@tabler/icons-react"; import { useMemo, useState } from "react"; import { useSearchParams } from "react-router"; import { ActionQueryError } from "../../components/action-query-error"; import { DispatchShell } from "../../components/dispatch-shell"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { Popover, PopoverAnchor, PopoverContent, } from "../../components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../components/ui/select"; import { Skeleton } from "../../components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "../../components/ui/tabs"; import { cn } from "../../lib/utils"; export function meta() { return [{ title: "Thread Debug — Dispatch" }]; } interface ThreadDebugSource { id: string; label: string; kind: "current" | "env" | "configured"; current: boolean; connected: boolean; databaseUrlEnv: string | null; databaseAuthTokenEnv: string | null; canInspectAll: boolean; } interface ThreadSearchResult { id: string; ownerEmail: string; title: string; preview: string; messageCount: number; createdAt: number; updatedAt: number; snippet: string; } interface ThreadMessage { index: number; id: string | null; role: string; createdAt: string | number | null; status: unknown; text: string; contentParts: any[]; attachments: any[]; metadata: unknown; } interface ThreadRun { id: string; status: string; turnId?: string | null; abortReason: string | null; errorCode?: string | null; errorDetail?: string | null; terminalReason?: string | null; dispatchMode?: string | null; diagStage?: string | null; workerStage?: string | null; startedAt: number; completedAt: number | null; heartbeatAt: number | null; lastProgressAt?: number | null; durationMs?: number | null; peakRssMb?: number | null; inFlightSince?: number | null; hasDispatchPayload?: boolean; events: Array<{ seq: number; event: any; rawEventData: string }>; } type ThreadDebugMode = "failures" | "threads"; type FailureStatus = "all" | "errored" | "aborted" | "truncated"; type FailureRegime = "all" | "interactive" | "scheduled"; type FailureRange = "24h" | "7d" | "30d"; interface FailureTaxonomy { code: string; label: string; regime: "interactive" | "scheduled"; source: "error_code" | "error_detail" | "unknown"; } interface RunFailureLike { id?: string; status?: string | null; errorCode?: string | null; errorDetail?: string | null; terminalReason?: string | null; abortReason?: string | null; dispatchMode?: string | null; diagStage?: string | null; workerStage?: string | null; durationMs?: number | null; heartbeatAt?: number | null; lastProgressAt?: number | null; } interface AgentRunFailure { id: string; threadId: string; sourceId?: string; sourceLabel?: string; source?: { id: string; label: string; kind?: string; databaseUrlEnv?: string | null; }; ownerEmail: string; turnId?: string | null; threadTitle: string; threadPreview: string; status: string; errorCode: string | null; errorDetail: string | null; terminalReason: string | null; abortReason: string | null; heartbeatAt?: number | null; lastProgressAt?: number | null; dispatchMode: string | null; diagStage: string | null; workerStage?: string | null; startedAt: number; completedAt: number | null; durationMs: number | null; regime?: "interactive" | "scheduled"; failureTaxonomy?: FailureTaxonomy; } interface AgentRunFailuresResponse { failures: AgentRunFailure[]; sources: Array<{ source: { id: string; label: string; kind?: string; databaseUrlEnv?: string | null; }; status: "ok" | "unsupported" | "unavailable" | "disconnected"; failureCount: number; errorCode?: string | null; }>; partial: boolean; count?: number; access: { viewerEmail: string; scope: string; canInspectAll: boolean }; filters?: { sourceId?: string; status?: FailureStatus; lookbackHours?: number; limit?: number; }; } interface ThreadDebugResponse { source: { id: string; label: string; kind: string; databaseUrlEnv: string | null; }; access: { viewerEmail: string; scope: string; canInspectAll: boolean }; thread: ThreadSearchResult; lookup?: { requestedId: string; threadId: string; runId: string | null }; messages: ThreadMessage[]; debug: any; debugRuns: any[]; queuedMessages: any[]; threadData: any; rawThreadData: string; runs: ThreadRun[]; traces: { summaries: any[]; spans: any[] }; feedback: any[]; satisfaction: any[]; evals: any[]; checkpoints: any[]; } const FAILURE_RANGE_HOURS: Record = { "24h": 24, "7d": 7 * 24, "30d": 30 * 24, }; const EMPTY_FAILURES: AgentRunFailure[] = []; function parseMode(value: string | null): ThreadDebugMode { return value === "threads" ? "threads" : "failures"; } function parseFailureStatus(value: string | null): FailureStatus { return value === "errored" || value === "aborted" || value === "truncated" ? value : "all"; } function parseFailureRegime(value: string | null): FailureRegime { return value === "interactive" || value === "scheduled" ? value : "all"; } function parseFailureRange(value: string | null): FailureRange { return value === "7d" || value === "30d" ? value : "24h"; } function failureSourceId(failure: AgentRunFailure): string { return failure.sourceId || failure.source?.id || "current"; } function failureSourceLabel(failure: AgentRunFailure): string { return ( failure.sourceLabel || failure.source?.label || failureSourceId(failure) ); } function isTechnicalIdentifier(value: string): boolean { return /^(?:job|run|thread|turn|request|req)-[a-z0-9][a-z0-9_-]*$/i.test( value.trim(), ); } function displayListTitle( value: string | null | undefined, fallbackValue: string | null | undefined, fallbackLabel: string, ): string { const candidate = [value, fallbackValue] .map((entry) => entry?.trim() || "") .find((entry) => entry && !isTechnicalIdentifier(entry)); if (!candidate) return fallbackLabel; const withoutDate = candidate .replace(/\s+[—-]\s+\d{1,2}\/\d{1,2}\/\d{4}\s*$/, "") .trim(); if (!withoutDate) return fallbackLabel; if (/^job:/i.test(withoutDate)) { return withoutDate .replace(/^job:\s*/i, "") .replace(/[-_]+/g, " ") .replace(/\s+/g, " ") .replace(/\b\w/g, (character) => character.toUpperCase()) .trim(); } return withoutDate; } function formatDate(value: number | string | null | undefined): string { if (value == null || value === "") return "n/a"; const numeric = Number(value); const date = Number.isFinite(numeric) ? new Date(numeric) : new Date(value); if (Number.isNaN(date.getTime())) return "n/a"; return date.toLocaleString(); } function formatRelativeDate(value: number | string | null | undefined): string { if (value == null || value === "") return "n/a"; const numeric = Number(value); const timestamp = Number.isFinite(numeric) ? numeric : new Date(value).getTime(); if (!Number.isFinite(timestamp)) return "n/a"; const elapsed = Date.now() - timestamp; if (elapsed < 0) return "in a moment"; if (elapsed < 60_000) return "just now"; if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`; if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`; return `${Math.floor(elapsed / 86_400_000)}d ago`; } function formatDuration(value: number | null | undefined): string { if (value == null || !Number.isFinite(value)) return "n/a"; if (value < 1_000) return `${Math.round(value)}ms`; if (value < 60_000) return `${(value / 1_000).toFixed(1)}s`; return `${(value / 60_000).toFixed(1)}m`; } function humanizeIdentifier(value: string | null | undefined): string { if (!value) return "Unknown failure"; return value .replace(/^(error:|failure:)/, "") .replace(/[_-]+/g, " ") .replace(/\b\w/g, (character) => character.toUpperCase()); } function failureLabel(run: RunFailureLike): string { const code = run.errorCode || run.terminalReason || run.abortReason; const labels: Record = { stale_run: "Worker heartbeat stopped", background_worker_never_started: "Background worker never started", background_worker_failed: "Background worker setup failed", builder_gateway_network_error: "Gateway stream ended early", provider_timeout: "Provider timed out", provider_network_error: "Provider connection dropped", provider_config_error: "Provider configuration rejected", authentication_error: "Provider authentication failed", overloaded_error: "Provider was overloaded", "aborted:user": "Stopped by user", }; return (code && labels[code]) || humanizeIdentifier(code); } function runDiagnosis(run: RunFailureLike): { title: string; summary: string; nextStep: string; code: string; } { const code = run.errorCode || run.terminalReason || run.abortReason || "unknown"; if (code === "stale_run") { const workerStarted = run.dispatchMode === "background-processing"; return { title: "Worker stopped reporting", summary: workerStarted ? "The worker claimed this run, then stopped writing heartbeat or progress signals before it finished. This is a liveness failure, not proof that the provider failed." : "The run stayed active until liveness recovery closed it. No completed result was recorded, so inspect the handoff and retained evidence before blaming a provider.", nextStep: workerStarted ? "Check the last worker stage and database heartbeat writes." : "Check the scheduled background handoff and whether a worker claimed it.", code, }; } if (code === "background_worker_never_started") { return { title: "Background worker never started", summary: "The handoff was acknowledged, but no background worker claimed the run.", nextStep: "Check the background route, authentication, and function logs.", code, }; } if (code === "background_worker_failed") { return { title: "Background worker failed during setup", summary: "The worker claimed the run but stopped before it could start the turn.", nextStep: "Use the recorded setup stage and worker logs to find the first failure.", code, }; } if (code === "builder_gateway_network_error") { return { title: "Gateway stream ended early", summary: run.errorDetail || "The model stream ended before the run emitted a terminal event.", nextStep: "Retry once; if it repeats, inspect gateway and provider transport health.", code, }; } if (code === "aborted:user") { return { title: "Stopped by user", summary: "This run was explicitly aborted and is not a system failure.", nextStep: "No recovery action is required.", code, }; } return { title: failureLabel(run), summary: run.errorDetail || "The run ended without a more specific explanation.", nextStep: "Open the timeline and inspect the last recorded stage or event.", code, }; } function eventType(event: any): string { return typeof event?.type === "string" ? event.type : "event"; } function eventIsNoise(event: any): boolean { return [ "thinking", "text", "tool_input_delta", "stream_keepalive", "activity", ].includes(eventType(event)); } function summarizeEvents(events: ThreadRun["events"]) { const toolNames = new Map(); let toolStarts = 0; for (const entry of events) { if (eventType(entry.event) !== "tool_start") continue; toolStarts += 1; const name = String(entry.event?.tool ?? "tool"); toolNames.set(name, (toolNames.get(name) ?? 0) + 1); } return { toolStarts, toolNames: [...toolNames.entries()], meaningful: events.filter((entry) => !eventIsNoise(entry.event)).slice(-12), }; } function json(value: unknown): string { try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function eventLabel(event: any): string { if (!event || typeof event !== "object") return "event"; if (event.type === "tool_start") return `tool_start · ${event.tool}`; if (event.type === "tool_done") return `tool_done · ${event.tool}`; if (event.type === "text") return "text"; if (event.type === "error") return `error · ${event.errorCode ?? "agent"}`; return String(event.type ?? "event"); } function messageTitle(message: ThreadMessage): string { const role = message.role || "unknown"; return `${role.charAt(0).toUpperCase()}${role.slice(1)} ${message.index + 1}`; } function toolParts(message: ThreadMessage): any[] { return message.contentParts.filter((part) => part?.type === "tool-call"); } function diagnosticStage(value: string | null | undefined): string | null { if (!value) return null; try { const parsed = JSON.parse(value) as { stage?: unknown; detail?: unknown }; const stage = typeof parsed.stage === "string" && parsed.stage.trim() ? parsed.stage.trim() : value; const detail = typeof parsed.detail === "string" && parsed.detail.trim() ? parsed.detail.trim() : ""; return detail ? `${stage}: ${detail}` : stage; } catch { return value; } } function RawBlock({ value, className, }: { value: unknown; className?: string; }) { return (
      {typeof value === "string" ? value : json(value)}
    
); } function ResultCard({ result, selected, onSelect, }: { result: ThreadSearchResult; selected: boolean; onSelect: () => void; }) { const title = displayListTitle( result.title, result.preview, "Untitled thread", ); return ( ); } function FailureCard({ failure, selected, onSelect, }: { failure: AgentRunFailure; selected: boolean; onSelect: () => void; }) { const t = useT(); const diagnosis = runDiagnosis(failure); const title = displayListTitle( failure.threadTitle, failure.threadPreview, "Agent run", ); const statusLabel = failure.status === "errored" ? t("dispatch.pages.threadDebugErrored", { defaultValue: "Errored", }) : failure.status === "aborted" ? t("dispatch.pages.threadDebugAborted", { defaultValue: "Aborted", }) : failure.status === "truncated" ? t("dispatch.pages.threadDebugTruncated", { defaultValue: "Truncated", }) : failure.status; return ( ); } function MessageBlock({ message }: { message: ThreadMessage }) { const tools = toolParts(message); return (
{message.role} {messageTitle(message)}
{message.attachments.length > 0 ? ( {message.attachments.length} files ) : null} {formatDate(message.createdAt)}
{message.text ? (
{message.text}
) : (
No text content
)} {tools.length > 0 ? (
{tools.map((tool, index) => (
{tool.toolName ?? tool.name ?? "tool-call"}
))}
) : null}
); } function EvidenceStat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function DiagnosisPanel({ run, eventCount, toolCount, }: { run: ThreadRun; eventCount: number; toolCount: number; }) { const diagnosis = runDiagnosis(run); const isStoppedByUser = diagnosis.code === "aborted:user"; const isScheduled = run.id?.startsWith("job-") || run.dispatchMode === "background-processing"; const workerClaimed = run.dispatchMode === "background-processing"; const staleThreshold = isScheduled ? workerClaimed ? "45s after claim" : "90s before claim" : "15s"; return (
Evidence:{" "} {eventCount.toLocaleString()} {" "} retained events {toolCount.toLocaleString()} {" "} tool starts Liveness uses the newer heartbeat or progress timestamp.
); } function RunTimeline({ run }: { run: ThreadRun }) { const summary = summarizeEvents(run.events); return (
{run.events.length.toLocaleString()} events retained {summary.toolStarts} tool starts
Started {formatDate(run.startedAt)}
{summary.toolNames.length > 0 ? (
{summary.toolNames.map(([name, count]) => ( {name} ×{count} ))}
) : null}
{summary.meaningful.length > 0 ? ( summary.meaningful.map((entry) => (
#{entry.seq} {eventLabel(entry.event)}
)) ) : (
No summarized events were retained for this run.
)}
Show raw event stream {run.events.length.toLocaleString()} records
{run.events.map((entry) => (
#{entry.seq} {eventLabel(entry.event)}
))}
); } function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) { const rawBundle = useMemo( () => ({ thread: detail.thread, debug: detail.debug, debugRuns: detail.debugRuns, queuedMessages: detail.queuedMessages, threadData: detail.threadData, runs: detail.runs, traces: detail.traces, feedback: detail.feedback, satisfaction: detail.satisfaction, evals: detail.evals, checkpoints: detail.checkpoints, }), [detail], ); const primaryRun = detail.runs.find((run) => run.id === detail.lookup?.runId) ?? detail.runs[0] ?? null; const eventCount = detail.runs.reduce( (total, run) => total + run.events.length, 0, ); const toolCount = detail.runs.reduce( (total, run) => total + summarizeEvents(run.events).toolStarts, 0, ); return (
{detail.thread.title || detail.thread.preview || detail.thread.id}
{detail.lookup?.runId || detail.thread.id} {detail.lookup?.runId ? ( ) : null}
{primaryRun ? ( {primaryRun.status} ) : null} {detail.source.label}
{detail.thread.ownerEmail} {detail.messages.length} messages {detail.runs.length} runs updated {formatDate(detail.thread.updatedAt)}
{primaryRun ? ( ) : null} Overview Timeline Transcript Technical

This is the compact readout. Open Timeline for the last meaningful signals, Transcript for persisted messages and tool calls, or Technical for raw records.

{detail.messages.length === 0 && eventCount > 0 ? (
No persisted messages are available, but this run retained{" "} {eventCount.toLocaleString()} execution events. The timeline is the authoritative audit trail for this run.
) : null}
{detail.runs.length > 0 ? ( detail.runs.map((run) => (
{run.status} {run.id} {formatDuration(run.durationMs)}
)) ) : (
No retained runs.
)}
{detail.messages.length > 0 ? ( detail.messages.map((message) => ( )) ) : (
No persisted messages.
{eventCount > 0 ? (
Open Timeline to audit the retained execution events.
) : null}
)}
Run records {detail.runs.length} {detail.runs.length === 1 ? "run" : "runs"}
{detail.runs.length > 0 ? ( detail.runs.map((run) => (
{run.status} {run.id}
{run.errorDetail ? (
{run.errorDetail}
) : null}
)) ) : (
No retained run records.
)}
Traces, feedback, and evaluations diagnostic records
Debug runs
0 ? detail.debugRuns : (detail.debug ?? {}) } />
Trace summaries
Trace spans
Feedback and evals
Raw thread bundle JSON
); } export default function ThreadDebugRoute() { const t = useT(); const [routeSearchParams, setRouteSearchParams] = useSearchParams(); const mode = parseMode(routeSearchParams.get("mode")); const sourceId = routeSearchParams.get("source") || (mode === "failures" ? "all" : "current"); const ownerEmail = routeSearchParams.get("owner") || ""; const query = routeSearchParams.get("query") || ""; const status = parseFailureStatus(routeSearchParams.get("status")); const regime = parseFailureRegime(routeSearchParams.get("regime")); const range = parseFailureRange(routeSearchParams.get("range")); const runId = routeSearchParams.get("runId") || ""; const threadId = routeSearchParams.get("threadId") || ""; const inspectSourceId = routeSearchParams.get("inspectSource") || ""; const [lookupId, setLookupId] = useState(""); const [searchFocused, setSearchFocused] = useState(false); function updateRouteState( updates: Record, ) { const next = new URLSearchParams(routeSearchParams); for (const [key, value] of Object.entries(updates)) { if (value == null || value === "") next.delete(key); else next.set(key, value); } setRouteSearchParams(next, { replace: true }); } const sourcesQuery = useActionQuery<{ access: { viewerEmail: string; orgId: string | null; role: string | null; envAdmin: boolean; canInspectAll: boolean; memberCount: number; }; sources: ThreadDebugSource[]; }>("list-agent-thread-sources", {}); const { data: sourcesData } = sourcesQuery; const sources: ThreadDebugSource[] = sourcesData?.sources ?? []; const failureParams = useMemo( () => ({ sourceId, ownerEmail: ownerEmail.trim() || undefined, status, regime, lookbackHours: FAILURE_RANGE_HOURS[range], limit: 25, }), [ownerEmail, range, regime, sourceId, status], ); const { data: failuresData, isLoading: failuresLoading, error: failuresError, refetch: refetchFailures, } = useActionQuery( "list-agent-run-failures", failureParams, { enabled: mode === "failures" }, ); const failures = failuresData?.failures ?? EMPTY_FAILURES; const failurePatterns = useMemo(() => { const patterns = new Map(); for (const failure of failures) { const diagnosis = runDiagnosis(failure); const current = patterns.get(diagnosis.code); patterns.set(diagnosis.code, { label: diagnosis.title, count: (current?.count ?? 0) + 1, }); } return [...patterns.values()].sort((a, b) => b.count - a.count); }, [failures]); const unavailableFailureSources = (failuresData?.sources ?? []).filter( (source) => source.status !== "ok", ); const failureSourceStatusLabels = { ok: "ok", disconnected: t("dispatch.pages.threadDebugDisconnected", { defaultValue: "disconnected", }), unsupported: t("dispatch.pages.threadDebugUnsupported", { defaultValue: "unsupported", }), unavailable: t("dispatch.pages.threadDebugUnavailable", { defaultValue: "unavailable", }), }; const threadSourceId = sourceId === "all" ? "current" : sourceId; const detailSourceId = runId && inspectSourceId ? inspectSourceId : threadSourceId; const searchParams = useMemo( () => ({ sourceId: threadSourceId, query: query.trim() || undefined, limit: 25, }), [query, threadSourceId], ); const { data: searchData, isLoading: searchLoading, error: searchError, refetch: refetchSearch, } = useActionQuery<{ count: number; threads: ThreadSearchResult[]; access: { scope: string; canInspectAll: boolean }; source: { id: string; label: string }; }>("search-agent-threads", searchParams, { enabled: mode === "threads" }); const searchThreads: ThreadSearchResult[] = searchData?.threads ?? []; const ownerEmailSuggestions = useMemo(() => { const emailQuery = query.trim().includes("@") ? query.trim().toLowerCase() : ""; return [...new Set(searchThreads.map((thread) => thread.ownerEmail))] .filter( (email) => !emailQuery || email.toLowerCase().includes(emailQuery), ) .slice(0, 8); }, [query, searchThreads]); const detailParams = useMemo( () => ({ sourceId: detailSourceId, ...(runId ? { runId } : { threadId }), ownerEmail: ownerEmail.trim() || undefined, maxRuns: 20, maxEvents: 800, maxTraceSpans: 600, }), [detailSourceId, ownerEmail, runId, threadId], ); const { data: detail, isLoading: detailLoading, error: detailError, refetch: refetchDetail, } = useActionQuery( "get-agent-thread-debug", detailParams, { enabled: Boolean(runId || threadId), }, ); const detailPane = (
{detailError ? ( void refetchDetail()} /> ) : null} {detailLoading ? (
) : detail ? ( ) : (
Choose a run to inspect
See the diagnosis first, then open the timeline or technical evidence.
)}
); return (
{sourcesQuery.isError ? ( void sourcesQuery.refetch()} /> ) : null} { const nextMode = parseMode(value); updateRouteState({ mode: nextMode, source: nextMode === "threads" && sourceId === "all" ? "current" : sourceId, runId: null, threadId: null, inspectSource: null, }); }} > {t("dispatch.pages.threadDebugFailedRuns", { defaultValue: "Failed runs", })} {t("dispatch.pages.threadDebugThreads", { defaultValue: "Threads", })}

Run health

Start with the dominant failure pattern, then open one run.

updateRouteState({ owner: event.target.value }) } aria-label={t("dispatch.pages.threadDebugOwner", { defaultValue: "Owner email", })} placeholder={t("dispatch.pages.threadDebugOwner", { defaultValue: "Owner email", })} />
{unavailableFailureSources.length > 0 ? (
{t("dispatch.pages.threadDebugUnavailableSources", { defaultValue: "Unavailable sources:", })}{" "} {unavailableFailureSources .map( ({ source, status: sourceStatus }) => `${source.label} (${failureSourceStatusLabels[sourceStatus]})`, ) .join(", ")}
) : null} {failuresData?.partial ? (
{t("dispatch.pages.threadDebugPartialResults", { defaultValue: "Partial results", })}
) : null}
{failuresError ? ( void refetchFailures()} /> ) : null}
Needs attention
{failurePatterns[0] ? `${failurePatterns[0].count} ${failurePatterns[0].label.toLowerCase()}` : "No active failure pattern"}
{failures.length}
{failuresLoading ? ( <> ) : null} {!failuresLoading && failures.length === 0 ? (
{t("dispatch.pages.threadDebugNoFailures", { defaultValue: "No failed runs found.", })}
) : null} {failures.map((failure) => ( updateRouteState({ inspectSource: failureSourceId(failure), runId: failure.id, threadId: null, }) } /> ))}
{detailPane}
0} onOpenChange={setSearchFocused} > updateRouteState({ query: event.target.value }) } onFocus={() => setSearchFocused(true)} placeholder={t( "dispatch.pages.threadDebugSearchPlaceholder", { defaultValue: "Search threads or email", }, )} aria-label="Search threads or email" /> event.preventDefault()} >
{ownerEmailSuggestions.map((email) => ( ))}
setLookupId(event.target.value)} placeholder={t( "dispatch.pages.threadDebugLookupPlaceholder", { defaultValue: "Paste thread or request/run ID", }, )} className="font-mono" />
{searchError ? ( void refetchSearch()} /> ) : null}
{searchLoading ? ( <> ) : null} {!searchLoading && searchThreads.length === 0 ? (
{t("dispatch.pages.threadDebugNoThreads", { defaultValue: "No threads found.", })}
) : null} {searchThreads.map((result) => ( updateRouteState({ threadId: result.id, runId: null, inspectSource: null, }) } /> ))}
{detailPane}
); }