diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx new file mode 100644 index 0000000000..81c127b389 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { useTrafficStream } from "./hooks/useTrafficStream"; +import { useTrafficFilters } from "./hooks/useTrafficFilters"; +import { useResizablePanels } from "./hooks/useResizablePanels"; +import { useSessionRecorder } from "./hooks/useSessionRecorder"; +import { CaptureModesToolbar } from "./components/CaptureModesToolbar"; +import { TopBarControls } from "./components/TopBarControls"; +import { RequestStreamingList } from "./components/RequestStreamingList"; +import { DetailsPanel } from "./components/DetailsPanel"; + +const BUFFER_MAX = 1000; + +export function TrafficInspectorPageClient() { + const [containerHeight, setContainerHeight] = useState(600); + const listContainerRef = useRef(null); + const [selectedRequest, setSelectedRequest] = useState(null); + const { filters, setProfile, setHost, setAgent, setStatus, setSessionId } = + useTrafficFilters(); + const [{ listWidth, collapsed }, { startDrag, toggleCollapse }] = useResizablePanels(); + const [streamState, streamActions] = useTrafficStream(filters); + const recorder = useSessionRecorder(); + + const listContainerCallback = useCallback((el: HTMLDivElement | null) => { + listContainerRef.current = el; + if (el) setContainerHeight(el.clientHeight); + }, []); + + const exportHar = useCallback(async () => { + try { + const res = await fetch("/api/tools/traffic-inspector/export/har"); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `traffic-${Date.now()}.har`; + a.click(); + URL.revokeObjectURL(url); + } catch { + // ignore + } + }, []); + + const handleSessionSelect = useCallback( + (id: string | undefined) => { + setSessionId(id); + }, + [setSessionId] + ); + + const handleRecordStart = useCallback(() => { + void recorder.start(); + }, [recorder]); + + const handleRecordStop = useCallback(() => { + void recorder.stop(); + }, [recorder]); + + return ( +
+ {/* Capture modes toolbar */} +
+ +
+ + {/* Top bar filter/controls */} +
+ +
+ + {/* Split pane */} +
+ {/* List pane */} +
+
+ + {streamState.total} requests + + +
+ + {!collapsed && ( +
+ +
+ )} + + {collapsed && ( +
+ + {streamState.total} reqs + +
+ )} +
+ + {/* Drag handle */} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CaptureModesToolbar.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CaptureModesToolbar.tsx new file mode 100644 index 0000000000..849f10e23b --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CaptureModesToolbar.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/shared/utils/cn"; +import { CustomHostsManager } from "./CustomHostsManager"; +import { HttpProxySnippetCard } from "./HttpProxySnippetCard"; + +interface CaptureModeState { + agentBridge: boolean; // always on, cannot disable + customHosts: boolean; + httpProxy: boolean; + systemWide: boolean; +} + +interface CaptureModesToolbarProps { + customHostCount: number; +} + +export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProps) { + const [modes, setModes] = useState({ + agentBridge: true, + customHosts: false, + httpProxy: false, + systemWide: false, + }); + const [showHosts, setShowHosts] = useState(false); + const [showProxy, setShowProxy] = useState(false); + const [proxyPort] = useState(8080); + + const toggleMode = (key: keyof CaptureModeState) => { + if (key === "agentBridge") return; // always on + setModes((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const buttons: Array<{ + key: keyof CaptureModeState; + label: string; + alwaysOn?: boolean; + warn?: boolean; + extra?: React.ReactNode; + }> = [ + { key: "agentBridge", label: "AgentBridge", alwaysOn: true }, + { + key: "customHosts", + label: `Custom hosts (${customHostCount})`, + }, + { + key: "httpProxy", + label: `HTTP_PROXY :${proxyPort}`, + }, + { + key: "systemWide", + label: "System-wide", + warn: true, + }, + ]; + + return ( + <> +
+ {buttons.map(({ key, label, alwaysOn, warn }) => { + const active = modes[key]; + return ( + + ); + })} + +
+ + +
+
+ + {showHosts && setShowHosts(false)} />} + {showProxy && setShowProxy(false)} />} + + ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx new file mode 100644 index 0000000000..ca3b46b3c5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { z } from "zod"; + +interface CustomHost { + host: string; + enabled: boolean; + label?: string | null; + kind: "llm" | "app" | "custom"; +} + +interface CustomHostsManagerProps { + onClose: () => void; +} + +export function CustomHostsManager({ onClose }: CustomHostsManagerProps) { + const [hosts, setHosts] = useState([]); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const HostInputSchema = z.string().min(1).max(253).regex(/^[a-z0-9.-]+$/i, "Invalid hostname"); + + const fetchHosts = async () => { + setLoading(true); + try { + const res = await fetch("/api/tools/traffic-inspector/custom-hosts"); + if (res.ok) { + const data = (await res.json()) as { hosts: CustomHost[] }; + setHosts(data.hosts ?? []); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void fetchHosts(); + }, []); + + const addHost = async () => { + setError(null); + const parsed = HostInputSchema.safeParse(input.trim()); + if (!parsed.success) { + setError(parsed.error.errors[0]?.message ?? "Invalid host"); + return; + } + const host = parsed.data; + try { + const res = await fetch("/api/tools/traffic-inspector/custom-hosts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ host, enabled: true }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; + setError(body?.error?.message ?? "Failed to add host"); + return; + } + setInput(""); + await fetchHosts(); + } catch { + setError("Network error"); + } + }; + + const deleteHost = async (host: string) => { + try { + await fetch(`/api/tools/traffic-inspector/custom-hosts/${encodeURIComponent(host)}`, { + method: "DELETE", + }); + await fetchHosts(); + } catch { + // ignore + } + }; + + return ( +
+
+
+

Custom Hosts

+ +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addHost()} + placeholder="api.openai.com" + className="flex-1 rounded border border-border bg-bg-subtle px-3 py-1.5 text-sm text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + +
+ {error &&

{error}

} + +
+ {loading &&

Loading…

} + {!loading && hosts.length === 0 && ( +

No custom hosts added yet.

+ )} + {hosts.map((h) => ( +
+ {h.host} + +
+ ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/DetailsPanel.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/DetailsPanel.tsx new file mode 100644 index 0000000000..c270111e63 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/DetailsPanel.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { cn } from "@/shared/utils/cn"; +import { HeadersTab } from "./tabs/HeadersTab"; +import { RequestBodyTab } from "./tabs/RequestBodyTab"; +import { ResponseBodyTab } from "./tabs/ResponseBodyTab"; +import { TimingTab } from "./tabs/TimingTab"; +import { LlmDetailsTab } from "./tabs/LlmDetailsTab"; +import { ConversationTab } from "./tabs/ConversationTab"; +import { StatsTab } from "./tabs/StatsTab"; +import { AnnotationField } from "./shared/AnnotationField"; + +type TabId = "conversation" | "headers" | "request" | "response" | "timing" | "llm" | "stats"; + +interface Tab { + id: TabId; + label: string; + icon: string; + llmOnly?: boolean; +} + +const TABS: Tab[] = [ + { id: "conversation", label: "Conversation", icon: "chat_bubble" }, + { id: "headers", label: "Headers", icon: "list" }, + { id: "request", label: "Request", icon: "upload" }, + { id: "response", label: "Response", icon: "download" }, + { id: "timing", label: "Timing", icon: "timer" }, + { id: "llm", label: "LLM", icon: "psychology", llmOnly: true }, + { id: "stats", label: "Stats", icon: "bar_chart" }, +]; + +interface DetailsPanelProps { + request: InterceptedRequest | null; + allRequests: InterceptedRequest[]; +} + +export function DetailsPanel({ request, allRequests }: DetailsPanelProps) { + const [activeTab, setActiveTab] = useState("conversation"); + + if (!request) { + return ( +
+
+ +

Select a request to inspect it.

+
+
+ ); + } + + const isLlm = request.detectedKind === "llm"; + const visibleTabs = TABS.filter((t) => !t.llmOnly || isLlm); + + // Ensure active tab is valid + const currentTab = visibleTabs.find((t) => t.id === activeTab) ? activeTab : "conversation"; + + return ( +
+ {/* Tab bar */} +
+ {visibleTabs.map((tab) => { + const selected = currentTab === tab.id; + return ( + + ); + })} +
+ + {/* Tab content */} +
+ {currentTab === "conversation" && } + {currentTab === "headers" && } + {currentTab === "request" && } + {currentTab === "response" && } + {currentTab === "timing" && } + {currentTab === "llm" && isLlm && } + {currentTab === "stats" && } +
+ + {/* Annotation footer */} +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/HttpProxySnippetCard.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/HttpProxySnippetCard.tsx new file mode 100644 index 0000000000..588caa392f --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/HttpProxySnippetCard.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/shared/utils/cn"; + +interface HttpProxySnippetCardProps { + port: number; + onClose: () => void; +} + +type Lang = "bash" | "python" | "node"; + +export function HttpProxySnippetCard({ port, onClose }: HttpProxySnippetCardProps) { + const [lang, setLang] = useState("bash"); + const [copied, setCopied] = useState(false); + + const snippets: Record = { + bash: `export HTTP_PROXY=http://127.0.0.1:${port}\nexport HTTPS_PROXY=http://127.0.0.1:${port}\nexport NODE_TLS_REJECT_UNAUTHORIZED=0\n# then run your command:\ncurl https://api.openai.com/v1/models`, + python: `import os\nos.environ["HTTP_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["HTTPS_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"\n# then use requests or httpx as usual`, + node: `process.env.HTTP_PROXY = "http://127.0.0.1:${port}";\nprocess.env.HTTPS_PROXY = "http://127.0.0.1:${port}";\nprocess.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";\n// then use fetch / axios / undici as usual`, + }; + + const copy = async () => { + await navigator.clipboard.writeText(snippets[lang]); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+

+ HTTP Proxy Snippet — port {port} +

+ +
+ +
+ {(["bash", "python", "node"] as Lang[]).map((l) => ( + + ))} +
+ +
+          {snippets[lang]}
+        
+ +
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx new file mode 100644 index 0000000000..9fbcca5b95 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestRow.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { ContextColorBar } from "./shared/ContextColorBar"; +import { AgentEmoji } from "./shared/AgentEmoji"; + +interface RequestRowProps { + request: InterceptedRequest; + selected: boolean; + onClick: () => void; + style?: React.CSSProperties; +} + +function statusColor(status: InterceptedRequest["status"]): string { + if (status === "in-flight") return "text-gray-400"; + if (status === "error") return "text-red-400"; + if (typeof status === "number") { + if (status < 300) return "text-green-400"; + if (status < 400) return "text-yellow-400"; + if (status < 500) return "text-orange-400"; + return "text-red-400"; + } + return "text-text-muted"; +} + +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${bytes}B`; +} + +function formatTime(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleTimeString("en", { hour12: false }); + } catch { + return ""; + } +} + +export function RequestRow({ request, selected, onClick, style }: RequestRowProps) { + const pathShort = request.path.length > 32 ? `…${request.path.slice(-30)}` : request.path; + const sc = statusColor(request.status); + + return ( +
(e.key === "Enter" || e.key === " ") && onClick()} + style={style} + className={cn( + "flex items-stretch gap-1 border-b border-border/40 cursor-pointer hover:bg-bg-subtle", + "focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500", + selected && "bg-surface" + )} + > + +
+
+ {formatTime(request.timestamp)} + {request.method} + + {String(request.status)} + + {formatSize(request.responseSize)} + + + +
+
+ {request.host} + {pathShort} +
+ {request.contextKey && ( +
+ ctx #{request.contextKey.slice(0, 6)} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx new file mode 100644 index 0000000000..112eb2ad93 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/RequestStreamingList.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useRef } from "react"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { useVirtualList } from "../hooks/useVirtualList"; +import { RequestRow } from "./RequestRow"; + +interface RequestStreamingListProps { + requests: InterceptedRequest[]; + selectedId: string | null; + onSelect: (req: InterceptedRequest) => void; + containerHeight: number; +} + +export function RequestStreamingList({ + requests, + selectedId, + onSelect, + containerHeight, +}: RequestStreamingListProps) { + const { virtualItems, totalHeight, containerRef, rowRef } = useVirtualList( + requests, + containerHeight + ); + + if (requests.length === 0) { + return ( +
+
+ +

No requests captured yet.

+

Make sure AgentBridge is running or enable another capture mode.

+
+
+ ); + } + + return ( +
} + className="h-full overflow-y-auto relative" + style={{ contain: "strict" }} + > +
+ {virtualItems.map(({ index, item, top }) => ( +
+ onSelect(item)} + /> +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/TopBarControls.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/TopBarControls.tsx new file mode 100644 index 0000000000..0a9aa4ed3f --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/TopBarControls.tsx @@ -0,0 +1,189 @@ +"use client"; + +import type { ListFilters } from "@/mitm/inspector/types"; +import type { AgentId } from "@/mitm/types"; +import { cn } from "@/shared/utils/cn"; +import { SessionRecorderBar } from "./session/SessionRecorderBar"; +import { SessionPicker } from "./session/SessionPicker"; +import type { SessionInfo } from "../hooks/useSessionRecorder"; + +type Profile = "llm" | "custom" | "all"; + +interface TopBarControlsProps { + filters: ListFilters; + onProfileChange: (p: Profile) => void; + onHostChange: (h: string | undefined) => void; + onAgentChange: (a: AgentId | undefined) => void; + onStatusChange: (s: ListFilters["status"]) => void; + paused: boolean; + onPause: () => void; + onResume: () => void; + onClear: () => void; + onExport: () => void; + connected: boolean; + total: number; + maxSize?: number; + // session recorder + recording: boolean; + session: SessionInfo | null; + elapsed: number; + sessions: SessionInfo[]; + onRecordStart: () => void; + onRecordStop: () => void; + onSessionSelect: (id: string | undefined) => void; + onSessionDelete: (id: string) => void; +} + +const PROFILES: Array<{ id: Profile; label: string }> = [ + { id: "llm", label: "LLM only" }, + { id: "custom", label: "Custom" }, + { id: "all", label: "All" }, +]; + +export function TopBarControls({ + filters, + onProfileChange, + onHostChange, + onAgentChange, + onStatusChange, + paused, + onPause, + onResume, + onClear, + onExport, + connected, + total, + maxSize = 1000, + recording, + session, + elapsed, + sessions, + onRecordStart, + onRecordStop, + onSessionSelect, + onSessionDelete, +}: TopBarControlsProps) { + const profile: Profile = (filters.profile as Profile) ?? "llm"; + + return ( +
+ {/* Profile selector */} +
+ {PROFILES.map((p) => ( + + ))} +
+ + {/* Host filter */} + onHostChange(e.target.value || undefined)} + className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main w-32 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + + {/* Status filter */} + + + {/* Action buttons */} + + + + + + + {/* Session controls */} +
+ + + + {/* Live indicator */} +
+ + {connected ? "live" : "offline"} + + {total}/{maxSize} + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx new file mode 100644 index 0000000000..c9b9042005 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import type { NormalizedTurn } from "@/mitm/inspector/types"; +import { cn } from "@/shared/utils/cn"; +import { MessageContent } from "./MessageContent"; + +interface ChatBubbleProps { + turn: NormalizedTurn; +} + +const ROLE_STYLES: Record = { + system: "border border-red-500/40 bg-red-900/20 text-red-200", + user: "ml-auto bg-blue-600/30 border border-blue-500/30 text-blue-100", + assistant: "bg-purple-900/30 border border-purple-500/30 text-purple-100", + tool: "bg-gray-800 border border-gray-600/30 text-gray-200", +}; + +const ROLE_LABEL: Record = { + system: "System", + user: "User", + assistant: "Assistant", + tool: "Tool", +}; + +export function ChatBubble({ turn }: ChatBubbleProps) { + const [collapsed, setCollapsed] = useState(turn.role === "system"); + + const isSystem = turn.role === "system"; + const isUser = turn.role === "user"; + + return ( +
+
+ {ROLE_LABEL[turn.role]} + {isSystem && ( + + )} +
+ {!collapsed && } + {collapsed && isSystem && ( +

System prompt hidden — click to expand

+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/MessageContent.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/MessageContent.tsx new file mode 100644 index 0000000000..b6dc106057 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/MessageContent.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { NormalizedBlock } from "@/mitm/inspector/types"; +import { ToolCallBlock } from "./ToolCallBlock"; +import { ToolResultBlock } from "./ToolResultBlock"; + +interface MessageContentProps { + blocks: NormalizedBlock[]; +} + +export function MessageContent({ blocks }: MessageContentProps) { + return ( +
+ {blocks.map((block, i) => { + if (block.type === "text") { + return ( +

+ {block.text} +

+ ); + } + if (block.type === "tool_use") { + return ( + + ); + } + if (block.type === "tool_result") { + return ( + + ); + } + return null; + })} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolCallBlock.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolCallBlock.tsx new file mode 100644 index 0000000000..6d01759e8f --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolCallBlock.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState } from "react"; +import { JsonViewer } from "../shared/JsonViewer"; + +interface ToolCallBlockProps { + id: string; + name: string; + input: unknown; +} + +export function ToolCallBlock({ id, name, input }: ToolCallBlockProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolResultBlock.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolResultBlock.tsx new file mode 100644 index 0000000000..1066d3590c --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolResultBlock.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useState } from "react"; +import { JsonViewer } from "../shared/JsonViewer"; + +interface ToolResultBlockProps { + toolUseId: string; + content: unknown; +} + +export function ToolResultBlock({ toolUseId, content }: ToolResultBlockProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionPicker.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionPicker.tsx new file mode 100644 index 0000000000..2a52d4091b --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionPicker.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import type { SessionInfo } from "../../hooks/useSessionRecorder"; + +interface SessionPickerProps { + sessions: SessionInfo[]; + selectedId?: string; + onSelect: (id: string | undefined) => void; + onDelete: (id: string) => void; +} + +export function SessionPicker({ sessions, selectedId, onSelect, onDelete }: SessionPickerProps) { + const [open, setOpen] = useState(false); + + const selected = sessions.find((s) => s.id === selectedId); + + return ( +
+ + + {open && ( +
+ + {sessions.length === 0 && ( +

No sessions yet

+ )} + {sessions.map((s) => ( +
+ + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionRecorderBar.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionRecorderBar.tsx new file mode 100644 index 0000000000..d35714894c --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/session/SessionRecorderBar.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; +import type { SessionInfo } from "../../hooks/useSessionRecorder"; + +interface SessionRecorderBarProps { + recording: boolean; + session: SessionInfo | null; + elapsed: number; + onStart: (name?: string) => void; + onStop: () => void; +} + +function formatElapsed(s: number): string { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; + return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; +} + +export function SessionRecorderBar({ + recording, + session, + elapsed, + onStart, + onStop, +}: SessionRecorderBarProps) { + return ( +
+ {recording ? ( + <> + + {formatElapsed(elapsed)} + {session?.name && ( + {session.name} + )} + + + ) : ( + <> + + Not recording + + + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AgentEmoji.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AgentEmoji.tsx new file mode 100644 index 0000000000..8cadbfd3b1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AgentEmoji.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { AgentId } from "@/mitm/types"; + +const AGENT_COLORS: Record = { + antigravity: { emoji: "🔵", label: "AG", color: "text-blue-400" }, + kiro: { emoji: "🟠", label: "KR", color: "text-orange-400" }, + copilot: { emoji: "🟢", label: "CP", color: "text-green-400" }, + codex: { emoji: "🟣", label: "CD", color: "text-purple-400" }, + cursor: { emoji: "🔶", label: "CU", color: "text-yellow-400" }, + zed: { emoji: "🔷", label: "ZD", color: "text-sky-400" }, + "claude-code": { emoji: "🟡", label: "CC", color: "text-yellow-300" }, + "open-code": { emoji: "⚪", label: "OC", color: "text-gray-400" }, + trae: { emoji: "⬛", label: "TR", color: "text-gray-500" }, +}; + +interface AgentEmojiProps { + agentId?: AgentId | string; + className?: string; +} + +export function AgentEmoji({ agentId, className }: AgentEmojiProps) { + if (!agentId) return 🌐; + const info = AGENT_COLORS[agentId as AgentId]; + if (!info) return 🌐; + return ( + + {info.emoji} {info.label} + + ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AnnotationField.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AnnotationField.tsx new file mode 100644 index 0000000000..6d7ddd98b9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/shared/AnnotationField.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useAnnotations } from "../../hooks/useAnnotations"; + +interface AnnotationFieldProps { + requestId: string | null; + initialValue?: string; +} + +export function AnnotationField({ requestId, initialValue = "" }: AnnotationFieldProps) { + const [value, setValue] = useState(initialValue); + const { save, saving } = useAnnotations(requestId); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + setValue(e.target.value); + save(e.target.value); + }, + [save] + ); + + return ( +
+