Files
vercel__workflow/packages/web/app/components/run-detail-view.tsx
Mitul Shah 685528259d feat(web): add trace step shortcut helper (#2582)
* feat(web): add trace step shortcut helper

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web): compact trace shortcut helper

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web): reuse kbd for shortcut helper

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web): add kbd variants

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web): animate shortcut helper copy

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): smooth shortcut helper transition

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): space shortcut helper keys

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): polish shortcut helper animation

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web): move shortcut helper into trace viewer

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web-shared): extract trace shortcut helper into own module

Move the rotating shortcut helper out of trace-viewer.tsx into a
dedicated component with a CSS-driven crossfade, center it over the
whole viewer, and always rotate both hints.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ship

* deslop

* Update trace step shortcut helper text

Add helper text to indicate keyboard shortcuts for trace step.

Signed-off-by: Mitul Shah <mitulxshah@gmail.com>

---------

Signed-off-by: Mitul Shah <mitulxshah@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:45:39 +00:00

964 lines
33 KiB
TypeScript

import { parseWorkflowName } from '@workflow/utils/parse-name';
import type { SpanSelectionInfo } from '@workflow/web-shared';
import {
DecryptButton,
ErrorBoundary,
EventListView,
hydrateResourceIO,
hydrateResourceIOWithKey,
NewTraceViewer,
type SidebarDataContextValue,
StreamViewer,
stepEventsToStepEntity,
} from '@workflow/web-shared';
import type { Event, WorkflowRun } from '@workflow/world';
import {
AlertCircle,
GitBranch,
HelpCircle,
List,
Loader2,
Lock,
} from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '~/components/ui/alert-dialog';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '~/components/ui/breadcrumb';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '~/components/ui/tooltip';
import { useEventsListData } from '~/lib/client/hooks/use-events-list-data';
import { mapRunToExecution } from '~/lib/flow-graph/graph-execution-mapper';
import { useWorkflowGraphManifest } from '~/lib/flow-graph/use-workflow-graph';
import { useStreamReader } from '~/lib/hooks/use-stream-reader';
import { fetchEvent, getEncryptionKeyForRun } from '~/lib/rpc-client';
import type { EnvMap } from '~/lib/types';
import {
cancelRun,
recreateRun,
resumeHook,
unwrapServerActionResult,
useWorkflowResourceData,
useWorkflowStreams,
useWorkflowTraceViewerData,
wakeUpRun,
} from '~/lib/workflow-api-client';
import { useServerConfig } from '~/lib/world-config-context';
import { CopyableText } from './display-utils/copyable-text';
import { LiveStatus } from './display-utils/live-status';
import { RelativeTime } from './display-utils/relative-time';
import { StatusBadge } from './display-utils/status-badge';
import { WorkflowGraphExecutionViewer } from './flow-graph/workflow-graph-execution-viewer';
import { RunActionsButtons } from './run-actions';
import { Skeleton } from './ui/skeleton';
/**
* Graph tab content component that fetches the manifest internally
* This ensures the manifest is only fetched when the Graph tab is mounted
*/
function GraphTabContent({
run,
allEvents,
env,
}: {
run: WorkflowRun;
allEvents: Event[] | null;
env: EnvMap;
}) {
// Fetch workflow graph manifest only when this tab is mounted
const {
manifest: graphManifest,
loading: graphLoading,
error: graphError,
} = useWorkflowGraphManifest();
// Find the workflow graph for this run
const workflowGraph = useMemo(() => {
if (!graphManifest || !run.workflowName) return null;
const runWorkflowName = String(run.workflowName).trim();
// Primary lookup: manifest key match.
const direct = graphManifest.workflows[runWorkflowName];
if (direct) return direct;
const runShortName = parseWorkflowName(runWorkflowName)?.shortName;
const workflows = Object.values(graphManifest.workflows);
// Fallbacks: workflowId/workflowName/shortName match.
return (
workflows.find((wf) => {
if (wf.workflowId === runWorkflowName) return true;
if (wf.workflowName === runWorkflowName) return true;
if (!runShortName) return false;
return parseWorkflowName(wf.workflowName)?.shortName === runShortName;
}) ?? null
);
}, [graphManifest, run.workflowName]);
// Reconstruct step entities from events for the graph mapper
const stepsFromEvents = useMemo(() => {
if (!allEvents) return [];
const stepEventsMap = new Map<string, Event[]>();
for (const event of allEvents) {
if (event.eventType.startsWith('step_') && event.correlationId) {
const existing = stepEventsMap.get(event.correlationId);
if (existing) {
existing.push(event);
} else {
stepEventsMap.set(event.correlationId, [event]);
}
}
}
return Array.from(stepEventsMap.values())
.map(stepEventsToStepEntity)
.filter((s): s is NonNullable<typeof s> => s !== null);
}, [allEvents]);
// Map run data to execution overlay
const execution = useMemo(() => {
if (!workflowGraph || !run.runId) return null;
return mapRunToExecution(
run,
stepsFromEvents as any,
allEvents || [],
workflowGraph
);
}, [workflowGraph, run, stepsFromEvents, allEvents]);
if (graphLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-4 text-muted-foreground">
Loading workflow graph…
</span>
</div>
);
}
if (graphError) {
return (
<div className="flex items-center justify-center w-full h-full p-4">
<Alert variant="destructive" className="max-w-lg">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Loading Workflow Graph</AlertTitle>
<AlertDescription>{graphError.message}</AlertDescription>
</Alert>
</div>
);
}
if (!workflowGraph) {
return (
<div className="flex items-center justify-center w-full h-full">
<Alert className="max-w-lg">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Workflow Graph Not Found</AlertTitle>
<AlertDescription>
Could not find the workflow graph for this run. The workflow may
have been deleted or the graph manifest may need to be regenerated.
</AlertDescription>
</Alert>
</div>
);
}
return (
<WorkflowGraphExecutionViewer
workflow={workflowGraph}
execution={execution || undefined}
env={env}
/>
);
}
interface RunDetailViewProps {
runId: string;
selectedId?: string;
}
type Tab = 'trace' | 'graph' | 'streams' | 'events';
export function RunDetailView({
runId,
// TODO: This should open the right sidebar within the trace viewer
selectedId: _selectedId,
}: RunDetailViewProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { serverConfig } = useServerConfig();
const [cancelling, setCancelling] = useState(false);
const [rerunning, setRerunning] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [showRerunDialog, setShowRerunDialog] = useState(false);
// Empty env object - server actions read from process.env
const env: EnvMap = useMemo(() => ({}), []);
// Read tab and streamId from URL search params (graph tab is hidden for now)
const rawTab = (searchParams.get('tab') as Tab) || 'trace';
const activeTab = rawTab === 'graph' ? 'trace' : rawTab;
const selectedStreamId = searchParams.get('streamId');
// Helper to update URL search params
const updateSearchParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
}
navigate(`?${params.toString()}`, { preventScrollReset: true });
},
[navigate, searchParams]
);
const setActiveTab = useCallback(
(tab: Tab) => {
// When switching away from streams tab, clear streamId so
// useStreamReader stops fetching/polling in the background.
if (tab !== 'streams') {
updateSearchParams({ tab, streamId: null });
} else {
updateSearchParams({ tab });
}
},
[updateSearchParams]
);
const setSelectedStreamId = useCallback(
(streamId: string | null) => {
updateSearchParams({ streamId });
},
[updateSearchParams]
);
// Handler for clicking on stream refs in the trace viewer
const handleStreamClick = useCallback(
(streamId: string) => {
updateSearchParams({ tab: 'streams', streamId });
},
[updateSearchParams]
);
const handleRunRefClick = useCallback(
(targetRunId: string) => {
// Navigate to the target run with a clean URL (no search params)
// so the sidebar panel resets
navigate(`/run/${encodeURIComponent(targetRunId)}`);
},
[navigate]
);
const handleWakeUpSleep = useCallback(
async (runId: string, correlationId: string) => {
return wakeUpRun(env, runId, { correlationIds: [correlationId] });
},
[env]
);
// Ref for encryption key so event loading callbacks can access it
// without being recreated when the key changes
const encryptionKeyRef = useRef<Uint8Array | null>(null);
const handleResolveHook = useCallback(
async (hookToken: string, payload: unknown) => {
await resumeHook(env, hookToken, payload);
},
[env]
);
const handleLoadEventData = useCallback(
async (event: Event) => {
const { error, result } = await unwrapServerActionResult(
fetchEvent(env, event.runId, event.eventId, 'all')
);
if (error) {
throw error;
}
const fullEvent = encryptionKeyRef.current
? await hydrateResourceIOWithKey(result, encryptionKeyRef.current)
: hydrateResourceIO(result);
if ('eventData' in fullEvent) {
return fullEvent.eventData;
}
return null;
},
[env]
);
// Callback for sidebar EventsList — takes (correlationId, eventId)
const handleLoadSidebarEventData = useCallback(
async (_correlationId: string, eventId: string) => {
const { error, result } = await unwrapServerActionResult(
fetchEvent(env, runId, eventId, 'all')
);
if (error) {
throw error;
}
const fullEvent = encryptionKeyRef.current
? await hydrateResourceIOWithKey(result, encryptionKeyRef.current)
: hydrateResourceIO(result);
if ('eventData' in fullEvent) {
return fullEvent.eventData;
}
return null;
},
[env, runId]
);
// Only show graph tab for local backend
const isLocalBackend =
serverConfig.backendId === 'local' ||
serverConfig.backendId === '@workflow/world-local';
// Fetch run + events for the trace viewer (always asc)
const {
run: runData,
events: allEvents,
loading,
error,
update,
hasEncryptedData,
loadMoreTraceData,
hasMoreTraceData,
isLoadingMoreTraceData,
} = useWorkflowTraceViewerData(env, runId, { live: true });
const run = runData ?? ({} as WorkflowRun);
// Encryption key persisted for the lifetime of this run page.
// Once fetched (via Decrypt button), it's used automatically for all
// subsequent resource + event hydration, even across span selection changes.
const [encryptionKey, setEncryptionKey] = useState<Uint8Array | null>(null);
encryptionKeyRef.current = encryptionKey;
// Separate event fetching for the Events tab with user-controlled sort order
const [eventsSortOrder, setEventsSortOrder] = useState<'asc' | 'desc'>('asc');
const {
events: eventsListData,
loading: eventsListLoading,
hasMore: hasMoreEventsTab,
loadingMore: loadingMoreEventsTab,
loadMore: loadMoreEventsTab,
searchByExactId,
} = useEventsListData(env, runId, {
sortOrder: eventsSortOrder,
encryptionKey: encryptionKey ?? undefined,
enabled: activeTab === 'events',
});
const [spanSelection, setSpanSelection] = useState<SpanSelectionInfo | null>(
null
);
const {
data: spanDetailData,
loading: spanDetailLoading,
error: spanDetailError,
refresh: refreshSpanDetail,
} = useWorkflowResourceData(
env,
spanSelection?.resource ?? 'run',
spanSelection?.resourceId ?? '',
{
runId: spanSelection?.runId,
enabled: Boolean(
spanSelection?.resource &&
spanSelection?.resourceId &&
spanSelection.resource !== 'hook'
),
encryptionKey: encryptionKey ?? undefined,
}
);
const [isDecrypting, setIsDecrypting] = useState(false);
const handleDecrypt = useCallback(async () => {
if (encryptionKey) {
refreshSpanDetail();
return;
}
setIsDecrypting(true);
try {
const { error: keyError, result: keyResult } =
await unwrapServerActionResult(getEncryptionKeyForRun(env, runId));
if (keyError) {
toast.error(`Failed to fetch encryption key: ${keyError.message}`);
return;
}
if (!keyResult) {
toast.error('Encryption is not configured for this deployment.');
return;
}
setEncryptionKey(keyResult);
} finally {
setIsDecrypting(false);
}
}, [encryptionKey, env, runId, refreshSpanDetail]);
const handleSpanSelect = useCallback((info: SpanSelectionInfo) => {
setSpanSelection(info);
}, []);
const sidebarData = useMemo<SidebarDataContextValue>(
() => ({
run,
events: allEvents ?? [],
spanDetailData: spanDetailData ?? null,
spanDetailError,
spanDetailLoading,
onSpanSelect: handleSpanSelect,
onStreamClick: handleStreamClick,
onRunClick: handleRunRefClick,
onWakeUpSleep: handleWakeUpSleep,
onLoadEventData: handleLoadSidebarEventData,
onResolveHook: handleResolveHook,
encryptionKey: encryptionKey ?? undefined,
onDecrypt: handleDecrypt,
isDecrypting,
hasEncryptedData,
}),
[
run,
allEvents,
spanDetailData,
spanDetailError,
spanDetailLoading,
handleSpanSelect,
handleStreamClick,
handleRunRefClick,
handleWakeUpSleep,
handleLoadSidebarEventData,
handleResolveHook,
encryptionKey,
handleDecrypt,
isDecrypting,
hasEncryptedData,
]
);
// Fetch streams for this run
const {
streams,
loading: streamsLoading,
error: streamsError,
} = useWorkflowStreams(env, runId);
const {
chunks: streamChunks,
isLive: streamIsLive,
error: streamError,
} = useStreamReader(env, selectedStreamId, runId, encryptionKey, run.status);
const handleCancelClick = () => {
setShowCancelDialog(true);
};
const handleConfirmCancel = async () => {
if (cancelling) return;
try {
setCancelling(true);
setShowCancelDialog(false);
await cancelRun(env, runId);
// Trigger a refresh of the data
await update();
toast.success('Run cancelled');
} catch (err) {
console.error('Failed to cancel run:', err);
toast.error('Failed to cancel run', {
description:
err instanceof Error ? err.message : 'An unknown error occurred',
});
} finally {
setCancelling(false);
}
};
const handleRerunClick = () => {
setShowRerunDialog(true);
};
const handleConfirmRerun = async () => {
if (rerunning) return;
try {
setRerunning(true);
setShowRerunDialog(false);
// Start a new run with the same workflow and input arguments
const newRunId = await recreateRun(env, run.runId);
toast.success('New run started', {
description: `Run ID: ${newRunId}`,
});
// Navigate to the new run
navigate(`/run/${newRunId}`);
} catch (err) {
console.error('Failed to re-run workflow:', err);
toast.error('Failed to start new run', {
description:
err instanceof Error ? err.message : 'An unknown error occurred',
});
} finally {
setRerunning(false);
setShowRerunDialog(false);
}
};
if (error && !runData) {
return (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading workflow run</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
const workflowName = parseWorkflowName(run.workflowName)?.shortName;
// At this point, we've already returned if there was an error
// So hasError is always false here
const hasError = false;
const errorMessage = '';
return (
<>
{/* Cancel Confirmation Dialog */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Workflow Run?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the workflow execution immediately, and no further
steps will be executed. Partial workflow execution may occur. Are
you sure you want to cancel the run?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep Running</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmCancel}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Cancel Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Replay Run Confirmation Dialog */}
<AlertDialog open={showRerunDialog} onOpenChange={setShowRerunDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Replay Run?</AlertDialogTitle>
<AlertDialogDescription>
This can potentially re-run code that is meant to only execute
once. Are you sure you want to replay the workflow run?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmRerun}>
Replay Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="flex flex-col h-[calc(100vh-97px)]">
<div className="flex-none space-y-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/">Runs</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="font-mono text-xs">
{runId}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{/* Run Overview Header */}
<div className="space-y-4 p-6 border rounded-lg">
{/* Title Row */}
<div className="flex items-start justify-between">
<div className="mb-4">
<h1 className="text-xl font-semibold">
{workflowName ? (
workflowName
) : (
<Skeleton className="w-[260px] h-[28px]" />
)}
</h1>
</div>
<div className="flex items-center justify-between gap-2">
<LiveStatus hasError={hasError} errorMessage={errorMessage} />
<RunActionsButtons
env={env}
runId={runId}
runStatus={run.status}
events={allEvents}
eventsLoading={loading}
loading={loading}
onRerunClick={handleRerunClick}
onCancelClick={handleCancelClick}
callbacks={{ onSuccess: update }}
/>
</div>
</div>
{/* Status and Timeline Row */}
<div className="flex items-start gap-8">
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Status</div>
{run.status ? (
<StatusBadge status={run.status} context={run} />
) : (
<Skeleton className="w-[55px] h-[24px]" />
)}
</div>
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Duration</div>
<div className="text-xs">
{run.runId ? (
run.startedAt ? (
(() => {
const ms =
(run.completedAt
? new Date(run.completedAt).getTime()
: Date.now()) - new Date(run.startedAt).getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return remainingSeconds > 0
? `${minutes}m ${remainingSeconds}s`
: `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0
? `${hours}h ${remainingMinutes}m`
: `${hours}h`;
})()
) : (
'-'
)
) : (
<Skeleton className="w-[60px] h-[20px]" />
)}
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Run ID</div>
{run.runId ? (
<CopyableText text={run.runId}>
<div className="text-xs mt-0.5 font-mono">{run.runId}</div>
</CopyableText>
) : (
<Skeleton className="w-[280px] h-[20px]" />
)}
</div>
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Queued</div>
{run.createdAt ? (
<div className="text-xs">
<RelativeTime date={run.createdAt} />
</div>
) : (
<Skeleton className="w-[110px] h-[20px]" />
)}
</div>
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Started</div>
<div className="text-xs">
{run.runId ? (
run.startedAt ? (
<RelativeTime date={run.startedAt} />
) : (
'-'
)
) : (
<Skeleton className="w-[110px] h-[20px]" />
)}
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Completed</div>
<div className="text-xs">
{run.runId ? (
run.completedAt ? (
<RelativeTime date={run.completedAt} />
) : (
'-'
)
) : (
<Skeleton className="w-[110px] h-[20px]" />
)}
</div>
</div>
{run.expiredAt != null && (
<div className="flex flex-col gap-1">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs text-muted-foreground cursor-help flex items-center gap-1">
Expired
<HelpCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>
The storage data for this run has expired and is no
longer available.
</p>
</TooltipContent>
</Tooltip>
<div className="text-xs">
<RelativeTime date={run.expiredAt} />
</div>
</div>
)}
</div>
</div>
</div>
<div className="mt-4 flex-1 flex flex-col min-h-0">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as Tab)}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="mb-4 flex-none">
<TabsTrigger value="trace" className="gap-2">
<List className="h-4 w-4" />
Trace
</TabsTrigger>
{/* Graph tab hidden for now */}
{false && isLocalBackend && (
<TabsTrigger value="graph" className="gap-2">
<GitBranch className="h-4 w-4" />
Graph
</TabsTrigger>
)}
<TabsTrigger value="events" className="gap-2">
<List className="h-4 w-4" />
Events
</TabsTrigger>
<TabsTrigger value="streams" className="gap-2">
<List className="h-4 w-4" />
Streams
</TabsTrigger>
</TabsList>
<TabsContent value="trace" className="mt-0 flex-1 min-h-0">
<ErrorBoundary title="Failed to load trace viewer">
<div className="relative h-full -mx-6 bg-background-100 border-t border-gray-alpha-400 overflow-hidden">
<NewTraceViewer
run={run}
events={allEvents ?? []}
loading={loading}
sidebarData={sidebarData}
onLoadMore={loadMoreTraceData}
hasMore={hasMoreTraceData}
isLoadingMore={isLoadingMoreTraceData}
/>
</div>
</ErrorBoundary>
</TabsContent>
<TabsContent value="events" className="mt-0 flex-1 min-h-0">
<ErrorBoundary title="Failed to load events list">
<div className="h-full">
<EventListView
events={eventsListData}
run={run}
onLoadEventData={handleLoadEventData}
hasMoreEvents={hasMoreEventsTab}
isLoadingMoreEvents={loadingMoreEventsTab}
onLoadMoreEvents={loadMoreEventsTab}
encryptionKey={encryptionKey ?? undefined}
isLoading={eventsListLoading}
sortOrder={eventsSortOrder}
onSortOrderChange={setEventsSortOrder}
onDecrypt={handleDecrypt}
isDecrypting={isDecrypting}
hasEncryptedData={hasEncryptedData}
onExactIdSearch={searchByExactId}
/>
</div>
</ErrorBoundary>
</TabsContent>
<TabsContent value="streams" className="mt-0 flex-1 min-h-0">
<ErrorBoundary title="Failed to load stream data">
<div className="h-full flex gap-4">
{/* Stream list sidebar */}
<div
className="w-64 flex-shrink-0 border rounded-lg overflow-hidden"
style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'var(--ds-background-100)',
}}
>
<div
className="px-3 py-2 border-b text-xs font-medium"
style={{
borderColor: 'var(--ds-gray-300)',
color: 'var(--ds-gray-900)',
}}
>
Streams ({streams.length})
</div>
<div className="overflow-auto max-h-[calc(100vh-400px)]">
{streamsLoading ? (
<div className="p-4 flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : streamsError ? (
<div className="p-4 text-xs text-destructive">
{streamsError.message}
</div>
) : streams.length === 0 ? (
<div
className="p-4 text-xs"
style={{ color: 'var(--ds-gray-600)' }}
>
No streams found for this run
</div>
) : (
streams.map((streamId) => (
<button
key={streamId}
type="button"
onClick={() => setSelectedStreamId(streamId)}
className="w-full text-left px-3 py-2 text-xs font-mono truncate hover:bg-accent transition-colors"
style={{
backgroundColor:
selectedStreamId === streamId
? 'var(--ds-gray-200)'
: 'transparent',
color: 'var(--ds-gray-1000)',
}}
title={streamId}
>
{streamId}
</button>
))
)}
</div>
</div>
{/* Stream viewer */}
<div className="flex-1 min-w-0">
{selectedStreamId ? (
streamError?.includes('encrypted') && !encryptionKey ? (
<div
className="h-full flex flex-col items-center justify-center gap-3 rounded-lg border p-4"
style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'var(--ds-gray-100)',
}}
>
<Lock
className="h-6 w-6"
style={{ color: 'var(--ds-gray-700)' }}
/>
<div
className="text-sm"
style={{ color: 'var(--ds-gray-900)' }}
>
This stream is encrypted.
</div>
<DecryptButton
onClick={handleDecrypt}
loading={isDecrypting}
/>
</div>
) : (
<StreamViewer
streamId={selectedStreamId}
chunks={streamChunks}
isLive={streamIsLive}
error={
streamError?.includes('encrypted')
? null
: streamError
}
/>
)
) : (
<div
className="h-full flex items-center justify-center rounded-lg border"
style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'var(--ds-gray-100)',
}}
>
<div
className="text-sm"
style={{ color: 'var(--ds-gray-600)' }}
>
{streams.length > 0
? 'Select a stream to view its data'
: 'No streams available'}
</div>
</div>
)}
</div>
</div>
</ErrorBoundary>
</TabsContent>
{/* Graph tab hidden for now */}
{false && isLocalBackend && (
<TabsContent value="graph" className="mt-0 flex-1 min-h-0">
<ErrorBoundary title="Failed to load execution graph">
<div className="h-full min-h-[500px]">
<GraphTabContent
run={run}
allEvents={allEvents}
env={env}
/>
</div>
</ErrorBoundary>
</TabsContent>
)}
</Tabs>
</div>
</div>
</>
);
}