feat(ui): build session viewer to read raw AI transcripts and tool calls

This commit is contained in:
2026-05-16 11:10:05 -07:00
parent c7e720bdc9
commit 67ee2b49d7
2 changed files with 495 additions and 55 deletions

View File

@@ -411,8 +411,15 @@ function SectionTile({
// Vision
// ──────────────────────────────────────────────────
function BriefPanel({ plan, projectId, onChange }: { plan: Plan; projectId: string; onChange: (p: Plan) => void; }) {
function BriefPanel({
plan,
projectId,
onChange,
}: {
plan: Plan;
projectId: string;
onChange: (p: Plan) => void;
}) {
const [draft, setDraft] = useState(plan.brief ?? "");
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(!plan.brief);
@@ -445,7 +452,7 @@ function BriefPanel({ plan, projectId, onChange }: { plan: Plan; projectId: stri
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// For now we just read text files (txt, md, csv).
// In the future you can send PDFs to an extraction API.
const reader = new FileReader();
@@ -463,20 +470,106 @@ function BriefPanel({ plan, projectId, onChange }: { plan: Plan; projectId: stri
<div className="vibn-enter" style={{ position: "relative" }}>
<button
onClick={() => setEditing(true)}
style={{ position: "absolute", top: 0, right: 0, padding: "6px 12px", borderRadius: 6, background: "transparent", border: "1px solid #e8e4dc", fontSize: "0.75rem", color: "#6b6560", cursor: "pointer", display: "flex", alignItems: "center", gap: 6 }}
style={{
position: "absolute",
top: 0,
right: 0,
padding: "6px 12px",
borderRadius: 6,
background: "transparent",
border: "1px solid #e8e4dc",
fontSize: "0.75rem",
color: "#6b6560",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<Pencil style={{ width: 12, height: 12 }} /> Edit
</button>
<div style={{ padding: "8px 0" }}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
p: ({ node, ...props }) => <p style={{ fontSize: "0.95rem", lineHeight: 1.6, color: "#1a1a1a", marginBottom: 16 }} {...props} />,
h1: ({ node, ...props }) => <h1 style={{ fontSize: "1.4rem", fontWeight: 600, color: "#1a1a1a", marginTop: 24, marginBottom: 16 }} {...props} />,
h2: ({ node, ...props }) => <h2 style={{ fontSize: "1.2rem", fontWeight: 600, color: "#1a1a1a", marginTop: 20, marginBottom: 12 }} {...props} />,
h3: ({ node, ...props }) => <h3 style={{ fontSize: "1.05rem", fontWeight: 600, color: "#1a1a1a", marginTop: 16, marginBottom: 10 }} {...props} />,
ul: ({ node, ...props }) => <ul style={{ fontSize: "0.95rem", lineHeight: 1.6, color: "#1a1a1a", paddingLeft: 24, marginBottom: 16, listStyleType: "disc" }} {...props} />,
ol: ({ node, ...props }) => <ol style={{ fontSize: "0.95rem", lineHeight: 1.6, color: "#1a1a1a", paddingLeft: 24, marginBottom: 16, listStyleType: "decimal" }} {...props} />,
li: ({ node, ...props }) => <li style={{ marginBottom: 6 }} {...props} />,
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => (
<p
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
marginBottom: 16,
}}
{...props}
/>
),
h1: ({ node, ...props }) => (
<h1
style={{
fontSize: "1.4rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 24,
marginBottom: 16,
}}
{...props}
/>
),
h2: ({ node, ...props }) => (
<h2
style={{
fontSize: "1.2rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 20,
marginBottom: 12,
}}
{...props}
/>
),
h3: ({ node, ...props }) => (
<h3
style={{
fontSize: "1.05rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 16,
marginBottom: 10,
}}
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
paddingLeft: 24,
marginBottom: 16,
listStyleType: "disc",
}}
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
paddingLeft: 24,
marginBottom: 16,
listStyleType: "decimal",
}}
{...props}
/>
),
li: ({ node, ...props }) => (
<li style={{ marginBottom: 6 }} {...props} />
),
}}
>
{plan.brief}
</ReactMarkdown>
</div>
@@ -488,15 +581,48 @@ function BriefPanel({ plan, projectId, onChange }: { plan: Plan; projectId: stri
<div className="vibn-enter">
{!plan.brief && (
<div style={emptyVisionBox}>
<p style={{ margin: 0, marginBottom: 12, fontSize: "0.9rem", color: "#444441", lineHeight: 1.55 }}>
No project brief uploaded. You can paste your PRD, requirements document, or notes below. The AI will read this to understand the full scope of your project.
<p
style={{
margin: 0,
marginBottom: 12,
fontSize: "0.9rem",
color: "#444441",
lineHeight: 1.55,
}}
>
No project brief uploaded. You can paste your PRD, requirements
document, or notes below. The AI will read this to understand the
full scope of your project.
</p>
<div style={{ display: 'flex', gap: '12px' }}>
<label style={{ ...primaryBtn, cursor: 'pointer', display: 'inline-flex' }}>
<div style={{ display: "flex", gap: "12px" }}>
<label
style={{
...primaryBtn,
cursor: "pointer",
display: "inline-flex",
}}
>
Upload Text File (.md, .txt)
<input type="file" accept=".txt,.md,.csv" style={{ display: 'none' }} onChange={handleFileUpload} />
<input
type="file"
accept=".txt,.md,.csv"
style={{ display: "none" }}
onChange={handleFileUpload}
/>
</label>
<button onClick={() => setEditing(true)} style={{ padding: "8px 16px", borderRadius: 7, background: "transparent", color: "#1a1a1a", border: "1px solid #1a1a1a", fontSize: "0.82rem", fontWeight: 600, cursor: "pointer" }}>
<button
onClick={() => setEditing(true)}
style={{
padding: "8px 16px",
borderRadius: 7,
background: "transparent",
color: "#1a1a1a",
border: "1px solid #1a1a1a",
fontSize: "0.82rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Paste Manually
</button>
</div>
@@ -504,12 +630,52 @@ function BriefPanel({ plan, projectId, onChange }: { plan: Plan; projectId: stri
)}
{editing && (
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10, overflow: "hidden", marginTop: plan.brief ? 0 : 24 }}>
<div style={{ display: "flex", borderBottom: "1px solid #e8e4dc", background: "#faf8f5" }}>
<button onClick={() => setEditorView("write")} style={{ ...tabStyle(editorView === "write" ? "write" : ""), borderRadius: 0, border: "none", borderRight: "1px solid #e8e4dc", borderBottom: editorView === "write" ? "1px solid transparent" : "1px solid #e8e4dc", background: editorView === "write" ? "#fff" : "transparent" }}>
<div
style={{
background: "#fff",
border: "1px solid #e8e4dc",
borderRadius: 10,
overflow: "hidden",
marginTop: plan.brief ? 0 : 24,
}}
>
<div
style={{
display: "flex",
borderBottom: "1px solid #e8e4dc",
background: "#faf8f5",
}}
>
<button
onClick={() => setEditorView("write")}
style={{
...tabStyle(editorView === "write" ? "write" : ""),
borderRadius: 0,
border: "none",
borderRight: "1px solid #e8e4dc",
borderBottom:
editorView === "write"
? "1px solid transparent"
: "1px solid #e8e4dc",
background: editorView === "write" ? "#fff" : "transparent",
}}
>
Write
</button>
<button onClick={() => setEditorView("preview")} style={{ ...tabStyle(editorView === "preview" ? "preview" : ""), borderRadius: 0, border: "none", borderRight: "1px solid #e8e4dc", borderBottom: editorView === "preview" ? "1px solid transparent" : "1px solid #e8e4dc", background: editorView === "preview" ? "#fff" : "transparent" }}>
<button
onClick={() => setEditorView("preview")}
style={{
...tabStyle(editorView === "preview" ? "preview" : ""),
borderRadius: 0,
border: "none",
borderRight: "1px solid #e8e4dc",
borderBottom:
editorView === "preview"
? "1px solid transparent"
: "1px solid #e8e4dc",
background: editorView === "preview" ? "#fff" : "transparent",
}}
>
Preview
</button>
<div style={{ flex: 1, borderBottom: "1px solid #e8e4dc" }} />
@@ -518,34 +684,142 @@ function BriefPanel({ plan, projectId, onChange }: { plan: Plan; projectId: stri
{editorView === "write" ? (
<textarea
value={draft}
onChange={(e) => { setDraft(e.target.value); setDirty(true); }}
onChange={(e) => {
setDraft(e.target.value);
setDirty(true);
}}
placeholder="Paste your PRD or project scope here..."
style={{ width: "100%", minHeight: 300, padding: 20, border: "none", outline: "none", fontSize: "0.9rem", lineHeight: 1.6, fontFamily: "var(--font-inter), sans-serif", color: "#1a1a1a", resize: "vertical" }}
style={{
width: "100%",
minHeight: 300,
padding: 20,
border: "none",
outline: "none",
fontSize: "0.9rem",
lineHeight: 1.6,
fontFamily: "var(--font-inter), sans-serif",
color: "#1a1a1a",
resize: "vertical",
}}
/>
) : (
<div style={{ padding: 20, minHeight: 300, maxHeight: 500, overflowY: "auto" }}>
<div
style={{
padding: 20,
minHeight: 300,
maxHeight: 500,
overflowY: "auto",
}}
>
{draft ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
p: ({ node, ...props }) => <p style={{ fontSize: "0.95rem", lineHeight: 1.6, color: "#1a1a1a", marginBottom: 16 }} {...props} />,
h1: ({ node, ...props }) => <h1 style={{ fontSize: "1.4rem", fontWeight: 600, color: "#1a1a1a", marginTop: 24, marginBottom: 16 }} {...props} />,
h2: ({ node, ...props }) => <h2 style={{ fontSize: "1.2rem", fontWeight: 600, color: "#1a1a1a", marginTop: 20, marginBottom: 12 }} {...props} />,
ul: ({ node, ...props }) => <ul style={{ fontSize: "0.95rem", lineHeight: 1.6, color: "#1a1a1a", paddingLeft: 24, marginBottom: 16, listStyleType: "disc" }} {...props} />,
li: ({ node, ...props }) => <li style={{ marginBottom: 6 }} {...props} />,
}}>{draft}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => (
<p
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
marginBottom: 16,
}}
{...props}
/>
),
h1: ({ node, ...props }) => (
<h1
style={{
fontSize: "1.4rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 24,
marginBottom: 16,
}}
{...props}
/>
),
h2: ({ node, ...props }) => (
<h2
style={{
fontSize: "1.2rem",
fontWeight: 600,
color: "#1a1a1a",
marginTop: 20,
marginBottom: 12,
}}
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
style={{
fontSize: "0.95rem",
lineHeight: 1.6,
color: "#1a1a1a",
paddingLeft: 24,
marginBottom: 16,
listStyleType: "disc",
}}
{...props}
/>
),
li: ({ node, ...props }) => (
<li style={{ marginBottom: 6 }} {...props} />
),
}}
>
{draft}
</ReactMarkdown>
) : (
<p style={{ color: "#a09a90", fontStyle: "italic", margin: 0 }}>Nothing to preview.</p>
<p style={{ color: "#a09a90", fontStyle: "italic", margin: 0 }}>
Nothing to preview.
</p>
)}
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 12, padding: "12px 20px", borderTop: "1px solid #e8e4dc", background: "#faf8f5" }}>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 12,
padding: "12px 20px",
borderTop: "1px solid #e8e4dc",
background: "#faf8f5",
}}
>
{plan.brief && (
<button onClick={() => { setDraft(plan.brief ?? ""); setDirty(false); setEditing(false); }} style={{ padding: "8px 16px", borderRadius: 6, background: "transparent", border: "1px solid #e8e4dc", color: "#6b6560", fontSize: "0.82rem", fontWeight: 500, cursor: "pointer" }}>
<button
onClick={() => {
setDraft(plan.brief ?? "");
setDirty(false);
setEditing(false);
}}
style={{
padding: "8px 16px",
borderRadius: 6,
background: "transparent",
border: "1px solid #e8e4dc",
color: "#6b6560",
fontSize: "0.82rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Cancel
</button>
)}
<button onClick={handleSave} disabled={!dirty || saving} style={{ ...primaryBtn, opacity: !dirty || saving ? 0.5 : 1 }}>
{saving ? <Loader2 style={{ width: 14, height: 14 }} className="animate-spin" /> : null}
<button
onClick={handleSave}
disabled={!dirty || saving}
style={{ ...primaryBtn, opacity: !dirty || saving ? 0.5 : 1 }}
>
{saving ? (
<Loader2
style={{ width: 14, height: 14 }}
className="animate-spin"
/>
) : null}
{saving ? "Saving..." : "Save Brief"}
</button>
</div>
@@ -555,7 +829,6 @@ function BriefPanel({ plan, projectId, onChange }: { plan: Plan; projectId: stri
);
}
function VisionPanel({
plan,
projectId,
@@ -817,23 +1090,44 @@ function SessionsPanel({
) : (
<ul style={list}>
{sessions.map((s) => (
<li key={s.id} style={sessionRow}>
<MessageSquare
size={14}
style={{ color: INK.mid, marginTop: 2, flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={sessionTitle}>{s.title}</div>
{s.summary && <div style={sessionSummary}>{s.summary}</div>}
<div style={sessionMeta}>
<span>
{s.messageCount}{" "}
{s.messageCount === 1 ? "message" : "messages"}
</span>
<span style={{ color: INK.muted }}>·</span>
<span>{relativeTime(s.updatedAt)}</span>
<li key={s.id} style={{ ...sessionRow, padding: 0 }}>
<a
href={`/${workspace}/project/${projectId}/sessions/${s.id}`}
style={{
display: "flex",
alignItems: "flex-start",
gap: 12,
textDecoration: "none",
color: "inherit",
width: "100%",
padding: "12px 14px",
borderRadius: 8,
transition: "background 0.15s",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.03)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "transparent")
}
>
<MessageSquare
size={14}
style={{ color: INK.mid, marginTop: 2, flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={sessionTitle}>{s.title}</div>
{s.summary && <div style={sessionSummary}>{s.summary}</div>}
<div style={sessionMeta}>
<span>
{s.messageCount}{" "}
{s.messageCount === 1 ? "message" : "messages"}
</span>
<span style={{ color: INK.muted }}>·</span>
<span>{relativeTime(s.updatedAt)}</span>
</div>
</div>
</div>
</a>
</li>
))}
</ul>

View File

@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2, ArrowLeft, Bot, User, Code2, Wrench } from "lucide-react";
import ReactMarkdown from "react-markdown";
interface ToolCall {
id: string;
name: string;
args: any;
}
interface Message {
id: string;
role: "user" | "assistant";
content?: string;
toolCalls?: ToolCall[];
textSegments?: string[];
createdAt: string;
}
export default function SessionViewer() {
const params = useParams();
const router = useRouter();
const workspace = params.workspace as string;
const projectId = params.projectId as string;
const sessionId = params.sessionId as string;
const [loading, setLoading] = useState(true);
const [thread, setThread] = useState<any>(null);
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
fetch(`/api/chat/threads/${sessionId}`)
.then(r => r.json())
.then(d => {
setThread(d.thread);
setMessages(d.messages || []);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [sessionId]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-[#faf8f5]">
<Loader2 className="w-5 h-5 animate-spin text-zinc-400" />
</div>
);
}
if (!thread) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-[#faf8f5]">
<p className="text-zinc-500 mb-4">Session not found.</p>
<button
onClick={() => router.push(`/${workspace}/project/${projectId}/plan`)}
className="text-indigo-600 font-medium text-sm hover:underline"
>
&larr; Back to Plan
</button>
</div>
);
}
return (
<div className="flex-1 bg-[#faf8f5] overflow-y-auto flex flex-col">
<header className="sticky top-0 bg-white border-b border-zinc-200 px-6 py-4 flex items-center gap-4 z-10">
<button
onClick={() => router.push(`/${workspace}/project/${projectId}/plan`)}
className="w-8 h-8 flex items-center justify-center rounded-md hover:bg-zinc-100 text-zinc-500 transition-colors"
title="Back to Sessions"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-lg font-semibold text-zinc-900 leading-none mb-1">{thread.title}</h1>
<p className="text-xs text-zinc-500 font-mono">{new Date(thread.createdAt).toLocaleString()}</p>
</div>
</header>
<div className="flex-1 max-w-4xl mx-auto w-full p-6 pb-20 space-y-8">
{messages.map((m, i) => (
<div key={m.id || i} className="flex gap-4">
<div className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center border mt-1">
{m.role === "user" ? (
<div className="bg-zinc-100 border-zinc-200 w-full h-full rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-zinc-600" />
</div>
) : (
<div className="bg-indigo-600 border-indigo-700 w-full h-full rounded-full flex items-center justify-center">
<Bot className="w-4 h-4 text-white" />
</div>
)}
</div>
<div className="flex-1 min-w-0 space-y-3">
{m.role === "user" && m.content && (
<div className="text-[15px] text-zinc-800 leading-relaxed font-medium whitespace-pre-wrap">
{m.content}
</div>
)}
{m.role === "assistant" && (
<>
{m.toolCalls && m.toolCalls.length > 0 && (
<div className="space-y-1.5 mb-4">
{m.toolCalls.map((tc, idx) => (
<div key={idx} className="bg-zinc-100 border border-zinc-200 rounded-md p-2.5 text-xs font-mono overflow-x-auto text-zinc-600">
<div className="flex items-center gap-2 text-zinc-800 font-semibold mb-1">
<Wrench className="w-3.5 h-3.5" />
{tc.name}
</div>
<pre className="text-[10px] leading-relaxed text-zinc-500 mt-1">
{JSON.stringify(tc.args, null, 2)}
</pre>
</div>
))}
</div>
)}
{m.content && (
<div className="prose prose-sm prose-zinc max-w-none bg-white border border-zinc-200 p-4 rounded-xl shadow-sm">
<ReactMarkdown>{m.content}</ReactMarkdown>
</div>
)}
{/* Fallback for when textSegments exists but content is empty */}
{!m.content && m.textSegments && m.textSegments.length > 0 && (
<div className="prose prose-sm prose-zinc max-w-none bg-white border border-zinc-200 p-4 rounded-xl shadow-sm">
<ReactMarkdown>{m.textSegments.join("\n\n")}</ReactMarkdown>
</div>
)}
</>
)}
</div>
</div>
))}
{messages.length === 0 && (
<p className="text-center text-zinc-500 italic mt-10">Empty session.</p>
)}
</div>
</div>
);
}