feat(ui): traffic-inspector conversation chat bubbles + session recorder/picker (F8)

This commit is contained in:
diegosouzapw
2026-05-28 07:25:09 -03:00
parent e5a0d3df22
commit 389b035bee
6 changed files with 303 additions and 0 deletions

View File

@@ -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<NormalizedTurn["role"], string> = {
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<NormalizedTurn["role"], string> = {
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 (
<div className={cn("max-w-[85%] rounded-lg px-3 py-2", isUser ? "ml-auto" : "mr-auto", ROLE_STYLES[turn.role])}>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-medium opacity-70">{ROLE_LABEL[turn.role]}</span>
{isSystem && (
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className="text-xs opacity-70 hover:opacity-100 focus-ring rounded"
>
{collapsed ? "Expand" : "Collapse"}
</button>
)}
</div>
{!collapsed && <MessageContent blocks={turn.blocks} />}
{collapsed && isSystem && (
<p className="text-xs opacity-60 italic">System prompt hidden click to expand</p>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-2">
{blocks.map((block, i) => {
if (block.type === "text") {
return (
<p key={i} className="text-sm text-text-main whitespace-pre-wrap break-words">
{block.text}
</p>
);
}
if (block.type === "tool_use") {
return (
<ToolCallBlock key={i} id={block.id} name={block.name} input={block.input} />
);
}
if (block.type === "tool_result") {
return (
<ToolResultBlock
key={i}
toolUseId={block.tool_use_id}
content={block.content}
/>
);
}
return null;
})}
</div>
);
}

View File

@@ -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 (
<div className="rounded border border-amber-500/40 bg-amber-900/20 px-3 py-2 text-sm">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex w-full items-center gap-2 text-left focus-ring rounded"
>
<span className="material-symbols-outlined text-[14px] text-amber-400" aria-hidden="true">
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-amber-300 font-mono font-medium">{name}</span>
<span className="text-text-muted text-xs font-mono ml-auto">{id.slice(0, 8)}</span>
</button>
{expanded && (
<div className="mt-2 border-t border-amber-500/20 pt-2">
<JsonViewer data={input} />
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="rounded border border-green-500/40 bg-green-900/20 px-3 py-2 text-sm">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex w-full items-center gap-2 text-left focus-ring rounded"
>
<span className="material-symbols-outlined text-[14px] text-green-400" aria-hidden="true">
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-green-300 font-mono font-medium text-xs">tool_result</span>
<span className="text-text-muted text-xs font-mono ml-auto">{toolUseId.slice(0, 8)}</span>
</button>
{expanded && (
<div className="mt-2 border-t border-green-500/20 pt-2">
<JsonViewer data={content} />
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main hover:bg-surface focus-ring"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
folder_open
</span>
{selected ? selected.name ?? `Session ${selected.id.slice(0, 6)}` : "Sessions"}
<span className="material-symbols-outlined text-[12px] ml-1" aria-hidden="true">
{open ? "expand_less" : "expand_more"}
</span>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 min-w-[200px] rounded-lg border border-border bg-surface shadow-lg py-1">
<button
type="button"
onClick={() => { onSelect(undefined); setOpen(false); }}
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-bg-subtle focus-ring"
>
All traffic (no session)
</button>
{sessions.length === 0 && (
<p className="px-3 py-2 text-xs text-text-muted italic">No sessions yet</p>
)}
{sessions.map((s) => (
<div key={s.id} className="flex items-center group">
<button
type="button"
onClick={() => { onSelect(s.id); setOpen(false); }}
className={`flex-1 text-left px-3 py-1.5 text-xs hover:bg-bg-subtle focus-ring ${
selectedId === s.id ? "text-blue-400 font-medium" : "text-text-main"
}`}
>
{s.name ?? `Session ${s.id.slice(0, 6)}`}
<span className="text-text-muted ml-1">({s.requestCount} reqs)</span>
</button>
<button
type="button"
onClick={() => { onDelete(s.id); if (selectedId === s.id) onSelect(undefined); }}
className="px-2 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 focus-ring rounded"
aria-label="Delete session"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
delete
</span>
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm border",
recording
? "border-red-500/40 bg-red-900/20 text-red-200"
: "border-border bg-bg-subtle text-text-muted"
)}
>
{recording ? (
<>
<span className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse" />
<span className="font-mono text-xs">{formatElapsed(elapsed)}</span>
{session?.name && (
<span className="text-xs opacity-70 truncate max-w-[120px]">{session.name}</span>
)}
<button
type="button"
onClick={onStop}
className="ml-auto rounded border border-red-500/50 px-2 py-0.5 text-xs hover:bg-red-800/30 focus-ring"
>
Stop
</button>
</>
) : (
<>
<span className="inline-block h-2 w-2 rounded-full bg-gray-500" />
<span className="text-xs">Not recording</span>
<button
type="button"
onClick={() => onStart()}
className="ml-auto rounded border border-border px-2 py-0.5 text-xs hover:bg-surface focus-ring"
>
REC
</button>
</>
)}
</div>
);
}