mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(ui): traffic-inspector conversation chat bubbles + session recorder/picker (F8)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user